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
32 changes: 24 additions & 8 deletions contracts/embedder-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@
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
undeclared "permitted cast"; amendment A2 (2026-08-10) extends A1 to
host-resource methods/statics (class-prototype authority), adds the
stage-3 decorator form, and makes interface members receive their
containing object as `this`.** 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 @@ -244,13 +247,26 @@ class Trap extends Error { … } // existing; component-fatal, never a value
- 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.
Scope (as extended by A2): plain function imports (bare and interface
members), host-resource **methods and statics** — mark instance methods
on the class (the CLASS PROTOTYPE is the per-declaration brand
authority, read at wrap time; instance-level overrides change the
dispatched body, never suspendability), statics on the function itself.
Constructors are never markable (synchronous by the C2 amendment). Two
spellings, one brand: the direct call (`f: suspending(fn)` — canonical,
the only form available in record literals) and a stage-3 method
decorator (`@suspending` on instance or static methods). The decorator
refuses non-method positions and the legacy `experimentalDecorators`
calling convention loudly, at class-definition time. 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.
- **Interface members are invoked with their containing object as
receiver** (A2): a class instance is a fully supported spelling of an
interface provider — methods reading instance state work, matching the
resource static arm's behavior. World-level bare imports have no
containing object and are called unbound.
- Params are positional; param names appear only in types/docs (they are
excluded from the world digest — `contracts/digest.md`).

