From 87ea174a3fc52cc699c10aa545d8e0b89c2f141b Mon Sep 17 00:00:00 2001 From: Lann Martin Date: Sun, 6 Sep 2026 22:03:26 -0400 Subject: [PATCH] =?UTF-8?q?driver:=20four=20review=20findings=20=E2=80=94?= =?UTF-8?q?=20stale=20settled=20entry,=20poisoned-probe=20spin,=20corpse?= =?UTF-8?q?=20settlements,=20exception-exit=20hand-off?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial correctness review of the export-call driver (boundary.ts) and its store-level helpers, judged against definitions.py's single-loop `canon_lift`/`Store.tick` model and arch §6. Each finding was confirmed by a store-level regression test that fails on the pre-fix tree. - F1 one settlement, one delivery. `noteAwaiting` records a settlement into `store.settled` EAGERLY; the P5 race-winner path then called `resumeWith` directly and left that entry queued. A body that re-parks synchronously inside `resumeWith` (a `waitUntil` returning without yielding on a pending cancel) is back in `awaiting` when the next `serviceSettled` runs, so the OLD value was delivered against the NEW park. The winner site now splices the thread's queued entries before resuming. (driver_stale_settled_test.ts) - F2 the deadlock probe's "a thread became READY" branch consulted `store.readyCandidates()` unfiltered while `tick` filtered poisoned instances, so a ready thread of a corpse made the probe re-arm every macrotask instead of trapping — silent hang plus CPU spin. The poison filter moves into `readyCandidates`; `tick`'s copy is deleted. (driver_poisoned_probe_test.ts) - F3 a poisoned instance's outstanding async import settled into the corpse: a fulfilment lowered results into its memory (possibly re-entering its realloc), a rejection parked on `store.hostFailure` and failed the next driver — a HEALTHY sibling's export call, contra arch §6 #173. The settle continuation's renounced-call guard now also covers a poisoned lowering instance. (poisoned_host_call_retire_test.ts) - F4 exit by exception is still an exit. `drive`'s throw path skipped `ensureSettlementPump`, orphaning a sibling's in-flight host call; `driveAsync`'s trap exit armed the pump only on real host calls, leaving a sibling's hop-park queued in `store.settled` where it gates every later driver's `tick` (#280's trace via the trap exit). `drive` hands off on throw; the pump's work predicate covers queued tails and hop-parked threads, racing the hop promises alongside the host calls. (driver_trap_exit_liveness_test.ts) - F6 the speculative `addPendingResumption(chosen)` collapsed with a genuine entry for the same thread (Set by identity) and the `finally` removed the only one; recorded as added only when this driver added it. Baseline 702 → 723 passed; conformance 1511 commands / 0 failed / 0 stale; sched-seeds green. --- runtime/src/exec/boundary.ts | 101 ++++++++-- runtime/src/task/scheduler.ts | 25 ++- runtime/tests/driver_poisoned_probe_test.ts | 88 +++++++++ runtime/tests/driver_stale_settled_test.ts | 112 +++++++++++ .../tests/driver_trap_exit_liveness_test.ts | 185 ++++++++++++++++++ .../tests/poisoned_host_call_retire_test.ts | 129 ++++++++++++ 6 files changed, 612 insertions(+), 28 deletions(-) create mode 100644 runtime/tests/driver_poisoned_probe_test.ts create mode 100644 runtime/tests/driver_stale_settled_test.ts create mode 100644 runtime/tests/driver_trap_exit_liveness_test.ts create mode 100644 runtime/tests/poisoned_host_call_retire_test.ts diff --git a/runtime/src/exec/boundary.ts b/runtime/src/exec/boundary.ts index 55274bd..54eb518 100644 --- a/runtime/src/exec/boundary.ts +++ b/runtime/src/exec/boundary.ts @@ -34,6 +34,7 @@ import { EventCode, withActivation, hasRealHostCall, + isInstancePoisoned, type EventTuple, NeedsJspi, needsJspi, @@ -442,6 +443,24 @@ function drive( store: Store, done: () => boolean, what: string, +): void | Promise { + try { + return driveLoop(store, done, what); + } 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 + // queued tail (which gates `Store.tick`) — does not unwind with it. The + // `done()` path hands both to the settlement pump; so must this one. + ensureSettlementPump(store); + throw e; + } +} + +/** `drive`'s loop proper; see `drive` for the exception-exit hand-off. */ +function driveLoop( + store: Store, + done: () => boolean, + what: string, ): void | Promise { for (;;) { traceDrive("drive", store, done, "top"); @@ -832,6 +851,18 @@ function fireSettlementNudge(store: Store): void { } } +/** + * What an exiting driver must hand over: real host calls it was watching, a + * queued activation tail (which gates `Store.tick` for every later driver), + * or a hop-parked thread — the #280 rule ("a driver is not done while ANY + * thread of ANY task is hop-parked") applied to the exits that cannot + * evaluate their `done` predicate, i.e. the exception exits. + */ +function pumpWork(store: Store): boolean { + return hasRealHostCall(store) || store.settled.length > 0 || + entryHopThreads(store).length > 0; +} + /** * Ensure a settlement pump is watching `store`'s real outstanding host calls. * Idempotent and cheap; called at every driver exit. Never throws. @@ -844,7 +875,7 @@ export function ensureSettlementPump(store: Store): void { return; } if (store.hostFailure !== undefined) return; - if (!hasRealHostCall(store)) return; + if (!pumpWork(store)) return; settlementPumps.add(store); void settlementPumpLoop(store); } @@ -863,20 +894,32 @@ async function settlementPumpLoop(store: Store): Promise { // it in a loop. if (store.hostFailure !== undefined) return; const real = realHostCalls(store); - if (real.length === 0) return; - const nudge = armSettlementNudge(store); - // The host-call arrival one-shot does NOT ride here, deliberately: a - // registration this snapshot misses always reaches `ensureSettlementPump` - // (which fires the nudge) — `driveAsync`'s finally, `drive`'s synchronous - // completion, and `HostActivity.pump()`'s async half is itself a - // `driveStoreAsync`. Any driver live meanwhile is what we stand down for. - // Rejections are not this pump's to report: the registration site's - // own continuation parks them on `store.hostFailure`. - await Promise.race([ - ...real.map((p) => p.then(() => {}, () => {})), - nudge, - ]); - if (storeDriverDepth(store) > 0) continue; + // A queued tail is serviceable RIGHT NOW: drive without parking. A + // hop-parked thread lands on the engine's own schedule, so its promise + // is raced alongside the host calls — that is how an exception exit's + // orphaned hop (F4/#280) gets an owner. + const hops = store.settled.length > 0 + ? [] + : entryHopThreads(store).map((t) => t.awaiting).filter(( + p, + ): p is Promise => p !== null); + if (store.settled.length === 0) { + if (real.length === 0 && hops.length === 0) return; + const nudge = armSettlementNudge(store); + // The host-call arrival one-shot does NOT ride here, deliberately: a + // registration this snapshot misses always reaches `ensureSettlementPump` + // (which fires the nudge) — `driveAsync`'s finally, `drive`'s synchronous + // completion, and `HostActivity.pump()`'s async half is itself a + // `driveStoreAsync`. Any driver live meanwhile is what we stand down for. + // Rejections are not this pump's to report: the registration site's + // own continuation parks them on `store.hostFailure`. + await Promise.race([ + ...real.map((p) => p.then(() => {}, () => {})), + ...hops.map((p) => p.then(() => {}, () => {})), + nudge, + ]); + if (storeDriverDepth(store) > 0) continue; + } // Drive unconditionally after a wake: `storeQuiescent` cannot see a // READY waiting thread (the usual product of a settlement — the // continuation readied the guest and deleted its own host call), so @@ -906,7 +949,7 @@ async function settlementPumpLoop(store: Store): Promise { // fired the nudge after our last snapshot check must not be lost. if ( !failed && store.hostFailure === undefined && - storeDriverDepth(store) === 0 && hasRealHostCall(store) + storeDriverDepth(store) === 0 && pumpWork(store) ) { ensureSettlementPump(store); } @@ -1237,8 +1280,13 @@ async function driveAsync( // resumption site here re-checks membership and promise identity // synchronously — mechanisms (a) and (b), which is where that note // already puts the weight. + // ONLY IF WE ADDED IT (issue #158, same rule as the `finally` below): + // `pendingResumptions` is a Set by identity, so a genuine entry for + // `chosen` minted meanwhile — or already held — collapses with ours, + // and removing "ours" would drop the genuine one. const sole = storeDriverDepth(store) === 1; - if (sole) store.addPendingResumption(chosen); + const added = sole && !store.pendingResumptions.has(chosen); + if (added) store.addPendingResumption(chosen); let winner: AwaitWinner | null; try { // `armDriverArrival` rides the race for every driver, not just the one @@ -1257,7 +1305,7 @@ async function driveAsync( armHostCallArrival(store), ]); } finally { - if (sole) store.removePendingResumption(chosen); + if (added) store.removePendingResumption(chosen); } // Resume whichever thread actually settled -- not necessarily the one we // claimed. Resuming only the claimed thread would spin: its promise may @@ -1271,10 +1319,18 @@ async function driveAsync( // promise, after which its OLD promise settles late — membership is // true again but the tag's value belongs to a settlement this thread // has already consumed. Compare promise identity too. + // ONE SETTLEMENT, ONE DELIVERY (definitions.py `Thread.resume` is + // atomic). `noteAwaiting` records settlements EAGERLY, so this + // promise's `store.settled` entry is already queued; left there, a + // body that re-parks SYNCHRONOUSLY inside `resumeWith` gets the OLD + // value delivered against its NEW park by the next `serviceSettled`. if ( winner !== null && store.awaiting.has(winner.t) && winner.t.awaiting === winner.p ) { + for (let i = store.settled.length - 1; i >= 0; i--) { + if (store.settled[i].t === winner.t) store.settled.splice(i, 1); + } winner.t.resumeWith(winner.value, winner.failure); } continue; @@ -2505,7 +2561,9 @@ export function createLoweredImport(input: { // subtask that never started") and park that AssertionError on // `store.hostFailure`, poisoning whatever unrelated embedder call // came next. - if (subtask.resolved()) return; + // POISONED is the same discard (arch §6 #173): no addressee, and + // lowering would write into the corpse's memory via its `realloc`. + if (subtask.resolved() || isInstancePoisoned(opts.instance)) return; try { onResolve(toResults(v)); } catch (e) { @@ -2517,8 +2575,9 @@ export function createLoweredImport(input: { // Same guard, different reason: a rejection of a RENOUNCED call is // not a host failure. The guest cancelled and was told so; surfacing // the rejection would fail an unrelated later call with the error of - // an operation nobody is waiting for. - if (subtask.resolved()) return; + // an operation nobody is waiting for. POISONED is the same discard + // (arch §6 #173): it would fail a HEALTHY sibling's export call. + if (subtask.resolved() || isInstancePoisoned(opts.instance)) return; store.hostFailure = e; }, ); diff --git a/runtime/src/task/scheduler.ts b/runtime/src/task/scheduler.ts index 5b4126e..316f6e9 100644 --- a/runtime/src/task/scheduler.ts +++ b/runtime/src/task/scheduler.ts @@ -937,9 +937,21 @@ export class Store { this.waiting.splice(i, 1); } - /** Ready waiting threads, in wait order (the FIFO of the default policy). */ + /** + * Ready waiting threads, in wait order (the FIFO of the default policy). + * + * A POISONED instance's threads are not candidates: they are a corpse's and + * must never resume (polyengine's per-instance poisoning divergence). The + * filter lives here, not in `tick` alone, because the answer is also a + * VERDICT elsewhere — the drivers' deadlock probe asks "did anything become + * ready?" and must get an answer that agrees with what `tick` will actually + * run, or it re-arms forever on a thread `tick` refuses (exec/boundary.ts's + * probe, against `canon_lift`'s `trap_if(not candidates)`). + */ readyCandidates(): SchedulableThread[] { - return this.waiting.filter((t) => t.ready()); + return this.waiting.filter((t) => + t.ready() && !isInstancePoisoned(t.task?.inst) + ); } /** @@ -1166,11 +1178,10 @@ export class Store { // // What is added is polyengine's per-instance poisoning divergence: a // poisoned instance is a corpse, its threads must never resume, and the - // MARKER is the whole test. `Thread.resumeWith` makes the same call on - // the tail path. - const candidates = this.readyCandidates().filter((t) => - !isInstancePoisoned(t.task.inst) - ); + // MARKER is the whole test. That filter lives in `readyCandidates` (so + // the drivers' deadlock probe reads the same candidate set this does). + // `Thread.resumeWith` makes the same call on the tail path. + const candidates = this.readyCandidates(); if (candidates.length === 0) return false; const thread = chooseCandidate(candidates); const inst = thread.task.inst; diff --git a/runtime/tests/driver_poisoned_probe_test.ts b/runtime/tests/driver_poisoned_probe_test.ts new file mode 100644 index 0000000..3614bcd --- /dev/null +++ b/runtime/tests/driver_poisoned_probe_test.ts @@ -0,0 +1,88 @@ +// F2: the deadlock probe consults `readyCandidates()`, which `tick` does not. +// +// THE SHAPE (store-level: the end-to-end participants are two live activations +// of ONE instance under stackful async lifts, one JSPI-suspended while its +// sibling traps — no checked-in example guest has them). +// +// * instance I is poisoned; a waiting entry of I is `ready()`; +// * `Store.tick` filters poisoned instances out of its candidate set +// (task/scheduler.ts:1171) and returns false — nothing can run; +// * the driver's deadlock probe reads the UNFILTERED `readyCandidates()` +// (exec/boundary.ts:1111), concludes "a thread became READY", and +// `continue`s. Next turn: identical. Forever, one macrotask per turn. +// +// definitions.py `canon_lift`'s sync loop traps on an empty candidate set. The +// verdict must agree with what `tick` can actually run, so the abandoned +// export call must REJECT with the deadlock trap, not idle-spin. + +import { assertEq } from "./support/asserts.ts"; +import { driveStoreAsync } from "../src/exec/mod.ts"; +import { notifyInstancePoisoned, Store } from "../src/task/mod.ts"; + +function assert(cond: boolean, msg: string): asserts cond { + if (!cond) throw new Error(`assertion failed: ${msg}`); +} + +Deno.test({ + name: + "F2: a driver whose only ready thread belongs to a poisoned instance traps instead of spinning", + fn: async () => { + const store = new Store(); + const inst = { handles: [] as unknown[] }; + notifyInstancePoisoned(inst, new Error("the sibling activation trapped")); + + // The abandoned export's own activation: parked on a promise nobody will + // ever settle (its wasm frame died with the instance). + const parked = { + task: { inst }, + awaiting: new Promise(() => {}) as Promise | null, + resumeWith(): void { + throw new Error("a poisoned instance's thread must never resume"); + }, + }; + store.noteAwaiting(parked, parked.awaiting!); + + // The sibling suspension point that stays `ready()` forever: `tick` will + // never pick it (poisoned), but `readyCandidates()` still reports it. + const sp = { + owner: parked, + task: { inst }, + ready: () => true, + waiting: () => true, + resume(): void { + throw new Error("a poisoned instance's thread must never resume"); + }, + }; + // deno-lint-ignore no-explicit-any + store.startWaiting(sp as any); + + let finished = false; + const driving = driveStoreAsync(store, () => finished, "export 'abandoned'"); + let outcome: { ok: true } | { err: unknown } | undefined; + driving.then(() => (outcome = { ok: true }), (e) => (outcome = { err: e })); + + // The probe costs one macrotask per turn; a correct verdict needs a couple + // of them. Bounded, per the brief. + for (let i = 0; i < 20 && outcome === undefined; i++) { + await new Promise((r) => setTimeout(r, 0)); + } + + // Whatever happened, do not leave a spinning loop behind. + finished = true; + await driving.catch(() => {}); + for (let i = 0; i < 5 && outcome === undefined; i++) { + await Promise.resolve(); + } + + assert( + outcome !== undefined && "err" in outcome, + "the driver never settled: the deadlock probe kept re-arming because " + + "`readyCandidates()` reports a thread `tick` refuses to run", + ); + const msg = String( + (outcome.err as { message?: string })?.message ?? outcome.err, + ); + assertEq(msg.includes("deadlock detected"), true); + assertEq(msg.includes("export 'abandoned'"), true); + }, +}); diff --git a/runtime/tests/driver_stale_settled_test.ts b/runtime/tests/driver_stale_settled_test.ts new file mode 100644 index 0000000..4d53b8e --- /dev/null +++ b/runtime/tests/driver_stale_settled_test.ts @@ -0,0 +1,112 @@ +// F1: `driveAsync`'s race-winner path resumes a thread directly but leaves the +// settlement's own `store.settled` entry behind. +// +// THE SHAPE (store-level; no checked-in example guest has the participants — +// a callback-ABI callee cancelled by its caller, whose `waitUntil` returns +// WITHOUT yielding on the pending cancel and re-parks inside the very +// `resumeWith` the winner path just called). +// +// * `noteAwaiting` records the settlement EAGERLY: the parked promise's +// first reaction pushes `{t, value}` onto `store.settled`; +// * the winner path (exec/boundary.ts, the `winner.t.resumeWith(...)` site) +// then resumes `t` directly and never splices that entry out; +// * `serviceSettled` drops a leftover entry only when `!awaiting.has(t)`. A +// SYNCHRONOUS re-park inside `resumeWith` puts `t` back in `awaiting`, so +// the next iteration delivers the OLD value against the NEW promise. +// +// definitions.py `Thread.resume` is atomic: one settlement, one delivery. The +// assertion below is exactly that — the value arrives once. + +import { assertEq } from "./support/asserts.ts"; +import { driveStoreAsync, registerHostCall } from "../src/exec/mod.ts"; +import { Store } from "../src/task/mod.ts"; + +function assert(cond: boolean, msg: string): asserts cond { + if (!cond) throw new Error(`assertion failed: ${msg}`); +} + +/** + * A promise-parked thread that re-parks synchronously inside `resumeWith` — + * the `waitUntil`-with-a-pending-cancel shape (task/thread.ts:260 returns + * without yielding, so the body runs straight on to a fresh `awaitValue`). + * + * Only the fields `Store.serviceSettled` / `noteAwaiting` / the driver's + * winner path touch: `task.inst`, `awaiting`, `resumeWith`. + */ +class ReparkingThread { + readonly task = { inst: { handles: [] as unknown[] } }; + awaiting: Promise | null = null; + readonly received: unknown[] = []; + + constructor(private readonly store: Store) {} + + resumeWith(value: unknown, failure?: { error: unknown }): void { + this.received.push(failure === undefined ? value : failure); + // What `Thread.resumeWith` does first, verbatim (task/thread.ts:141-142). + this.awaiting = null; + this.store.awaiting.delete(this); + // ... and then the body runs, and blocks again on a NEW promise before + // control ever returns to the driver. + const next = new Promise(() => {}); + this.awaiting = next; + this.store.noteAwaiting(this, next); + } +} + +Deno.test({ + name: + "F1: a settlement consumed by the race-winner path is not re-delivered to a synchronous re-park", + fn: async () => { + const store = new Store(); + const t = new ReparkingThread(store); + + // The park the driver will race. + let settleA!: (v: unknown) => void; + const p1 = new Promise((r) => (settleA = r)); + t.awaiting = p1; + store.noteAwaiting(t, p1); + + // A real, never-settling host call: it keeps `pendingHostCalls` non-empty + // so the driver takes the P5 servicing race (the winner path) instead of + // the deadlock probe. + const stuck = new Promise(() => {}); + registerHostCall(store, stuck); + + let finished = false; + const driving = driveStoreAsync(store, () => finished, "F1 driver"); + + // Let the driver reach the race. + for (let i = 0; i < 10; i++) await Promise.resolve(); + await new Promise((r) => setTimeout(r, 0)); + assertEq(t.received.length, 0); + + settleA("A"); + + // Give the winner path and the next loop iteration (whose `serviceSettled` + // is where the stale entry would be dispatched) room to run. + for (let i = 0; i < 20; i++) await Promise.resolve(); + await new Promise((r) => setTimeout(r, 0)); + for (let i = 0; i < 20; i++) await Promise.resolve(); + + assert( + t.received.length > 0, + "the settlement was never delivered at all (test setup, not the bug)", + ); + assertEq(t.received, ["A"]); + // The corollary: nothing of that settlement is left queued against the + // NEW park. + assertEq(store.settled.length, 0); + + // Teardown: let the driver exit and leave `pendingHostCalls` empty so the + // settlement pump inherits nothing. + finished = true; + store.pendingHostCalls.delete(stuck); + const wake: Promise = Promise.resolve().then(() => { + store.pendingHostCalls.delete(wake); + }); + registerHostCall(store, wake); + await driving; + assertEq(store.pendingHostCalls.size, 0); + assertEq(store.hostFailure, undefined); + }, +}); diff --git a/runtime/tests/driver_trap_exit_liveness_test.ts b/runtime/tests/driver_trap_exit_liveness_test.ts new file mode 100644 index 0000000..d5efd93 --- /dev/null +++ b/runtime/tests/driver_trap_exit_liveness_test.ts @@ -0,0 +1,185 @@ +// F4: a driver that exits by THROWING leaves sibling work unowned. +// +// THE SHAPE (store-level; the participants are two instances of one +// instantiation, one trapping while the other's activation is in flight) +// +// (a) `drive`'s tick loop resumes sibling B, which registers a real host +// call, then resumes a thread that traps. The throw leaves through +// `drive` without ever reaching `ensureSettlementPump` (armed only on +// the `done()` path, exec/boundary.ts:468). B's call settles, B goes +// READY — and nobody ticks the store. +// (b) `driveAsync`'s trap exit arms the pump only when a REAL host call +// remains (`ensureSettlementPump` bails otherwise), so a sibling's +// hop-park landing in `store.settled` sits there unserviced — and a +// non-empty `settled` makes `Store.tick` refuse for every later driver. +// +// arch §6 #173 (siblings stay usable) and the #280 rule ("a driver is not +// done while ANY thread of ANY task is hop-parked") both assume a normal +// exit; run_tests.py `lift_and_run` drains the whole store either way. + +import { assertEq } from "./support/asserts.ts"; +import { driveStoreAsync, registerHostCall } from "../src/exec/mod.ts"; +import { createDtorEntry } from "../src/exec/boundary.ts"; +import { ComponentInstanceState, Store } from "../src/task/mod.ts"; +import { Trap } from "../src/cabi/mod.ts"; + +function assert(cond: boolean, msg: string): asserts cond { + if (!cond) throw new Error(`assertion failed: ${msg}`); +} + +/** The thread that traps when resumed. Ready only once `arm()` has run, so + * the tick order below is fixed without relying on `chooseCandidate`. */ +class TrappingThread { + readonly task = { inst: { handles: [] as unknown[] } }; + #armed = false; + arm(): void { + this.#armed = true; + } + ready(): boolean { + return this.#armed; + } + waiting(): boolean { + return true; + } + resume(): never { + this.#armed = false; + throw new Trap("wasm trap: unreachable"); + } +} + +Deno.test({ + name: + "F4a: drive's throw path still hands sibling liveness to the settlement pump", + fn: async () => { + const store = new Store(); + const trapper = new TrappingThread(); + + // The healthy SIBLING instance's thread: its resumption registers a real + // host call (the async-lower shape) and arms the trapper. + let settle!: () => void; + const raw = new Promise((r) => (settle = r)); + let registered = false; + const sibling = { + task: { inst: { handles: [] as unknown[] } }, + resumed: 0, + awake: true, + ready(): boolean { + return this.awake; + }, + waiting(): boolean { + return true; + }, + wake(): void { + this.awake = true; + }, + resume(): void { + this.awake = false; + this.resumed++; + if (registered) return; + registered = true; + const call: Promise = raw.then(() => { + store.pendingHostCalls.delete(call); + sibling.wake(); + }); + registerHostCall(store, call); + trapper.arm(); + }, + }; + // deno-lint-ignore no-explicit-any + store.startWaiting(sibling as any); + // deno-lint-ignore no-explicit-any + store.startWaiting(trapper as any); + + // A plain-mode lifted call whose own activation completes synchronously: + // everything below happens in its `drive` loop. + const impl = new ComponentInstanceState(1, store); + const lifted = createDtorEntry({ dtor: () => undefined, instance: impl }); + + let thrown: unknown; + try { + lifted(0); + } catch (e) { + thrown = e; + } + assert(thrown instanceof Trap, `expected the sibling trap, got ${thrown}`); + assertEq(sibling.resumed, 1); + assertEq(store.pendingHostCalls.size, 1); + + // The sibling's host call answers. Its continuation readies the sibling + // and deletes its own entry — readying is all it does; somebody has to + // tick the store, and after a throwing exit that is the settlement pump. + settle(); + for (let i = 0; i < 20 && sibling.resumed < 2; i++) { + await new Promise((r) => setTimeout(r, 0)); + } + + assert( + sibling.resumed >= 2, + "the healthy sibling was never resumed: `drive` threw without arming " + + "the settlement pump, so its in-flight host call had no keeper", + ); + assertEq(store.pendingHostCalls.size, 0); + }, +}); + +Deno.test({ + name: + "F4b: driveAsync's trap exit leaves no unserviced sibling tail in store.settled", + fn: async () => { + const store = new Store(); + const trapper = new TrappingThread(); + + // The sibling hop-parks when resumed (a promising entry settling one + // microtask after the core call returns — jspi pin (j)). + let settle!: (v: unknown) => void; + const hop = new Promise((r) => (settle = r)); + const sibling = { + task: { inst: { handles: [] as unknown[] } }, + awake: true, + awaiting: null as Promise | null, + tails: 0, + ready(): boolean { + return this.awake; + }, + waiting(): boolean { + return true; + }, + resume(): void { + this.awake = false; + this.awaiting = hop; + store.noteAwaiting(this, hop); + trapper.arm(); + }, + resumeWith(): void { + this.awaiting = null; + store.awaiting.delete(this); + this.tails++; + }, + }; + // deno-lint-ignore no-explicit-any + store.startWaiting(sibling as any); + // deno-lint-ignore no-explicit-any + store.startWaiting(trapper as any); + + const driving = driveStoreAsync(store, () => false, "export 'trapping'"); + let thrown: unknown; + await driving.catch((e) => (thrown = e)); + assert(thrown instanceof Trap, `expected the sibling trap, got ${thrown}`); + assertEq(store.awaiting.size, 1); + + // The hop lands. Its tail is queued in `store.settled`, which gates + // `Store.tick` for EVERY later driver until someone services it. + settle(undefined); + for (let i = 0; i < 20 && sibling.tails === 0; i++) { + await new Promise((r) => setTimeout(r, 0)); + } + + assert( + sibling.tails === 1, + "the sibling's activation tail was never serviced: `driveAsync` threw " + + "and left it queued in `store.settled`, where it gates `Store.tick` " + + "for every later driver", + ); + assertEq(store.settled.length, 0); + }, +}); diff --git a/runtime/tests/poisoned_host_call_retire_test.ts b/runtime/tests/poisoned_host_call_retire_test.ts new file mode 100644 index 0000000..b42ba64 --- /dev/null +++ b/runtime/tests/poisoned_host_call_retire_test.ts @@ -0,0 +1,129 @@ +// F3: poisoning an instance does not retire the corpse's outstanding host +// calls, so their LATE settlement lands on `store.hostFailure` — the channel +// the next driver on the store reads, i.e. a healthy sibling's export call. +// +// THE SHAPE +// * instance I async-lowers a host import; the call sits in +// `store.pendingHostCalls` with a continuation that owns the outcome; +// * a sibling activation of I traps; I is poisoned (a corpse); +// * the import's promise settles anyway. The continuation is unguarded +// (exec/boundary.ts:2497-2523): `subtask.resolved()` is false, so a +// rejection is parked on `store.hostFailure`. +// +// arch §6 #173 ("sibling instances of the same instantiation stay usable") +// and boundary.ts:1616 both say the corpse must be inert. A renounced call's +// late rejection is already discarded for exactly this reason; a POISONED +// call's must be too. + +import { assertEq } from "./support/asserts.ts"; +import { + createLoweredImport, + newStats, + type ResolvedOptions, +} from "../src/exec/boundary.ts"; +import { + ComponentInstanceState, + notifyInstancePoisoned, + popCurrentThread, + pushCurrentThread, + Store, + Task, + type TaskOptions, + Thread, +} from "../src/task/mod.ts"; +import { Trap } from "../src/cabi/mod.ts"; +import type { FuncType } from "../src/cabi/types.ts"; + +/** `func()` — async-typed, no results: nothing to lower, so the only thing + * under test is where the late settlement's outcome goes. */ +const FT: FuncType = { params: [], results: [], async: true }; + +const TASK_OPTS: TaskOptions = { + async_: true, + callback: true, + stringEncoding: "utf8", + memory: null, +}; + +Deno.test( + "F3: a poisoned instance's outstanding host call is retired — its late " + + "rejection does not reach store.hostFailure", + async () => { + const store = new Store(); + const inst = new ComponentInstanceState(0, store); + const memory = new WebAssembly.Memory({ initial: 1 }); + const view = { + addrType: "i32" as const, + get bytes() { + return new Uint8Array(memory.buffer); + }, + get view() { + return new DataView(memory.buffer); + }, + get length() { + return memory.buffer.byteLength; + }, + ptrType: () => "i32" as const, + ptrSize: () => 4 as const, + }; + let rejectRaw!: (e: unknown) => void; + const raw = new Promise((_, rej) => (rejectRaw = rej)); + const opts: ResolvedOptions = { + stringEncoding: "utf8", + // deno-lint-ignore no-explicit-any + memory: view as any, + realloc: null, + postReturn: null, + callback: null, + async: true, + cancellable: false, + coreType: { params: [], results: ["i32"] }, + instance: inst, + }; + const call = createLoweredImport({ + name: "host-fn-in-flight", + ft: FT, + opts, + hostFn: () => raw, + stats: newStats(), + mode: "plain", + suspendable: false, + deferCancel: false, + abortable: false, + }) as (...args: number[]) => unknown; + + const task = new Task(FT, TASK_OPTS, inst, () => [], () => {}); + const thread = new Thread(task, (function* () {})()); + pushCurrentThread(thread); + try { + // No params and no results: the async lower takes no retptr lane. + call(); + } finally { + popCurrentThread(thread); + } + assertEq(store.pendingHostCalls.size, 1); + assertEq(store.hostFailure, undefined); + + // A sibling activation of the same instance traps: the instance is a + // corpse from here on (polyengine's named divergence, arch §6 #173). + notifyInstancePoisoned(inst, new Trap("wasm trap: unreachable")); + + // The corpse's import settles late. Nobody is waiting for it. + rejectRaw(new Error("late host rejection of an abandoned call")); + for (let i = 0; i < 10; i++) await Promise.resolve(); + await new Promise((r) => setTimeout(r, 0)); + + // Discarded, exactly as a renounced call's rejection is (:2517-2522). + // Left on `hostFailure` it fails the next driven call on this store — + // a HEALTHY sibling instance's export — with the corpse's error. + const parked = store.hostFailure; + assertEq( + parked === undefined + ? "" + : String((parked as { message?: string })?.message ?? parked), + "", + ); + // And it no longer counts as outstanding external work. + assertEq(store.pendingHostCalls.size, 0); + }, +);