Skip to content

component: protect fiber handoffs without adding thread states - #14620

Open
Byte-Naut wants to merge 4 commits into
bytecodealliance:mainfrom
Byte-Naut:issue-14241-3
Open

Byte-Naut wants to merge 4 commits into
bytecodealliance:mainfrom
Byte-Naut:issue-14241-3

Conversation

@Byte-Naut

@Byte-Naut Byte-Naut commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #14241.

This supersedes #14418. Based on @alexcrichton's feedback there, this takes the smaller ownership-repair path: GuestThreadState, WaitMode, and WorkItem keep their existing shapes — WaitMode::Fiber and WorkItem::ResumeFiber still carry the fiber directly, and waiting or scheduled threads continue to appear Running to the thread intrinsics — and the fix concentrates on guarding the intervals where a fiber is outside the store's scheduler state.

Motivation

An unfinished StoreFiber that is dropped directly triggers an assertion in StoreFiber::drop. Several paths in the scheduler moved a live fiber out of one store-owned location and into another with a fallible step in between. When that step fails — a table lookup error, bail_bug!, or a panic — the fiber is dropped mid-transfer, turning an ordinary error into a host panic or process abort.

Changes

This rebases on top of #14624, #14635, #14638, and #14610, which landed between the first push and now.

Commit 1 — protect fiber handoffs without adding thread states

resume_fiber is converted from async fn to a plain function returning impl Future. A ResumingFiber guard is constructed before returning the future, so a caller that drops the future before its first poll still disposes of the fiber through the store. The fiber enters the guard after being taken from a WaitMode::Fiber entry, a WorkItem::ResumeFiber item, or a GuestThreadState::Ready slot — the same locations as before — and the guard holds it until it reaches its next store-owned location.

For every SuspendReason arm that hands the fiber to a destination, the destination slot is checked for an existing fiber before the fiber is moved out of the guard. If the check fails, the fiber stays in the guard and is disposed on return. GuestThreadState::check_no_fiber centralises that check for Suspended and Ready. set_thread_running and mark_ready are restructured so all fallible lookups complete before any ownership transfer begins. The worker-fiber take is reordered so worker_item is asserted empty and the take happens in one non-fallible step.

Commit 2 — guard pending work and preserve queues on promotion panic

handle_work_item is converted from async fn to a plain function returning impl Future<Output = Result<()>>. The ResumeFiber arm is handled in the non-async prefix so the guard is established before the future is returned, matching the treatment in resume_fiber. promote is restructured to scan the queues in place rather than draining and rebuilding them; a predicate panic no longer drops the remaining items.

Commit 3 — clarify the oldest-first promotion scan

One-line comment clarification; no behaviour change.

Design notes

The fix does not add scheduler states, new fields to ConcurrentState, or per-suspension allocations. The existing StoreFiber::drop assertion remains the final defence. Two properties worth checking in review: every SuspendReason arm checks its destination before taking the fiber out of the guard (check_no_fiber for thread slots, the entry API for wait sets, and next_switch_item for subtask yields); and the ResumingFiber guard together with the FiberFuture inside fiber::resolve_or_release cover the full resume interval including pre-first-poll cancellation.

Testing

cargo test -p wasmtime --lib --features component-model-async --offline
cargo test -p wasmtime-cli --test wast --no-default-features --features component-model-async,cranelift,winch,wat,wast --offline -- component-model/async
cargo test -p wasmtime-cli --test all --features component-model-async --offline -- component_model
cargo test -p wasmtime --lib --features component-model-async --offline --config profile.test.package.wasmtime.debug-assertions=false --config profile.dev.package.wasmtime.debug-assertions=false -- fiber_ownership_tests
cargo clippy -p wasmtime --all-targets --features component-model-async,task-group-hook --offline -- -Dwarnings
cargo check -p wasmtime --no-default-features --features runtime,component-model-async --offline

Results: 221 unit tests (including 27 fiber ownership tests), 128 async WAST trials on Cranelift and Winch, 258 component model integration tests, fmt and clippy clean, non-async and no_std builds pass. The fiber ownership tests also pass with debug assertions disabled.

Five paths are covered by fault-injection tests in concurrent/fiber_ownership_tests.rs, verified against unpatched builds that exit SIGABRT:

  • unpolled_resume_disposes_fiber: resume_fiber future dropped before first poll
  • yielding_invalid_thread_returns_table_error: SuspendReason::Yielding with an invalid destination thread
  • full_table_during_switch_save_keeps_fiber: resource table full when saving next_switch_item
  • unpolled_work_item_disposes_fiber: handle_work_item ResumeFiber future dropped before first poll
  • promotion_panic_keeps_queued_fibers: predicate panic during promote_work_item_matching

