Repository navigation
boundary: plain mode — a completed async export stays pending while an unrelated host call is outstanding #350
Description
plain mode — a completed async export stays pending while an unrelated host call is outstanding
Severity: P1 Confidence: high
Location: runtime/src/exec/boundary.ts:1229-1236, 1310-1315 (a later export's drive completes synchronously through driveLoop :412-456, which fires no arrival), :750 (fireDriverArrival only on driveAsync entry at depth > 1), :907-912, 946-950 (incumbent parked on Promise.race([...pendingHostCalls, armDriverArrival, armHostCallArrival])), :1282-1293 (onResolvedHook installed only after an idle exit, so onResolve cannot settle a driver that is still parked).
Authority: embedder-api.md "Idle async calls remain pending" — "Its Promise resolves when the task returns its value, not when all producer threads exit". docs/architecture.md §6 — "Arrival notifications wake parked drivers so they can yield or reconsider their waits. New host-call registrations also wake incumbent drivers rather than leaving them parked on an obsolete snapshot of pending work." definitions.py Task.return_ (:521-526): the result is delivered through on_resolve at return time.
Expected: xNext() resolves with 9 promptly after xPush(9) returns, regardless of other host calls in flight.
Actual: xNext()'s driver is parked racing the unrelated host promise; xPush(9)'s driver ticks next's callback (task.return(9), resolvedSeen = true) and exits synchronously ("done" at :431), firing nothing. The incumbent never re-evaluates done(); the export stays pending until the unrelated host call settles — which may be never (a long-lived accept/timer import). Same instance or sibling. jspi mode is unaffected only because the promising hop forces driveAsync (depth > 1 → fireDriverArrival).
Repro: completed_export_pending.ts (fixture two-inst.wasm):
[plain] control xNext after xPush(9): resolved:9
[plain] same-instance: xPush(9) resolved:undefined | xNext TIMEOUT (expected resolved:9)
[plain] after host call settles: xNext resolved:9 | xCallHost resolved:4
[plain] sibling: yPush(9) resolved:undefined | yNext TIMEOUT
[jspi] same-instance: … xNext resolved:9
Distinct from known issues: parked_driver_host_call_test.ts fixed the "second host call registered with no new driver" half of #239; this is the other half — a synchronous drive resolving the parked driver's own task registers no host call and fires no arrival.
Fix direction: install the result hook eagerly (resolve the export from onResolve/Task.return_ as definitions.py does, independent of which driver is live — the comment at :1252 already notes the callback "does not stop whichever driver now owns the task"), or fire fireDriverArrival(store) on every synchronous drive exit / on Task.return_.
Running the repro
Scripts below were run from the repository root at baseline 1a4f5e1 with
the shim and fixtures built (just shim fixtures):
deno run --config runtime/deno.json --allow-read --allow-env=POLYENGINE_SCHED_SEED <script>
<REPO>/ in the scripts is the absolute path of the checkout (the review ran
them from outside the tree). Each repro was run at least twice and once under a
nonzero POLYENGINE_SCHED_SEED; output was identical across runs.
support.ts
// Shared fixture plumbing for the export-drivers adversarial track.
// Mirrors runtime/tests/embedder/support.ts but with absolute repo paths.
export const REPO = "<REPO>/";
import { Translator } from "<REPO>/runtime/src/shim/mod.ts";
import {
type EmbedderOptions,
instantiate,
} from "<REPO>/runtime/src/embedder/mod.ts";
const shim = await Deno.readFile(
REPO + "target/wasm32-unknown-unknown/release/translator_shim.wasm",
);
export const translator = await Translator.create(shim);
export async function artifactsOf(rel: string) {
const componentBytes = await Deno.readFile(REPO + rel);
const { plan, adapters } = translator.translate(componentBytes);
return { plan, componentBytes, adapters };
}
export async function fixture(
rel: string,
imports: Record<string, unknown> = {},
opts: EmbedderOptions = {},
// deno-lint-ignore no-explicit-any
): Promise<any> {
return await instantiate(await artifactsOf(rel), imports, opts);
}
export const guest = (n: string) => `examples/guests/build/${n}.component.wasm`;
export const embed = (n: string) => `runtime/tests/embedder/${n}.wasm`;
export const turn = () => new Promise<void>((r) => setTimeout(r, 0));
export const micro = () => Promise.resolve();
export async function caught(f: () => unknown): Promise<unknown> {
try {
await f();
} catch (e) {
return e;
}
return undefined;
}
export function raceTimeout<T>(p: Promise<T>, ms: number): Promise<T | "TIMEOUT"> {
return Promise.race([
p,
new Promise<"TIMEOUT">((r) => setTimeout(() => r("TIMEOUT"), ms)),
]);
}
export const show = (e: unknown) =>
e instanceof Error ? `${e.name}: ${e.message.split("\n")[0].slice(0, 160)}` : String(e);completed_export_pending.ts
// F6: in plain mode, an async export whose task RETURNED (completed by a
// later export call whose driver finished synchronously) stays pending as
// long as an unrelated host call is outstanding: the parked incumbent driver
// is never woken because the synchronous `drive` path fires no arrival.
import { Translator } from "<REPO>/runtime/src/shim/mod.ts";
import { instantiate } from "<REPO>/runtime/src/embedder/mod.ts";
import { raceTimeout, REPO, show, turn } from "./support.ts";
const shim = await Deno.readFile(REPO + "target/wasm32-unknown-unknown/release/translator_shim.wasm");
const translator = await Translator.create(shim);
const componentBytes = await Deno.readFile("/tmp/opencode/hb-review/export-drivers/two-inst.wasm");
function deferred<T>() {
let resolve!: (v: T) => void, reject!: (e: unknown) => void;
const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej; });
return { promise, resolve, reject };
}
async function make(jspi: boolean, hx: () => unknown) {
const { plan, adapters } = translator.translate(componentBytes);
// deno-lint-ignore no-explicit-any
return await instantiate({ plan, adapters, componentBytes }, { hostX: hx, hostY: () => 1 }, { jspi }) as any;
}
const tag = (p: Promise<unknown>, ms = 500) =>
raceTimeout(p.then((v) => "resolved:" + JSON.stringify(v), (e) => "rejected:" + show(e)), ms);
for (const jspi of [false, true]) {
const m = jspi ? "jspi" : "plain";
// Control: no outstanding host call -> next() resolves right after push().
{
const c = await make(jspi, () => 1);
const xn = c.exports.xNext();
await turn();
await c.exports.xPush(9);
console.log(`[${m}] control xNext after xPush(9):`, await tag(xn));
}
// Same instance: one long-lived host import in flight (never settles here).
{
const d = deferred<number>();
const c = await make(jspi, () => d.promise);
const call = c.exports.xCallHost(); // parks on the host promise
const xn = c.exports.xNext();
await turn();
const push = await tag(c.exports.xPush(9));
const first = await tag(xn);
console.log(`[${m}] same-instance: xPush(9) ${push} | xNext ${first} (expected resolved:9)`);
// Release the unrelated host call: does xNext settle only now?
d.resolve(4);
console.log(`[${m}] after host call settles: xNext ${await tag(xn)} | xCallHost ${await tag(call)}`);
}
// Two instances: the outstanding host call belongs to a sibling.
{
const d = deferred<number>();
const c = await make(jspi, () => d.promise);
const call = c.exports.xCallHost();
const yn = c.exports.yNext();
await turn();
const push = await tag(c.exports.yPush(9));
console.log(`[${m}] sibling: yPush(9) ${push} | yNext ${await tag(yn)} (expected resolved:9)`);
d.resolve(4);
console.log(`[${m}] after host call settles: yNext ${await tag(yn)} | xCallHost ${await tag(call)}`);
}
}two-inst.wat (custom fixture; build with wasm-tools parse two-inst.wat -o two-inst.wasm)
;; Two-instance probe: nested component $C instantiated twice (x, y) in one
;; store. Each instance exposes long-poll (`next`/`push`/`push-bad`), an
;; async callback export that awaits a host import (`call-host`), a flag that
;; makes the next callback trap (`arm-bad`), and sync `ping`/`trap`.
(component
(component $C
(import "host-call" (func $host-call async (result u32)))
(core module $Mem (memory (export "mem") 1))
(core instance $mem (instantiate $Mem))
(type $FT (future u8))
(core func $waitable.join (canon waitable.join))
(core func $set.new (canon waitable-set.new))
(core func $future.new (canon future.new $FT))
(core func $future.read (canon future.read $FT async (memory $mem "mem")))
(core func $future.write (canon future.write $FT async (memory $mem "mem")))
(core func $subtask.drop (canon subtask.drop))
(core func $ret-next (canon task.return (result u32) (memory $mem "mem")))
(core func $ret-void (canon task.return (memory $mem "mem")))
(core func $host-call' (canon lower (func $host-call) async (memory $mem "mem")))
(core module $M
(import "" "mem" (memory 1))
(import "" "waitable.join" (func $join (param i32 i32)))
(import "" "set.new" (func $set.new (result i32)))
(import "" "future.new" (func $future.new (result i64)))
(import "" "future.read" (func $future.read (param i32 i32) (result i32)))
(import "" "future.write" (func $future.write (param i32 i32) (result i32)))
(import "" "subtask.drop" (func $subtask.drop (param i32)))
(import "" "ret-next" (func $ret-next (param i32)))
(import "" "ret-void" (func $ret-void))
(import "" "host-call" (func $host-call (param i32) (result i32)))
(global $rx (mut i32) (i32.const 0))
(global $tx (mut i32) (i32.const 0))
(global $bad (mut i32) (i32.const 0))
;; long-poll
(func (export "next") (result i32)
(local $ends i64) (local $ws i32)
(local.set $ends (call $future.new))
(global.set $rx (i32.wrap_i64 (local.get $ends)))
(global.set $tx (i32.wrap_i64 (i64.shr_u (local.get $ends) (i64.const 32))))
(if (i32.ne (call $future.read (global.get $rx) (i32.const 8)) (i32.const -1)) (then unreachable))
(local.set $ws (call $set.new))
(call $join (global.get $rx) (local.get $ws))
(i32.or (i32.const 2) (i32.shl (local.get $ws) (i32.const 4))))
(func (export "next-cb") (param $code i32) (param $index i32) (param $payload i32) (result i32)
(if (i32.ne (local.get $code) (i32.const 4)) (then unreachable))
(if (global.get $bad) (then unreachable))
(call $ret-next (i32.load8_u (i32.const 8)))
(i32.const 0))
(func $do-push (param $v i32)
(i32.store8 (i32.const 16) (local.get $v))
(if (i32.ne (call $future.write (global.get $tx) (i32.const 16)) (i32.const 0)) (then unreachable))
(call $ret-void))
(func (export "push") (param $v i32) (result i32)
(call $do-push (local.get $v)) (i32.const 0))
(func (export "push-bad") (param $v i32) (result i32)
(global.set $bad (i32.const 1)) (call $do-push (local.get $v)) (i32.const 0))
(func (export "void-cb") (param i32 i32 i32) (result i32) unreachable)
;; async callback export awaiting a host import
(func (export "call-host") (result i32)
(local $packed i32) (local $ws i32)
(local.set $packed (call $host-call (i32.const 32)))
(if (i32.eq (i32.and (local.get $packed) (i32.const 15)) (i32.const 2))
(then (call $ret-next (i32.load (i32.const 32))) (return (i32.const 0))))
(local.set $ws (call $set.new))
(call $join (i32.shr_u (local.get $packed) (i32.const 4)) (local.get $ws))
(i32.or (i32.const 2) (i32.shl (local.get $ws) (i32.const 4))))
(func (export "call-host-cb") (param $code i32) (param $index i32) (param $payload i32) (result i32)
(if (i32.ne (local.get $code) (i32.const 1)) (then unreachable))
(if (i32.ne (local.get $payload) (i32.const 2)) (then unreachable))
(if (global.get $bad) (then unreachable))
(call $subtask.drop (local.get $index))
(call $ret-next (i32.load (i32.const 32)))
(i32.const 0))
(func (export "arm-bad") (global.set $bad (i32.const 1)))
(func (export "ping") (result i32) (i32.const 42))
(func (export "trap") unreachable))
(core instance $m (instantiate $M (with "" (instance
(export "mem" (memory $mem "mem"))
(export "waitable.join" (func $waitable.join))
(export "set.new" (func $set.new))
(export "future.new" (func $future.new))
(export "future.read" (func $future.read))
(export "future.write" (func $future.write))
(export "subtask.drop" (func $subtask.drop))
(export "ret-next" (func $ret-next))
(export "ret-void" (func $ret-void))
(export "host-call" (func $host-call'))))))
(func (export "next") async (result u32)
(canon lift (core func $m "next") async (memory $mem "mem") (callback (core func $m "next-cb"))))
(func (export "push") async (param "v" u32)
(canon lift (core func $m "push") async (memory $mem "mem") (callback (core func $m "void-cb"))))
(func (export "push-bad") async (param "v" u32)
(canon lift (core func $m "push-bad") async (memory $mem "mem") (callback (core func $m "void-cb"))))
(func (export "call-host") async (result u32)
(canon lift (core func $m "call-host") async (memory $mem "mem") (callback (core func $m "call-host-cb"))))
(func (export "arm-bad") (canon lift (core func $m "arm-bad")))
(func (export "ping") (result u32) (canon lift (core func $m "ping")))
(func (export "trap") (canon lift (core func $m "trap"))))
(import "host-x" (func $hx async (result u32)))
(import "host-y" (func $hy async (result u32)))
(instance $x (instantiate $C (with "host-call" (func $hx))))
(instance $y (instantiate $C (with "host-call" (func $hy))))
(export "x-next" (func $x "next"))
(export "x-push" (func $x "push"))
(export "x-push-bad" (func $x "push-bad"))
(export "x-call-host" (func $x "call-host"))
(export "x-arm-bad" (func $x "arm-bad"))
(export "x-ping" (func $x "ping"))
(export "x-trap" (func $x "trap"))
(export "y-next" (func $y "next"))
(export "y-push" (func $y "push"))
(export "y-push-bad" (func $y "push-bad"))
(export "y-call-host" (func $y "call-host"))
(export "y-arm-bad" (func $y "arm-bad"))
(export "y-ping" (func $y "ping"))
(export "y-trap" (func $y "trap")))
Activity
lannbot commentedon Sep 12, 2026
Cross-check against the pinned Component Model reference (
definitions.py@7c67611) and wasmtime4675ee1. Verdict vocabulary: SPEC-BACKED = the reference mandates the expected behavior; CONTRACT-ONLY = spec silent, polyengine's own contract decides; POLICY-QUESTION = neither decides; WEAKENED = part of the claim is overstated (corrections below).Spec:
Task.return_—trap_if(self.state == Task.State.RESOLVED); trap_if(self.num_borrows > 0); assert(result is not None); self.on_resolve(result); self.state = Task.State.RESOLVED. The host's resultchannel is the
on_resolvecallback it passed toStore.invoke(f, on_start, on_resolve); it fires synchronously insidetask.return, before the guesteven continues, and is conditioned on nothing else in the store. There is no
notion of a per-export "driver" and no dependency on other in-flight host
calls (
Store.tickmerely resumes any ready thread). So the referencemandates: the host learns the result at return time, unconditionally. The
spec says nothing about JS turns; "promptly" is the embedding's translation,
and the contract fixes that translation.
Contract:
embedder-api.md:180-183"Its Promise resolves when the taskreturns its value, not when all producer threads exit" — the issue quotes it
correctly, and the sentence's whole purpose is to decouple resolution from
anything other than
task.return.architecture.md:270-272"Arrivalnotifications wake parked drivers so they can yield or reconsider their
waits. New host-call registrations also wake incumbent drivers rather than
leaving them parked on an obsolete snapshot of pending work" — also quoted
correctly; the observed behavior is exactly a driver parked on an obsolete
snapshot. The code path is as described: the incumbent is parked in the
Promise.race([...pendingHostCalls, armDriverArrival, armHostCallArrival])at
boundary.ts:946-950(or:907-912); a laterdrivethat completessynchronously returns
"done"fromdriveLoop(boundary.ts:427-431)without firing an arrival (
fireDriverArrivalonly atdriveAsyncentry,boundary.ts:750);onResolvedHookis installed only after an idle exit(
boundary.ts:1282-1293), so the task's ownon_resolvecannot settle astill-parked driver. The runtime's own comment at
boundary.ts:1246-1253("settle from onResolve, not the last thread's exit ... definitions.py
Task.return_") states the intended rule the parked path violates. Nocontract clause qualifies this.
Wasmtime: same behavior as the spec, i.e. different from polyengine.
task_return→task_complete:if let Caller::Host { tx, .. } ... tx.send(result)(
concurrent.rs:3606-3609) attask.returntime; the host'sStagedCallfuture polls that oneshot (
concurrent.rs:6339-6348), andpoll_untilpollsthe embedder's future first on every loop iteration (
concurrent.rs:1320-1324"poll the future we were passed as an argument and return immediately if it's
ready"). Outstanding host futures in
ConcurrentState::futuresnever gatethat. A pending, never-settling host task therefore cannot delay a completed
guest task's result past the next event-loop iteration.
Verdict: SPEC-BACKED (the reference delivers the result inside
Task.return_;the contract restates it; polyengine's parked driver defers delivery
indefinitely on unrelated host work).
wasmtime: different behavior — the result is sent to the caller's oneshot at
task.returnand observed on the next loop iteration, regardless of otherhost tasks.
Severity: keep P1 — liveness: with a long-lived import (accept loop, timer)
the completed export never resolves, and an embedder that needs that result
to make the unrelated call settle deadlocks. Plain mode is the default for
callback-only components, so this is the common path.
Notes: The issue's two fix directions are both spec-shaped; the first
(install the result hook eagerly so
on_resolvesettles the export directly,as
Task.return_does) is closer to the reference and removes the dependencyon which driver is live. Nothing to correct in the issue text.
lannbot commentedon Sep 12, 2026
Re-evaluated on current main
5616bce, Deno 2.9.5. Keep P1; first scheduler fix in this batch. In plain mode, both same-instance and sibling scenarios time out after the result-producing push has completed; settling the unrelated host import immediately releases the withheld result. JSPI controls succeed.This directly affects the long-lived imports/cross-task wakeups in docs/consumers.md. Decouple result notification from driver lifetime, preserving required core-return/result-lift ordering. Test together with #357: a returned value must survive unrelated work failing, not merely become observable after unrelated work finishes.