Skip to content

Hitting bail_bug! + panic combining threading & yields #14618

Closed

Description

@alexcrichton

This test:

;;! component_model_async = true
;;! component_model_threading = true

;; $A makes an async-lowered call to $J's callback-lifted "f", so $A's fiber is
;; parked waiting for "f" to suspend or return.  "f" then makes a sync-lowered
;; call to $K's sync-typed "g", which spawns a thread and yields to it.  When
;; that thread exits, $K has a sync-typed call in progress, so control must
;; switch back to "g"'s thread rather than to $A.  $A must only be resumed once
;; "f" returns.
(component
  (component $K
    (core module $libc (table (export "t") 1 funcref))
    (core instance $libc (instantiate $libc))
    (core type $ft (func (param i32)))
    (core func $thread.new-indirect (canon thread.new-indirect $ft (core table $libc "t")))
    (core func $thread.yield-then-resume (canon thread.yield-then-resume))
    (core module $m
      (import "" "thread.new-indirect" (func $new (param i32 i32) (result i32)))
      (import "" "thread.yield-then-resume" (func $ytr (param i32) (result i32)))
      (import "" "t" (table 1 funcref))
      (func $start (param i32))
      (elem (i32.const 0) func $start)
      (func (export "g") (result i32)
        (drop (call $ytr (call $new (i32.const 0) (i32.const 0))))
        (i32.const 42))
    )
    (core instance $i (instantiate $m (with "" (instance
      (export "thread.new-indirect" (func $thread.new-indirect))
      (export "thread.yield-then-resume" (func $thread.yield-then-resume))
      (export "t" (table $libc "t"))))))
    (func (export "g") (result u32) (canon lift (core func $i "g")))
  )
  (component $J
    (import "g" (func $g (result u32)))
    (core func $g (canon lower (func $g)))
    (core func $task.return (canon task.return (result u32)))
    (core module $m
      (import "" "g" (func $g (result i32)))
      (import "" "task.return" (func $ret (param i32)))
      (func (export "f") (result i32)
        (call $ret (call $g))
        (i32.const 0 (; EXIT ;)))
      (func (export "cb") (param i32 i32 i32) (result i32) unreachable)
    )
    (core instance $i (instantiate $m (with "" (instance
      (export "g" (func $g))
      (export "task.return" (func $task.return))))))
    (func (export "f") async (result u32)
      (canon lift (core func $i "f") async (callback (core func $i "cb"))))
  )
  (component $A
    (import "f" (func $f async (result u32)))
    (core module $libc (memory (export "memory") 1))
    (core instance $libc (instantiate $libc))
    (core func $f (canon lower (func $f) async (memory (core memory $libc "memory"))))
    (core func $task.return (canon task.return (result u32)))
    (core module $m
      (import "" "f" (func $f (param i32) (result i32)))
      (import "" "task.return" (func $ret (param i32)))
      (import "libc" "memory" (memory 1))
      (func (export "run") (result i32)
        ;; The subtask status should be RETURNED, and the result 42.
        (if (i32.ne (call $f (i32.const 0)) (i32.const 2 (; RETURNED ;)))
          (then unreachable))
        (call $ret (i32.load (i32.const 0)))
        (i32.const 0 (; EXIT ;)))
      (func (export "cb") (param i32 i32 i32) (result i32) unreachable)
    )
    (core instance $i (instantiate $m
      (with "" (instance
        (export "f" (func $f))
        (export "task.return" (func $task.return))))
      (with "libc" (instance $libc))))
    (func (export "run") async (result u32)
      (canon lift (core func $i "run") async (callback (core func $i "cb"))))
  )
  (instance $k (instantiate $K))
  (instance $j (instantiate $J (with "g" (func $k "g"))))
  (instance $a (instantiate $A (with "f" (func $j "f"))))
  (func (export "run") (alias export $a "run"))
)
(assert_return (invoke "run") (u32.const 42))

fails with

$ cargo run wast foo.wast -W component-model-threading
warning: linker stderr: ld: __eh_frame section too large (max 16MB) to encode dwarf unwind offsets in compact unwind table, performance of exception handling might be affected
  |
  = note: `#[warn(linker_messages)]` on by default

warning: `wasmtime-cli` (bin "wasmtime") generated 1 warning
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.15s
     Running `target/debug/wasmtime wast foo.wast -W component-model-threading`

thread 'main' (1310505) panicked at crates/wasmtime/src/runtime/bug.rs:63:13:
BUG: switch item already set
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

thread 'main' (1310505) panicked at crates/wasmtime/src/runtime/fiber.rs:497:9:
attempted to drop in-progress fiber without first calling `StoreFiber::dispose`
stack backtrace:
   0:        0x1051fcb44 - std[b23ccf54642dae0e]::backtrace_rs::backtrace::libunwind::trace
                               at /rustc/2d8144b7880597b6e6d3dfd63a9a9efae3f533d3/library/std/src/../../backtrace/src/backtrac

a similar test

Details
;;! component_model_async_stackful = true
;;! component_model_more_async_builtins = true

