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
160 changes: 156 additions & 4 deletions contracts/embedder-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -350,7 +350,11 @@ class PeerTrappedError extends Error { // A7: a stream/future op whose peer ins
immediately). One calling convention; async-first per docs/architecture.md §1. Exactly
two exceptions (C2 amendments): resource constructors (synchronous —
see Resources) and `future<T>`-typed results (eager handles — see
Streams and futures).
Streams and futures). The default surface is Promise-shaped; a
synchronous *view* of it exists as an explicit per-use adapter —
`sync()`, amendment A25 below — and WIT getters/setters are pre-ruled
to ride it as accessors once they become implementable (see
§"Resources" → "Getters and setters").
- **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
Expand Down Expand Up @@ -471,6 +475,92 @@ class PeerTrappedError extends Error { // A7: a stream/future op whose peer ins
discards), calls that resolve eagerly — the signal simply never fires.
The signal fires only for guest-initiated cancellation; instance
teardown does not abort in-flight calls (future amendment material).
- **`sync()` adapts a WIT-sync export to a synchronous call** (amendment
A25, 2026-08-30). Some host contexts cannot usefully receive a Promise
no matter how promptly it resolves: an event handler deciding whether
to call `preventDefault()` before it returns, a sort comparator, a
`Proxy` trap, a JS accessor. Even an already-resolved Promise defers
observation by a microtask, which is too late for all of these. For a
WIT-**sync** export whose guest completes synchronously — the
overwhelmingly common case for sync-typed WIT — the runtime can deliver
the result synchronously, and `sync()` is the explicit spelling for
asking it to. The default surface stays Promise-shaped; `sync()` is an
adapter the embedder applies per use, never a mode.

**Placement and spelling.** `sync()` and its types (`Sync<F>`) are
exported from `@polyengine/runtime/embedder` — application machinery in
A22's sense, like `createStream`: only an instantiating application
holds export functions, so this is deliberately NOT host-module
vocabulary and does not touch `@polyengine/protocol`. Recognition is by
brand (`polyengine.syncCallable/1`, a registry symbol per A9) so views
work across mixed runtime copies. Dispatch by target shape:

- `sync(fn)` where `fn` is a lifted export function (plain export,
interface member, or resource static): returns the synchronous form
`(...args) => T`.
- `sync(instance)` where `instance` is a guest-resource wrapper:
returns a view object whose members call the synchronous forms with
`instance` as receiver. Calling `sync(method)` on a bare prototype
method throws (`TypeError`) naming the `sync(instance)` spelling —
a free function cannot supply the receiver.
- `sync(cls)` where `cls` is a guest-resource class: returns a view
object of synchronous statics (constructors are already synchronous;
`new` the class itself).
- `sync(record)` where `record` is an exports record or nested
interface record: returns a view with every member mapped by these
same rules, recursively; non-branded members pass through unchanged.
- Views are stable: repeated `sync(x)` on the same target returns the
same view object.
- `sync()` on an **async-typed** export throws `TypeError` at adapter
time, naming the export and its async type: async WIT functions have
no synchronous form by definition. Anything unbranded also throws
`TypeError`.

**Call semantics.** Arguments lower synchronously; the call enters
through a plain (non-`promising`) entry; the reference's synchronous
driving loop (`canon_lift`, definitions.py line 2213) runs the task to
resolution; results lift synchronously. A `result<T, E>` in
function-result position throws `ComponentException<E>` synchronously
and resolves `T` otherwise, exactly as the Promise surface rejects and
resolves; handle-valued results (streams, futures, resources) return
their handles synchronously by the usual value mapping. Call-scoped
borrows are released on completion or unwind, as on the async surface.

**Failure ladder** (ordered; the first three are non-poisoning and
leave the instance enterable):

1. *Entry refusals* shared with the Promise surface (reentrance
forbidden, poisoned-instance refusal naming the original trap) are
thrown synchronously, before entering.
2. *Hop-window contention* (jspi mode only): a promising-wrapped entry
settles through a microtask hop even when nothing suspended, and the
hop-quiescence gate defers Promise-surface calls that would race a
pending lift. A synchronous call cannot defer, so it **refuses**
instead: `SyncEntryBusy` (`e.name === "SyncEntryBusy"`), a
transient, non-poisoning refusal — retry after in-flight activity
settles, or use the Promise surface. The constructor sync entry
(below) previously bypassed the hop gate entirely; it now shares
this refusal, closing a latent lift-corruption window.
3. *Blocking built-in* reached through the plain entry: `NeedsJspi`, a
capability error — same as the constructor rule.
4. *Genuine suspension*: a `Suspending`-wrapped host import reached
from the unwrapped frame fails as a trap, and a trap escaping a
lifted call poisons the entered instances (CM poisoning semantics).
This is the documented cost, stated loudly: `sync()` is for calls
the embedder knows complete synchronously. A component instantiated
with zero `suspending()`-marked imports and no async built-ins can
never hit this arm.

