How to Fix: wasip3: waitable-set.wait traps if called under ‘async callback’
wasip3: waitable-set.wait traps when called inside an async callback
This failure happens because the runtime is treating callback reentrancy as an invalid place to block, even though the current async callback rules and test expectations indicate that waitable-set.wait should be allowed in this path. The result is a trap at the guard instead of progressing through the expected WASI P3 async control flow.
What the bug looks like
The issue appears when an async callback invokes waitable-set.wait. Instead of waiting on the set, the engine traps due to a defensive guard that assumes waiting is always illegal during callback execution.
That assumption is too broad. In the current model, there is an important distinction between:
- Synchronous guest reentry where blocking would violate runtime invariants
- Approved async callback execution where the guest is expected to resume and interact with waitable objects safely
The linked test for wait-during-callback.wast makes that expectation explicit: calling waitable-set.wait from that callback path should work.
Understanding the Root Cause
At the implementation level, the trap usually comes from a guard similar to: if the runtime is currently inside a callback, disallow wait. That logic is attractive because it prevents deadlocks and illegal nested scheduling, but it conflates two different states:
- The runtime is in a context where blocking is structurally unsafe
- The runtime is in an async-enabled callback frame where waiting is part of the intended execution model
The bug exists because the guard checks only a coarse-grained “currently in callback” state instead of checking whether the callback was entered under an async-capable execution mode.
In other words, the runtime likely has a state machine that looks conceptually like this:
enum ContextState {
Normal,
InCallback,
InSyncReentry,
}
fn waitable_set_wait(...) {
if state == InCallback {
trap("cannot wait during callback");
}
...
}
That is too strict for WASI P3 async semantics. The runtime needs a more precise distinction, such as:
enum ContextState {
Normal,
InCallback { async_allowed: bool },
InSyncReentry,
}
fn waitable_set_wait(...) {
match state {
InSyncReentry => trap("cannot wait during synchronous reentry"),
InCallback { async_allowed: false } => trap("wait not allowed in this callback"),
_ => proceed(),
}
}
The core root cause is therefore overly broad callback guarding. The engine is protecting itself against invalid reentrancy, but it is doing so without honoring the narrower async behavior expected by the spec tests.
Step-by-Step Solution
The fix is to replace the blanket callback prohibition with a state check that explicitly allows waits during async callbacks while still rejecting truly unsafe blocking scenarios.
1. Locate the trap guard
Find the implementation of waitable-set.wait and identify the condition that traps when called from a callback. It often looks like a boolean such as in_callback, is_reentrant, or a scheduler/frame-state check.
fn waitable_set_wait(store: &mut Store, set: WaitableSet) -> Result<Event, Trap> {
if store.runtime_state.in_callback {
return Err(Trap::new("wait called during callback"));
}
perform_wait(store, set)
}
2. Split callback state from blocking-safety state
Introduce a more expressive runtime state. The runtime must know whether the current callback is:
- A synchronous host callback where blocking is illegal
- An async callback resume point where waiting is permitted
enum ExecutionContext {
Guest,
HostCallbackSync,
HostCallbackAsync,
SyncReentry,
}
struct RuntimeState {
context: ExecutionContext,
}
3. Update callback entry points
When the runtime enters a callback, mark the correct context. This is the most important part of the fix. If async callbacks are entered through a dedicated path, label them explicitly.
fn enter_sync_callback(state: &mut RuntimeState) {
state.context = ExecutionContext::HostCallbackSync;
}
fn enter_async_callback(state: &mut RuntimeState) {
state.context = ExecutionContext::HostCallbackAsync;
}
fn leave_callback(state: &mut RuntimeState) {
state.context = ExecutionContext::Guest;
}
4. Narrow the wait guard
Reject only the states that are genuinely unsafe. Do not reject all callback contexts.
fn waitable_set_wait(store: &mut Store, set: WaitableSet) -> Result<Event, Trap> {
match store.runtime_state.context {
ExecutionContext::HostCallbackSync | ExecutionContext::SyncReentry => {
Err(Trap::new("waitable-set.wait is not allowed during synchronous reentry"))
}
ExecutionContext::HostCallbackAsync | ExecutionContext::Guest => {
perform_wait(store, set)
}
}
}
5. Preserve scheduler invariants
Allowing wait in async callbacks does not mean ignoring scheduler correctness. Before calling into the wait implementation, verify that:
- The callback frame is registered as suspendable
- The current task/fiber/future can yield safely
- Wakeup delivery cannot violate borrow or store exclusivity rules
fn perform_wait(store: &mut Store, set: WaitableSet) -> Result<Event, Trap> {
if !store.scheduler.current_frame_is_suspendable() {
return Err(Trap::new("current frame cannot suspend"));
}
store.scheduler.park_on(set)
}
6. Add or update regression tests
You need a regression test that proves the exact bug is fixed and a companion test that proves unsafe contexts still trap.
;; should succeed: wait during async callback
(test "wait-during-async-callback"
(invoke "run_async_callback_then_wait")
(assert_return ...))
;; should still trap: wait during sync callback or forbidden reentry
(test "wait-during-sync-callback-traps"
(invoke "run_sync_callback_then_wait")
(assert_trap ...))
7. Validate against the spec-aligned test
Run the existing component-model async callback wait test after the state transition changes. If the implementation is correct, the trap disappears for the async path while remaining intact for forbidden synchronous cases.
8. Review adjacent guards
If the runtime has similar checks for poll, yield, subtask.join, or event delivery APIs, audit them too. The same overly broad state flag may be causing additional spec mismatches.
fn can_block(state: &RuntimeState) -> bool {
matches!(
state.context,
ExecutionContext::Guest | ExecutionContext::HostCallbackAsync
)
}
This shared helper is often a cleaner long-term design than scattering callback-specific trap logic across multiple APIs.
Common Edge Cases
Nested callbacks
If an async callback triggers another callback, the runtime should not collapse all nested frames into one boolean flag. Use a context stack or frame-based metadata so inner calls inherit correct suspendability rules.
Borrowed store or instance state
If the engine keeps mutable borrows alive across the callback boundary, allowing wait may expose aliasing or resume-time corruption. Ensure borrowed resources are released or pinned safely before suspension.
Wake-before-park races
Some implementations can lose notifications if the waitable is signaled just before the callback issues wait. Make sure the scheduler performs an atomic register-and-check or equivalent race-safe pattern.
Incorrect callback classification
If the callback entry path is mislabeled as synchronous when it is actually async-capable, the trap will persist even after updating the wait guard. Verify the state assignment at every host-to-guest transition.
Deadlock prevention logic that is too global
Some runtimes use a single no blocking while reentered rule to avoid deadlocks. That rule may need to become more nuanced so that only synchronous reentry is blocked.
Test passes but scheduler semantics are still wrong
Removing the trap is not enough. If the scheduler cannot safely suspend from that callback frame, the bug will resurface as hangs, dropped wakeups, or corrupted task state instead of an immediate trap.
FAQ
Why is waiting during an async callback valid here?
Because the expected WASI P3 async behavior distinguishes async callback execution from unsafe synchronous reentrancy. The relevant test demonstrates that this path is intended to be supported, not trapped unconditionally.
Should I remove all callback-related wait guards?
No. You should remove only the over-broad prohibition. Waiting should still trap in contexts where the runtime cannot safely suspend, such as synchronous host reentry or non-suspendable frames.
What is the safest implementation strategy?
The safest approach is to model execution context explicitly, mark callback entry points accurately, and centralize the decision in a helper like can_block(). That avoids future bugs where one API treats async callbacks correctly but another still uses a stale boolean guard.
Recommended implementation checklist
- Replace boolean
in_callbackchecks with an explicit execution context - Mark async callback entry separately from synchronous callback entry
- Allow
waitable-set.waitin async-capable contexts - Keep traps for synchronous reentry and non-suspendable frames
- Add regression tests for both allowed and forbidden cases
- Audit related blocking APIs for the same bug pattern
In short, the issue is not that waitable-set.wait lacks protection. The issue is that the current protection is keyed to being in a callback instead of whether this callback context is allowed to suspend. Fix that distinction, and the async callback path will match the intended behavior without weakening runtime safety.