Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions godot-core/src/obj/gd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -878,6 +878,12 @@ impl<T: GodotClass> Gd<T> {
);

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(&[]);
Expand Down
6 changes: 6 additions & 0 deletions godot-core/src/task/futures.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,12 @@ impl<R: InParamTuple + IntoDynamicSend> Future for SignalFuture<R> {
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"
),
Expand Down
25 changes: 25 additions & 0 deletions itest/rust/src/engine_tests/async_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
32 changes: 31 additions & 1 deletion itest/rust/src/object_tests/call_deferred_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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();
Expand Down
Loading