Repository navigation
spawn_local panics when called from a sync export (regression in 0.63.0) #1754
Description
As of 0.63.0, calling wit_bindgen::spawn_local from a sync export traps with wasm unreachable instruction executed. The panic comes from assert!(!task.is_null()) added in #1721. On 0.62.0 the same code worked: the spawned future went onto the SPAWNED queue and the next async export's executor ran it.
We have a component that exports wasi:http/types@0.3.0 and wraps the host's imported wasi:http/types, logging each call. Several functions in that interface are sync in WIT but take or return futures carrying resources. For example, request.new takes trailers: future<result<option<trailers>, error-code>>. Our exported trailers resource wraps the imported one, so we need a task that waits on the incoming future, converts the resource, and writes the result to a new future:
fn new(
headers: Headers,
contents: Option<StreamReader<u8>>,
trailers: FutureReader<Result<Option<Trailers>, ErrorCode>>,
options: Option<RequestOptions>,
) -> (Request, FutureReader<Result<(), ErrorCode>>) {
let headers = headers.into_inner::<TraceFields>().fields;
let (trailers_tx, trailers_rx) = wit_future::new(|| Ok(None));
wit_bindgen::spawn_local(async move { // <- panics in 0.63.0
let trailers = match trailers.await {
Ok(Some(t)) => Ok(Some(t.into_inner::<TraceFields>().fields)),
Ok(None) => Ok(None),
Err(e) => Err(e),
};
let _ = trailers_tx.write(trailers).await;
});
let (request, read) = types::Request::new(headers, contents, trailers_rx, options);
(Request::new(Self::new(request)), read)
}request.consume-body, response.new and response.consume-body follow the same pattern. The crate is built with the async-spawn feature and uses a single wit-bindgen version.
Observed (0.63.0, wasmtime 49.0.3)
Error: error while executing at wasm backtrace:
...
5: core::panicking::panic_fmt
6: core::panicking::panic
7: trace.wasm!wasi:http/types@0.3.0#[static]request.new
Caused by:
wasm trap: wasm `unreachable` instruction executedExpected
A supported way to start background work from a sync export. Minimally, display a clear error that says this isn't supported, such as "spawn_local called outside of an async export"? Right now the user sees only a bare unreachable trap.
I know our use of the 0.62 behavior was fragile. The spawned task only ran if an async export happened to run later in the same instance. A component that exports only wasi:http/types never ran it, so its futures never resolved.
Questions
- Is there a supported way to adapt futures or streams in a sync export? This is unavoidable for any component that exports
wasi:http/typesand wraps resources. - If spawning from a sync export can't be supported, could
spawn_localfall back to the oldSPAWNEDqueue when no task is set? - Is there a pattern you'd recommend instead, for example an option to export sync-typed WIT functions with the async ABI?
Full reproduction: componentized/http#36 (the wit-bindgen 0.62 → 0.63 bump), failing tests in components/trace*.
Activity
alexcrichton commentedon Oct 19, 2026
This is similar in nature to #1456 and to a related but not yet filed issue where you can't start an async function from a sync function (I think it'd all hit the same panic).
The general problem here is that there's contextual information needed from an async export to perform some operations like spawning or polling or somesuch. Spawning in particular requires something somewhere to be looking at the set of things to spawn and drive forward, and the relevant PR here refined that to "whatever the current async export is" as opposed to "whatever next looks at this
static mut".The problem here is definitely the worst for spawning, though. I'm not really sure of a great way to fix it unfortunately :(
alexcrichton commentedon Oct 19, 2026
(sorry, wrong button)
scothis commentedon Oct 19, 2026
Thanks @alexcrichton.
I'm running into sync-calling-async issues in a few different spots. Blocking the sync return while waiting for the async operation to complete would be fine in these cases, and is exactly how p2 behaved.
My understanding of the component model async design is that it was "colorless". Is my understanding of the design goal off, or is this a gap in the current ecosystem that can be resolved? I don't expect an easy fix, just trying to understand how fundamental and locked in the problem is.
alexcrichton commentedon Oct 20, 2026
The easiest fix would be to have the export itself be an
asyncWIT function. If you're implementing thewasi:httpinterfaces, however, that's not possible to change. Otherwise fixing this is going to require maybe an opt-in on the generator itself, but I'm not really sure. The current implementation ofspawn_localis that the Rust-level futures execute within the original component-model task they were spawned within, but this simply isn't possible with sync WIT functions. That's what makes this so difficult to implement, there's just not a model it fits within.As for colorless, well that was part of the original intention but that's been adjusted over time and then there's also the fact that the specification as-is still has to be reflected into guest languages, and
wit-bindgen's doing the best it can do. The bug here is arguably inwit-bindgenand providingspawn_localat all, as opposed to being in the spec.scothis commentedon Oct 20, 2026
Given one of the original goals for the wasi interfaces was for them to be virtualizable. If this can't be resolved generically in the component model, perhaps the issue is in the wasi spec(s). Since the interface cannot know the details of the implementing component, the natural end is that just about every method becomes async.
That's obviously a bigger conversation than wit-bindgen.
scothis commentedon Oct 20, 2026
Started a Zulip thread
https://bytecodealliance.zulipchat.com/#narrow/channel/219900-wasi/topic/Trouble.20virtualizing.20wasi.20interfaces/with/633406161