Skip to content

Fix async panics at shutdown - #1625

Merged
Bromeon merged 1 commit into
masterfrom
bugfix/async-cancel-at-exit
Jun 3, 2026
Merged

Bromeon merged 1 commit into
masterfrom
bugfix/async-cancel-at-exit

Conversation

@Bromeon

@Bromeon Bromeon commented Jun 2, 2026

Copy link
Copy Markdown
Member

During teardown, SceneTree frees objects while async tasks still await their signals. Freeing disconnects the resolver -> this marks the future Dead -> wakes -> deferred poll -> causes panic in SignalFuture::poll(). The panic is caught but spams errors on every exit.

The panicking poll fires after MainLoop de-init, but before the first level de-init hook. For Godot 4.5, we have no MainLoop hook, so we check SceneTree::get_root().

Closes #1624.

@Bromeon Bromeon added bug c: async Everything related to async tasks/signals labels Jun 2, 2026
@Bromeon
Bromeon force-pushed the bugfix/async-cancel-at-exit branch from 0143f2d to e668aa1 Compare June 2, 2026 19:51
@GodotRust

Copy link
Copy Markdown

API docs are being generated and will be shortly available at: https://godot-rust.github.io/docs/gdext/pr-1625

@Bromeon
Bromeon force-pushed the bugfix/async-cancel-at-exit branch 2 times, most recently from f782da7 to 50ef91b Compare June 2, 2026 21:23
Comment thread godot-core/src/task/async_runtime.rs Resolved
Comment thread godot-core/src/task/futures.rs Resolved
Comment thread godot-core/src/task/futures.rs Resolved
@Bromeon
Bromeon force-pushed the bugfix/async-cancel-at-exit branch from 50ef91b to 95eefb8 Compare June 3, 2026 07:07
@Bromeon

Bromeon commented Jun 3, 2026 •

Copy link
Copy Markdown
Member Author

Thanks for the feedback! I refactored is_engine_exiting() multiple times, was indeed a bit messy.

I'm also not sure what signals can emit during shutdown, but in theory, if there are some signals that get emitted during shutdown, they would now get silently ignored as soon as ENGINE_EXITING is true.

That's true, do you think this is a problem in practice? What would you suggest? πŸ€”

@TitanNano

Copy link
Copy Markdown
Contributor

That's true, do you think this is a problem in practice? What would you suggest? πŸ€”

This:

We will also drop all futures in cleanup() so we don't really have to clear the task here.

We eliminated the panic/error that is caused by the freed signal object during shutdown. So now any future that is being polled during shutdown is assumed to have a genuine reason to be polled. I think it's easier to handle future panics and issues that might come up instead of users having to figure out why their edge case is silently ignored.

@Bromeon

Bromeon commented Jun 3, 2026

Copy link
Copy Markdown
Member Author

I think it's easier to handle future panics and issues that might come up instead of users having to figure out why their edge case is silently ignored.

I think you're right about potentially legitimate use cases for tasks during shutdown. For example, awaiting a tree_exiting signal (which is emitted after MainLoop stage deinit, I just checked).

I changed the PR to no longer cancel the tasks on shutdown (but the guard in Drop stays). The Drop guard removes spurious panics (no UB) when the awaited object is freed under the parked task.

However, without the guard in poll_future(), we can run into actual UB (not just panics) in safeguard-disengaged mode. Without the early return in poll_future(), this happens:

  • future.as_mut().poll(&mut ctx)
  • Resumes user code after .await
  • This user code can capture a Gd<Node> that was engine-freed during shutdown.
  • User accesses the Gd -> use-after-free.

This even affects the awaited node itself: with deferred polling, the signal can fire (resolving the future) and the node is freed before the task actually resumes -> even the awaited Gd may be dangling by then.

Yes, safeguards-release-disengaged is explicitly allowed to cause UB, but typically this should only punish "wrong use" like calling free() twice. In this case it's the engine freeing the object, not the user.

So I wonder if this isn't surprising? Right now I also don't have a good solution apart from cancelling or introducing liveness-checks in disengaged mode.

@Bromeon
Bromeon force-pushed the bugfix/async-cancel-at-exit branch from 95eefb8 to 763898b Compare June 3, 2026 14:51
During teardown, `SceneTree` frees objects while async tasks still await
their signals. Freeing disconnects the resolver -> future marked `Dead`
-> wakes -> deferred poll -> panic in `SignalFuture::poll()`. Panic is
caught but spams errors on every exit.

The panicking poll fires after `MainLoop` de-init, but before the first
level de-init hook. For Godot 4.5, we have no `MainLoop` hook, so we
check `SceneTree::get_root()`.
@Bromeon
Bromeon force-pushed the bugfix/async-cancel-at-exit branch from 763898b to a72dd95 Compare June 3, 2026 14:53

@TitanNano TitanNano left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Without the early return in poll_future(), this happens:

  • future.as_mut().poll(&mut ctx)
  • Resumes user code after .await
  • This user code can capture a Gd that was engine-freed during shutdown.
  • User accesses the Gd -> use-after-free.

