Skip to content

Commit 8503df8

Browse files
committed
Handle dynamic-free-while-bound by crashing in Debug, leaking in Release
1 parent 4394855 commit 8503df8

File tree

2 files changed

+46
-20
lines changed

2 files changed

+46
-20
lines changed

godot-core/src/storage.rs

Lines changed: 40 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
*/
66

77
use crate::obj::GodotClass;
8-
use crate::out;
8+
use crate::{godot_error, out};
99
use godot_ffi as sys;
1010

1111
#[derive(Copy, Clone, Debug)]
@@ -343,17 +343,45 @@ pub unsafe fn as_storage<'u, T: GodotClass>(
343343
pub unsafe fn destroy_storage<T: GodotClass>(instance_ptr: sys::GDExtensionClassInstancePtr) {
344344
let raw = instance_ptr as *mut InstanceStorage<T>;
345345

346-
// The following caused UB in 4.0.4 itests, only on Linux. Debugging was not conclusive; would need more time.
347-
// Since it doesn't occur in 4.1+, we disable it -- time can be spent better on newer versions.
348-
#[cfg(since_api = "4.1")]
349-
assert!(
350-
!(*raw).is_bound(),
351-
"tried to destroy object while a bind() or bind_mut() call is active\n \
352-
object: {}",
353-
(*raw).debug_info()
354-
);
355-
356-
let _drop = Box::from_raw(raw);
346+
// We cannot panic here, since this code is invoked from a C callback. Panicking would mean unwinding into C code, which is UB.
347+
// We have the following options:
348+
// 1. Print an error as a best-effort, knowing that UB is likely to occur whenever the user will access &T or &mut T. (Technically, the
349+
// mere existence of these references is UB since the T is dead.)
350+
// 2. Abort the process. This is the safest option, but a very drastic measure, and not what gdext does elsewhere.
351+
// We can use Godot's OS.crash() API here.
352+
// 3. Change everything to "C-unwind" API. Would make the FFI unwinding safe, but still not clear if Godot would handle it appropriately.
353+
// Even if yes, it's likely the same behavior as OS.crash().
354+
// 4. Prevent destruction of the Rust part (InstanceStorage). This would solve the immediate problem of &T and &mut T becoming invalid,
355+
// but it would leave a zombie object behind, where all base operations and Godot interactions suddenly fail, which likely creates
356+
// its own set of edge cases. It would _also_ make the problem less observable, since the user can keep interacting with the Rust
357+
// object and slowly accumulate memory leaks.
358+
// - Letting Gd<T> and InstanceStorage<T> know about this specific object state and panicking in the next Rust call might be an option,
359+
// but we still can't control direct access to the T.
360+
//
361+
// For now we choose option 2 in Debug mode, and 4 in Release.
362+
let mut leak_rust_object = false;
363+
if (*raw).is_bound() {
364+
let error = format!(
365+
"Destroyed an object from Godot side, while a bind() or bind_mut() call was active.\n \
366+
This is a bug in your code that may cause UB and logic errors. Make sure that objects are not\n \
367+
destroyed while you still hold a Rust reference to them, or use Gd::free() which is safe.\n \
368+
object: {}",
369+
(*raw).debug_info()
370+
);
371+
372+
// In Debug mode, crash which may trigger breakpoint.
373+
// In release mode, leak player object (Godot philosophy: don't crash if somehow avoidable). Likely leads to follow-up issues.
374+
if cfg!(debug_assertions) {
375+
crate::engine::Os::singleton().crash(error.into());
376+
} else {
377+
leak_rust_object = true;
378+
godot_error!("{}", error);
379+
}
380+
}
381+
382+
if !leak_rust_object {
383+
let _drop = Box::from_raw(raw);
384+
}
357385
}
358386

359387
// ----------------------------------------------------------------------------------------------------------------------------------------------

itest/rust/src/object_tests/object_test.rs

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -268,8 +268,7 @@ fn object_user_free_during_bind() {
268268
obj.free(); // now succeeds
269269
}
270270

271-
#[cfg(since_api = "4.1")] // See destroy_storage() comment.
272-
#[itest]
271+
#[itest(skip)] // This deliberately crashes the engine. Un-skip to manually test this.
273272
fn object_user_dynamic_free_during_bind() {
274273
// Note: we could also test if GDScript can access free() when an object is bound, to check whether the panic is handled or crashes
275274
// the engine. However, that is only possible under the following scenarios:
@@ -283,16 +282,15 @@ fn object_user_dynamic_free_during_bind() {
283282

284283
let mut copy = obj.clone(); // TODO clone allowed while bound?
285284

286-
expect_panic("engine-routed free() on user while it's bound", move || {
287-
copy.call("free".into(), &[]);
288-
});
285+
// This technically triggers UB, but in practice no one accesses the references.
286+
// There is no alternative to test this, see destroy_storage() comments.
287+
copy.call("free".into(), &[]);
289288

290289
drop(guard);
291290
assert!(
292-
obj.is_instance_valid(),
293-
"object lives on after failed dynamic free()"
291+
!obj.is_instance_valid(),
292+
"dynamic free() destroys object even if it's bound"
294293
);
295-
obj.free(); // now succeeds
296294
}
297295

298296
// TODO test if engine destroys it, eg. call()

0 commit comments

Comments
 (0)