Skip to content

async-typed exports stay pending on idle instead of trapping as deadlock (#292) - #310

Merged
lannbot merged 1 commit into
mainfrom
fix/292-async-export-idle-pending
Sep 7, 2026
Merged

async-typed exports stay pending on idle instead of trapping as deadlock (#292)#310
lannbot merged 1 commit into
mainfrom
fix/292-async-export-idle-pending

Conversation

@lannbot

@lannbot lannbot commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

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() -> u32 woken by a later push — rejected immediately with deadlock detected ... no thread is ready and no host call is outstanding. Reproduced without wit-bindgen in both plain and jspi mode (fixture runtime/tests/embedder/long-poll.wat).

Authorities. definitions.py canon_lift runs the trapping driving loop only if not ft.async_ (line 2189); for an async-typed export it returns after the first thread.resume() and driving is the embedder's Store.tick, which never traps. wasmtime: run_concurrent = poll_until(trap_on_idle=false) (a call_concurrent future stays pending on idle); trap-on-idle is the pub(super) variant behind the blocking [Typed]Func::call_async. Polyengine's Promise-shaped export is the call_concurrent side — architecture.md already argues the JS event loop is always dwelling in run_concurrent.

Change.

  • drive/driveAsync take an IdlePolicy ("trap" default, unchanged for sync lifts, dtor entry, pumps). For ft.async the export driver exits on idle; the Promise is settled from a one-shot Task.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).
  • Sync-typed exports keep the spec's deadlock trap in every mode.
  • The wast invoke directive is a blocking call, so the conformance harness opts back in via exec-level InstantiateInput.trapOnIdle (wasmtime's run_concurrent_trap_on_idle). Deliberately not in EmbedderOptions: a JS embedder is never blocked, so call_async has no analogue there.
  • Contract note: embedder-api.md §"Functions and async"; architecture.md driver table row.

Tests. long_poll_test.ts: pending-then-resolved, repeated cycle, poison rejects the pending Promise, trapOnIdle restores the trap — plain and jspi. Case (a) fails pre-fix with the deadlock trap.

Gates (run by the implementation track): just test-runtime 737 passed; just conformance 1285 passed / 0 failed / 126 xfail, no stale xfails (async/deadlock, reentrance:429, switch-to-ready-callback:359,367 still trap via the harness flag); just test-ct-runner 24 passed; POLYENGINE_SCHED_SEED=1 runtime suite 734 passed; deno.lock clean.

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.

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
lannbot enabled auto-merge September 7, 2026 14:43
@lannbot
lannbot merged commit 0053907 into main Sep 7, 2026
4 checks passed
@lannbot
lannbot deleted the fix/292-async-export-idle-pending branch September 7, 2026 14:47
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Async export parked on a guest-internal waker (no host call outstanding) traps as deadlock; wasmtime would stay pending

2 participants