diff --git a/godot-core/src/obj/gd.rs b/godot-core/src/obj/gd.rs index a2f763cf5..2778a274d 100644 --- a/godot-core/src/obj/gd.rs +++ b/godot-core/src/obj/gd.rs @@ -878,6 +878,12 @@ impl Gd { ); let callable = Callable::from_once_fn("run_deferred", move |_| { + // Skip if the engine is exiting: the deferred call would otherwise run after `SceneTree` teardown, where accessing freed objects + // (e.g. autoloads) panics. This matches Godot's own `call_deferred()`, which drops queued calls to freed objects at shutdown. + // See `async_runtime::is_engine_exiting()`. + if crate::task::is_engine_exiting() { + return; + } gd_function(obj); }); callable.call_deferred(&[]); diff --git a/godot-core/src/task/futures.rs b/godot-core/src/task/futures.rs index 25a41de32..07f539f76 100644 --- a/godot-core/src/task/futures.rs +++ b/godot-core/src/task/futures.rs @@ -62,6 +62,12 @@ impl Future for SignalFuture { match poll_result { Poll::Pending => Poll::Pending, Poll::Ready(Ok(value)) => Poll::Ready(value), + // A freed signal object normally means a logic error -> panic. But on engine exit, the object may be freed before the + // engine-exiting flag is set; `SignalFutureResolver::drop` then marks the future `Dead` instead of leaving it pending. So we + // also check the flag here: if the engine is exiting, park silently (the runtime drops the future in `cleanup()`). + Poll::Ready(Err(FallibleSignalFutureError)) if crate::task::is_engine_exiting() => { + Poll::Pending + } Poll::Ready(Err(FallibleSignalFutureError)) => panic!( "the signal object of a SignalFuture was freed, while the future was still waiting for the signal to be emitted" ), diff --git a/itest/rust/src/engine_tests/async_test.rs b/itest/rust/src/engine_tests/async_test.rs index 8e3711fb3..f96d56dfa 100644 --- a/itest/rust/src/engine_tests/async_test.rs +++ b/itest/rust/src/engine_tests/async_test.rs @@ -177,6 +177,31 @@ fn signal_future_cancelled_at_engine_exit() { ); } +// Second regression test for https://github.com/godot-rust/gdext/issues/1624: same outcome, but with the real shutdown ordering where the +// object is freed *before* the engine-exiting flag is set, so the future is already `Dead` when polled (the drop-time guard never fires). +#[itest] +fn signal_future_cancelled_at_engine_exit_ordering() { + let obj = Object::new_alloc(); + let signal = Signal::from_object_signal(&obj, "script_changed"); + + let mut future = pin!(signal.to_future::<()>()); + let mut cx = Context::from_waker(Waker::noop()); + + assert_eq!(future.as_mut().poll(&mut cx), Poll::Pending); + + // Object freed while flag NOT yet set -> resolver marks future Dead and wakes it. + obj.free(); + + // Engine begins shutdown only now (deferred poll runs after teardown started). + let _exiting_guard = task::simulate_engine_exiting(); + + assert_eq!( + future.as_mut().poll(&mut cx), + Poll::Pending, + "SignalFuture must park silently (not panic) when polled during engine teardown" + ); +} + #[cfg(feature = "experimental-threads")] #[itest(async)] fn signal_future_non_send_arg_panic() -> TaskHandle { diff --git a/itest/rust/src/object_tests/call_deferred_test.rs b/itest/rust/src/object_tests/call_deferred_test.rs index c69cf49ee..6433871e8 100644 --- a/itest/rust/src/object_tests/call_deferred_test.rs +++ b/itest/rust/src/object_tests/call_deferred_test.rs @@ -5,11 +5,13 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ +use std::cell::Cell; use std::ops::DerefMut; +use std::rc::Rc; use godot::obj::WithBaseField; use godot::prelude::*; -use godot::task::{SignalFuture, TaskHandle}; +use godot::task::{self, SignalFuture, TaskHandle}; use crate::framework::itest; @@ -143,6 +145,34 @@ fn run_deferred_gd_user_class(ctx: &crate::framework::TestContext) -> TaskHandle guard.create_assertion_task() } +// Regression test for https://github.com/godot-rust/gdext/issues/1624: A `run_deferred[_gd]` closure must not run after `SceneTree` teardown, +// matching Godot's `call_deferred()` which drops such queued calls. +#[itest(async)] +fn run_deferred_skipped_when_exiting(ctx: &crate::framework::TestContext) -> TaskHandle { + let mut test_node = DeferredTestNode::new_alloc(); + ctx.scene_tree.clone().add_child(&test_node); + + // The guard clears the exiting flag when dropped. The deferred call is flushed only a frame later, so we must keep the guard alive until + // after that flush -- otherwise the flag would be cleared too early and the closure would run. Hence it is moved into the async task below. + let guard = task::simulate_engine_exiting(); + + let ran = Rc::new(Cell::new(false)); + let ran_setter = ran.clone(); + test_node.run_deferred_gd(move |_| ran_setter.set(true)); + + // `test_completed` fires from `process()`, by which point the deferred queue has already been flushed (see other tests above). + let mut guard_node = test_node.bind_mut(); + let run_test: SignalFuture<(StringName,)> = guard_node.signals().test_completed().to_future(); + drop(guard_node); + + task::spawn(async move { + let _ = run_test.await; + let was_run = ran.get(); + drop(guard); // Keep the flag set until after the deferred flush. + assert!(!was_run, "run_deferred_gd() must not run during shutdown"); + }) +} + #[itest(async)] fn run_deferred_engine_class(ctx: &crate::framework::TestContext) -> TaskHandle { let mut test_node = DeferredTestNode::new_alloc();