;; $B is called asynchronously by $C and, before it ever suspends, cancels
;; one of its own subtasks that is queued behind backpressure in $A, then
;; makes another async call.  Per the spec the cancel returns
;; CANCELLED_BEFORE_STARTED, the next call is queued (STARTING), and
;; everything returns normally.  Wasmtime: `BUG: switch item already set`.
(component
  (component $A
    (core module $m
      (import "" "backpressure.inc" (func $bp-inc))
      (import "" "task.return" (func $task-return))
      (func (export "f") (call $bp-inc) (call $task-return)))
    (core func $bp-inc (canon backpressure.inc))
    (core func $task-return (canon task.return))
    (core instance $i (instantiate $m (with "" (instance
      (export "backpressure.inc" (func $bp-inc))
      (export "task.return" (func $task-return))))))
    (func (export "f") async (canon lift (core func $i "f") async)))

  (component $B
    (import "f" (func $f async))
    (core module $libc (memory (export "mem") 1))
    (core instance $libc (instantiate $libc))
    (core func $f (canon lower (func $f) async (memory (core memory $libc "mem"))))
    (core func $subtask-cancel (canon subtask.cancel async))
    (core func $task-return (canon task.return))
    (core module $m
      (import "" "f" (func $f (result i32)))
      (import "" "subtask.cancel" (func $subtask-cancel (param i32) (result i32)))
      (import "" "task.return" (func $task-return))
      (func (export "run")
        ;; returns eagerly (RETURNED = 2), leaving backpressure enabled in $A
        (if (i32.ne (call $f) (i32.const 2)) (then unreachable))
        ;; queued behind backpressure: subtask 1, STARTING = 0
        (if (i32.ne (call $f) (i32.const 0x10)) (then unreachable))
        ;; CANCELLED_BEFORE_STARTED = 3
        (if (i32.ne (call $subtask-cancel (i32.const 1)) (i32.const 3)) (then unreachable))
        ;; queued again: subtask 2, STARTING
        (if (i32.ne (call $f) (i32.const 0x20)) (then unreachable))
        (call $task-return)))
    (core instance $i (instantiate $m (with "" (instance
      (export "f" (func $f))
      (export "subtask.cancel" (func $subtask-cancel))
      (export "task.return" (func $task-return))))))
    (func (export "run") async (canon lift (core func $i "run") async)))

  (component $C
    (import "run" (func $run async))
    (core module $libc (memory (export "mem") 1))
    (core instance $libc (instantiate $libc))
    (core func $run (canon lower (func $run) async (memory (core memory $libc "mem"))))
    (core func $task-return (canon task.return))
    (core module $m
      (import "" "run" (func $run (result i32)))
      (import "" "task.return" (func $task-return))
      (func (export "run")
        ;; $B returns without blocking
        (if (i32.ne (call $run) (i32.const 2)) (then unreachable))
        (call $task-return)))
    (core instance $i (instantiate $m (with "" (instance
      (export "run" (func $run))
      (export "task.return" (func $task-return))))))
    (func (export "run") async (canon lift (core func $i "run") async)))

  (instance $a (instantiate $A))
  (instance $b (instantiate $B (with "f" (func $a "f"))))
  (instance $c (instantiate $C (with "run" (func $b "run"))))
  (export "run" (func $c "run")))

(assert_return (invoke "run"))

fails with:

$ cargo run wast foo.wast -W component-model-async-stackful,component-model-more-async-builtins
warning: linker stderr: ld: __eh_frame section too large (max 16MB) to encode dwarf unwind offsets in compact unwind table, performance of exception handling might be affected
  |
  = note: `#[warn(linker_messages)]` on by default

warning: `wasmtime-cli` (bin "wasmtime") generated 1 warning
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.14s
     Running `target/debug/wasmtime wast foo.wast -W component-model-async-stackful,component-model-more-async-builtins`

thread 'main' (1312064) panicked at crates/wasmtime/src/runtime/bug.rs:63:13:
BUG: switch item already set
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

If useful, an LLM summary is:

Details

cleanup_thread (crates/wasmtime/src/runtime/component/concurrent.rs)
starts with take_next_switch_item(). That moves the store-wide
next_switch_item (normally "resume the caller") into switch_item
("run this next"). If switch_item is already set, set_switch_item
hits bail_bug!("switch item already set"). The rejected work item holds
a fiber, which is dropped without StoreFiber::dispose.

Three paths reach it:

  1. The host drops an unstarted call_async future. Default
    configuration. Cancelling the call goes through
    cancel_guest_subtask_without_lowered_parameters → cleanup_thread.
    The cancelled thread never ran, so next_switch_item isn't its own:
    it belongs to whatever the event loop was about to do. When a guest
    makes an async call to another guest, the event loop sets switch_item
    (start the callee) and next_switch_item (resume the caller). The event
    loop yields to the executor every 128 work items
    (MAX_TURNS_WITHOUT_YIELD). If that yield lands right there and the
    host then drops the future, the panic follows. Dropping a future is
    routine (timeouts, disconnects, select!), and a guest can keep the
    event loop busy to make the window more frequent.
  2. A guest's subtask.cancel of an unstarted subtask, before the
    canceller has suspended. The same code path. It needs
    component-model-async-stackful and
    component-model-more-async-builtins. In release builds it's an error
    (bail_bug! returns Err), not a panic.
  3. A thread exits while another thread is already in switch_item.
    It needs component-model-threading:
    • A makes an async call to callback-lifted f, so A's resume is in
      next_switch_item.
    • f makes a sync call to sync-typed g, which spawns thread T and
      yields to it.
    • When T exits, g's thread is put into switch_item, then
      cleanup_thread tries to install A's resume as well.

Activity

  1. added
    bugIncorrect behavior in the current implementation that needs fixing
    on Oct 8, 2026
  2. added a commit that references this issue on Oct 9, 2026
  3. dicej commented on Oct 9, 2026

    @dicej
    Contributor

    Fixed by #14624

  4. added a commit that references this issue on Oct 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugIncorrect behavior in the current implementation that needs fixingwasm-proposal:component-model-asyncIssues related to the WebAssembly Component Model async proposalwasm-proposal:component-threadingIssues related to cooperative threading in the component model

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions