From b24683af5fe907cf8c1d540dda9c24ec9277074f Mon Sep 17 00:00:00 2001 From: Lann Martin Date: Mon, 7 Sep 2026 10:42:40 -0400 Subject: [PATCH] async-typed exports stay pending on idle instead of trapping as deadlock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- contracts/embedder-api.md | 14 ++ docs/architecture.md | 2 +- harness/src/runtime-executor.ts | 12 +- runtime/src/exec/boundary.ts | 189 ++++++++++++++++++++++- runtime/src/exec/executor.ts | 24 +++ runtime/src/task/mod.ts | 29 ++++ runtime/src/task/scheduler.ts | 25 +++ runtime/tests/embedder/long-poll.wasm | Bin 0 -> 1286 bytes runtime/tests/embedder/long-poll.wat | 97 ++++++++++++ runtime/tests/embedder/long_poll_test.ts | 138 +++++++++++++++++ 10 files changed, 523 insertions(+), 7 deletions(-) create mode 100644 runtime/tests/embedder/long-poll.wasm create mode 100644 runtime/tests/embedder/long-poll.wat create mode 100644 runtime/tests/embedder/long_poll_test.ts diff --git a/contracts/embedder-api.md b/contracts/embedder-api.md index 340593d..69de056 100644 --- a/contracts/embedder-api.md +++ b/contracts/embedder-api.md @@ -200,6 +200,20 @@ self-driving. Bounds: an operation waiting on the *embedder's* half of a host stream/future hangs until the embedder acts (never a trap), and a settlement-time failure surfaces on the next call into the instance. +**An async-typed export's Promise may stay pending indefinitely** (#292). +When its task parks with no ready thread and no host call outstanding — +the long-poll shape: `next: async func() -> event` woken by a later +`push` — the Promise stays pending; it settles when a later call (any +export, a host stream/future operation) runs the task to completion, and +rejects with the poisoning cause if the instance is poisoned first. This +is definitions.py `canon_lift`, whose trapping driving loop runs only for +sync-typed exports (line 2189), and wasmtime `call_concurrent` under +`run_concurrent`; the embedder's event loop is always dwelling, so the +blocking `call_async` shape (trap on idle) has no JS analogue and is not +offered. An async guest that genuinely can never progress therefore +hangs rather than traps, as it does under `run_concurrent`. **Sync-typed +exports keep the spec's deadlock trap** in every mode. + ### Import marks Three marks, each a `Symbol.for` brand defined in and imported from diff --git a/docs/architecture.md b/docs/architecture.md index 74a5348..8f24fcc 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -318,7 +318,7 @@ Mapping the reference model onto the web platform: | scheduler | JS event loop + explicit ready queues; cooperative, matching the CM model — no preemption exists or is needed | | `Waitable` / `WaitableSet` | host-side event structures; `wait` = suspension (stackful) or the callback return-code protocol (stackless) | | callback ABI | no suspension at all: the scheduler invokes the callback export with events | -| sync `canon_lift` driving loop | same scheduler: pump ready threads until resolved, with the spec's deadlock trap | +| sync `canon_lift` driving loop | same scheduler: pump ready threads until resolved, with the spec's deadlock trap. Async-typed exports have no such loop in the reference (`canon_lift` returns after the first resume, line 2189), so their driver exits on idle and the Promise stays pending for a later driver to settle — wasmtime `call_concurrent`, not `call_async` (#292; contracts/embedder-api.md §"Functions and async") | | `Subtask`, backpressure, cancellation | direct ports of the reference structures | JSPI's three roles, precisely: diff --git a/harness/src/runtime-executor.ts b/harness/src/runtime-executor.ts index bfad4c2..38c0b96 100644 --- a/harness/src/runtime-executor.ts +++ b/harness/src/runtime-executor.ts @@ -163,7 +163,17 @@ export class RuntimeExecutor implements CommandExecutor { } let handle: ComponentHandle; try { - handle = await instantiateComponent({ plan, componentBytes: bytes, adapters }); + handle = await instantiateComponent({ + plan, + componentBytes: bytes, + adapters, + // The wast `invoke` directive is a BLOCKING call: an async-typed + // export that goes idle with its task unresolved is a deadlock for + // this runner, not a Promise to leave pending (#292). wasmtime's + // wast runner takes the same route — `[Typed]Func::call_async`, i.e. + // `run_concurrent_trap_on_idle`. + trapOnIdle: true, + }); } catch (e) { if (e instanceof Trap) { if (expect === "trap") throw new TrapError(e.message); diff --git a/runtime/src/exec/boundary.ts b/runtime/src/exec/boundary.ts index 54eb518..de101c0 100644 --- a/runtime/src/exec/boundary.ts +++ b/runtime/src/exec/boundary.ts @@ -33,6 +33,7 @@ import { driveSyncLift, EventCode, withActivation, + addInstancePoisonedListener, hasRealHostCall, isInstancePoisoned, type EventTuple, @@ -435,17 +436,45 @@ function traceDrive(loop: string, store: Store, done: () => boolean, branch: str ); } +/** + * What a driving loop does when it runs out of moves with `done()` still + * false — the reference's empty-candidate-set state. + * + * `"trap"` is definitions.py `canon_lift`'s `trap_if(not candidates)` (line + * 2189) and the default for every driver in this runtime: the sync-lift + * paths, the destructor entry, and the pumps (which cannot reach the verdict + * anyway — see `driveStoreAsync`). + * + * `"exit"` returns instead, leaving `done()` false for the caller to notice. + * It exists for ONE caller: an **async-typed** lifted export (#292). The + * reference's driving loop is guarded by `if not ft.async_`, so for such an + * export `canon_lift` returns right after the first `thread.resume()` and the + * driving — with it, the idle verdict — belongs to the embedder's + * `Store.tick`, which never traps. wasmtime draws the same line: + * `run_concurrent` is `poll_until(trap_on_idle=false)` (a `call_concurrent` + * future simply stays pending on idle), and the trapping variant + * `run_concurrent_trap_on_idle` is `pub(super)`, backing only the blocking + * `[Typed]Func::call_async`. Polyengine's Promise-shaped export is + * `call_concurrent` under an always-live `run_concurrent` (docs/architecture.md + * §"Mapping the reference model"), hence "exit". + */ +type IdlePolicy = "trap" | "exit"; + /** * Pump `store` until `done()` holds. Returns `undefined` if that was achieved * synchronously, or a Promise that settles when it has been. + * + * Under `idle: "exit"` it may also return with `done()` still false; see + * `IdlePolicy`. */ function drive( store: Store, done: () => boolean, what: string, + idle: IdlePolicy = "trap", ): void | Promise { try { - return driveLoop(store, done, what); + return driveLoop(store, done, what, idle); } catch (e) { // EXIT BY EXCEPTION IS STILL AN EXIT. A trap unwinds this call, but the // sibling work this loop already started — a registered host call, a @@ -461,6 +490,7 @@ function driveLoop( store: Store, done: () => boolean, what: string, + idle: IdlePolicy, ): void | Promise { for (;;) { traceDrive("drive", store, done, "top"); @@ -493,9 +523,19 @@ function driveLoop( // activation has not run yet (see `Store.tick`). if (store.awaiting.size > 0 || store.hasPendingResumptions()) { traceDrive("drive", store, done, "->async(awaiting/pending)"); - return driveAsync(store, done, what); + return driveAsync(store, done, what, idle); } if (store.pendingHostCalls.size === 0) { + // The idle verdict. Under "exit" (an async-typed lift, #292) this is + // not a fault at all: the task simply has nothing to run right now and + // the export's Promise stays pending until a later driver finishes it. + if (idle === "exit") { + traceDrive("drive", store, done, "EXIT-idle"); + // Same hand-off as the `done()` exit above: work this loop started + // outlives it. + ensureSettlementPump(store); + return; + } traceDrive("drive", store, done, "DEADLOCK-TRAP"); trapIf( true, @@ -505,7 +545,7 @@ function driveLoop( ); } traceDrive("drive", store, done, "->async(hostcalls)"); - return driveAsync(store, done, what); + return driveAsync(store, done, what, idle); } } @@ -960,6 +1000,7 @@ async function driveAsync( store: Store, done: () => boolean, what: string, + idle: IdlePolicy = "trap", ): Promise { const depth = storeDriverDepth(store) + 1; driverDepth.set(store, depth); @@ -1152,6 +1193,10 @@ async function driveAsync( continue; } if (store.readyCandidates().length === 0) { + if (idle === "exit") { + traceDrive("driveAsync", store, done, "EXIT-idle"); + return; + } trapIf( true, `wasm trap: deadlock detected: event loop cannot make ` + @@ -1336,6 +1381,10 @@ async function driveAsync( continue; } if (store.pendingHostCalls.size === 0) { + if (idle === "exit") { + traceDrive("driveAsync", store, done, "EXIT-idle"); + return; + } traceDrive("driveAsync", store, done, "DEADLOCK-TRAP"); trapIf( true, @@ -1439,6 +1488,46 @@ function takeHostFailure(store: Store): unknown { */ export const SYNC_ENTRY: unique symbol = Symbol("polyengine.syncEntry"); +// --------------------------------------------------------------------------- +// Pending async-typed lifts, and their poisoning (#292) +// --------------------------------------------------------------------------- +// +// An async-typed export whose driver exited idle (see `IdlePolicy`) leaves a +// host-visible Promise settled by nothing but the task itself finishing. If +// the task instead dies — a LATER driver runs it and traps — the instance is +// poisoned and that task's threads will never unregister, so the Promise +// would hang forever. That is precisely the failure #66 fixed for parked +// stream/future ends, and it gets the same treatment: a poisoning listener +// that rejects every pending lift of the instance with the poisoning cause. +// +// Registered on the extra-listener seam rather than `setOnInstancePoisoned` +// (which streams.ts owns) — see `addInstancePoisonedListener` for the +// evaluation-order reason both are seams. +const pendingLifts = new WeakMap void>>(); + +function registerPendingLift(inst: object, reject: (c: unknown) => void): void { + let s = pendingLifts.get(inst); + if (s === undefined) pendingLifts.set(inst, (s = new Set())); + s.add(reject); +} + +function unregisterPendingLift( + inst: object, + reject: (c: unknown) => void, +): void { + pendingLifts.get(inst)?.delete(reject); +} + +addInstancePoisonedListener((inst, cause) => { + const s = pendingLifts.get(inst as object); + if (s === undefined || s.size === 0) return; + // Drained before dispatch: a rejection handler running synchronously must + // not see, or re-enter, this set. + const waiters = [...s]; + s.clear(); + for (const r of waiters) r(cause); +}); + export function createLiftedFunction(input: { name: string; ft: FuncType; @@ -1497,6 +1586,17 @@ export function createLiftedFunction(input: { * is nothing to poison — the same structural safety as `entryRefusal`. */ refuseOnEntryHops?: boolean; + /** + * Make **async-typed** exports trap on idle instead of leaving their + * Promise pending (#292). Default false; see `IdlePolicy`. + * + * `InstantiateInput.trapOnIdle`'s only consumer is the conformance harness, + * whose `invoke` directive is a *blocking* call — the wast semantics + * wasmtime serves with `run_concurrent_trap_on_idle` behind + * `[Typed]Func::call_async`, not with `call_concurrent`. It is deliberately + * absent from the embedder layer's options. + */ + trapOnIdle?: boolean; }): (...args: ComponentValue[]) => unknown { const { name, @@ -1516,6 +1616,9 @@ export function createLiftedFunction(input: { // built-in, so it is `promising`-wrapped exactly when the imports are // `Suspending`-wrapped. const enteredCore = enterWasm(core, mode); + // See the comment at the `drive` call in `invokeNow` and `IdlePolicy`. + const idlePolicy: IdlePolicy = + ft.async === true && input.trapOnIdle !== true ? "exit" : "trap"; const taskOpts: TaskOptions = { async_: opts.async, callback: opts.callback !== null, @@ -1751,7 +1854,60 @@ export function createLiftedFunction(input: { throw e; } + /** + * The task outlived its driver (#292): hand the host a Promise settled by + * the task itself. + * + * Resolution rides `finishHostEntry` unchanged — it already holds + * `completed`/`resultsToHost` — fired from `Task.onFinished`, i.e. the + * moment this task's last thread unregisters. That point is safe for the + * old `done` predicate's task-scoped clauses: `resolvedSeen` is + * guaranteed (`unregisterThread`'s own `trapIf(state !== "resolved")` + * fires first otherwise), and this task's threads are gone from + * `store.awaiting`, so `midWasmCall()` is false by construction. + * `hopParked()` is deliberately NOT re-tested: a hop is the obligation of + * the driver that put it in flight (#280), never of a Promise waiting on + * another task. + * + * Rejection has two sources: `finishHostEntry` itself throwing, and the + * instance being poisoned by a later driver that ran this task into a + * trap. Neither calls `unwind()`. A trap on the background path happens + * under ANOTHER driver's `invokeNow`, whose own `catch` already unwinds + * the FACT sync-call scopes and restores `may_leave` — running it twice + * would restore sibling instances' `may_leave` from underneath a lift + * that driver is still mid-flight in. This mirrors what already happens + * to a post-`task.return` producer thread that traps later. + */ + const backgroundCompletion = (): Promise => + new Promise((resolve, reject) => { + // Degenerate case: the task is already over (its threads unregistered + // during the drive) and only a foreign hop kept `done` false. Nothing + // will fire `onFinished`, so settle now. + if (task.threads.length === 0) { + try { + resolve(finishHostEntry()); + } catch (e) { + reject(e); + } + return; + } + const onPoison = (cause: unknown): void => { + task.onFinished = null; + reject(cause); + }; + registerPendingLift(inst, onPoison); + task.onFinished = () => { + unregisterPendingLift(inst, onPoison); + try { + resolve(finishHostEntry()); + } catch (e) { + reject(e); + } + }; + }); + let pending: void | Promise; + let driveDone: () => boolean = () => true; try { // Completion is "the task resolved AND its threads have drained", not // merely "resolved". `task.return` resolves the task, but the activation @@ -1810,10 +1966,29 @@ export function createLiftedFunction(input: { // covers this task's own suspended thread. const midWasmCall = () => task.threads.some((t) => store.awaiting.has(t)); const hopParked = () => entryHopThreads(store).length > 0; + driveDone = () => resolvedSeen && !midWasmCall() && !hopParked(); + // ASYNC-TYPED EXPORTS DO NOT TRAP ON IDLE (#292). definitions.py + // `canon_lift` runs the driving loop — and with it the + // empty-candidate-set `trap_if` — only `if not ft.async_` (line 2189); + // for an async-typed export it returns right after the first + // `thread.resume()` and driving is the embedder's `Store.tick`, which + // never traps. wasmtime splits the same way (`run_concurrent` = + // `poll_until(trap_on_idle=false)` vs the `pub(super)` + // `run_concurrent_trap_on_idle` behind the blocking `call_async`), and + // polyengine's Promise-shaped export is the `call_concurrent` side. + // So the driver EXITS — it does not park and does not trap — and the + // task is left live for whichever driver next runs the store (the next + // export call, the settlement pump, a host stream op): exactly the + // between-calls liveness that already services post-`task.return` + // producer threads. Repro: an async export parked WAITing on an + // intra-component future a later export call writes. + // Sync-typed exports are unchanged: their loop traps on idle in every + // mode, which is what the paragraphs above describe. pending = drive( store, - () => resolvedSeen && !midWasmCall() && !hopParked(), + driveDone, `export '${name}'`, + idlePolicy, ); } catch (e) { unwind(); @@ -1821,13 +1996,17 @@ export function createLiftedFunction(input: { } if (pending === undefined) { try { + if (idlePolicy === "exit" && !driveDone()) return backgroundCompletion(); return finishHostEntry(); } catch (e) { unwind(); throw e; } } - return pending.then(finishHostEntry, (e) => { + return pending.then(() => { + if (idlePolicy === "exit" && !driveDone()) return backgroundCompletion(); + return finishHostEntry(); + }, (e) => { unwind(); throw e; }); diff --git a/runtime/src/exec/executor.ts b/runtime/src/exec/executor.ts index 803fee0..bf67363 100644 --- a/runtime/src/exec/executor.ts +++ b/runtime/src/exec/executor.ts @@ -157,6 +157,25 @@ export interface InstantiateInput { * checked. */ loadedPlan?: LoadedPlan; + /** + * Make **async-typed** exports trap on idle (#292). Default false. + * + * An async-typed export whose task parks on something only a *later* call + * can ready — a `future.read` on an intra-component future — is not a + * deadlock: definitions.py `canon_lift` runs its trapping driving loop only + * `if not ft.async_` (line 2189), leaving the driving to the embedder. So + * by default such an export's Promise simply stays pending. Setting this + * restores the trap, which is what a *blocking* call wants — wasmtime's + * `run_concurrent_trap_on_idle` behind `[Typed]Func::call_async`. + * + * Its only consumer is the conformance harness's `invoke` directive + * (harness/src/runtime-executor.ts). Deliberately NOT surfaced by the + * embedder layer (`EmbedderOptions`): the embedder's exports are the + * `call_concurrent` shape. + * + * Sync-typed exports are unaffected either way — their loop always traps. + */ + trapOnIdle?: boolean; } /** An instantiated component: its export surface plus introspection state. */ @@ -243,6 +262,8 @@ class Executor { readonly adapterBytes: Map; readonly hostImports: HostImports; readonly verifyHash: boolean; + /** See `InstantiateInput.trapOnIdle`. */ + readonly trapOnIdle: boolean; /** See `InstantiateInput.jspi` and jspi/bridge.ts's invariant. */ readonly suspensionMode: SuspensionMode; @@ -392,6 +413,7 @@ class Executor { this.adapterBytes = input.adapters ?? new Map(); this.hostImports = input.imports ?? {}; this.verifyHash = input.verifyHash ?? true; + this.trapOnIdle = input.trapOnIdle ?? false; // AUTO-DETECTION IS ON by default. `chooseMode` picks jspi when the // embedder opts in OR when the plan needs suspension: a stackful async // lift, or a genuinely blocking built-in — classified per DECLARATION @@ -895,6 +917,8 @@ class Executor { trapState: this.trapState, syncCallStack: this.syncCallStack, allInstances: () => this.componentInstances.values(), + // Async-typed exports only; see `InstantiateInput.trapOnIdle`. + trapOnIdle: this.trapOnIdle, }); // Every SYNC-TYPED export additionally carries a plain-entered // variant (see SYNC_ENTRY, contracts/embedder-api.md §"Functions and async"): diff --git a/runtime/src/task/mod.ts b/runtime/src/task/mod.ts index 712025f..c2890e6 100644 --- a/runtime/src/task/mod.ts +++ b/runtime/src/task/mod.ts @@ -146,6 +146,27 @@ export class Task { state: TaskState = "initial"; /** TaskBorrowScope (cabi/context.ts): live borrows lowered into this task. */ numBorrows = 0; + + /** + * Fired once, when this task's LAST thread unregisters (#292). + * + * The reference has no such hook: `canon_lift` for an async-typed export + * returns to the embedder right after the first `thread.resume()` + * (definitions.py line 2189 — the driving loop with the empty-candidate-set + * `trap_if` is guarded by `if not ft.async_`), and the embedder's own + * `Store.tick` never traps on idle. Polyengine surfaces such an export as a + * Promise, so it needs the one thing a Python generator embedding gets for + * free: a signal for "the task is over" once its driver has gone home. Set + * only by exec/boundary.ts, only on the background path — a lifted async + * export whose driver exited idle with the task unresolved — and only ever + * once per task. + * + * Fires AFTER `unregisterThread`'s `trapIf(state !== "resolved")`, so a + * task that finished without resolving traps there as before and this never + * runs. Must not throw: it is called from inside guest-driven thread exit, + * under some OTHER call's driver. + */ + onFinished: (() => void) | null = null; implicitThread: Thread | null = null; readonly threads: Thread[] = []; /** @@ -308,6 +329,14 @@ export class Task { assert_(thread.index !== null, "unregister of an unindexed thread"); this.inst.threads.remove(thread.index); thread.index = null; + if (this.threads.length === 0 && this.onFinished !== null) { + // Last, and after the table removal: the callback resolves a host-facing + // Promise off this task's results, so everything this task owns must + // already be released when it runs. One-shot (see `onFinished`). + const f = this.onFinished; + this.onFinished = null; + f(); + } } /** diff --git a/runtime/src/task/scheduler.ts b/runtime/src/task/scheduler.ts index d6a60fb..a3df2b0 100644 --- a/runtime/src/task/scheduler.ts +++ b/runtime/src/task/scheduler.ts @@ -163,6 +163,30 @@ export function setOnInstancePoisoned( onInstancePoisoned = f; } +/** + * Additional poisoning observers, appended to the single `onInstancePoisoned` + * hook above (#292). Separate from it for the same evaluation-order reason + * the hook exists at all — the registrant (exec/boundary.ts, rejecting the + * pending Promises of async-typed lifts whose task will now never finish) + * already imports this module, and we must not import it back — but a Set + * rather than a second single slot, because "the" poisoning action is + * streams.ts's and this is strictly extra. + * + * Ordering is deliberate: the primary hook (stream/future-end retirement, + * #66) runs FIRST, so a listener that settles host-visible Promises observes + * ends already retired rather than ends about to be. + */ +const instancePoisonedListeners = new Set< + (inst: { handles: Iterable }, cause: unknown) => void +>(); + +/** @internal — see `instancePoisonedListeners`. */ +export function addInstancePoisonedListener( + f: (inst: { handles: Iterable }, cause: unknown) => void, +): void { + instancePoisonedListeners.add(f); +} + /** * @internal — invoke the poisoning hook. For the bracket-break sites that * live outside this module (`Thread.resumeWith`, exec/boundary.ts `poison`): @@ -178,6 +202,7 @@ export function notifyInstancePoisoned( // (polyengine#145 ask 1). if (!poisonedInstances.has(inst)) poisonedInstances.set(inst, cause); onInstancePoisoned?.(inst, cause); + for (const f of instancePoisonedListeners) f(inst, cause); } /** Poisoned instances → poisoning cause, for late-settle retirement diff --git a/runtime/tests/embedder/long-poll.wasm b/runtime/tests/embedder/long-poll.wasm new file mode 100644 index 0000000000000000000000000000000000000000..bcde10bda1c255bd2f83a55379a91a96ca2d7332 GIT binary patch literal 1286 zcmcgs!EVz)5PdVd_BwGInMwstkm3kV$qDt)6Q&ZJdgBA?Hj4-}c9l3ya-f#r!kv#O zKf|Z+56rp_*r~W8*}F62dAqZ3p9kq;wg&<|hgbx_Q{ovzYG!8)^mUfbER26@XZd`? z+U3wMy-lYV!5Nn&&OoKSH_j1@Tm)yTtUW6ED+Rb)l(4r-r$u^xX~!S)X(lU1+rVgH zi*aUGQd9T+vRKY-K$nyt+!@T+(-qnnH;pdN5$Foq4cs(3m)%2CnewgI{Z9Td(+!moA7X&pp$xAF z-)@H#BMBmPNCQgBxB3;rh=v>^<_HPLoaka6#*&@r>g#|LR7R{^auSS$6;CRBj3gT1 z7>Fr}B3)-iei#Ti>PSWp2@O^4C?Ld7y1O=}Q@Rdz=%#yQbAxKtBwDv<_U@7Fw<3;} zj+x{mXYi3{7@pk7(MwV?1q%8ipIGvy*~G5dT&0`KJe_nzZ`WNngl;hV8a!VU7i;F# zLMdU~XtMM<@KpTt0KNd2Dk%kq9)f+f6MN)h)XwW*LyC7|vY%3o7=IzApc1LxmFPXv zKn>QwP1>MZyg|2Us`S<7(h&w9MA#?e(Fw5epU6!T->I=auM2E-83kVNGt5$5nTtMVp!8!u-O_v+6XVq?hHU2MXC{Sa1Djdjlx|2v1sQwTs9)Iv8t>Xvv tFY?({p4qGzxh_JvRQx~1NPRqD)UAu;U+80BeCceimC=kuCg3;kegoHN8^Hho literal 0 HcmV?d00001 diff --git a/runtime/tests/embedder/long-poll.wat b/runtime/tests/embedder/long-poll.wat new file mode 100644 index 0000000..ec2522f --- /dev/null +++ b/runtime/tests/embedder/long-poll.wat @@ -0,0 +1,97 @@ +;; The long-poll shape: an async-typed export that parks on something only a +;; LATER export call can ready (issue #292). +;; +;; `next` is an async callback-ABI export whose body starts an intra-component +;; `future.read` (BLOCKED), joins the readable end to a waitable set and +;; returns WAIT. Nothing host-side is outstanding at that point, so the export +;; driver runs out of moves — which used to trap as "deadlock detected". It is +;; not one: definitions.py `canon_lift` runs its trapping driving loop only +;; `if not ft.async_` (line 2189), so an async-typed export's Promise is +;; simply left pending for a later driver. `push(v)` writes the future, which +;; completes the read, runs `next-cb` and resolves `next`'s Promise with `v`. +;; +;; `push-bad(v)` is the poisoning variant: it sets a flag and writes, so the +;; callback that `next` is parked in traps. That must REJECT the pending +;; `next()` Promise (with the trap) rather than leave it hanging — the same +;; guarantee #66 gave parked stream/future ends. +;; +;; Regenerate: wasm-tools parse long-poll.wat -o long-poll.wasm +(component + (core module $Memory (memory (export "mem") 1)) + (core instance $memory (instantiate $Memory)) + (core module $M + (import "" "mem" (memory 1)) + (import "" "waitable.join" (func $waitable.join (param i32 i32))) + (import "" "waitable-set.new" (func $waitable-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 "" "task.return-next" (func $task.return-next (param i32))) + (import "" "task.return-push" (func $task.return-push)) + (global $rx (mut i32) (i32.const 0)) + (global $tx (mut i32) (i32.const 0)) + (global $ws (mut i32) (i32.const 0)) + (global $bad (mut i32) (i32.const 0)) + + (func (export "next") (result i32) + (local $ret64 i64) (local $ret i32) + (local.set $ret64 (call $future.new)) + (global.set $rx (i32.wrap_i64 (local.get $ret64))) + (global.set $tx (i32.wrap_i64 (i64.shr_u (local.get $ret64) (i64.const 32)))) + (local.set $ret (call $future.read (global.get $rx) (i32.const 8))) + (if (i32.ne (local.get $ret) (i32.const -1 (; BLOCKED ;))) (then unreachable)) + (global.set $ws (call $waitable-set.new)) + (call $waitable.join (global.get $rx) (global.get $ws)) + (i32.or (i32.const 2 (; WAIT ;)) (i32.shl (global.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 (; FUTURE_READ ;))) (then unreachable)) + (if (i32.ne (local.get $payload) (i32.const 0 (; COMPLETED ;))) (then unreachable)) + ;; The poisoning arm: `push-bad` armed this, so the guest faults while + ;; completing `next`. + (if (global.get $bad) (then unreachable)) + (call $task.return-next (i32.load8_u (i32.const 8))) + (i32.const 0 (; EXIT ;)) + ) + (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 (; COMPLETED ;))) + (then unreachable)) + (call $task.return-push) + ) + (func (export "push") (param $v i32) (result i32) + (call $do-push (local.get $v)) + (i32.const 0 (; EXIT ;)) + ) + (func (export "push-bad") (param $v i32) (result i32) + (global.set $bad (i32.const 1)) + (call $do-push (local.get $v)) + (i32.const 0 (; EXIT ;)) + ) + (func (export "push-cb") (param i32 i32 i32) (result i32) unreachable) + ) + (type $FT (future u8)) + (canon waitable.join (core func $waitable.join)) + (canon waitable-set.new (core func $waitable-set.new)) + (canon future.new $FT (core func $future.new)) + (canon future.read $FT async (memory (core memory $memory "mem")) (core func $future.read)) + (canon future.write $FT async (memory (core memory $memory "mem")) (core func $future.write)) + (canon task.return (result u32) (memory (core memory $memory "mem")) (core func $task.return-next)) + (canon task.return (memory (core memory $memory "mem")) (core func $task.return-push)) + (core instance $m (instantiate $M (with "" (instance + (export "mem" (memory $memory "mem")) + (export "waitable.join" (func $waitable.join)) + (export "waitable-set.new" (func $waitable-set.new)) + (export "future.new" (func $future.new)) + (export "future.read" (func $future.read)) + (export "future.write" (func $future.write)) + (export "task.return-next" (func $task.return-next)) + (export "task.return-push" (func $task.return-push)) + )))) + (func (export "next") async (result u32) + (canon lift (core func $m "next") async (memory (core memory $memory "mem")) (callback (core func $m "next-cb")))) + (func (export "push") async (param "v" u32) + (canon lift (core func $m "push") async (memory (core memory $memory "mem")) (callback (core func $m "push-cb")))) + (func (export "push-bad") async (param "v" u32) + (canon lift (core func $m "push-bad") async (memory (core memory $memory "mem")) (callback (core func $m "push-cb")))) +) diff --git a/runtime/tests/embedder/long_poll_test.ts b/runtime/tests/embedder/long_poll_test.ts new file mode 100644 index 0000000..df5ea47 --- /dev/null +++ b/runtime/tests/embedder/long_poll_test.ts @@ -0,0 +1,138 @@ +// The long-poll shape: an async-typed export parked on a guest-internal +// waitable is PENDING, not deadlocked (issue #292). +// +// `next()` starts an intra-component `future.read` and returns WAIT. At that +// point nothing is ready and no host call is outstanding, which used to make +// the export driver declare +// Trap: wasm trap: deadlock detected: event loop cannot make further +// progress (export 'next': ...) +// It is not a deadlock. definitions.py `canon_lift` runs its trapping driving +// loop only `if not ft.async_` (line 2189): for an async-typed export it +// returns right after the first `thread.resume()` and the driving belongs to +// the embedder's `Store.tick`, which never traps. wasmtime splits the same +// way — `run_concurrent` is `poll_until(trap_on_idle = false)`, so a +// `call_concurrent` future stays pending on idle, and the trapping variant +// backs only the blocking `[Typed]Func::call_async`. Polyengine's +// Promise-shaped export is the `call_concurrent` side, so the Promise stays +// pending until a later driver — here the next export call — finishes the +// task. +// +// Every case runs in both suspension modes: the verdict sites live in three +// different loops (`driveLoop`'s synchronous fall-through and `driveAsync`'s +// two), and plain vs jspi picks different ones. + +import { assertEq } from "../support/asserts.ts"; +import { caught, haveFixture, instantiateFixture } from "./support.ts"; +import { instantiateComponent } from "../../src/exec/executor.ts"; +import { Translator } from "../../src/shim/mod.ts"; +import { readArtifact } from "./support.ts"; + +const FIXTURE = "runtime/tests/embedder/long-poll.wasm"; +const ready = await haveFixture(FIXTURE); + +/** A macrotask turn: what a stalled-vs-pending distinction needs. */ +const macrotask = () => new Promise((r) => setTimeout(r, 0)); + +for (const jspi of [false, true]) { + const tag = jspi ? "jspi" : "plain"; + + Deno.test({ + name: `long-poll (${tag}): next() stays pending until a later push() readies it`, + ignore: !ready, + fn: async () => { + const c = await instantiateFixture(FIXTURE, {}, { jspi }); + let settled: unknown = "pending"; + const next = (c.exports.next as () => Promise)(); + next.then((v) => (settled = v), (e) => (settled = e)); + // A full macrotask turn with no driver running: pre-fix this had + // already rejected with the deadlock trap. + await macrotask(); + assertEq(settled, "pending", "next() must not settle on its own"); + await (c.exports.push as (v: number) => Promise)(7); + assertEq(await next, 7); + }, + }); + + Deno.test({ + name: `long-poll (${tag}): the cycle repeats on one instance`, + ignore: !ready, + fn: async () => { + const c = await instantiateFixture(FIXTURE, {}, { jspi }); + const next1 = (c.exports.next as () => Promise)(); + await macrotask(); + await (c.exports.push as (v: number) => Promise)(7); + assertEq(await next1, 7); + const next2 = (c.exports.next as () => Promise)(); + await macrotask(); + await (c.exports.push as (v: number) => Promise)(9); + assertEq(await next2, 9); + }, + }); + + Deno.test({ + name: `long-poll (${tag}): a trap while completing next() rejects the pending Promise`, + ignore: !ready, + fn: async () => { + const c = await instantiateFixture(FIXTURE, {}, { jspi }); + const next = (c.exports.next as () => Promise)(); + // Keep the rejection handled from the start: the poisoning listener + // settles it synchronously, inside `push-bad`'s own driver. + const nextOutcome = caught(() => next); + await macrotask(); + // `push-bad` writes the future, which readies `next`'s callback, which + // traps. `push-bad`'s own call rejects... + const pushErr = await caught(() => + (c.exports.pushBad as (v: number) => Promise)(7) + ); + assertEq(pushErr !== undefined, true, "push-bad must reject"); + // ...and so must the Promise nobody was driving. Without the poisoning + // seam this hangs forever (the #66 failure, for lifts). + const err = await nextOutcome; + assertEq(err !== undefined, true, "the pending next() must reject"); + assertEq( + /trap|unreachable/i.test(String(err)), + true, + `expected a trap, got: ${err}`, + ); + // The instance is a corpse from here on. + const later = await caught(() => (c.exports.next as () => unknown)()); + assertEq( + String(later).includes("cannot enter component instance"), + true, + `expected an entry refusal, got: ${later}`, + ); + }, + }); +} + +// The harness's opt-back-in. `invoke` in a wast file is a BLOCKING call, so +// the conformance runner keeps today's trap via `InstantiateInput.trapOnIdle` +// — wasmtime's `run_concurrent_trap_on_idle`. Exercised at the exec level +// because the embedder layer deliberately does not expose the flag. +Deno.test({ + name: "long-poll: trapOnIdle restores the deadlock trap for async exports", + ignore: !ready, + fn: async () => { + const shim = await readArtifact( + "target/wasm32-unknown-unknown/release/translator_shim.wasm", + ); + const componentBytes = (await readArtifact(FIXTURE))!; + const { plan, adapters } = (await Translator.create(shim!)).translate( + componentBytes, + ); + const handle = await instantiateComponent({ + plan, + componentBytes, + adapters, + trapOnIdle: true, + }); + const err = await caught(() => + (handle.exports["next"] as () => unknown)() + ); + assertEq( + String(err).includes("deadlock detected"), + true, + `expected the deadlock trap, got: ${err}`, + ); + }, +});