Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 31 additions & 6 deletions contracts/embedder-api.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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`).

Expand Down Expand Up @@ -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
Expand Down
20 changes: 17 additions & 3 deletions contracts/intrinsics.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
14 changes: 12 additions & 2 deletions runtime/src/embedder/instantiate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand All @@ -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. */
Expand Down Expand Up @@ -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]);
Expand Down
5 changes: 5 additions & 0 deletions runtime/src/embedder/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
90 changes: 83 additions & 7 deletions runtime/src/exec/boundary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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) => {
Expand Down
45 changes: 44 additions & 1 deletion runtime/src/exec/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<number>();
/** 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`). */
Expand Down Expand Up @@ -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<void> {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
});
}

Expand Down
1 change: 1 addition & 0 deletions runtime/src/jspi/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@

export * from "./mechanics.ts";
export * from "./bridge.ts";
export * from "./suspending.ts";
Loading
Loading