**Mechanics and cost.** In plain mode the lifted function already
completes synchronously inside the entered bracket; `sync()` merely
skips the Promise wrapper — near-zero cost. In jspi mode every
sync-typed export carries a second, plain-entered lifted entry
(generalizing the constructor-only `CONSTRUCTOR_SYNC_ENTRY` mechanism
to a uniform `SYNC_ENTRY`); like the constructor entry it is
deliberately not recorded against the bridge invariant (entries
wrapped iff imports wrapped) — safe because a synchronously-completing
activation never reaches the Suspending seam. Unused sync entries cost
nothing per call.

## Resources

Expand All @@ -496,11 +586,14 @@ handle, the runtime calls `instance[Symbol.dispose]?.()` (dtor). Method
`self` is the instance — no reps, no side tables.

**Constructors are synchronous** (C2 amendment): a JS class constructor
cannot await, so `new R(...)` is the one exception to Promise-shaped
exports. A guest constructor that does not complete synchronously raises
cannot await, so `new R(...)` is one of the two exceptions to
Promise-shaped exports (§"Functions and async"). A guest constructor that
does not complete synchronously raises
a named error rather than half-constructing; if a consumer ever needs a
suspending constructor, the escape hatch is a generated async static
factory — deferred until demanded.
factory — deferred until demanded. Since A25 the constructor's plain
entry is one instance of the general `SYNC_ENTRY` mechanism and shares
its failure ladder, including the `SyncEntryBusy` hop-window refusal.

Ownership at the boundary, both directions:

Expand Down Expand Up @@ -560,6 +653,64 @@ 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.

### Getters and setters (pre-ruling, 2026-08-30 — not yet implementable)

Upstream, WebAssembly/component-model#701 (approved, emoji-gated 📡) adds
property getters and setters to WIT and the name mangling: `[get]foo` /
`[set]foo` at interface level, `[method][get]r.foo` / `[method][set]r.foo`
on resource instances, `[static][get]r.foo` / `[static][set]r.foo` on
resource types. Validation upstream: getters take no parameters (beyond
`self`) and must return a value; setters take exactly one parameter
(beyond `self`) and return nothing or `result<_, error?>`; **`[get]`/
`[set]` functions must not be `async`**; every `[set]` requires its
`[get]`; getter/setter type agreement is deliberately not required
(WebIDL `PutForwards` precedent). Implementation here is blocked on the
toolchain (spec merge → wit-parser/wasm-tools → a wasmtime release
carrying the 📡 gate → bumping the pinned `wasmtime-environ`); the
dependency chain is tracked in polyengine#254. This section pre-rules the
JS shape so the eventual implementation is mechanical.

**Export side (guest-implemented): real JS accessors, sync-required both
directions.** Bindgen emits `get prop(): T` / `set prop(v)` as true
accessors — on resource classes for `[method]` forms, as static accessors
for `[static]` forms, and on the exports record for interface-level
forms. Accessors ride A25's sync calling convention: the underlying calls
enter through `SYNC_ENTRY` and share A25's failure ladder, so a guest
getter/setter that parks fails with A25's named errors rather than
half-working. Accessors thereby join constructors as sync-required
contexts (a JS getter *could* return a Promise, but a JS setter cannot
express async completion or rejection at all — the assignment expression
discards the setter's continuation; symmetric sync-required semantics
are ruled to match, and match WebIDL expectations). A fallible setter
(`result<_, error?>`) throws `ComponentException` synchronously.
Divergent getter/setter types map to TS 4.3+ asymmetric accessor types.
The WASI migration path (`get-prop`/`set-prop` *methods*) stays
Promise-shaped like any method; where the spec permits both spellings to
coexist and a bindings collision results, **the accessor wins** and the
shadowed method is dropped with a bindgen warning (the spec sanctions
generator choice here).

**Import side (host-implemented): property get and assignment on the A2
receiver.** `[get]foo` dispatches as a property read of
`self[camelCase(foo)]` (or of the containing interface object for
interface-level forms) and `[set]foo` as the corresponding assignment —
per call, receiver rules unchanged from A2. This retires limit 1 of the
platform-class pattern above for WIT worlds that declare accessors:
`URLSearchParams.prototype.size` becomes bindable as `size: get() ->
u32`. Accessors are never `suspending()`-markable (consistent with the
upstream not-`async` rule, with the wrap-time probe's
data-properties-only constraint, and with `@suspending`'s existing loud
refusal of accessor positions); a host getter that returns a Promise is
refused exactly as any sync-typed import returning a Promise without the
mark (A1).

