Skip to content

Fix use-after-free in single-threaded TaskPool::scope during unwinding - #25248

Open
kiana1kaslana wants to merge 6 commits into
bevyengine:mainfrom
kiana1kaslana:fix-scope-unwind-soundness
Open

Fix use-after-free in single-threaded TaskPool::scope during unwinding#25248
kiana1kaslana wants to merge 6 commits into
bevyengine:mainfrom
kiana1kaslana:fix-scope-unwind-soundness

Conversation

@kiana1kaslana

Copy link
Copy Markdown

Objective

Fix soundness issue C-12: the single-threaded TaskPool::scope can trigger use-after-free when the scope callback panics.

Solution

The single-threaded Scope used .detach() on spawned tasks and had no
Drop implementation. When a panic occurred in the scope callback, the
completion loop (block_on(executor.run(...))) in scope_with_executor
was skipped. The executor is declared first (dropped last due to
reverse declaration order), so its Drop would later drop queued
futures after their borrowed state (results, pending_tasks) had
already been freed — producing use-after-free.

This fix mirrors the multi-threaded Scope's approach
(crates/bevy_tasks/src/task_pool.rs):

  • Store Task handles in a RefCell<Vec<Task<()>>> instead of calling
    .detach()
  • Implement Drop for Scope that drives the executor while cancelling
    all spawned tasks via task.cancel().await, ensuring future destructors
    run while the borrowed state is still alive

Testing

  • cargo +nightly check -p bevy_tasks ... — passes
  • cargo +nightly test -p bevy_tasks ... — 5 unit + 5 doc tests pass
  • cargo +nightly miri test -p bevy_tasks ... — passes with zero UB
  • Added regression test scope_panic_cancels_pending_futures ...

The single-threaded `Scope` used `.detach()` on spawned tasks and had
no `Drop` implementation. When a panic occurred in the scope callback,
the completion loop in `scope_with_executor` was skipped, causing the
executor (dropped last due to reverse declaration order) to drop queued
futures after their borrowed state (`results`, `pending_tasks`) had
already been freed.

This mirrors the multi-threaded `Scope`'s approach:
- Store `Task` handles in a `RefCell<Vec<Task<()>>>` instead of detaching
- Implement `Drop` to cancel all pending tasks while the executor is
  still running
- Add a regression test that passes under Miri
@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Welcome, new contributor!

Please make sure you've read our contributing guide, as well as our policy regarding AI usage, and we look forward to reviewing your pull request shortly ✨

@hymm hymm 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.

This fix makes sense to me. Web tasks weren't cancellable until we started using the web-task crate recently. Switching to that crate might have surfaced this problem.

The comments are a bit too verbose and seem to be addressed to reviewers rather than future readers of the code. Made some suggestions for cutting some text. Could probably cut more, but that'd be just nit picking.

I did ask a question about the test that needs answering before I approve.

Comment thread crates/bevy_tasks/src/single_threaded_task_pool.rs
Comment thread crates/bevy_tasks/src/single_threaded_task_pool.rs
Comment thread crates/bevy_tasks/src/single_threaded_task_pool.rs Outdated
///
/// This test should pass under Miri without reporting any UB.
#[test]
fn scope_panic_cancels_pending_futures() {

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.

did you confirm that this test fails miri before this fix? The task doesn't access any scoped data, so I would expect this to pass.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

You're right — I ran cargo +nightly miri test scope_panic_cancels_pending_futures on the parent commit and it passes. The pending future doesn't hold any scoped references, so the executor dropping it after unwind doesn't trigger Miri. I've simplified the test to check panic propagation without claiming Miri detection.

The soundness fix itself (storing Task handles in a RefCell + Drop cancelling them before executor teardown) should be clear from the diff.

kiana1kaslana and others added 5 commits August 3, 2026 20:25
Co-authored-by: Mike <mike.hsu@gmail.com>
Co-authored-by: Mike <mike.hsu@gmail.com>
Co-authored-by: Mike <mike.hsu@gmail.com>
The test uses pending() which holds no scoped references, so Miri
passes even on the parent commit. Rewrite the doc comment to describe
what the test actually checks (panic propagation without UB), and
remove the claim about detecting use-after-free via Miri.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants