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
49 changes: 49 additions & 0 deletions contracts/embedder-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,55 @@ Ownership at the boundary, both directions:
| host passes `own<R>` | wrapper invalidated (transferred) | instance registered; guest owns its handle |
| host passes `borrow<R>` | wrapper stays valid | guest must not retain past the call (runtime-enforced per CABI); a never-registered instance gets a rep allocated for the call's duration (C2 amendment) |

### Pattern (non-normative): binding platform classes directly

A host-implemented resource does not need a hand-written class: when a WIT
resource's shape matches a native platform class, pass the class itself —
the pattern the draft web embedding builds its import story on
(WebAssembly/component-model PR #686 "interface object" imports; tracked
in deltic#115), available here today because the pieces already line up:
method dispatch is a per-call `self[camelCase(member)]` lookup, WIT
constructor args flow to `new Class(...)`, and the value conventions are
the natural JS shapes (`Uint8Array` IS a `BufferSource`; a record is a
plain camelCase object, i.e. an options bag).

```ts
const instance = await instantiate(artifacts, {
"test:platform/web": { params: URLSearchParams, decoder: TextDecoder },
});
```

Executable reference: `runtime/tests/embedder/platform_class_test.ts` +
`platform-class.wat` (kebab→camel `to-string`→`toString`, string/bool/
`list<u8>`/record conversions, and each limit below, pinned with exact
failure modes).

The limits, and the one-line bridges (a `class X extends Native { … }`
wrapper stays inside the pattern):

1. **Getter-backed properties are not methods.** WIT has no attributes, so
a `size: func() -> u32` bound against an accessor (`URLSearchParams.
prototype.size`) finds no callable member: the call traps ("the
<Class> instance has no method 'size'"). The wrap-time suspending
probe reads only DATA properties — it never invokes accessors, so
merely binding such a class is safe; the limit surfaces per-call, and
only for guests that call the member. (Consequence: an A2 suspending
mark cannot ride an accessor-backed member.) Bridge: a real method
delegating to the property.
2. **Platform "absent" is `null`; WIT `none` is `undefined`.** A native
returning `null` where WIT expects `option<T>` takes the `some` branch
and fails the inner conversion: the call rejects with the conversion
layer's `TypeError` naming the import — not a trap, never `none`.
Bridge: `get(k) { return super.get(k) ?? undefined; }`.
3. **Platform exceptions are unbranded, so they trap** — even from a
`result`-typed import (§"Error model"): a result-typed WIT signature
does not convert host exceptions into `err` values. Bridge: try/catch
in a subclass override, rethrowing `new ComponentException(payload)`.

Named types in the imported interface (a `record decoder-options` the
constructor takes, say) need no imports-object entry — only functions and
resource classes are read from the embedder.

## Streams and futures

Handles, not raw shared objects (`SharedStreamImpl` identity stays
Expand Down
33 changes: 31 additions & 2 deletions runtime/src/embedder/instantiate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -627,8 +627,19 @@ class Facade {
// 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)];
//
// The probe must not INVOKE accessors: a platform getter (e.g.
// `URLSearchParams.prototype.size`) brand-checks its receiver, and a
// raw `prototype[member]` read runs it with `this` = the prototype —
// an engine TypeError at instantiation, even for guests that never
// call the member. Only a data-property function can carry the A2
// mark (stage-3 method decorators install data properties), so an
// accessor-backed member yields no wrap-time function here and stays
// a call-time concern for the per-call lookup below.
const protoFn = dataMember(
(cls as { prototype?: 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)];
Expand Down Expand Up @@ -1104,6 +1115,24 @@ function pick(
return undefined;
}

/**
* Read a DATA property from `obj` (walking its prototype chain, nearest own
* descriptor wins) without ever invoking accessors. Accessor-backed and
* absent members both yield `undefined`. Used by the A2 wrap-time suspending
* probe, which must not run platform getters against a bare prototype.
*/
function dataMember(obj: unknown, key: string): unknown {
for (
let o = obj;
o !== null && (typeof o === "object" || typeof o === "function");
o = Object.getPrototypeOf(o)
) {
const d = Object.getOwnPropertyDescriptor(o, key);
if (d !== undefined) return "value" in d ? d.value : undefined;
}
return undefined;
}

function isThenable(v: unknown): boolean {
return v !== null && typeof v === "object" && "then" in v &&
typeof (v as { then: unknown }).then === "function";
Expand Down
Binary file added runtime/tests/embedder/platform-class.wasm
Binary file not shown.
212 changes: 212 additions & 0 deletions runtime/tests/embedder/platform-class.wat
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
;; "Zero-glue platform class" fixture for the embedder conventions layer
;; (contracts/embedder-api.md §"Resources", §"Value mapping (normative)",
;; §"Error model", §"Naming and casing" — see platform_class_test.ts for the
;; pins this exercises). Models a WIT interface whose two resources are bound
;; DIRECTLY to native web platform classes (`URLSearchParams`, `TextDecoder`)
;; with no host-side wrapper:
;;
;; resource params {
;; constructor(init: string);
;; append: func(name: string, value: string);
;; has: func(name: string) -> bool;
;; to-string: func() -> string; // kebab->camel: toString
;; get: func(name: string) -> option<string>;
;; size: func() -> u32; // a GETTER on the native class,
;; // not a method (deliberate limit)
;; }
;; record decoder-options { fatal: bool }
;; resource decoder {
;; constructor(label: string, options: decoder-options);
;; decode: func(data: list<u8>) -> result<string, string>; // result-typed
;; // on purpose: native throws are
;; // unbranded -> trap, not `err`
;; }
;;
;; Guest exports are thin lower/lift trampolines, one per probe, following
;; imports.wat / host-result-payload.wat / host-borrow.wat's style: own vs
;; borrow are both plain i32 handle-table indices at the core level (no
;; `canon resource.drop` calls here — nothing in the pins depends on
;; disposal, so the fixture stays minimal).
;;
;; Canonical ABI bookkeeping (definitions.py, this repo's tie-breaker):
;; - MAX_FLAT_RESULTS = 1: any multi-value result (string, option<string>,
;; result<string,string>) spills to a return pointer. For an *import*
;; call (`canon lower`), that pointer is an extra trailing i32 PARAM the
;; guest must supply itself (flatten_functype's 'lower' arm). For an
;; *export* (`canon lift`), the guest's core function simply RETURNS the
;; i32 address where it already wrote the tuple ('lift' arm) — so
;; `roundtrip`'s export result reuses the exact scratch address
;; `to-string`'s import call wrote into.
;; - `option<T>` desugars to `variant { none, some(T) }` (despecialize):
;; case 0 = none, case 1 = some. `result<T, E>` desugars to
;; `variant { ok(T), error(E) }`: case 0 = ok, case 1 = error.
;; - store_variant: 1-byte discriminant (2 cases), then the payload at the
;; max case alignment (4, since the payload is a string (ptr,len) pair) —
;; same layout host-result-payload.wat documents.
;;
;; Regenerate: wasm-tools parse platform-class.wat -o platform-class.wasm
(component
(import "test:platform/web" (instance $api
(export "params" (type $Params (sub resource)))
(export "[constructor]params"
(func (param "init" string) (result (own $Params))))
(export "[method]params.append"
(func (param "self" (borrow $Params)) (param "name" string) (param "value" string)))
(export "[method]params.has"
(func (param "self" (borrow $Params)) (param "name" string) (result bool)))
(export "[method]params.to-string"
(func (param "self" (borrow $Params)) (result string)))
(export "[method]params.get"
(func (param "self" (borrow $Params)) (param "name" string) (result (option string))))
(export "[method]params.size"
(func (param "self" (borrow $Params)) (result u32)))
(export "decoder" (type $Decoder (sub resource)))
;; The options record must be a NAMED type export of this instance:
;; wasmparser's import validation (`all_valtypes_named_in_func`) rejects
;; anonymous records/variants in imported function signatures.
(type $optsDef (record (field "fatal" bool)))
(export "decoder-options" (type $Opts (eq $optsDef)))
(export "[constructor]decoder"
(func (param "label" string) (param "options" $Opts)
(result (own $Decoder))))
(export "[method]decoder.decode"
(func (param "self" (borrow $Decoder)) (param "data" (list u8))
(result (result string (error string)))))))

(alias export $api "params" (type $Params))
(alias export $api "decoder" (type $Decoder))
(alias export $api "[constructor]params" (func $ctorParams))
(alias export $api "[method]params.append" (func $append))
(alias export $api "[method]params.has" (func $has))
(alias export $api "[method]params.to-string" (func $toString))
(alias export $api "[method]params.get" (func $get))
(alias export $api "[method]params.size" (func $size))
(alias export $api "[constructor]decoder" (func $ctorDecoder))
(alias export $api "[method]decoder.decode" (func $decode))

(core module $Mem
(memory (export "mem") 1)
;; The fixed-address label the decoder probes construct with; harmless
;; content ("utf-8" is also a valid, if odd, URLSearchParams init string
;; for probe-size, which does not care about its params' contents).
(data (i32.const 64) "utf-8")
(global $next (mut i32) (i32.const 4096))
(func (export "realloc")
(param $old i32) (param $oldsz i32) (param $align i32) (param $newsz i32)
(result i32)
(local $ret i32)
(global.set $next
(i32.and (i32.add (global.get $next) (i32.sub (local.get $align) (i32.const 1)))
(i32.xor (i32.sub (local.get $align) (i32.const 1)) (i32.const -1))))
(local.set $ret (global.get $next))
(global.set $next (i32.add (global.get $next) (local.get $newsz)))
(local.get $ret)))
(core instance $mem (instantiate $Mem))

;; Args-only lowerings: strings are read from guest memory but nothing is
;; written back, so no realloc capability is needed.
(canon lower (func $ctorParams) (memory $mem "mem") (core func $ctorParams'))
(canon lower (func $append) (memory $mem "mem") (core func $append'))
(canon lower (func $has) (memory $mem "mem") (core func $has'))
(canon lower (func $size) (core func $size'))
(canon lower (func $ctorDecoder) (memory $mem "mem") (core func $ctorDecoder'))
;; Result-bearing lowerings: the host's string payload must be written into
;; guest memory, so these need realloc too.
(canon lower (func $toString)
(memory $mem "mem") (realloc (func $mem "realloc")) (core func $toString'))
(canon lower (func $get)
(memory $mem "mem") (realloc (func $mem "realloc")) (core func $get'))
(canon lower (func $decode)
(memory $mem "mem") (realloc (func $mem "realloc")) (core func $decode'))

(core module $M
(import "" "ctorParams" (func $ctorParams (param i32 i32) (result i32)))
(import "" "append" (func $append (param i32 i32 i32 i32 i32)))
(import "" "has" (func $has (param i32 i32 i32) (result i32)))
(import "" "toString" (func $toString (param i32 i32)))
(import "" "get" (func $get (param i32 i32 i32 i32)))
(import "" "size" (func $size (param i32) (result i32)))
(import "" "ctorDecoder" (func $ctorDecoder (param i32 i32 i32) (result i32)))
(import "" "decode" (func $decode (param i32 i32 i32 i32)))
(import "mem" "mem" (memory 1))

;; Scratch addresses for spilled (>1 flat value) results. Disjoint from
;; each other, from the "utf-8" label at 64, and from the realloc bump
;; region starting at 4096 where every argument string/list the runtime
;; copies in for us actually lands.
;; 0 : to-string's / roundtrip's string tuple (ptr, len) [8B]
;; 16 : get's option<string> (disc, ptr, len) [12B]
;; 32 : decode's result<string,string> (disc, ptr, len) [12B]

;; roundtrip(init, name, value) -> string
;; construct(init); append(name, value); return to-string()
(func (export "roundtrip")
(param $ip i32) (param $il i32)
(param $np i32) (param $nl i32)
(param $vp i32) (param $vl i32)
(result i32)
(local $h i32)
(local.set $h (call $ctorParams (local.get $ip) (local.get $il)))
(call $append (local.get $h) (local.get $np) (local.get $nl) (local.get $vp) (local.get $vl))
(call $toString (local.get $h) (i32.const 0))
(i32.const 0))

;; probe-has(init, name) -> bool
(func (export "probe-has")
(param $ip i32) (param $il i32) (param $np i32) (param $nl i32)
(result i32)
(local $h i32)
(local.set $h (call $ctorParams (local.get $ip) (local.get $il)))
(call $has (local.get $h) (local.get $np) (local.get $nl)))

;; probe-get(init, name) -> option<string>
(func (export "probe-get")
(param $ip i32) (param $il i32) (param $np i32) (param $nl i32)
(result i32)
(local $h i32)
(local.set $h (call $ctorParams (local.get $ip) (local.get $il)))
(call $get (local.get $h) (local.get $np) (local.get $nl) (i32.const 16))
(i32.const 16))

;; probe-size() -> u32 (constructs a fixed instance; the getter-vs-method
;; limit is what this probe is for, not the constructor argument)
(func (export "probe-size") (result i32)
(local $h i32)
(local.set $h (call $ctorParams (i32.const 64) (i32.const 5)))
(call $size (local.get $h)))

;; probe-decode(fatal, data) -> result<string, string>
(func (export "probe-decode")
(param $fatal i32) (param $dp i32) (param $dl i32)
(result i32)
(local $h i32)
(local.set $h (call $ctorDecoder (i32.const 64) (i32.const 5) (local.get $fatal)))
(call $decode (local.get $h) (local.get $dp) (local.get $dl) (i32.const 32))
(i32.const 32)))

(core instance $i (instantiate $M
(with "" (instance
(export "ctorParams" (func $ctorParams'))
(export "append" (func $append'))
(export "has" (func $has'))
(export "toString" (func $toString'))
(export "get" (func $get'))
(export "size" (func $size'))
(export "ctorDecoder" (func $ctorDecoder'))
(export "decode" (func $decode'))))
(with "mem" (instance $mem))))

(func (export "roundtrip")
(param "init" string) (param "name" string) (param "value" string) (result string)
(canon lift (core func $i "roundtrip") (memory $mem "mem") (realloc (func $mem "realloc"))))
(func (export "probe-has")
(param "init" string) (param "name" string) (result bool)
(canon lift (core func $i "probe-has") (memory $mem "mem") (realloc (func $mem "realloc"))))
(func (export "probe-get")
(param "init" string) (param "name" string) (result (option string))
(canon lift (core func $i "probe-get") (memory $mem "mem") (realloc (func $mem "realloc"))))
(func (export "probe-size") (result u32)
(canon lift (core func $i "probe-size")))
(func (export "probe-decode")
(param "fatal" bool) (param "data" (list u8)) (result (result string (error string)))
(canon lift (core func $i "probe-decode") (memory $mem "mem") (realloc (func $mem "realloc")))))
Loading
Loading