async-typed exports stay pending on idle instead of trapping as deadlock (#292) - #310
Merged
Merged
Conversation
An async-typed export whose task parks on a guest-internal waitable with nothing host-side outstanding (the long-poll shape: `next` woken by a later `push`) trapped at the export driver's idle verdict. definitions.py `canon_lift` runs its trapping driving loop only `if not ft.async_` (line 2189); for async-typed exports driving belongs to the embedder's `Store.tick`, which never traps. wasmtime splits the same way: `run_concurrent` leaves a `call_concurrent` future pending on idle, and trap-on-idle backs only the blocking `call_async`. Polyengine's Promise-shaped export is the `call_concurrent` side. The export driver now exits on idle for `ft.async` (an `IdlePolicy` on the drive loops, default "trap" for every other caller); the Promise is settled from a one-shot `Task.onFinished` fired when the task's last thread unregisters, and rejected with the poisoning cause via a new poison-seam listener if the instance dies first (the #66 treatment, for lifts). Sync-typed exports keep the spec's trap in every mode. The conformance harness's `invoke` is a blocking call, so it opts back in through an exec-level `InstantiateInput.trapOnIdle` (wasmtime's `run_concurrent_trap_on_idle`), deliberately absent from the embedder options. Contract note in embedder-api.md §"Functions and async". Fixes #292.
lannbot
enabled auto-merge
September 7, 2026 14:43
This was referenced Sep 7, 2026
lannbot
pushed a commit
that referenced
this pull request
Sep 8, 2026
…rain `backgroundCompletion` (#310) resolved the host's Promise from `Task.onFinished` — the moment the task's last thread unregistered. A callback-ABI export that keeps spawned futures alive for the instance's life (wit-bindgen `spawn_local`: an event loop, a driver, an accept loop) never reaches that moment, so a lift that went idle before `task.return` and was later woken by another driver had its results captured and its Promise left pending forever. Silent hang; found while tracing #312. The reference's answer event is `on_resolve`, called from `Task.return_`; `lift_and_run` keeps ticking afterwards for OTHER work, not to produce the result. wasmtime's `call_concurrent` is the same shape. So: the lift's own `onResolve` closure now fires a one-shot hook that settles the Promise, and an already-resolved task settles at once (generalizing the old `threads.length === 0` case). No deferral to "no wasm call in flight": `driveDone`'s liveness clauses keep a DRIVER driving, and on this path there is no lift driver left to stop. `Task.onFinished` and its `unregisterThread` firing block are deleted; nothing else used them. Test: runtime/tests/lift_background_return_test.ts — an export goes idle with an immortal thread and a parked returner, a later export call drives the store, the returner calls `task.return`, the first Promise resolves while the immortal thread is still parked. Red on main, green with the fix. Closes #313.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #292.
An async-typed export whose task parks on a guest-internal waitable with nothing host-side outstanding — the long-poll shape,
next: async func() -> u32woken by a laterpush— rejected immediately withdeadlock detected ... no thread is ready and no host call is outstanding. Reproduced without wit-bindgen in both plain and jspi mode (fixtureruntime/tests/embedder/long-poll.wat).Authorities. definitions.py
canon_liftruns the trapping driving loop onlyif not ft.async_(line 2189); for an async-typed export it returns after the firstthread.resume()and driving is the embedder'sStore.tick, which never traps. wasmtime:run_concurrent=poll_until(trap_on_idle=false)(acall_concurrentfuture stays pending on idle); trap-on-idle is thepub(super)variant behind the blocking[Typed]Func::call_async. Polyengine's Promise-shaped export is thecall_concurrentside — architecture.md already argues the JS event loop is always dwelling inrun_concurrent.Change.
drive/driveAsynctake anIdlePolicy("trap"default, unchanged for sync lifts, dtor entry, pumps). Forft.asyncthe export driver exits on idle; the Promise is settled from a one-shotTask.onFinished(fires when the task's last thread unregisters, after the existing "finished without resolving" trap) and rejected with the poisoning cause via a new poison-seam listener if the instance dies first (host streams: a guest trap strands parked host read/write promises #66's treatment, for lifts).invokedirective is a blocking call, so the conformance harness opts back in via exec-levelInstantiateInput.trapOnIdle(wasmtime'srun_concurrent_trap_on_idle). Deliberately not inEmbedderOptions: a JS embedder is never blocked, socall_asynchas no analogue there.Tests.
long_poll_test.ts: pending-then-resolved, repeated cycle, poison rejects the pending Promise,trapOnIdlerestores the trap — plain and jspi. Case (a) fails pre-fix with the deadlock trap.Gates (run by the implementation track):
just test-runtime737 passed;just conformance1285 passed / 0 failed / 126 xfail, no stale xfails (async/deadlock,reentrance:429,switch-to-ready-callback:359,367still trap via the harness flag);just test-ct-runner24 passed;POLYENGINE_SCHED_SEED=1runtime suite 734 passed;deno.lockclean.Not a published-surface break: no embedder option changes; the behavior change removes a trap the contract never promised for async-typed exports.
Automerge armed.