diff --git a/contracts/embedder-api.md b/contracts/embedder-api.md index 6aef11a..62157ca 100644 --- a/contracts/embedder-api.md +++ b/contracts/embedder-api.md @@ -1,7 +1,9 @@ # Embedder API conventions (host-facing) -Status: **v0.1 — C1 deliverable (docs/milestones.md), normative for the C2 -implementation.** This document supersedes `descriptor-ir.md`'s interim +Status: **v0.2 — C1 deliverable (docs/milestones.md), normative for the C2 +implementation; amendment A1 (2026-08-10) makes sync-import suspension a +declared, per-function capability (`suspending()`), replacing v0.1's +undeclared "permitted cast".** This document supersedes `descriptor-ir.md`'s interim "host value mapping" table as the destination for host-facing value shapes. The runtime's *raw* boundary (`instance.exports`, `HostImports`) keeps the `definitions.py` interpreter shapes as an **internal** surface; the @@ -225,8 +227,30 @@ class Trap extends Error { … } // existing; component-fatal, never a value - **Imports match their WIT type**: an `async func` import may be a plain `async` JS function (or return a value synchronously); a sync `func` import is typed to return `T` synchronously. Returning a Promise from a - sync-typed import is *permitted* but rides JSPI (engine floor caveat) — - bindgen's types make that a visible, deliberate cast. + sync-typed import parks the calling **wasm frame** and is a *declared* + capability (amendment A1): wrap the function in `suspending()` (exported + from the embedder surface). The marker + - is per-declaration — only marked imports are handed to wasm as + `WebAssembly.Suspending`, so unmarked imports keep the plain calling + convention and sync-only components keep their zero-cost pin; + - is auto-detection evidence — a marked import selects jspi mode without + an explicit `jspi: true` (an explicit `jspi: false` still forces plain, + where a returned Promise is refused as before); + - carries real costs, deliberately visible: every call through a marked + import pays the engine's continuation hop even when it returns + synchronously (`contracts/intrinsics.md` pin (j)), and a marked import + reached from a `start` function traps (pin (c): a start function may + not block — the trap fires even for synchronous returns); + - rides the engine floor: on a non-JSPI engine a marked import that + returns a Promise is refused at the call site (`NeedsJspi`), never + silently degraded. + Scope: plain function imports (bare and interface members). Resource + methods/statics/constructors are outside A1 (constructors are synchronous + by the C2 amendment). Semantics of the park: the reference's + `thread.wait_until(subtask.resolved)` (definitions.py canon_lower) — a + plain non-cancellable wait; the instance-entry gate stays held (the #43 + hold rule); result lowering runs at resume time under the suspension + point's attribution claim. - Params are positional; param names appear only in types/docs (they are excluded from the world digest — `contracts/digest.md`). @@ -382,8 +406,9 @@ must park — the one p2 idiom that fights a JS host. Three-tier strategy: `wasi:io/poll` via the libc baseline yet **no leg ever called a pollable method**; (b) buffer-backed streams: sync `read`/`check-write` serve from host-side buffers filled by background pumps, so the sync fast path never -parks; (c) when a guest genuinely blocks: the Promise-from-sync-import -path rides JSPI (engine-floor caveat, visible in types). A pollable is a +parks; (c) when a guest genuinely blocks: a `suspending()`-marked import +(amendment A1) parks the frame on JSPI (engine-floor caveat, visible in +types and in the marker). A pollable is a thin class over a task-core waitable: ```ts diff --git a/contracts/intrinsics.md b/contracts/intrinsics.md index bb55927..9e6f1ab 100644 --- a/contracts/intrinsics.md +++ b/contracts/intrinsics.md @@ -225,6 +225,20 @@ determinacy park, and plain mode provably never needs the drain (without JSPI a frame cannot park mid-invocation, so a held gate always belongs to the currently-running activation — the one obstacle a drain cannot remove); the plain path stays zero-cost for sync-only components. -Host-import lowers are deliberately outside the suspension -classification — a sync-lowered Promise-returning host function degrades -to a clean `NeedsJspi` capability signal. +Host-import lowers joined the suspension classification on 2026-08-10 +(embedder-api.md amendment A1): a `suspending()`-marked import is a +genuine blocker — Suspending-wrapped, importer-contaminating (transitive +suspendability, so entries get promising-wrapped per pin (c)), and +evidence for auto-detection — with the park implemented as +`blockCurrentActivation` on the recorded settlement +(`readyFunc`-driven; result lowering deferred to `produce` so realloc +re-entry runs under the resume-time attribution claim, the issue-#24 +discipline). The park is the reference's plain non-cancellable +`thread.wait_until(subtask.resolved)` (canon_lower line 2286); the gate +stays held across it (the #43 hold rule). UNMARKED sync-lowered +Promise-returning host functions still degrade to the clean `NeedsJspi` +capability signal in every mode — marking is the embedder's explicit, +per-declaration opt-in, never inferred. Pinned by +`runtime/tests/embedder/suspending_imports_test.ts` (park round trip, +resume-time realloc, pin-(c) start trap, refusal messages) and the +plain-mode guard in `runtime/tests/async_lower_test.ts`. diff --git a/runtime/src/embedder/instantiate.ts b/runtime/src/embedder/instantiate.ts index fba5ad5..ded3945 100644 --- a/runtime/src/embedder/instantiate.ts +++ b/runtime/src/embedder/instantiate.ts @@ -24,6 +24,7 @@ import { instantiateComponent, } from "../exec/mod.ts"; import { camelCase, parseLeafName, pascalCase } from "./casing.ts"; +import { isSuspending, suspending } from "../jspi/suspending.ts"; import { NameCollisionError, WitError } from "./errors.ts"; import { type ImportLeaf, requiredImports } from "./imports.ts"; import { @@ -442,7 +443,7 @@ class Facade { // and those objects do not exist until `instantiateComponent` has run — // which is after this wrapper has to be handed to it. let impl: RawFn | null = null; - return (...raw: unknown[]) => { + const wrapper = (...raw: unknown[]) => { if (impl === null) { const ft = this.#funcType( this.artifacts.plan.imports[importIndex].type, @@ -452,6 +453,9 @@ class Facade { } return impl(...raw); }; + // A1 brand relay, layer 2 of 2 (see #dispatcher): the executor reads the + // brand off this wrapper, which is what lands in its hostImports record. + return isSuspending(dispatch) ? suspending(wrapper) : wrapper; } /** A host-implemented resource type: register the class, own the mapping. */ @@ -502,7 +506,13 @@ class Facade { `${describe(fn)}); expected '${camelCase(m.name)}'`, ); } - return (args) => (fn as RawFn)(...args); + // A1: the `suspending()` brand rides the dispatch closure so #wrapLeaf + // can relay it onto the value the executor actually receives. Plain + // functions only — resource methods/statics/constructors are outside + // A1's scope (constructors are synchronous by the C2 amendment). + const dispatch: (args: unknown[]) => unknown = (args) => + (fn as RawFn)(...args); + return isSuspending(fn) ? suspending(dispatch) : dispatch; } const clsName = pascalCase(m.resource); const cls = pick(provider, [], [clsName, m.resource]); diff --git a/runtime/src/embedder/mod.ts b/runtime/src/embedder/mod.ts index 54a69d1..7f51309 100644 --- a/runtime/src/embedder/mod.ts +++ b/runtime/src/embedder/mod.ts @@ -40,6 +40,11 @@ export { GuestResource, HostResourceRegistry } from "./resources.ts"; export { camelCase, type LeafName, parseLeafName, pascalCase } from "./casing.ts"; +// Per-declaration suspendability (contracts/embedder-api.md §"Functions and +// async", amendment A1): declares that a sync-typed host import may return a +// Promise, parking the calling wasm frame (JSPI engines only). +export { suspending } from "../jspi/suspending.ts"; + export { asTrackKeySpelling, compareSemver, diff --git a/runtime/src/exec/boundary.ts b/runtime/src/exec/boundary.ts index 0ae9c45..03f4069 100644 --- a/runtime/src/exec/boundary.ts +++ b/runtime/src/exec/boundary.ts @@ -48,8 +48,13 @@ import { type TaskOptions, Thread, } from "../task/mod.ts"; +import { currentTask } from "../task/scheduler.ts"; import { PlanError } from "../plan/loader.ts"; -import { enterWasm, type SuspensionMode } from "../jspi/mod.ts"; +import { + blockCurrentActivation, + enterWasm, + type SuspensionMode, +} from "../jspi/mod.ts"; /** * Structural view of an intrinsics `SyncCallScope`: everything this module @@ -1432,8 +1437,12 @@ export function createLoweredImport(input: { opts: ResolvedOptions; hostFn: (...args: unknown[]) => unknown; stats: ExecutionStats; + /** Executor's suspension mode; decides whether a sync lower may park. */ + mode: SuspensionMode; + /** Host fn carries the `suspending()` brand (embedder-api.md A1). */ + suspendable: boolean; }): CoreFn { - const { name, ft, opts, hostFn, stats } = input; + const { name, ft, opts, hostFn, stats, mode, suspendable } = input; const inst = opts.instance; const store = inst.store; @@ -1537,12 +1546,79 @@ export function createLoweredImport(input: { if (isPromiseLike(raw)) { if (!opts.async) { - // definitions.py line 2286: `thread.wait_until(subtask.resolved)` — - // blocking the calling *wasm frame*. - needsJspi( - `synchronous lower of import '${name}', whose host implementation ` + - `returned a Promise (the guest's wasm frame must block)`, + if (mode !== "jspi" || !suspendable) { + // definitions.py line 2286: `thread.wait_until(subtask.resolved)` — + // blocking the calling *wasm frame*. Parking needs BOTH jspi mode + // and the embedder's per-declaration `suspending()` marker: the + // Suspending wrap is applied per-declaration (`importValue`), so an + // unmarked import physically cannot suspend, whatever the mode. + needsJspi( + suspendable + ? `synchronous lower of import '${name}', whose host ` + + `implementation returned a Promise (the guest's wasm frame ` + + `must block)` + : `synchronous lower of import '${name}', whose host ` + + `implementation returned a Promise; a sync-typed import may ` + + `only park the frame when declared with suspending() ` + + `(contracts/embedder-api.md §"Functions and async")`, + ); + } + // The park (A1): the reference's plain, NON-cancellable wait — a + // cancel request against the caller stays pending-cancel and is + // delivered at its next cancellable wait, exactly as for any other + // mid-frame block. The instance-entry gate stays HELD across the park + // (the #43 hold rule; see `blockCurrentActivation`'s GATE LIFETIME + // note). + // + // The settle handler only RECORDS the outcome. All CABI work — + // `onResolve`'s result lowering (which may re-enter the guest through + // realloc) and `deliverResolve` — is deferred to `produce`, which + // runs at resume time under the suspension point's ambient claim. + // Lowering from the bare promise continuation instead would execute + // guest code in an unattributed chunk — the issue-#24 class the + // attribution sentinels exist to prevent. + let outcome: { value: unknown } | { error: unknown } | undefined; + const promise = Promise.resolve(raw).then( + (v) => { + store.pendingHostCalls.delete(promise); + outcome = { value: v }; + }, + (e) => { + store.pendingHostCalls.delete(promise); + outcome = { error: e }; + }, ); + // Registered so the driver's deadlock probe counts this park as + // externally-wakeable (driveAsync: `pendingHostCalls.size === 0` is a + // precondition of the deadlock verdict) and so teardown can observe + // the outstanding call, mirroring the async arm below. + store.pendingHostCalls.add(promise); + return blockCurrentActivation({ + store, + task: currentTask(), + readyFunc: () => outcome !== undefined, + cancellable: false, + produce: () => { + const done = outcome as { value: unknown } | { error: unknown }; + if ("error" in done) { + // A rejection of a sync-typed import is a host failure: it + // reaches the guest as a rejection of the import's Promise, + // which the engine turns back into a wasm trap (empirical + // fact (e); `SuspensionPoint` routes a produce-throw through + // exactly that path). Branded `WitError`s never reach the raw + // boundary — the bindgen adapter resolves them into err-shaped + // values one layer up. + throw done.error; + } + onResolve(toResults(done.value)); + subtask.deliverResolve(); + assert_(vi.done(), `${name}: unconsumed flat arguments`); + const flatResults = subtask.flatResults; + if (flatResults.length === 0) return undefined; + if (flatResults.length === 1) return flatResults[0]; + return flatResults; + }, + }); } const promise = Promise.resolve(raw).then( (v) => { diff --git a/runtime/src/exec/executor.ts b/runtime/src/exec/executor.ts index 1a51807..547738b 100644 --- a/runtime/src/exec/executor.ts +++ b/runtime/src/exec/executor.ts @@ -13,8 +13,10 @@ import type { ComponentValue, FuncType, ValType } from "../cabi/types.ts"; import { Trap } from "../cabi/trap.ts"; import { ComponentInstanceState, Store } from "../task/mod.ts"; import { + anySuspendingImport, assertModeConsistent, chooseMode, + isSuspending, planNeedsSuspension, suspendingImport, trampolineCanBlock, @@ -338,6 +340,9 @@ class Executor { /** Scratch: set by `importValue` while one module's imports are resolved. */ private sawBlockingImport = false; + /** LoweredIndex-es whose host functions carry the `suspending()` brand — + * populated by `buildLoweredImport`, read by `importValue` (A1). */ + private readonly suspendableLowerings = new Set(); /** Host trap held across a FACT exception barrier (see `HostTrapState`). */ readonly trapState: HostTrapState = { pending: undefined }; /** Export path -> why it has no runtime surface (see `buildExport`). */ @@ -376,7 +381,15 @@ class Executor { // and start-function suspension mapping: the conformance suite's // builtin-trap-poisons-instance / dont-block-start files, green // under detection. - this.suspensionMode = chooseMode(input.jspi, planNeedsSuspension(loaded.wire)); + this.suspensionMode = chooseMode( + input.jspi, + // Auto-detection evidence, two independent sources: the PLAN (a + // stackful async lift or a blocking built-in — per-declaration), and + // the IMPORTS RECORD (a `suspending()`-marked host function: the + // embedder's declared intent to park a sync-lowered frame, which no + // plan field can express — embedder-api.md amendment A1). + planNeedsSuspension(loaded.wire) || anySuspendingImport(this.hostImports), + ); } async verifyComponent(): Promise { @@ -864,6 +877,25 @@ class Executor { const optionsAsync = (i: number) => this.wire.canonicalOptions[i]?.async === true; const d = decl as { kind: string; async?: unknown; options?: unknown }; + // Host lowers (A1): `trampolineCanBlock` classifies DECLARATIONS and a + // `lower-import` declaration says nothing about the host's intent — the + // evidence is the `suspending()` brand on the host function, recorded by + // `buildLoweredImport` into `suspendableLowerings` (which resolving this + // very def just populated, one frame down). A marked lower is a genuine + // blocker: it marks the importer (transitive suspendability → + // promising-wrapped entries, satisfying jspi pin (c)) and gets the + // Suspending wrap so a returned Promise parks the frame instead of + // tripping the boundary's guard. + if (d.kind === "lower-import") { + const lowered = (d as unknown as { lowered: number }).lowered; + if (!this.suspendableLowerings.has(lowered)) return value; + this.sawBlockingImport = true; + this.noteImport(); + return suspendingImport( + value as (...a: never[]) => unknown, + "jspi", + ) as unknown as Importable; + } if (!trampolineCanBlock(d, optionsAsync)) return value; // `async-start-call` is wrapped (its jspi-only determinacy park must be // able to suspend the caller) but does NOT mark the importer: see @@ -1037,12 +1069,23 @@ class Executor { } const ft = this.funcType(decl.type, `import '${label}'`); const opts = this.resolveOptions(decl.options); + const suspendable = isSuspending(value); + // The Suspending-wrap decision is taken in `importValue`, which sees the + // trampoline only AFTER `createTrampoline`'s trap-recording wrapper has + // replaced this function's identity — a brand on the CoreFn would die + // there (measured: the returned Promise coerced to 0 through the + // unwrapped import). Record the decision as executor state instead, + // keyed by LoweredIndex; `importValue` runs later on the same call + // stack, so the set is populated by construction when it reads. + if (suspendable) this.suspendableLowerings.add(decl.lowered); return createLoweredImport({ name: label, ft, opts, hostFn: value as (...args: unknown[]) => unknown, stats: this.stats, + mode: this.suspensionMode, + suspendable, }); } diff --git a/runtime/src/jspi/mod.ts b/runtime/src/jspi/mod.ts index 5e7c954..50018ba 100644 --- a/runtime/src/jspi/mod.ts +++ b/runtime/src/jspi/mod.ts @@ -3,3 +3,4 @@ export * from "./mechanics.ts"; export * from "./bridge.ts"; +export * from "./suspending.ts"; diff --git a/runtime/src/jspi/suspending.ts b/runtime/src/jspi/suspending.ts new file mode 100644 index 0000000..85767d3 --- /dev/null +++ b/runtime/src/jspi/suspending.ts @@ -0,0 +1,76 @@ +// The per-declaration suspendability marker (contracts/embedder-api.md +// §"Functions and async", amendment A1; docs/architecture.md §5). +// +// Returning a Promise from a sync-typed host import blocks the calling wasm +// FRAME — a capability with per-call cost (jspi pin (j): a Suspending +// import's continuation is deferred even on the fast path) and legality +// consequences (pin (c): a Suspending import called outside a promising +// activation traps, so a start function must never reach one). Neither cost +// may be imposed silently on every host import, and the plan cannot know +// which imports intend to park (`planNeedsSuspension` sees declarations, +// not host implementations). The embedder therefore declares intent +// per-function: only imports wrapped in `suspending()` are handed to wasm +// as `WebAssembly.Suspending`, everything else keeps the plain calling +// convention and its zero-cost pin. +// +// Layering: this module is import-free on purpose (jspi/ stays standalone); +// the embedder surface re-exports `suspending` from `@deltic/runtime/embedder`. + +/** Brand carried by host functions declared suspendable. Local symbol by + * repo convention (see embedder/resources.ts `STATE`): bundle and source + * runtimes are never mixed in one process. */ +const SUSPENDING = Symbol("deltic.suspending-import"); + +interface Suspendable { + [SUSPENDING]?: true; +} + +/** + * Declare that this sync-typed host import may return a Promise, parking + * the calling wasm frame until it settles (JSPI engines only — the + * engine-floor caveat of contracts/embedder-api.md §"Functions and async"). + * + * The declaration is evidence for jspi auto-detection, forces the importing + * component's entries onto the promising convention (pin (c)), and adds a + * continuation hop to EVERY call through this import even when it returns + * synchronously (pin (j)) — mark only imports that genuinely park. Async- + * typed imports never need this: a Promise from an async import rides the + * task core with no JSPI involved. + * + * The value is marked in place (functions are objects); the return is the + * same function, typed for insertion into an imports record. + */ +export function suspending(fn: F): F { + (fn as F & Suspendable)[SUSPENDING] = true; + return fn; +} + +/** Brand check (executor-side). */ +export function isSuspending(value: unknown): boolean { + return typeof value === "function" && + (value as Suspendable)[SUSPENDING] === true; +} + +/** + * Does this imports record declare any suspending leaf? Evidence for + * `chooseMode`: a marked import is an embedder statement that a park is + * expected, so auto-detection selects jspi even when the plan itself shows + * no blocking declarations (the p2 sync-world case: a component whose only + * blocking site is a host pollable). Walks exactly the shapes + * `lookupHostImport` can reach: top-level values and one level of + * interface-record members. + */ +export function anySuspendingImport( + imports: Record | undefined, +): boolean { + if (imports === undefined) return false; + for (const value of Object.values(imports)) { + if (isSuspending(value)) return true; + if (value !== null && typeof value === "object") { + for (const member of Object.values(value)) { + if (isSuspending(member)) return true; + } + } + } + return false; +} diff --git a/runtime/tests/async_lower_test.ts b/runtime/tests/async_lower_test.ts index 25087f1..9f5cac1 100644 --- a/runtime/tests/async_lower_test.ts +++ b/runtime/tests/async_lower_test.ts @@ -103,6 +103,8 @@ function mkFixture(hostFn: (...a: unknown[]) => unknown): Fixture { opts, hostFn, stats: newStats(), + mode: "plain", + suspendable: false, }) as (...args: number[]) => unknown; const task = new Task(FT, TASK_OPTS, inst, () => [], () => {}); @@ -231,6 +233,11 @@ Deno.test("sync lower of a Promise-returning host import needs JSPI", () => { opts, hostFn: () => Promise.resolve(1), stats: newStats(), + // Plain mode: the A1 park arm is jspi-only, so this stays the guard pin + // for the no-JSPI path. The marked+jspi park itself is pinned by + // tests/embedder/suspending_imports_test.ts. + mode: "plain", + suspendable: false, }); const task = new Task(syncFt, { async_: false, diff --git a/runtime/tests/embedder/suspending_imports_test.ts b/runtime/tests/embedder/suspending_imports_test.ts new file mode 100644 index 0000000..99e6445 --- /dev/null +++ b/runtime/tests/embedder/suspending_imports_test.ts @@ -0,0 +1,254 @@ +// Per-declaration suspendable host imports — contracts/embedder-api.md +// §"Functions and async", amendment A1 (the `suspending()` marker), through +// the conventions facade. +// +// Before A1 the boundary REJECTED a Promise from a sync-typed lower in every +// mode (`NeedsJspi`, boundary.ts) — the M2 jspi flip lit the CM-async +// builtins' suspension sites but never the host-lower site, because no +// consumer and no suite command drives one (callback-ABI consumers use +// async-typed imports). A1 makes the park real: a `suspending()`-marked +// import may return a Promise, the calling wasm FRAME suspends on the +// engine's JSPI, and the settled value is lowered at resume time under the +// suspension point's ambient claim (the issue-#24 attribution discipline). +// +// Fixtures: `crates/translator-shim/testdata/imports.wasm` (sync-typed +// add/greet/log; greet's string result drives guest realloc at lowering +// time) and `tests/embedder/start-imports.wasm` (imports called from a core +// `start` function — the pin (c) legality boundary). + +import { assertEq } from "../support/asserts.ts"; +import { assert as assertTrue } from "../jspi/asserts.ts"; +import { + caught, + haveFixture, + instantiateFixture, + readArtifact, + testdata, +} from "./support.ts"; +import { suspending } from "../../src/embedder/mod.ts"; +import { + anySuspendingImport, + isSuspending, + isSupported, +} from "../../src/jspi/mod.ts"; + +const ready = (await haveFixture(testdata("imports"))) && isSupported(); + +/** A Promise that settles only after a real macrotask hop, so a "park" is a + * genuine suspension across the event loop, never a microtask formality. */ +function later(value: T): Promise { + return new Promise((r) => setTimeout(() => r(value), 0)); +} + +Deno.test({ + name: "suspending(): a marked sync-typed import parks the frame and resumes with the value", + ignore: !ready, + fn: async () => { + const logged: number[] = []; + const c = await instantiateFixture(testdata("imports"), { + log: (x: number) => void logged.push(x), + "host:api/math": { + add: suspending((a: number, b: number) => later(a + b)), + greet: (who: string) => `hello ${who}`, + }, + }); + // run = log(add(a, b)); return it. The frame parks inside `add`, and the + // POST-RESUME continuation still reaches the unmarked `log` import with + // the settled value — the resumed chunk runs with correct attribution. + assertEq(await c.exports.run(2, 40), 42); + assertEq(logged, [42]); + }, +}); + +Deno.test({ + name: "suspending(): resume-time result lowering drives guest realloc (string result)", + ignore: !ready, + fn: async () => { + // greet: string -> string. Lowering the settled result re-enters the + // guest through realloc — the CABI work A1 defers to `produce` so it + // runs under the suspension point's claim, not in a bare promise + // continuation (issue #24's mis-attribution class). + const c = await instantiateFixture(testdata("imports"), { + log: () => {}, + "host:api/math": { + add: (a: number, b: number) => a + b, + greet: suspending((who: string) => later(`hello ${who}`)), + }, + }); + assertEq(await c.exports.greetLen(), "hello ab".length); + }, +}); + +Deno.test({ + name: "suspending(): a marked import returning synchronously stays on the value path", + ignore: !ready, + fn: async () => { + // Marking declares that the import MAY park, not that it must: a plain + // return takes the Suspending fast path (jspi pin (j) adds only the + // continuation hop, which is unobservable at this level). + const c = await instantiateFixture(testdata("imports"), { + log: () => {}, + "host:api/math": { + add: suspending((a: number, b: number) => a + b), + greet: (who: string) => `hello ${who}`, + }, + }); + assertEq(await c.exports.run(20, 22), 42); + }, +}); + +Deno.test({ + name: "unmarked sync import returning a Promise still refuses, naming suspending()", + ignore: !ready, + fn: async () => { + // Fail-on-pre-fix shape, upgraded message: without the marker there is + // no Suspending wrap, so the frame physically cannot park — the refusal + // must tell the embedder about the A1 marker rather than dead-end on + // "needs JSPI" alone. + const c = await instantiateFixture(testdata("imports"), { + log: () => {}, + "host:api/math": { + add: (a: number, b: number) => later(a + b), // NOT marked + greet: (who: string) => `hello ${who}`, + }, + }); + const e = await caught(() => c.exports.run(1, 2)); + assertTrue(e !== undefined, "expected the export call to reject"); + assertTrue( + String(e).includes("suspending()"), + `refusal should name the marker, got: ${e}`, + ); + }, +}); + +Deno.test({ + name: "explicit jspi:false forces plain mode; a marked import's Promise still refuses", + ignore: !ready, + fn: async () => { + // The embedder's explicit override outranks marker evidence (chooseMode: + // `jspi: false` always forces plain — the engine-floor escape hatch). + const c = await instantiateFixture(testdata("imports"), { + log: () => {}, + "host:api/math": { + add: suspending((a: number, b: number) => later(a + b)), + greet: (who: string) => `hello ${who}`, + }, + }, { jspi: false }); + const e = await caught(() => c.exports.run(1, 2)); + assertTrue(e !== undefined, "expected the export call to reject"); + assertTrue( + String(e).includes("must block"), + `plain-mode refusal names the blocked frame, got: ${e}`, + ); + }, +}); + +Deno.test({ + name: "suspending(): a rejected host promise surfaces as the export call's failure", + ignore: !ready, + fn: async () => { + // A rejection at resume time routes through the suspension point's fail + // path: the engine unwinds the parked frame (empirical fact (e): a + // post-resume trap is an ordinary rejection). Branded WitErrors never + // reach this layer raw — this is the unbranded-failure path. + const c = await instantiateFixture(testdata("imports"), { + log: () => {}, + "host:api/math": { + add: suspending(() => + new Promise((_r, reject) => + setTimeout(() => reject(new Error("entropy pool on fire")), 0) + ) + ), + greet: (who: string) => `hello ${who}`, + }, + }); + const e = await caught(() => c.exports.run(1, 2)); + assertTrue(e !== undefined, "expected the export call to reject"); + assertTrue( + String(e).includes("entropy pool on fire"), + `the host's failure should surface, got: ${e}`, + ); + }, +}); + +const fallibleReady = + (await readArtifact("runtime/tests/embedder/host-result.wasm")) !== null && + isSupported(); + +Deno.test({ + name: "suspending(): a WitError rejection over a park becomes the guest's err case, not a trap", + ignore: !fallibleReady, + fn: async () => { + // The branded-throw contract survives the suspension: #wrapImportFn + // chains the marked import's Promise through its ok/fail adapters, so a + // WitError REJECTION settles the boundary promise with the err-shaped + // value — the parked frame resumes into `result::err` (run() == 1), and + // nothing traps. The sync-throw variant of this pin lives in + // host_imports_test.ts; this is the same rail at resume time. + const { WitError } = await import("../../src/embedder/mod.ts"); + const c = await instantiateFixture( + "runtime/tests/embedder/host-result.wasm", + { + "host:api/fallible": { + check: suspending(() => + new Promise((_r, reject) => + setTimeout(() => reject(new WitError(undefined)), 0) + ) + ), + }, + }, + ); + // run(): u32 — the guest hands back the flat discriminant it observed. + assertEq(await c.exports.run(), 1, "1 == the guest observed err"); + }, +}); + +const startReady = + (await readArtifact("runtime/tests/embedder/start-imports.wasm")) !== null && + (await haveFixture(testdata("imports"))) && isSupported(); + +Deno.test({ + name: "suspending(): a marked import reached from a start function traps (pin (c)), even returning synchronously", + ignore: !startReady, + fn: async () => { + // THE documented cost of marking (suspending.ts doc): a Suspending + // import called outside a promising activation traps unconditionally — + // and a start function is never a promising activation. This is the + // Component Model's own rule (a start function may not block) enforced + // by the engine, and it fires even when the marked import would have + // returned synchronously. Unmarked, the same fixture instantiates fine + // (start_imports_test.ts pins that). + const e = await caught(() => + instantiateFixture("runtime/tests/embedder/start-imports.wasm", { + "host:api/boot": { + tick: suspending(() => 7n), // synchronous return; marking alone trips pin (c) + note: (_msg: string) => {}, + }, + }) + ); + assertTrue(e !== undefined, "expected instantiation to fail"); + assertTrue( + String(e).includes("cannot block a synchronous task"), + `expected the dont-block-start wording, got: ${e}`, + ); + }, +}); + +Deno.test("suspending(): marker mechanics (brand, identity, record scan)", () => { + const fn = (x: number) => x; + const marked = suspending(fn); + assertTrue(marked === fn, "suspending() marks in place"); + assertTrue(isSuspending(marked), "brand readable"); + assertTrue(!isSuspending((x: number) => x), "unmarked fn clean"); + assertTrue(!isSuspending({}), "non-functions never branded"); + // Record scan: top-level and interface-member leaves, exactly the shapes + // `lookupHostImport` reaches. NB `suspending()` marks in place, so the + // negative case needs genuinely fresh functions. + assertTrue(anySuspendingImport({ log: marked })); + assertTrue(anySuspendingImport({ "ns:pkg/i@1.0": { f: marked } })); + assertTrue( + !anySuspendingImport({ log: () => 0, "ns:pkg/i@1.0": { g: () => 1 } }), + ); + assertTrue(!anySuspendingImport(undefined)); + assertTrue(!anySuspendingImport({})); +});