But this is not exclusive to shutdown, right? A node could always be freed before a task is polled again.

@Bromeon

Bromeon commented Jun 3, 2026

Copy link
Copy Markdown
Member Author

But this is not exclusive to shutdown, right? A node could always be freed before a task is polled again.

Yes, but usually the causality is more direct: someone calls free() or queue_free(), unloads a scene, etc. Here it's a shutdown event, which can also be triggered externally (it's even possible that the user shutting down isn't the developer). Either way, it might be good to prepare for this by e.g. hooking into the on_stage_deinit callbacks.

But when running in safeguards-strict/-balanced modes, this will hopefully be detected before it ever gets to the "UB" situation.

@Bromeon
Bromeon added this pull request to the merge queue Jun 3, 2026
Merged via the queue into master with commit 403aa97 Jun 3, 2026
23 checks passed
@Bromeon
Bromeon deleted the bugfix/async-cancel-at-exit branch June 3, 2026 19:19
@Bromeon Bromeon changed the title Cancel async tasks at shutdown Fix async panics at shutdown Jun 19, 2026
TW0hank0 added a commit to TW0hank0/positive_mahjong that referenced this pull request Aug 19, 2026
Update patch dependencies

This MR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [console_error_panic_hook](https://github.com/rustwasm/console_error_panic_hook) | workspace.dependencies | patch | `=0.1.5` β†’ `=0.1.7` |
| [godot](https://godot-rust.github.io) ([source](https://github.com/godot-rust/gdext)) | workspace.dependencies | patch | `=0.5.3` β†’ `=0.5.5` |
| [serde](https://serde.rs) ([source](https://github.com/serde-rs/serde)) | workspace.dependencies | patch | `=1.0.228` β†’ `=1.0.229` |
| [serde_json](https://github.com/serde-rs/json) | workspace.dependencies | patch | `=1.0.149` β†’ `=1.0.151` |

---

### Release Notes

<details>
<summary>rustwasm/console_error_panic_hook (console_error_panic_hook)</summary>

### [`v0.1.6`](rustwasm/console_error_panic_hook@0.1.5...0.1.6)

[Compare Source](rustwasm/console_error_panic_hook@0.1.5...0.1.6)

</details>

<details>
<summary>godot-rust/gdext (godot)</summary>

### [`v0.5.5`](https://github.com/godot-rust/gdext/blob/HEAD/Changelog.md#v055)

[Compare Source](godot-rust/gdext@v0.5.4...v0.5.5)

*9 August 2026*

##### 🌻 Features

- `EngineBitfield`: add `with()` and `without()` methods ([#&#8203;1627](godot-rust/gdext#1627))
- `#[func(virtual)]` now supports `async fn` for GDScript coroutines ([#&#8203;1645](godot-rust/gdext#1645))
- Add `PhantomData<T>` support for `GodotConvert` derive macro ([#&#8203;1619](godot-rust/gdext#1619))

##### πŸ“ˆ Performance

- Singletons are now cached ([#&#8203;1636](godot-rust/gdext#1636))
- Cache user singleton pointers ([#&#8203;1644](godot-rust/gdext#1644))
- Cheaper refcount operations ([#&#8203;1669](godot-rust/gdext#1669))

##### 🧹 Quality of life

- Always use godot\_error in panic hook ([#&#8203;1647](godot-rust/gdext#1647))
- Rename `cast_int/cast_float` to `to_vector*/to_rect*` ([#&#8203;1661](godot-rust/gdext#1661))
- Validate class registrations against `ClassDB` ([#&#8203;1664](godot-rust/gdext#1664))

##### πŸ› οΈ Bugfixes

- Fix `#[var(pub)]` panic during `OnEditor<Gd<T>>` reload ([#&#8203;1639](godot-rust/gdext#1639))
- Fix async `SignalFuture` panic when object is freed before exit flag ([#&#8203;1648](godot-rust/gdext#1648))
- Fix `api-custom` build when prebuilt lags detected Godot version ([#&#8203;1649](godot-rust/gdext#1649))
- Harden various parts of codebase ([#&#8203;1660](godot-rust/gdext#1660))
- Fix `GFile` `BufRead` past EOF ([#&#8203;1670](godot-rust/gdext#1670))

##### πŸ—οΈ Maintenance

- Replace `godot-cell` borrow-state proptests with exhaustive model check ([#&#8203;1650](godot-rust/gdext#1650))
- Make accidental access of `#[doc(hidden)]` symbols more explicit ([#&#8203;1651](godot-rust/gdext#1651))
- Follow-up to rustc / Godot changes ([#&#8203;1659](godot-rust/gdext#1659))
- Name virtual-callback layers after user-facing `I*` virtuals ([#&#8203;1662](godot-rust/gdext#1662))
- Move ref-counting operations from `DynMemory` to `RawGd` ([#&#8203;1667](godot-rust/gdext#1667))

### [`v0.5.4`](https://github.com/godot-rust/gdext/blob/HEAD/Changelog.md#v054)

[Compare Source](godot-rust/gdext@v0.5.3...v0.5.4)

*23 June 2026*

##### 🌻 Features

- Godot 4.7: add `api-4-7` level ([#&#8203;1641](godot-rust/gdext#1641))
- Type-Safe API for User-defined RPCs ([#&#8203;1535](godot-rust/gdext#1535), [#&#8203;1634](godot-rust/gdext#1634))
- String conversions: ` to_gstring()`, `to_string_name()` etc ([#&#8203;1623](godot-rust/gdext#1623))
- Thread-safe FFI scaffolding + cross-thread printing ([#&#8203;1632](godot-rust/gdext#1632))
- `Gd` introspection APIs: `dynamic_class`, `is_dynamic_class[_of]`, `is_ref_counted` ([#&#8203;1631](godot-rust/gdext#1631))
- Support async versions of `tools::load()` and `tools::try_load()` ([#&#8203;1601](godot-rust/gdext#1601))
- Support `@export_node_path` attribute ([#&#8203;1620](godot-rust/gdext#1620))

##### 🧹 Quality of life

- Slightly more flexible `GodotStringExt` through unsized impls ([#&#8203;1629](godot-rust/gdext#1629))
- Suppress Godot errors in fallible functions ([#&#8203;1637](godot-rust/gdext#1637))

##### πŸ› οΈ Bugfixes

- Fix Linux hot-reload panic when TLS destructors fire before init ([#&#8203;1611](godot-rust/gdext#1611))
- Fix panic-in-drop in future tests; harden against race conditions ([#&#8203;1617](godot-rust/gdext#1617))
- Auto-remove custom callables also when connected in untyped APIs ([#&#8203;1612](godot-rust/gdext#1612))
- Fix async panics at shutdown ([#&#8203;1625](godot-rust/gdext#1625))
- Fix `Object` returns with dynamic type `RefCounted` ([#&#8203;1628](godot-rust/gdext#1628))
- Fix memory leak for `connect_self()` on `RefCounted` ([#&#8203;1635](godot-rust/gdext#1635))
- Fix dangling singleton pointer after deinit ([#&#8203;1638](godot-rust/gdext#1638))
- Fix RPC borrow panic for `call_local` ([#&#8203;1643](godot-rust/gdext#1643))

##### πŸ—οΈ Maintenance

- Add `once` flag to `defer_startup_warn!` and `defer_startup_error!` ([#&#8203;1613](godot-rust/gdext#1613))
- CI: unzip occasionally fails; retry ([#&#8203;1616](godot-rust/gdext#1616))
- Rename self-registration parts: `plugin` -> `shard` ([#&#8203;1618](godot-rust/gdext#1618))
- Replace internal `PassiveGd` with safer `BorrowedGd` ([#&#8203;1633](godot-rust/gdext#1633))

##### πŸ“š Documentation

- Document `register-docs` security; warn when used in export builds ([#&#8203;1615](godot-rust/gdext#1615))
- Document `#[class(tool)]` + `ready()` behavior across editor reload ([#&#8203;1614](godot-rust/gdext#1614))
- Fix borrow panic in `godot::task::spawn` doc example ([#&#8203;1622](godot-rust/gdext#1622))

</details>

<details>
<summary>serde-rs/serde (serde)</summary>

### [`v1.0.229`](https://github.com/serde-rs/serde/releases/tag/v1.0.229)

[Compare Source](serde-rs/serde@v1.0.228...v1.0.229)

- Update to syn 3

</details>

<details>
<summary>serde-rs/json (serde_json)</summary>

### [`v1.0.151`](https://github.com/serde-rs/json/releases/tag/v1.0.151)

[Compare Source](serde-rs/json@v1.0.150...v1.0.151)

- Add RawValue::from\_string\_unchecked ([#&#8203;1331](serde-rs/json#1331), thanks [@&#8203;WonderLawrence](https://github.com/WonderLawrence))

### [`v1.0.150`](https://github.com/serde-rs/json/releases/tag/v1.0.150)

[Compare Source](serde-rs/json@v1.0.149...v1.0.150)

- Reject non-string enum object keys ([#&#8203;1324](serde-rs/json#1324), thanks [@&#8203;puneetdixit200](https://github.com/puneetdixit200))

</details>

---

### Configuration

πŸ“… **Schedule**: (in timezone Asia/Taipei)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

β™» **Rebasing**: Whenever MR becomes conflicted, or you tick the rebase/retry checkbox.

πŸ‘» **Immortal**: This MR will be recreated if closed unmerged. Get [config help](https://github.com/renovatebot/renovate/discussions) if that's undesired.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this MR, check this box

---

This MR has been generated by [Mend Renovate CLI](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0NC4zMy4yIiwidXBkYXRlZEluVmVyIjoiNDQuMzMuMiIsInRhcmdldEJyYW5jaCI6Im1hc3RlciIsImxhYmVscyI6W119-->

See merge request TW0hank0/positive_mahjong!142
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug c: async Everything related to async tasks/signals

Projects

None yet

Development

Successfully merging this pull request may close these issues.

godot::task::spawn tasks are not safely cancelled on application exit

3 participants