Expand Down
42 changes: 35 additions & 7 deletions runtime/src/embedder/instantiate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -507,11 +507,20 @@ class Facade {
);
}
// 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).
// can relay it onto the value the executor actually receives.
//
// A2 receiver rule: an interface member is invoked with its containing
// object as receiver (matching the static arm's `apply(cls)`), so a
// class INSTANCE is a fully supported spelling of an interface
// provider — methods reading instance state work. A world-level bare
// import has no containing object and stays unbound. (Previously the
// plain arm called extracted functions unbound: a class-instance
// provider type-checked, worked while stateless, and broke with
// `this === undefined` the moment a method touched state — the silent
// liberal-acceptance failure the contract forbids.)
const receiver = leaf.path.length === 0 ? undefined : provider;
const dispatch: (args: unknown[]) => unknown = (args) =>
(fn as RawFn)(...args);
(fn as RawFn).apply(receiver, args);
return isSuspending(fn) ? suspending(dispatch) : dispatch;
}
const clsName = pascalCase(m.resource);
Expand All @@ -523,10 +532,22 @@ class Facade {
}
switch (m.form) {
case "constructor":
// Never markable: guest-driven construction of a host resource is
// synchronous by the C2 amendment, and stage-3 reserves no
// constructor-decorator position.
// deno-lint-ignore no-explicit-any
return (args) => new (cls as any)(...args);
case "method":
return (args) => {
case "method": {
// A2: the brand authority for an instance method is the CLASS
// PROTOTYPE, read at wrap time — the Suspending-wrap decision is
// per-declaration and taken at instantiation, before any instance
// exists. Instance-level method overrides do not change
// suspendability (marking follows the WIT declaration, not the
// object); the per-call lookup below still dispatches to the
// override's BODY as before.
const protoFn = (cls as { prototype?: Record<string, unknown> })
?.prototype?.[camelCase(m.member)];
const dispatch: (args: unknown[]) => unknown = (args) => {
const [self, ...rest] = args;
const fn = (self as Record<string, unknown>)?.[camelCase(m.member)];
if (typeof fn !== "function") {
Expand All @@ -537,6 +558,8 @@ class Facade {
}
return (fn as RawFn).apply(self, rest);
};
return isSuspending(protoFn) ? suspending(dispatch) : dispatch;
}
case "static": {
const fn = (cls as Record<string, unknown>)[camelCase(m.member)];
if (typeof fn !== "function") {
Expand All @@ -545,7 +568,12 @@ class Facade {
`'${camelCase(m.member)}'`,
);
}
return (args) => (fn as RawFn).apply(cls, args);
// A2: a static's brand sits on the function itself (a stage-3
// static-method decorator marks the function value), readable here
// at wrap time.
const dispatch: (args: unknown[]) => unknown = (args) =>
(fn as RawFn).apply(cls, args);
return isSuspending(fn) ? suspending(dispatch) : dispatch;
}
}
}
Expand Down
58 changes: 56 additions & 2 deletions runtime/src/jspi/suspending.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,64 @@ interface Suspendable {
* typed imports never need this: a Promise from an async import rides the
* task core with no JSPI involved.
*
* Two forms, one brand (amendment A2):
*
* * **direct call** — `poll: suspending((list) => …)` — the canonical
* form, and the only one available inside record literals;
* * **stage-3 method decorator** — `@suspending read() { … }` on a
* provider class or a host-implemented resource class (methods and
* statics; the brand authority for instance methods is the CLASS
* PROTOTYPE — see instantiate.ts `#dispatcher`).
*
* The decorator form REFUSES anything it cannot mark, loudly: decorating a
* class, getter, setter, accessor or field throws at class-definition time
* (a silent no-op would surface as a runtime `NeedsJspi` far from the
* mistake), and the TypeScript-legacy `experimentalDecorators` calling
* convention throws with a pointer here — under that convention the
* decorator receives the PROTOTYPE, not the method, and marking it would
* both brand the wrong object and corrupt the property descriptor.
* Constructors are never markable (synchronous by the C2 amendment; the
* language reserves no constructor-decorator position anyway).
*
* The value is marked in place (functions are objects); the return is the
* same function, typed for insertion into an imports record.
* same function, typed for insertion into an imports record or for method
* replacement.
*/
export function suspending<F extends CallableFunction>(fn: F): F {
export function suspending<F extends CallableFunction>(
fn: F,
context?: unknown,
legacyDescriptor?: unknown,
): F {
// TypeScript-legacy method decorator convention: (prototype, key,
// descriptor). Detectable because stage-3 contexts are objects with a
// string `kind`, never string/symbol property keys.
if (
typeof context === "string" || typeof context === "symbol" ||
legacyDescriptor !== undefined
) {
throw new TypeError(
"suspending: legacy (experimentalDecorators) method decoration is not " +
"supported — the decorator would receive the prototype, not the " +
"method. Compile with stage-3 decorators (the default), or use the " +
"call form: `f: suspending(fn)`.",
);
}
if (context !== undefined) {
const kind = (context as { kind?: unknown }).kind;
if (kind !== "method") {
throw new TypeError(
`suspending: cannot decorate a ${String(kind)} — only methods ` +
`(instance or static) can be marked suspendable. Constructors are ` +
`synchronous by contract; for record-literal imports use the call ` +
`form: \`f: suspending(fn)\`.`,
);
}
}
if (typeof fn !== "function") {
throw new TypeError(
`suspending: expected a function, got ${typeof fn}`,
);
}
(fn as F & Suspendable)[SUSPENDING] = true;
return fn;
}
Expand Down
Binary file added runtime/tests/embedder/suspending-method.wasm
Binary file not shown.
58 changes: 58 additions & 0 deletions runtime/tests/embedder/suspending-method.wat
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
;; Host-implemented resource with a true `[method]`/`[static]` surface, for
;; the A1 scope extension (contracts/embedder-api.md §"Functions and async"):
;; a `suspending()`-marked resource METHOD is the shape the tier-(c) WASI
;; blocking profile hangs off (`[method]pollable.block`), and no corpus or
;; testdata component imports one — host-borrow/imported-resource use plain
;; functions taking borrows.
;;
;; `gauge` is the smallest host resource with all three member forms:
;; [constructor]gauge (synchronous by the C2 amendment — never marked)
;; [method]gauge.read (self borrow -> u32; the parking candidate)
;; [static]gauge.calibrate (() -> u32; the static parking candidate)
;;
;; `probe(v)` constructs, reads, drops, and returns the reading; `calib()`
;; calls the static. Flat u32s throughout: no memory, no realloc.
;;
;; Regenerate: wasm-tools parse suspending-method.wat -o suspending-method.wasm
(component
(import "host:api/dev" (instance $api
(export "gauge" (type $G (sub resource)))
(export "[constructor]gauge" (func (param "v" u32) (result (own $G))))
(export "[method]gauge.read" (func (param "self" (borrow $G)) (result u32)))
(export "[static]gauge.calibrate" (func (result u32)))))

(alias export $api "gauge" (type $G))
(alias export $api "[constructor]gauge" (func $ctor))
(alias export $api "[method]gauge.read" (func $read))
(alias export $api "[static]gauge.calibrate" (func $calibrate))

(canon lower (func $ctor) (core func $ctor'))
(canon lower (func $read) (core func $read'))
(canon lower (func $calibrate) (core func $calibrate'))
(canon resource.drop $G (core func $drop))

(core module $M
(import "" "ctor" (func $ctor (param i32) (result i32)))
(import "" "read" (func $read (param i32) (result i32)))
(import "" "calibrate" (func $calibrate (result i32)))
(import "" "drop" (func $drop (param i32)))
(func (export "probe") (param $v i32) (result i32)
(local $h i32)
(local $out i32)
(local.set $h (call $ctor (local.get $v)))
(local.set $out (call $read (local.get $h)))
(call $drop (local.get $h))
(local.get $out))
(func (export "calib") (result i32)
(call $calibrate)))

(core instance $i (instantiate $M (with "" (instance
(export "ctor" (func $ctor'))
(export "read" (func $read'))
(export "calibrate" (func $calibrate'))
(export "drop" (func $drop))))))

(func (export "probe") (param "v" u32) (result u32)
(canon lift (core func $i "probe")))
(func (export "calib") (result u32)
(canon lift (core func $i "calib"))))
137 changes: 137 additions & 0 deletions runtime/tests/embedder/suspending_imports_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,3 +252,140 @@ Deno.test("suspending(): marker mechanics (brand, identity, record scan)", () =>
assertTrue(!anySuspendingImport(undefined));
assertTrue(!anySuspendingImport({}));
});

// ---------------------------------------------------------------------------
// A2: decorator form, resource methods/statics, receiver binding
// ---------------------------------------------------------------------------

Deno.test({
name: "A2: @suspending on a provider-class method parks, with `this` bound to the provider",
ignore: !ready,
fn: async () => {
// Two pins in one: the stage-3 decorator marks the prototype method the
// plain arm reads off the instance, and the A2 receiver rule makes the
// extracted method see its instance state (pre-A2 the plain arm called
// extracted functions unbound — a stateful class provider broke with
// `this === undefined`).
class MathProvider {
#bias: number;
constructor(bias: number) {
this.#bias = bias;
}
@suspending
add(a: number, b: number): Promise<number> {
return later(a + b + this.#bias);
}
greet(who: string): string {
return `hello ${who}`;
}
}
const c = await instantiateFixture(testdata("imports"), {
log: () => {},
"host:api/math": new MathProvider(100),
});
assertEq(await c.exports.run(2, 40), 142);
},
});

Deno.test({
name: "A2: receiver binding alone — an unmarked stateful class provider works synchronously",
ignore: !ready,
fn: async () => {
// The receiver fix is independent of parking: no marks, no Promises,
// plain mode — instance state must still be reachable.
class MathProvider {
#bias = 1000;
add(a: number, b: number): number {
return a + b + this.#bias;
}
greet(who: string): string {
return `hello ${who}`;
}
}
const c = await instantiateFixture(testdata("imports"), {
log: () => {},
"host:api/math": new MathProvider(),
}, { jspi: false });
assertEq(await c.exports.run(2, 40), 1042);
},
});

const methodReady =
(await readArtifact("runtime/tests/embedder/suspending-method.wasm")) !==
null && isSupported();

Deno.test({
name: "A2: @suspending on a host-resource METHOD parks the frame (the pollable.block shape)",
ignore: !methodReady,
fn: async () => {
// The load-bearing scope extension: `[method]gauge.read` is the same
// WIT shape as `[method]pollable.block`, the site the tier-(c) WASI
// blocking profile hangs off. The brand authority is the class
// prototype, read at wrap time; the guest-driven CONSTRUCTOR stays
// synchronous (C2) while the method parks.
class Gauge {
#v: number;
constructor(v: number) {
this.#v = v;
}
@suspending
read(): Promise<number> {
return later(this.#v * 2);
}
@suspending
static calibrate(): Promise<number> {
return later(7);
}
}
const c = await instantiateFixture(
"runtime/tests/embedder/suspending-method.wasm",
{ "host:api/dev": { Gauge } },
);
assertEq(await c.exports.probe(21), 42);
assertEq(await c.exports.calib(), 7);
},
});

Deno.test("A2: the decorator refuses non-method positions at class-definition time", () => {
let raised: unknown;
try {
// deno-lint-ignore no-unused-vars
class Bad {
// deno-lint-ignore no-explicit-any
@(suspending as any)
get x(): number {
return 1;
}
}
} catch (e) {
raised = e;
}
assertTrue(raised instanceof TypeError, `expected TypeError, got ${raised}`);
assertTrue(
String(raised).includes("getter"),
`should name the offending kind, got: ${raised}`,
);
});

Deno.test("A2: the legacy experimentalDecorators convention is refused with guidance", () => {
// Simulate what `experimentalDecorators: true` would pass: (prototype,
// key, descriptor). Marking the prototype would brand the wrong object
// and corrupt the descriptor, so it must throw instead.
const proto = { read() {} };
let raised: unknown;
try {
// deno-lint-ignore no-explicit-any
(suspending as any)(
proto,
"read",
Object.getOwnPropertyDescriptor(proto, "read"),
);
} catch (e) {
raised = e;
}
assertTrue(raised instanceof TypeError, `expected TypeError, got ${raised}`);
assertTrue(
String(raised).includes("stage-3"),
`should point at the fix, got: ${raised}`,
);
});
Loading
Loading