From 9364e7e5b25b2cc7b8daf1cb146464ec19dbc67f Mon Sep 17 00:00:00 2001 From: Lann Martin Date: Thu, 10 Sep 2026 21:51:13 -0400 Subject: [PATCH] runtime: settle async results before suspended producer exit --- runtime/src/exec/boundary.ts | 17 +- .../jspi/fixtures/task-return-settlement.wasm | Bin 0 -> 1233 bytes .../jspi/fixtures/task-return-settlement.wat | 105 +++++++++ .../tests/jspi/task_return_settlement_test.ts | 202 ++++++++++++++++++ runtime/tests/lift_result_driver_test.ts | 78 +++++++ 5 files changed, 395 insertions(+), 7 deletions(-) create mode 100644 runtime/tests/jspi/fixtures/task-return-settlement.wasm create mode 100644 runtime/tests/jspi/fixtures/task-return-settlement.wat create mode 100644 runtime/tests/jspi/task_return_settlement_test.ts create mode 100644 runtime/tests/lift_result_driver_test.ts diff --git a/runtime/src/exec/boundary.ts b/runtime/src/exec/boundary.ts index 4650fcd..6d908dc 100644 --- a/runtime/src/exec/boundary.ts +++ b/runtime/src/exec/boundary.ts @@ -1283,15 +1283,18 @@ export function createLiftedFunction(input: { let outcome: DriveExit | Promise; try { - // Driver completion is not thread exhaustion. Require a captured result, - // no awaiting wasm call of this task, and no entry hop anywhere in the - // store. Other tasks' genuine SuspensionPoint parks may remain background - // work, but any engine hop this driver started still needs servicing. - // Callback tasks can retain waiting threads after task.return; awaiting - // those threads' final exit would prevent long-lived producers returning. const midWasmCall = () => task.threads.some((t) => store.awaiting.has(t)); const hopParked = () => entryHopThreads(store).length > 0; - const driveDone = () => resolvedSeen && !midWasmCall() && !hopParked(); + // Driver completion is not thread exhaustion. CONTRACT: an async result + // is independent of producer lifetime + // (embedder-api.md:180-185; definitions.py:521-526,2360-2369). A + // genuine SuspensionPoint may therefore be handed to the settlement + // pump once the result exists. Entry hops remain part of result-memory + // ordering and must complete first. Sync lifts retain full activation + // liveness. Callback tasks may retain waiting producer threads after + // task.return; awaiting their final exit would prevent result delivery. + const driveDone = () => + resolvedSeen && !hopParked() && (ft.async || !midWasmCall()); outcome = drive( store, driveDone, diff --git a/runtime/tests/jspi/fixtures/task-return-settlement.wasm b/runtime/tests/jspi/fixtures/task-return-settlement.wasm new file mode 100644 index 0000000000000000000000000000000000000000..a12c96bd03abfb319cc06d1a314f14f0c94afb41 GIT binary patch literal 1233 zcma)6v2xQu5PiFMI>}OO6azC1Gk`K_7-sB>4~QlO75`w_IYIzSL6Y&Lasg!;S_;09 zk6`ZVMNg0LlS!CLL-9u zSX~qE9TUQ6Rh8{kxv>|d_UeNVIET&wK0?vRj-RgaEdTAE4!veHh01Mv9Sr)O> z&Tagg5`WcYOxmLPJg;rLsY|KbN5R!{Z+CU&#nQmxonj1MCysPmT(!mWi=8#LotJhi z7sq?w{8M#RO8>`uhYxawJm{#|EPWprwbX&qiUJCsDOGPu#75THYKbK9c;YDjN$NY1 z+NE@Y4Edw4Ila?oNaU=)olt5{IHQcyFykC)^gN?CiC4SOq$acRcP{%cUs@0(_=wV+7{^i-+qdEK! z%h8~1jAW=Wb*efCZp;%=0kP*>_B+NSnR3Kz!X)sjlDNbvg1bS8L=-rJNggxYO$tO< zeniLse`vC**4I@j@|!t%(GQnx$jWg~^z)**DOVi3k?+nb5{Bl^t0mW+!o3|#2h#Sw n2NFgT9815Vbm}H$f~nZ { + try { + return await Deno.readFile(new URL(rel, root)); + } catch { + return null; + } +} + +const shimWasm = await readIfPresent( + "target/wasm32-unknown-unknown/release/translator_shim.wasm", +); +if (shimWasm === null) { + console.warn( + "SKIP task.return settlement: missing translator_shim.wasm " + + "(cargo build -p translator-shim --release --target wasm32-unknown-unknown)", + ); +} +const componentBytes = await Deno.readFile( + new URL("./fixtures/task-return-settlement.wasm", import.meta.url), +); + +Deno.test({ + name: + "#323: task.return settles before a producer's genuine JSPI suspension ends", + ignore: shimWasm === null, + fn: async () => { + const gate = Promise.withResolvers(); + const entered = Promise.withResolvers(); + const continued = Promise.withResolvers(); + const translator = await Translator.create(shimWasm!); + const translated = translator.translate(componentBytes); + let probeEntered = false; + const component = await instantiate({ + componentBytes, + ...translated, + }, { + "before-gate": () => Promise.resolve(), + hop: suspending(() => undefined), + gate: () => { + entered.resolve(); + return gate.promise; + }, + continued: () => continued.resolve(), + "probe-entered": () => void (probeEntered = true), + }); + + const result = component.exports.run() as Promise; + try { + await entered.promise; + const early = await Promise.race([ + result.then((value) => ({ state: "resolved", value })), + new Promise<{ state: "pending" }>((resolve) => + setTimeout(() => resolve({ state: "pending" }), 50) + ), + ]); + assertEquals( + JSON.stringify(early), + JSON.stringify({ state: "resolved", value: 42 }), + "task.return result was withheld behind the producer gate", + ); + } finally { + // Never strand the real JSPI activation when an assertion fails. + gate.resolve(); + } + + // Observe work after the suspension independently of the settled export. + await continued.promise; + assertEquals(await result, 42); + + // The producer traps after continuation. The export result stays delivered, + // while the failure is retained and surfaced by subsequent entry. + const later = await assertRejects( + () => component.exports.probe() as Promise, + "post-delivery producer failure must reach a later entry", + ); + assertEquals( + probeEntered, + false, + "retained poison must precede probe entry", + ); + assert( + isTrap(later) && String(later).includes("unreachable"), + `expected the original branded producer trap, got ${later}`, + ); + }, +}); + +Deno.test({ + name: "#323: a late host rejection remains observable after result delivery", + ignore: shimWasm === null, + fn: async () => { + const gate = Promise.withResolvers(); + const entered = Promise.withResolvers(); + const boom = new Error("late gate rejection"); + let probeEntered = false; + const translator = await Translator.create(shimWasm!); + const component = await instantiate({ + componentBytes, + ...translator.translate(componentBytes), + }, { + "before-gate": () => Promise.resolve(), + hop: suspending(() => undefined), + gate: () => { + entered.resolve(); + return gate.promise; + }, + continued: () => { + throw new Error("continued after rejected gate"); + }, + "probe-entered": () => void (probeEntered = true), + }); + + const result = component.exports.run() as Promise; + try { + await entered.promise; + const early = await Promise.race([ + result.then((value) => ({ state: "resolved", value })), + new Promise<{ state: "pending" }>((resolve) => + setTimeout(() => resolve({ state: "pending" }), 50) + ), + ]); + assertEquals( + JSON.stringify(early), + JSON.stringify({ state: "resolved", value: 42 }), + ); + } finally { + gate.reject(boom); + } + await new Promise((resolve) => setTimeout(resolve, 0)); + + const later = await assertRejects( + () => component.exports.probe() as Promise, + "late host rejection must reach a later entry", + ); + assert( + isTrap(later) && String(later).includes(boom.message), + `expected the branded host rejection with its original cause, got ${later}`, + ); + assertEquals( + probeEntered, + true, + "the probe entered before the resumed producer's rejection surfaced", + ); + }, +}); + +Deno.test({ + name: "#323: task.return after one suspension settles at the next suspension", + ignore: shimWasm === null, + fn: async () => { + const before = Promise.withResolvers(); + const after = Promise.withResolvers(); + const enteredBefore = Promise.withResolvers(); + const enteredAfter = Promise.withResolvers(); + const continued = Promise.withResolvers(); + const translator = await Translator.create(shimWasm!); + const component = await instantiate({ + componentBytes, + ...translator.translate(componentBytes), + }, { + "before-gate": () => { + enteredBefore.resolve(); + return before.promise; + }, + hop: suspending(() => undefined), + gate: () => { + enteredAfter.resolve(); + return after.promise; + }, + continued: () => continued.resolve(), + "probe-entered": () => {}, + }); + + const result = component.exports.run() as Promise; + try { + await enteredBefore.promise; + before.resolve(); + await enteredAfter.promise; + const early = await Promise.race([ + result.then((value) => ({ state: "resolved", value })), + new Promise<{ state: "pending" }>((resolve) => + setTimeout(() => resolve({ state: "pending" }), 50) + ), + ]); + assertEquals( + JSON.stringify(early), + JSON.stringify({ state: "resolved", value: 42 }), + ); + } finally { + before.resolve(); + after.resolve(); + } + await continued.promise; + }, +}); diff --git a/runtime/tests/lift_result_driver_test.ts b/runtime/tests/lift_result_driver_test.ts new file mode 100644 index 0000000..13978b5 --- /dev/null +++ b/runtime/tests/lift_result_driver_test.ts @@ -0,0 +1,78 @@ +import { assertEq } from "./support/asserts.ts"; +import { + createLiftedFunction, + newStats, + registerHostCall, + type ResolvedOptions, +} from "../src/exec/mod.ts"; +import { + ComponentInstanceState, + currentThread, + Store, +} from "../src/task/mod.ts"; +import type { FuncType } from "../src/cabi/types.ts"; + +const FT: FuncType = { params: [], results: [], async: true }; + +Deno.test("#323: a foreign driver failure does not unwind the live producer", async () => { + const store = new Store(); + const inst = new ComponentInstanceState(0, store); + const opts: ResolvedOptions = { + stringEncoding: "utf8", + memory: null, + realloc: null, + postReturn: null, + callback: () => (() => 0) as never, + async: true, + cancellable: false, + coreType: { params: [], results: ["i32"] }, + instance: inst, + }; + const gate = Promise.withResolvers(); + let releases = 0; + let producerThread!: { + syncCallStack: { releaseLenders(): void }[]; + }; + const ownerWait = { + owner: undefined as unknown, + ready: () => false, + waiting: () => true, + resume: () => {}, + }; + const producer = createLiftedFunction({ + name: "producer", + ft: FT, + opts, + core: () => { + producerThread = currentThread() as typeof producerThread; + producerThread.syncCallStack.push({ + releaseLenders: () => releases++, + }); + const task = (currentThread() as unknown as { + task: { return_(result: never[]): void }; + }).task; + task.return_([]); + ownerWait.owner = currentThread(); + store.startWaiting(ownerWait as never); + return gate.promise; + }, + stats: newStats(), + }); + + const result = producer() as Promise; + assertEq(await result, undefined); + + const boom = new Error("foreign driver failure"); + let wake!: () => void; + const host = new Promise((resolve) => (wake = resolve)); + registerHostCall(store, host); + store.hostFailure = boom; + wake(); + await new Promise((resolve) => setTimeout(resolve, 0)); + assertEq(releases, 0, "foreign failure must not unwind producer scopes"); + + store.stopWaiting(ownerWait as never); + producerThread.syncCallStack.pop(); + gate.resolve(0); + await new Promise((resolve) => setTimeout(resolve, 0)); +});