diff --git a/contracts/embedder-api.md b/contracts/embedder-api.md index 62157ca..197e3e6 100644 --- a/contracts/embedder-api.md +++ b/contracts/embedder-api.md @@ -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 @@ -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`). diff --git a/runtime/src/embedder/instantiate.ts b/runtime/src/embedder/instantiate.ts index ded3945..1dffbe1 100644 --- a/runtime/src/embedder/instantiate.ts +++ b/runtime/src/embedder/instantiate.ts @@ -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); @@ -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 }) + ?.prototype?.[camelCase(m.member)]; + const dispatch: (args: unknown[]) => unknown = (args) => { const [self, ...rest] = args; const fn = (self as Record)?.[camelCase(m.member)]; if (typeof fn !== "function") { @@ -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)[camelCase(m.member)]; if (typeof fn !== "function") { @@ -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; } } } diff --git a/runtime/src/jspi/suspending.ts b/runtime/src/jspi/suspending.ts index 85767d3..5f2e04a 100644 --- a/runtime/src/jspi/suspending.ts +++ b/runtime/src/jspi/suspending.ts @@ -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(fn: F): F { +export function suspending( + 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; } diff --git a/runtime/tests/embedder/suspending-method.wasm b/runtime/tests/embedder/suspending-method.wasm new file mode 100644 index 0000000..612e871 Binary files /dev/null and b/runtime/tests/embedder/suspending-method.wasm differ diff --git a/runtime/tests/embedder/suspending-method.wat b/runtime/tests/embedder/suspending-method.wat new file mode 100644 index 0000000..d76f98a --- /dev/null +++ b/runtime/tests/embedder/suspending-method.wat @@ -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")))) diff --git a/runtime/tests/embedder/suspending_imports_test.ts b/runtime/tests/embedder/suspending_imports_test.ts index 99e6445..e6b53b2 100644 --- a/runtime/tests/embedder/suspending_imports_test.ts +++ b/runtime/tests/embedder/suspending_imports_test.ts @@ -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 { + 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 { + return later(this.#v * 2); + } + @suspending + static calibrate(): Promise { + 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}`, + ); +}); diff --git a/wasi-shims/src/random.ts b/wasi-shims/src/random.ts index 4242365..852be06 100644 --- a/wasi-shims/src/random.ts +++ b/wasi-shims/src/random.ts @@ -9,13 +9,24 @@ export interface RandomOptions { /** * `crypto.getRandomValues` rejects requests over 65536 bytes - * (QuotaExceededError), while the WIT requires exactly `len` bytes back - * ("Return `len` cryptographically-secure random or pseudo-random bytes", - * random.wit) — there is no shorter-return latitude, and callers (Rust - * `getrandom`, the Go runtime) fill fixed-size buffers trusting the length. - * So: chunk the fill, never clamp it. Still synchronous, satisfying the - * WIT's "must not block ... including on requests for [large] numbers of - * bytes". + * (QuotaExceededError), while the 0.2 WIT this fragment serves requires + * exactly `len` bytes back ("Return `len` cryptographically-secure random + * or pseudo-random bytes", random.wit @0.2.x) — no shorter-return + * latitude, and callers (Rust `getrandom`, the Go runtime) fill fixed-size + * buffers trusting the length. So: chunk the fill, never clamp it. + * + * TRACK DIVERGENCE, for a future @0.3 fragment: `wasi:random@0.3.0` + * renames the parameter to `max-len` and PERMITS short reads + * ("Implementations MAY return fewer bytes than requested"; callers must + * loop; ≥1 byte required for max-len > 0). Authority moved with the WASI + * consolidation: `WebAssembly/WASI proposals/random/wit/random.wit` — the + * archived wasi-random repo's 0.3 rc still shows the old exact-len text. + * Chunk-to-full remains conforming there too ("up to max-len" includes + * exactly max-len) and makes conforming callers' mandatory loops terminate + * in one pass, so this helper serves both tracks unchanged. + * + * Either way the fill stays synchronous, satisfying both tracks' "must not + * block ... including on requests for [large] numbers of bytes". */ const GET_RANDOM_VALUES_MAX = 65536;