**Until support lands**: the runtime refuses unknown bracket forms in
mangled names loudly at instantiation (rather than misbinding them as
plain names — a `[get]foo` treated as a function named `[get]foo` would
be wrong in both directions), and the translator keeps the upstream
feature gate off. Digest impact: none expected — mangled externnames
differ textually and function kind is not separately hashed.

## Streams and futures

Handles, not raw shared objects (`SharedStreamImpl` identity stays
Expand Down Expand Up @@ -979,6 +1130,7 @@ equivalent of a semver major:
| `polyengine.suspending/1` | the marked function / class prototype (A1/A2) | suspendable sync imports |
| `polyengine.deferCancel/1` | the marked function (A23) | imports exempt from cancel-discard |
| `polyengine.abortable/1` | the marked function (A24) | imports receiving a per-call AbortSignal |
| `polyengine.syncCallable/1` | lifted export functions and guest-resource members (A25; defined in the runtime — application-tier, not host-module vocabulary) | sync-callable exports and their synchronous forms |
| `polyengine.stream/1` | `Stream.prototype` | embedder stream handles |
| `polyengine.streamWriter/1` | `StreamWriter.prototype` (A22) | embedder stream writer handles |
| `polyengine.future/1` | `Future.prototype` | embedder future handles |
Expand Down
12 changes: 12 additions & 0 deletions examples/kitchen-sink/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ One world exercising the surfaces an embedder actually touches:
| guest-implemented resource (`using`) | `api.counter` | `Counter` | §6 |
| streams: producers in, `Stream<T>` handle out | `tally`, `countdown` | §8 | §8 |
| futures: Promise in, EAGER `Future<T>` handle out | `promised-double`, `deferred-answer` | §9 | §9 |
| `sync()`: the synchronous view of a WIT-sync export | `allowed` | | §10 |

Run it:

Expand Down Expand Up @@ -51,6 +52,17 @@ What to notice:
exports**: `deferredAnswer()` returns an eager `Future<u32>` handle
synchronously (a Promise wrapper would adopt the thenable handle and
make `drop`/`cancel` unreachable). Awaiting the handle yields the value.
- **`sync()` reclaims the synchronous form of a WIT-sync export**, for a
handler that must decide before it returns (cancelable-event dispatch,
DOM's `preventDefault()` — even an already-resolved Promise only lets its
continuation run on a later microtask, too late once the handler has
returned). `sync(api.allowed)` works here for real: this instantiation
runs in JSPI mode (`read-sensor` is `suspending()`), so the call exercises
the SYNC_ENTRY re-entry path — it succeeds because `allowed` never
reaches the suspending import and so completes without parking. A guest
call that DOES try to park fails loudly instead (`NeedsJspi` /
`SyncEntryBusy` / trap) — `sync()` is for exports known to complete
synchronously, not a way to force one that doesn't.

Deliberately absent (to stay approachable): async-typed *imports* and
`error-context` — see `contracts/embedder-api.md` until an example covers
Expand Down
59 changes: 59 additions & 0 deletions examples/kitchen-sink/host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,14 @@
// §8 streams: natural producers in (array / ReadableStream), a
// Stream<T> handle out (for-await in chunks)
// §9 futures: a Promise in, an EAGER Future handle out
// §10 sync(): the explicit synchronous view, driving a cancelable
// dispatch from inside a handler that cannot await
//
// Run with: ./run.sh

import {
instantiate,
sync,
} from "@polyengine/runtime/embedder";
import { suspending, ComponentException } from "@polyengine/protocol";
import { defaultTranslator } from "@polyengine/translator";
Expand Down Expand Up @@ -276,4 +279,60 @@ const fut = api.deferredAnswer();
assertEq(typeof fut.drop, "function", "deferred-answer returns a handle");
assertEq(await fut, 42, "awaiting the handle yields the value");

// --- §10: sync() — the explicit synchronous view --------------------------

// The motivating case (contracts/embedder-api.md §"Functions and async",
// amendment A25): a cancelable-event dispatcher, DOM's model for
// preventDefault(). The handler must decide RIGHT NOW, before it returns
// control to the dispatcher — a Promise cannot express that. Even an
// ALREADY-RESOLVED Promise only lets its continuation run on a later
// microtask; by the time `.then()` fires, `dispatch()` below has already
// returned and the caller has moved on treating the event as un-cancelled.
// There is no synchronous way to peek inside a Promise.
//
// A tiny DOM-free stand-in for that dispatcher:
interface CancelableEvent {
defaultPrevented: boolean;
preventDefault(): void;
}
function dispatch(handler: (ev: CancelableEvent) => void): boolean {
const ev: CancelableEvent = {
defaultPrevented: false,
preventDefault() {
this.defaultPrevented = true;
},
};
handler(ev); // handler must decide before this call returns
return !ev.defaultPrevented;
}

// `api.allowed` is WIT-sync (`func(p: perms) -> bool`) but the generated
// export is Promise-shaped like every export (contracts/embedder-api.md
// §"Functions and async"). `sync()` reclaims the synchronous form so a
// handler can call it and act on the result immediately — no `await`,
// hence usable from a plain (non-async) handler function.
//
// This works for real here, not just in principle: `component` above was
// instantiated with `readSensor` marked `suspending()` (§2c), so the whole
// instantiation runs in JSPI mode and every export call goes through the
// SYNC_ENTRY re-entry path under the hood. `sync(api.allowed)` exercises
// that path for a genuine JSPI-mode instance — it works here because
// `allowed` never reaches the suspending `readSensor` import and so
// completes without parking. (A guest call that DOES park fails loudly
// instead of hanging — A25's failure ladder: SyncEntryBusy if the instance
// has in-flight activity to settle first (transient; retry or use the
// Promise surface), NeedsJspi for a blocking built-in reached through the
// plain entry (both leave the instance usable), or a trap if the guest
// genuinely suspends mid-call. `sync()` is for exports KNOWN to complete
// synchronously, not a way to force one that doesn't.)
const syncAllowed = sync(api.allowed);
const proceeded = dispatch((ev) => {
if (!syncAllowed({ read: true })) ev.preventDefault();
});
assertEq(proceeded, true, "sync-allowed permission not cancelled");
const cancelled = dispatch((ev) => {
if (!syncAllowed({ exec: true })) ev.preventDefault();
});
assertEq(cancelled, false, "sync-disallowed permission cancelled the event");

console.log(`kitchen-sink example: OK (${logs.length} log lines)`);
33 changes: 24 additions & 9 deletions runtime/src/embedder/casing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,14 @@ const MANGLED = /^\[([a-z-]+)\](.*)$/;

/**
* Decode a mangled leaf name; unmangled names come back as `plain`.
*
* An unknown bracket form throws rather than falling back to `plain`
* (contracts/embedder-api.md §"Getters and setters (pre-ruling…)", final
* paragraph): "the runtime refuses unknown bracket forms in mangled names
* loudly at instantiation (rather than misbinding them as plain names — a
* `[get]foo` treated as a function named `[get]foo` would be wrong in both
* directions)". Getter/setter support (`[get]`/`[set]`, upstream
* WebAssembly/component-model#701) is tracked in polyengine#254.
* @internal — leaf-name demangling, performed by the runtime and by
* bindgen-generated code.
*/
Expand All @@ -66,19 +74,26 @@ export function parseLeafName(raw: string): LeafName {
const [, tag, rest] = m;
switch (tag) {
case "constructor":
return { form: "constructor", resource: rest };
if (!rest.includes("[")) return { form: "constructor", resource: rest };
break;
case "method":
case "static": {
const dot = rest.indexOf(".");
if (dot < 0) break;
return {
form: tag,
resource: rest.slice(0, dot),
member: rest.slice(dot + 1),
};
const resource = rest.slice(0, dot);
// A resource name carrying a further bracket (`[method][get]r.p`, the
// exact getter/setter-on-instance spelling the pre-ruling names) is
// NOT a plain `[method]`/`[static]` leaf — it is one of the still-
// unimplemented forms, and must be refused the same way, not
// misparsed as a method whose resource is literally `[get]r`.
if (resource.includes("[")) break;
return { form: tag, resource, member: rest.slice(dot + 1) };
}
}
// Unknown bracket forms (`[async]`, `[dtor]`, future spellings) are left
// alone rather than guessed at: they surface verbatim, which is loud.
return { form: "plain", name: raw };
throw new Error(
`unrecognized mangled export/import name '${raw}': the bracket form is ` +
`not one this runtime understands (only [constructor]/[method]/` +
`[static] are implemented; getter/setter forms like [get]/[set] are ` +
`not yet implemented — polyengine#254)`,
);
}
Loading
Loading