Repository navigation
Disengaged safeguards appear to still check bind / bind_mut #1704
Copy link
Copy link
Open
Labels
questionNot a problem with the library, but a question regarding usage.Not a problem with the library, but a question regarding usage.
Description
I was experimenting with making multiple threads work with the same node by removing experimental-threads, and using safeguards-release-disengaged instead to prevent the bind guard interior mutability checker from complaining, however, I noticed that safeguards-release-disengaged appears to be broken?
Cargo.toml:
[package]
name = "gdext_rust"
version = "0.1.0"
edition = "2024"
[lib]
crate-type = ["cdylib"]
path = "src/gdext_rust.rs"
[dependencies.godot]
version = "0.5.5"
features = ["api-4-7", "safeguards-release-disengaged", "register-docs"]lib.rs:
use godot::prelude::*;
struct GdextRustExtLib;
#[gdextension]
unsafe impl ExtensionLibrary for GdextRustExtLib {}
#[derive(GodotClass)]
#[class(base=Node, init)]
struct RustNode {
#[base]
base: Base<Node>,
#[var]
ans: i64,
}
#[godot_api]
impl RustNode {
fn is_prime(x: i64) -> bool {
let mut y: i64 = 2;
while y * y <= x {
if x % y == 0 {
return false;
}
y += 1;
}
true
}
#[func]
fn compute_answer(&mut self, #[opt(default=100_000_000_i64)] n: i64) {
let mut ans: i64 = 0;
for i in 2_i64 .. n {
if RustNode::is_prime(i) {
ans += 1;
}
}
self.base_mut().set_deferred("ans", &ans.to_variant());
}
}main.gd:
extends Node
@onready var rust_node: RustNode = $RustNode
var counter: int = 0
var task_id: int = -1
var task_state: String = "running"
func _ready() -> void:
task_id = WorkerThreadPool.add_task(rust_node.compute_answer.bind(10_000_000))
func _process(_delta: float) -> void:
counter += 1
if task_id != -1 and WorkerThreadPool.is_task_completed(task_id):
WorkerThreadPool.wait_for_task_completion(task_id)
task_state = "completed"
task_id = -1
print("Frame #", counter, "; answer is: ", rust_node.ans, "; ", task_state)I expected the safeguard checks to not trigger due to the safeguards-release-disengaged option in Cargo.toml (I'm building with cargo build --release).
However, I'm seeing failed checks in the logs:
E 0:00:00:620 main.gd:17 @ _process(): [panic .../godot-core-0.5.5/src/storage/mod.rs:26]
Gd<T>::bind() failed, already bound; T = gdext_rust::RustNode.
Make sure to use `self.base_mut()` or `self.base()` instead of `self.to_gd()` when possible.
Details: cannot borrow while accessible mutable borrow exists.
<C++ Source> .../godot-core-0.5.5/src/private.rs:331 @ godot_core::private::install_godot_rust_hook::{{closure}}()
<Stack Trace> main.gd:17 @ _process()
Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
questionNot a problem with the library, but a question regarding usage.Not a problem with the library, but a question regarding usage.
Activity
Bromeon commentedon Sep 18, 2026
bind/bind_mutis something to potentially optimize, but there seems to be a misunderstanding:This flag is a pure optimization, you cannot rely on it for semantic reasons. Especially not because that usually means your code becomes unsound.
caphindsight commentedon Sep 18, 2026
Is it guaranteed to be optimized out? Because it seems like it doesn't work?
Bromeon commentedon Sep 18, 2026
No:
Bromeon commentedon Sep 18, 2026
Regarding threading, it's a known issue for a long time. But
experimental-threadsis already a big hack, and we shouldn't encourage even more unsound code just to shut up the compiler, because all it will achieve is UB at runtime 🫠In case you have time to devote to this topic, PR #1524 is a start and could use some input, or maybe you have other ideas. But I also totally understand if you don't want to deal with this and just get your game done 😀 unfortunately Godot really doesn't make it easy for Rust as it gives almost no guarantees about thread safety.
caphindsight commentedon Sep 18, 2026
Can you elaborate why this is so?
Is your point that this can be unsound, or that it is necessarily unsound?
I 100% agree with the former, but I cannot see a convincing argument for the latter. I can, for example, envision completely sound code that runs the following function on the thread:
What is the current behaviour with/without
experimental-threadshere? Would it allow running multiple such functions in parallel?From experimenting in the past days, my understanding is: no, it would not.
Would it not be sound to allow multiple of these functions running on worker threads?
The problem is, for me, that I absolutely love this crate, and nothing would make me happier than to use it in an actual game, but I have encountered a hard block: can't run rust code on Godot's worker threads. As small as this blocker sounds, it is a make or break... It would be a shame if this meant I can't use this crate, I am trying to find a way forward...
TitanNano commentedon Sep 18, 2026
I don't know your exact setup, and it's not a direct solution to the stated problem, but have you considered running your code on a Rust thread?
caphindsight commentedon Sep 18, 2026
I have. In my setup, I have a virtual function that is overwritten in GDScript, and it has to be called in the worker threads (it's procedural generation, essentially; the procedural engine is written in Rust, and calls into GDScript callbacks that define properties of specific assets). I find that the generated bind / thread guards prevent me from calling the virtual function in parallel, it doesn't matter if it happens from a Rust or Godot thread.
caphindsight commentedon Sep 18, 2026
May I offer a different perspective, perhaps?
There's a reason
unsafeis a core part of Rust: you can't do certain things, especially if performance is at stake, without it.Rust's model, the way I see it, is "use safe Rust whenever possible, use unsafe whenever it isn't".
Even swapping two elements in a slice can't be done without unsafe :)
So what I'm suggesting here is to have two things: an unsafe optout of the safeguards, a "use it at your own risk" kind of thing; and a safe threading design (which will probably come after, since it's hard to do) that potentially only supports a limited set of use cases.
My project would essentially only have a few places where I'd opt out of these checks. I would have to carefully check and test those places, but if it's only a few, it's a manageable amount. The rest of the codebase is still benefiting from safety.
I don't think method-level or class-level optouts from the bind guard / thread checker are a bad idea at all.
Bromeon commentedon Sep 18, 2026
Gd::bind_mut's interior mutability works likeRefCell::borrow_mut: ensure at runtime that only a single&mut Tever exists.In your initial post, you wrote:
implying that you ran into a panic.
This panic isn't "complaining" to annoy you, it means "you would at this point obtain a second
&mut T". This is always undefined behavior in Rust, no matter what you do with the resulting reference. Whether it appears to work is completely irrelevant -- the compiler can assume that this never happens and do according optimizations in the LLVM backend (such asrestrictpointers, instruction reordering, etc).Unsafe vs. unsound, see this page:
So if you ever have a situation where the dynamic borrow-check panics, it means that removing that check would cause UB. So you can't argue that the panic holds you back from achieving something, because the alternative is an mal-formed (aka unsound or buggy) program. I do see the performance PoV, but that's a very different line of reasoning 🙂
Out of curiosity, how would you test this?
caphindsight commentedon Sep 18, 2026
I'm arguing multiple things here and I fear they got mixed up. I do have opinions about threading, and there's another issue I opened at about the same time where I argue that a static function that doesn't bind the object (for example only calling call_deferred on the Gd) should be allowed to bypass guards.
I am not arguing that I should be selectively allowed to bypass bind guards by having two binds at the same time.
What I am arguing in this issue is simply that the stated behaviour "safeguards are stripped out from builds that use the disengage feature" does not seem to correspond to reality.
For me, this is a problem only because I have no way of not paying the performance penalty of checking the guards at runtime.
So, for example, I am not at all arguing for removing the bind_mut checker in debug builds.
You may argue that the way I arrived at this is wrong, since it took writing code that is UB. You would be correct. Technically, you're allowed to add the guards back in since it's undefined behaviour anyway. But in reality, we both know that the guards would still be checked if there was no undefined behaviour in my code, too. So adding the UB is still a valid way to spot the existence of checks, in practice (another way would be to inspect the ASM by hand).
Again, to reiterate. This issue is about the "disengage" crate feature not disengaging everything in release builds. I argue it should disengage everything, because I want my game to be fast. Not because I want to bypass it.
I have argued for allowing bypassing here only because there's a separate issue I have open at the same time, and it's easy to confuse both points.
caphindsight commentedon Sep 18, 2026
W.r.t. the other point about allowing to bypass bind guards, I wonder if something like this would work as a way to bypass them:
Bromeon commentedon Sep 19, 2026
Your initial post was pretty clear on "using
safeguards-release-disengagedinstead to prevent the bind guard interior mutability checker from complaining" and "I expected the safeguard checks to not trigger", so you simply didn't argue about performance.But you do now, fair.
#1693, yes and I already answered that it makes sense 🙂
I wonder how big the impact of the guards is, compared to e.g. locking on Godot side and FFI/marshaling overhead. Did you do any measurements here? We also have a
#[bench]infrastructure in godot-rust itself, but it might be more meaningful in the context of a frame of your game.Also, this is unanswered (genuine question of mine, I'm not trying to test you or anything):
caphindsight commentedon Sep 19, 2026
II> Your initial post was pretty clear on "using safeguards-release-disengaged instead to prevent the bind guard interior mutability checker from complaining" and "I expected the safeguard checks to not trigger", so you simply didn't argue about performance.
But you do now, fair.
Yes, I said I have experimented with this and found it to also be broken. Despite what some Rust developers may believe, experimenting with code that has an UB in it is not illegal in most jurisdictions.
This was my main argument from the start. I assumed that an option that says
safeguards-release-disengagedshould, well, disengage the safeguards in release builds, but I observed that it is apparently not so, contrary to what the documentation claims. What goals I may have been pursuing while I discovered this is irrelevant to the issue.I don't have the raw numbers yet.
It's a long, complicated answer. What would you do if you wrote the whole thing in C++? You'd inspect the code manually, looking at the most likely places it could fail, then playtest the build to iron out any crashes. There's ASAN / UBSAN / etc., your runtime borrow checker would help here too, just not shipped as part of the final game.
Bromeon commentedon Sep 19, 2026
It actually isn't, one of my tasks is to identify XY problems. And indeed, the report "I can't avoid safeguard panics" turned out to mean "I want to get more performance out of this". Given that a significant number of issues reported here is about misunderstandings of borrowing, UB and safety in general, I don't think it's inappropriate to double-check that a request has the right motivation.
If you have them, please share. I can do benchmarks in godot-rust itself of course, but seeing a more real-world use case would be interesting.
You're right that the docs are imprecise;
safeguards-disengageddoesn't guarantee that everything is disabled (it's not practically possible in Rust, and Godot has its own checks), and especially not that it enables different semantics (such as avoiding a panic that would otherwise be hit). This is the main risk I see -- that people misunderstand it and introduce bugs because they believe "ah, now it works". But if we know more about the performance, we can evaluate more angles.We would also need to consider how to disable checks for
bind/bind_mut...unwrap_uncheckedof inner cell? Direct raw pointer?(P.s. sorry edited wrong post, reverted)
caphindsight commentedon Sep 22, 2026
Brief comment on the performance aspect. I never got to the part where I can measure performance because of some locking issues with experimental-threads. The procedural generator I have written using this crate frequently drops frames when new chunks load in.
For comparison, I'm getting stable 240 FPS with the equivalent written in GDScript. Not like it's a fair comparison — the performance bottleneck is clearly the main thread waiting on the mutex during multithreaded calls which should be safe to do without the mutex.
I think fixing the static function issue I mentioned in the other thread (no pun intended) here will help with the FPS drops.
Currently I have GDScript code outperforming Rust by all metrics. And it was much faster to write, which is, of course, expected. Not a good start :(
TitanNano commentedon Sep 22, 2026
Generally, I would recommend avoiding as much GDScript / engine interaction as possible when you want to achieve maximum performance.
You can, for example, gather all the necessary data into a Rust struct up-front and then run your multithreaded logic on a Rust thread pool with e.g.
rayon.caphindsight commentedon Sep 22, 2026
I am trying to achieve a balanced solution between performance and development speed. So from my Rust code that runs the generator I would call into script virtual functions that return specific properties that tell the generation engine how to place assets. I understand that this has a performance hit, but with this design I can iterate on levels a lot faster.
Generally, I am using Godot for development, so "avoid engine interactions" is not an answer I can accept.
Bromeon commentedon Sep 23, 2026
You conveniently left out "as much as possible" in his quote. Also, the meaning is obviously not to avoid Godot, but that batched data exchange works better than thousands of individually spread out FFI calls, simply because there's a ceiling due to Godot's own per-call overhead. Of course, that doesn't excuse unoptimized paths on godot-rust side, it was merely a suggestion on how to deal with the status quo before anything is fixed 🙂
To make things faster, let's focus on technical facts. Was the slowness you encountered under
safeguards-disengagedfeature and Rust--releasebuilds? What order of magnitude are we talking -- you mention "240 FPS in GDScript but drops frames in Rust", which is very vague. Any chance we can isolate the offenders more clearly, e.g. in a minimal example? That would allow me to do more precise profiling/benchmarking. Ideal would be something we can run in godot-rust's#[bench]infrastructure (it just calls a function many times and measures it) -- I've been able to optimize a ton of the library using that approach.caphindsight commentedon Sep 23, 2026
I was trying to evaluate the performance on a realistic workload. As I'm sure you're aware, benchmarking a small line of code in a tight loop is not always a good indicator of how your game will be running. You won't catch various cache misses, branch mispredictions, etc. which are likely to happen in production workloads which are not just focused on spinning a tiny block of code over and over again.
I can put together a benchmark too, if that helps you. I'm not sure I see the point: clearly, running checks is less performant than not running checks. I believe in empowering the user to do what they want while still providing safety checks by default, that seems to also be the general Rust philosophy, I was surprised that the
disengagedlevel of checks doesn't fully disable them. That's why I filed this issue - I honestly thought this was just a bug, not a deliberate design decision.Yeah, I know it's vague, I didn't mean to report those numbers like they mean anything. I was describing my setup, so you better understand what it is that I'm trying to do. You conveniently left out the part where I explained how the reason I couldn't get a good handle on performance (like e.g. by running a profiler, like Very Sleepy, on a working prototype), is that I could never resolve the threading issues which I am convinced arise from grabbing exclusive locks in the generated bindings. If I could avoid grabbing those locks, I would have the real performance numbers to share.