thread-wait-resume.wast (carried forward from #14418) checks that a thread blocked in waitable-set.wait still produces cannot resume thread which is not suspended for thread.resume-later and thread.suspend-then-resume.

Reviewing

Three commits, each self-contained:

  1. Protect fiber handoffs without adding thread states. The core change: ResumingFiber, check_no_fiber, and the restructured handoff paths in resume_fiber, mark_ready, and set_thread_running. Start here.
  2. Guard pending work and preserve queues on promotion panic. handle_work_item pre-first-poll guard and the in-place promotion scan. "Hide whitespace" helps on the handle_work_item diff.
  3. Clarify the oldest-first promotion scan. One comment line; safe to skim.

The representation is unchanged from main: GuestThreadState still has Suspended and Ready carrying the fiber directly, WaitMode::Fiber and WorkItem::ResumeFiber still carry the fiber. Developed with assistance from Claude (Anthropic). Per the Bytecode Alliance AI Tool Use Policy, I'm the author and accountable for this change.

@Byte-Naut
Byte-Naut requested a review from a team as a code owner October 9, 2026 03:00
@Byte-Naut
Byte-Naut requested review from dicej and removed request for a team October 9, 2026 03:00
@github-actions github-actions Bot added the wasmtime:api Related to the API of the `wasmtime` crate itself label Oct 9, 2026

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

Thanks, @Byte-Naut; looks good overall; just a few inline suggestions.

async fn run_on_worker(self, item: WorkerItem) -> Result<()> {
let worker = if let Some(fiber) = self.0.concurrent_state_mut()?.worker.take() {
let state = self.0.concurrent_state_mut()?;
assert!(state.worker_item.is_none());

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.

While we're here, might as well turn this into a bail_bug! instead of panicking.

Entry::Vacant(entry) => {
entry.insert(WaitMode::Fiber(resuming.fiber.take().unwrap()));
}
Entry::Occupied(_) => panic!("assertion failed: old.is_none()"),

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.

Let's bail_bug! instead of panic!ing here. Also, please update the error message to be something like "entry unexpectedly already exists for {thread}".

}
}

if matches!(thread.state, GuestThreadState::Ready { .. }) {

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.

Nit: looks like these two if statements could be combined into a single match statement.

let wake_on_cancel = state.get_mut(thread.thread)?.wake_on_cancel.take();
if let Some((&thread, _)) = set_state.waiting.first_key_value() {
let wake_on_cancel = state.get_mut(thread.thread)?.wake_on_cancel;
assert!(wake_on_cancel.is_none() || wake_on_cancel == WakeOnCancel::Waiting(set));

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.

Let's make this a bail_bug! while we're here.

An unfinished StoreFiber dropped mid-transfer triggers a StoreFiber::drop
assertion and may double-panic on unwind. Several scheduler paths moved a
live fiber out of one store-owned location with a fallible step before it
reached the next, so a lookup error or bail_bug! could turn a recoverable
error into a host panic or process abort.

This commit fixes those windows without changing the scheduler
representation. GuestThreadState, WaitMode::Fiber, and
WorkItem::ResumeFiber keep their existing shapes; waiting threads and
scheduled fibers continue to appear Running to the thread intrinsics.

| Location | Before | After |
|---|---|---|
| resume_fiber | async fn; fiber disposed only in Err branch | plain fn returning impl Future; ResumingFiber guard built before returning |
| SuspendReason arms | write destination slot then move fiber | check_no_fiber on slot; move fiber only after check passes |
| set_thread_running | overwrites state unconditionally | returns bail_bug! for unexpected Suspended/Ready |
| wake_waiter (from mark_ready) | moves fiber out of wait set before checks | all fallible lookups complete before any transfer |
| worker take | worker.take() then fallible state access | assert worker_item empty; take in one non-fallible step |

GuestThreadState::check_no_fiber centralises the pre-handoff check for
Suspended and Ready. No public API changes, no new scheduler states, no
new ConcurrentState fields, no per-suspension allocations.
Two related gaps in work-item handling left fibers temporarily unguarded
or at risk of being lost on a predicate panic.

| Location | Before | After |
|---|---|---|
| handle_work_item | async fn; ResumeFiber arm entered async block before guard was built | plain fn returning impl Future; ResumeFiber guard built before returning, matching resume_fiber |
| Other work-item variants | fibers stay in store until first poll | unchanged |
| promote | drained queues into a local iterator; predicate panic dropped remaining items | scans high_priority and low_priority in place; predicate panic leaves unexamined items in the queues |

No behaviour change on the happy path.
Add a comment noting that scanning from the back of the deque considers
the oldest items first, matching the previous drain-and-rebuild ordering.
No behaviour change.
- Replace assert! with bail_bug! in run_on_worker, wake_waiter, and the
  SuspendReason::Waiting occupied-entry branch.
- Update the occupied-entry error message to name the thread:
  "entry unexpectedly already exists for {thread:?}".
- Combine the two if-statements in resume_thread into one match.
- Update the duplicate_waiter_keeps_old_fiber and
  waiter_assertion_keeps_fiber_in_set tests to use the bug() helper so
  they work with both debug and release builds.

Follows review suggestions from @dicej on bytecodealliance#14620.
@Byte-Naut

Copy link
Copy Markdown
Contributor Author

@dicej Thanks for the review! I've addressed all four suggestions: replaced the assert!s in run_on_worker and wake_waiter with bail_bug!, updated the occupied-entry message to name the thread, and combined the two ifs into one match. The branch also rebases on the changes that landed since the first push.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

wasmtime:api Related to the API of the `wasmtime` crate itself

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Ensure fibers are always disposed of gracefully in wasmtime::runtime::component::concurrent

2 participants