Skip to content

Task-group-hook cleanup is too eager, leading to a host panic #14504

Closed

Description

@alexcrichton

this input:

use wasmtime::component::{Component, Linker, TaskGroupHook, TaskGroupId};
use wasmtime::{Config, Engine, Result, Store};

struct Hook;

impl TaskGroupHook for Hook {
    fn handle_start(&mut self, id: TaskGroupId) -> Result<()> {
        println!("start  {id:?}");
        Ok(())
    }
    fn handle_enter(&mut self, id: TaskGroupId) -> Result<()> {
        println!("enter  {id:?}");
        Ok(())
    }
    fn handle_exit(&mut self, id: TaskGroupId) -> Result<()> {
        println!("exit   {id:?}");
        Ok(())
    }
    fn handle_finish(&mut self, id: TaskGroupId) -> Result<()> {
        println!("finish {id:?}");
        Ok(())
    }
}

const WAT: &str = r#"
(component
  (core module $m
    (import "" "task.return" (func $task.return))
    (import "" "backpressure.inc" (func $backpressure.inc))
    ;; `bg`: turn on backpressure, return a result, then keep running in the
    ;; background by yielding; the next callback invocation traps.
    (func (export "bg") (result i32)
      (call $backpressure.inc)
      (call $task.return)
      (i32.const 1 (; YIELD ;)))
    (func (export "bg-cb") (param i32 i32 i32) (result i32)
      unreachable)
    ;; `foo`: never gets to run because of the backpressure.
    (func (export "foo") (result i32)
      (call $task.return)
      (i32.const 0 (; EXIT ;)))
    (func (export "foo-cb") (param i32 i32 i32) (result i32)
      unreachable)
  )
  (core func $task.return (canon task.return))
  (core func $backpressure.inc (canon backpressure.inc))
  (core instance $i (instantiate $m (with "" (instance
    (export "task.return" (func $task.return))
    (export "backpressure.inc" (func $backpressure.inc))))))
  (func (export "bg") async
    (canon lift (core func $i "bg") async (callback (core func $i "bg-cb"))))
  (func (export "foo") async
    (canon lift (core func $i "foo") async (callback (core func $i "foo-cb"))))
)
"#;

#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<()> {
    let mut config = Config::new();
    config.wasm_component_model_async(true);
    let engine = Engine::new(&config)?;
    let component = Component::new(&engine, WAT)?;
    let mut store = Store::new(&engine, ());
    if std::env::var_os("NO_HOOK").is_none() {
        store.task_group_hook(Hook);
    }
    let linker = Linker::new(&engine);
    let instance = linker.instantiate_async(&mut store, &component).await?;
    let bg = instance.get_typed_func::<(), ()>(&mut store, "bg")?;
    let foo = instance.get_typed_func::<(), ()>(&mut store, "foo")?;

    println!("calling bg");
    bg.call_async(&mut store, ()).await?;
    println!("bg returned; calling foo");
    let r = foo.call_async(&mut store, ()).await;
    println!("foo result: {r:?}");
    Ok(())
}

yields:

$ cargo run
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.04s
     Running `target/debug/task-group-drop-guard-panic`
start  TaskGroupId(TaskGroup(0))
enter  TaskGroupId(TaskGroup(0))
exit   TaskGroupId(TaskGroup(0))
finish TaskGroupId(TaskGroup(0))
calling bg
start  TaskGroupId(TaskGroup(0))
enter  TaskGroupId(TaskGroup(0))
exit   TaskGroupId(TaskGroup(0))
bg returned; calling foo
start  TaskGroupId(TaskGroup(4))
enter  TaskGroupId(TaskGroup(0))
exit   TaskGroupId(TaskGroup(0))
finish TaskGroupId(TaskGroup(0))
finish TaskGroupId(TaskGroup(4))
exit   TaskGroupId(TaskGroup(0))

thread 'main' (266787) panicked at /home/alex/code/wasmtime2/crates/wasmtime/src/runtime/component/concurrent/func.rs:269:61:
called `Result::unwrap()` on an `Err` value: resource not present
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
exit   TaskGroupId(TaskGroup(0))

cc @dicej

and LLM report, if helpful, is

Details

task-group-hook: trap deletes TaskGroups still referenced -> host panic

Severity: medium-low (guest-triggered host panic). Requires the off-by-default
task-group-hook cargo feature and a hook installed via
Store::task_group_hook.

Root cause

On guest trap, set_trapped -> clean_up_task_groups
(concurrent/task_group_hook.rs:108-145) deletes every TaskGroup table
entry and calls handle_finish, while GuestTasks/HostTasks holding
those TaskGroupIds remain. Any later Waitable::delete_from
(concurrent.rs:5581) calls decrement_group_ref_count
(task_group_hook.rs:198), whose get_mut(group)? fails with
"resource not present". The call_async drop guard
SignalOnDrop::drop -> host_future_dropped(..).unwrap()
(concurrent/func.rs:269) then panics for a call parked in pending by
backpressure.

Also: unforced_current_thread still points into a finished group, so the
hook gets handle_exit after handle_finish, and ids can be reused after
finish.

Distinct from prior audit #17 (same unwrap, but there the task is
missing from pending; here the group was deleted).

Repro

Component with two callback-lifted async exports: bg does
backpressure.inc, task.return, then YIELD with an unreachable
callback; foo never starts. Host: bg.call_async, then foo.call_async.

cd repro
CARGO_TARGET_DIR=<repo>/target/audit-repro CARGO_INCREMENTAL=0 cargo run --release

Observed:

finish TaskGroupId(TaskGroup(0))
finish TaskGroupId(TaskGroup(4))
exit   TaskGroupId(TaskGroup(0))
thread 'main' panicked at crates/wasmtime/src/runtime/component/concurrent/func.rs:269:61:
called `Result::unwrap()` on an `Err` value: resource not present

Control: NO_HOOK=1 -> no panic; foo returns Err(unreachable trap).

Suggested fix

Don't delete TaskGroup entries on trap: mark them finished, call
handle_finish once, and make decrement_group_ref_count /
handle_thread_switch tolerate finished groups. Never unwrap() in
SignalOnDrop::drop.

Unconfirmed follow-on

Component instantiation doesn't check may_enter and creates a new group
via enter_guest_sync_call; on a poisoned store this can reuse a freed
TaskGroup slot, so a stale task's decrement hits the wrong group (the
#14247 problem the feature was meant to solve).

Activity

  1. self-assigned this
    on Oct 2, 2026
  2. dicej commented on Oct 2, 2026

    @dicej
    Contributor

    This could be a variation of #14459 in that we're not tracking guest task lifetimes properly. I'm currently working on that issue, and I'll try running this test when I've resolved it.

  3. dicej commented on Oct 5, 2026

    @dicej
    Contributor

    Nevermind, this wasn't related to #14459, but should be easy to fix anyway.

  4. added
    bugIncorrect behavior in the current implementation that needs fixing
    on Oct 9, 2026
  5. added 2 commits that reference this issue on Oct 9, 2026
    90d8ab8
    4d303c7
  6. added a commit that references this issue on Oct 10, 2026
    28313ae
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 proposal

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions