diff --git a/contracts/embedder-api.md b/contracts/embedder-api.md index e19e1eb..fedb3bd 100644 --- a/contracts/embedder-api.md +++ b/contracts/embedder-api.md @@ -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`-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 @@ -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`) 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` in + function-result position throws `ComponentException` 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 @@ -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: @@ -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 @@ -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 | diff --git a/examples/kitchen-sink/README.md b/examples/kitchen-sink/README.md index a41390a..0898599 100644 --- a/examples/kitchen-sink/README.md +++ b/examples/kitchen-sink/README.md @@ -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` handle out | `tally`, `countdown` | §8 | §8 | | futures: Promise in, EAGER `Future` handle out | `promised-double`, `deferred-answer` | §9 | §9 | +| `sync()`: the synchronous view of a WIT-sync export | `allowed` | | §10 | Run it: @@ -51,6 +52,17 @@ What to notice: exports**: `deferredAnswer()` returns an eager `Future` 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 diff --git a/examples/kitchen-sink/host.ts b/examples/kitchen-sink/host.ts index 69df35a..d4eed9e 100644 --- a/examples/kitchen-sink/host.ts +++ b/examples/kitchen-sink/host.ts @@ -14,11 +14,14 @@ // §8 streams: natural producers in (array / ReadableStream), a // Stream 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"; @@ -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)`); diff --git a/runtime/src/embedder/casing.ts b/runtime/src/embedder/casing.ts index f79b1ac..1dccb6c 100644 --- a/runtime/src/embedder/casing.ts +++ b/runtime/src/embedder/casing.ts @@ -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. */ @@ -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)`, + ); } diff --git a/runtime/src/embedder/instantiate.ts b/runtime/src/embedder/instantiate.ts index 0b2d9d7..9376bda 100644 --- a/runtime/src/embedder/instantiate.ts +++ b/runtime/src/embedder/instantiate.ts @@ -18,7 +18,7 @@ import type { ComponentValue } from "../cabi/types.ts"; import { Trap } from "../cabi/trap.ts"; import { type ComponentHandle, - CONSTRUCTOR_SYNC_ENTRY, + SYNC_ENTRY, type HostImports, hostResourceType, instantiateComponent, @@ -39,6 +39,7 @@ import { type ImportLeaf, requiredImports } from "./imports.ts"; import { hostDtorCall } from "../exec/boundary.ts"; import { buildGuestResourceClass, + type ExportWrapper, type GuestResourceSpec, HostResourceRegistry, invalidateWrapper, @@ -56,6 +57,7 @@ import { } from "./values.ts"; import { ImportResolver } from "./version.ts"; import { type ElemCodec, Future, Stream } from "./streams.ts"; +import { markSyncCallable, syncPayloadOf } from "./sync.ts"; /** * Relay the per-declaration host-import marks from the embedder's function @@ -389,7 +391,7 @@ class Facade { { name: b.name, ctor: null, ctorParams: null, methods: [], statics: [] }, // The rt is supplied per wrapper, so an anonymous class needs none here. { impl: null, dtor: null } as unknown as ResourceTypeInfo, - () => Promise.reject(new TypeError("no methods")), + () => () => Promise.reject(new TypeError("no methods")), () => [], ); return b.cls; @@ -1001,9 +1003,9 @@ class Facade { const s = spec(member.resource); // Prefer the plain-entered variant in jspi mode: the JS `new` // cannot await the Promise a promising-wrapped entry returns - // (exec/boundary.ts CONSTRUCTOR_SYNC_ENTRY). + // (exec/boundary.ts SYNC_ENTRY). s.ctor = ((fn as unknown as Record)[ - CONSTRUCTOR_SYNC_ENTRY + SYNC_ENTRY ] ?? fn) as RawFn; s.ctorParams = ft.params; rtOf(ft.results[0], specRt, member.resource); @@ -1015,6 +1017,7 @@ class Facade { raw: fn, params: ft.params, results: ft.results, + async: ft.async === true, }); rtOf(ft.params[0], specRt, member.resource); break; @@ -1025,6 +1028,7 @@ class Facade { raw: fn, params: ft.params, results: ft.results, + async: ft.async === true, }); break; } @@ -1042,12 +1046,8 @@ class Facade { const cls = buildGuestResourceClass( s, rt, - (fn, params, results, where, args) => - this.#wrapExportFn(fn, { params, results }, where)( - ...args, - ) as Promise< - unknown - >, + (raw, params, results, async, where) => + this.#wrapExportFn(raw, { params, results, async }, where), (args, params, where) => args.map((a, i) => fromHost(a, params[i], this.#opts(where))), ); @@ -1107,18 +1107,19 @@ class Facade { */ #wrapExportFn( fn: RawFn, - ft: { params: ValType[]; results: ValType[] }, + ft: { params: ValType[]; results: ValType[]; async?: boolean }, where: string, ): (...args: unknown[]) => Promise { const o = this.#opts(where); const resultType = ft.results.length === 0 ? null : ft.results[0]; + let wrapper: (...args: unknown[]) => Promise; if (resultType !== null && resultType.kind === "future") { // See `Future.deferred`: a `future` result cannot be delivered // *through* a Promise, because promise resolution adopts thenables and // `Future` is one. The handle is returned eagerly instead; it is // PromiseLike, so `await` still yields `T`. const element = resultType.element; - return (...args: unknown[]): Promise => { + wrapper = (...args: unknown[]): Promise => { // Advisory 9: the generic branch checks arity; so must this one. if (args.length !== ft.params.length) { throw new TypeError( @@ -1140,8 +1141,112 @@ class Facade { elementCodec(element, o), ) as unknown as Promise; }; + } else { + wrapper = async (...args: unknown[]): Promise => { + if (args.length !== ft.params.length) { + throw new TypeError( + `${where}: expected ${ft.params.length} argument(s), got ${args.length}`, + ); + } + const { lowered, release } = this.#lowerParams(ft.params, args, o); + let raw: unknown; + try { + raw = await fn(...lowered); + } finally { + // Call-scoped reps minted for `borrow` arguments of a + // host-implemented resource live exactly as long as the call. + release(); + } + if (resultType === null) return undefined; + if (resultType.kind === "result") { + const v = raw as Record; + if ("error" in v) { + throw new ComponentException( + resultType.error === null + ? undefined + : toHost(v["error"], resultType.error, o), + ); + } + return resultType.ok === null + ? undefined + : toHost(v["ok"], resultType.ok, o); + } + return toHost(raw as ComponentValue, resultType, o); + }; + } + // A25 brand (contracts/embedder-api.md §"Functions and async"): every + // returned wrapper is branded, additively — the default Promise-shaped + // surface above is unchanged either way. + if (ft.async === true) { + markSyncCallable(wrapper, { kind: "async" }); + } else { + markSyncCallable(wrapper, { + kind: "free", + fn: this.#buildSyncForm(fn, ft, where, o), + }); + } + return wrapper; + } + + /** + * The synchronous form of a sync-typed export (A25's `sync()` adapter), + * mirroring `#wrapExportFn`'s async form exactly minus the `await`: + * arity check, `#lowerParams`, the plain (`SYNC_ENTRY`) entry, result + * mapping. + * + * `SYNC_ENTRY` is the plain-entered variant `executor.ts` attaches to every + * sync-typed lifted export in jspi mode (exec/boundary.ts; in plain mode + * the lifted function itself already returns synchronously, so `fn` is + * used as-is — `fn[SYNC_ENTRY] ?? fn`). + */ + #buildSyncForm( + fn: RawFn, + ft: { params: ValType[]; results: ValType[] }, + where: string, + o: AdapterOptions, + ): (...args: unknown[]) => unknown { + const resultType = ft.results.length === 0 ? null : ft.results[0]; + const entry = ((fn as unknown as Record)[ + SYNC_ENTRY + ] as RawFn | undefined) ?? fn; + const unreachableThenable = (raw: unknown): never => { + // Defensive (see the dispatch prompt / A25 failure ladder): a genuine + // park through a plain entry surfaces as a trap, `NeedsJspi`, or + // `SyncEntryBusy` — never a settled thenable VALUE. A silent + // Promise-as-value here would corrupt lifting rather than fail loudly, + // so this is a diagnostic backstop, not a documented outcome. + void raw; + throw new Error( + `${where}: the sync entry returned a thenable, which should be ` + + `unreachable for a sync-typed WIT export (a genuine park surfaces ` + + `as a trap, NeedsJspi, or SyncEntryBusy instead) — this indicates ` + + `a runtime defect`, + ); + }; + if (resultType !== null && resultType.kind === "future") { + const element = resultType.element; + return (...args: unknown[]): unknown => { + if (args.length !== ft.params.length) { + throw new TypeError( + `${where}: expected ${ft.params.length} argument(s), got ` + + `${args.length}`, + ); + } + const { lowered, release } = this.#lowerParams(ft.params, args, o); + let raw: unknown; + try { + raw = entry(...lowered); + } finally { + release(); + } + if (isThenable(raw)) unreachableThenable(raw); + return Future.fromLifted( + raw as ComponentValue, + elementCodec(element, o), + ); + }; } - return async (...args: unknown[]): Promise => { + return (...args: unknown[]): unknown => { if (args.length !== ft.params.length) { throw new TypeError( `${where}: expected ${ft.params.length} argument(s), got ${args.length}`, @@ -1150,12 +1255,11 @@ class Facade { const { lowered, release } = this.#lowerParams(ft.params, args, o); let raw: unknown; try { - raw = await fn(...lowered); + raw = entry(...lowered); } finally { - // Call-scoped reps minted for `borrow` arguments of a - // host-implemented resource live exactly as long as the call. release(); } + if (isThenable(raw)) unreachableThenable(raw); if (resultType === null) return undefined; if (resultType.kind === "result") { const v = raw as Record; diff --git a/runtime/src/embedder/mod.ts b/runtime/src/embedder/mod.ts index 8a322e2..bca0d67 100644 --- a/runtime/src/embedder/mod.ts +++ b/runtime/src/embedder/mod.ts @@ -92,3 +92,5 @@ export { toHost, type ValueBridge, } from "./values.ts"; + +export { type Sync, sync } from "./sync.ts"; diff --git a/runtime/src/embedder/resources.ts b/runtime/src/embedder/resources.ts index b3bf942..c315044 100644 --- a/runtime/src/embedder/resources.ts +++ b/runtime/src/embedder/resources.ts @@ -21,6 +21,7 @@ import { hostDtorCall } from "../exec/boundary.ts"; import { COPY_URL, describeCrossCopy } from "./copy.ts"; import { InvalidHandleError } from "./errors.ts"; import { camelCase, pascalCase } from "./casing.ts"; +import { markSyncCallable, syncPayloadOf } from "./sync.ts"; /** * Internal state of a guest-resource wrapper. @@ -331,22 +332,35 @@ export interface GuestResourceSpec { raw: (...a: unknown[]) => unknown; params: ValType[]; results: ValType[]; + /** True for an `async func` — see A25's `{ kind: "async" }` brand. */ + async: boolean; }[]; statics: { member: string; raw: (...a: unknown[]) => unknown; params: ValType[]; results: ValType[]; + async: boolean; }[]; } -export type CallAdapter = ( +/** + * Build (once, at class-build time — never per call) the Promise-shaped + * wrapper for one method/static's raw lifted function, exactly as + * `Facade#wrapExportFn` would for a plain export. `buildGuestResourceClass` + * reads the A25 brand off the returned wrapper to install the matching + * `"method"`/`"free"`/`"async"` brand on the class member it builds around + * it — the wrapper itself IS what a per-call closure invokes, so a `self` + * receiver is `wrapper(self, ...args)` for a method the same way a bare + * export is `wrapper(...args)`. + */ +export type ExportWrapper = ( raw: (...a: unknown[]) => unknown, params: ValType[], results: ValType[], + async: boolean, where: string, - args: unknown[], -) => Promise; +) => (...args: unknown[]) => Promise; /** * Build the class for a guest-implemented resource. @@ -360,7 +374,7 @@ export type CallAdapter = ( export function buildGuestResourceClass( spec: GuestResourceSpec, rt: ResourceTypeInfo, - adapt: CallAdapter, + wrapExport: ExportWrapper, lowerArgs: (args: unknown[], params: ValType[], where: string) => unknown[], // deno-lint-ignore no-explicit-any ): any { @@ -403,23 +417,46 @@ export function buildGuestResourceClass( for (const m of spec.methods) { const js = camelCase(m.member); const where = `${className}.${js}`; + // Built ONCE at class-build time (A25: "prototype methods and statics + // must carry the brand at class-build time, not per call") — every + // instance's method call goes through this same wrapper, receiver + // (`self`) prepended. + const wrapped = wrapExport(m.raw, m.params, m.results, m.async, where); + const methodFn = function (this: GuestResource, ...args: unknown[]) { + // params[0] is the `borrow`/`own` self. + return wrapped(this, ...args); + }; + const payload = syncPayloadOf(wrapped); + if (payload !== undefined) { + // A resource method's sync form takes `self` as its first argument — + // exactly `wrapped`'s own synchronous form (params[0] IS self), so the + // "method" brand's `fn` is `payload.fn` verbatim, just re-tagged so + // `sync()` knows this one needs `sync(instance)` rather than being + // callable bare. + markSyncCallable( + methodFn, + payload.kind === "free" + ? { kind: "method", fn: payload.fn } + : payload, // kind "async": pass the brand through unchanged + ); + } Object.defineProperty(cls.prototype, js, { configurable: true, writable: true, - value: function (this: GuestResource, ...args: unknown[]) { - // params[0] is the `borrow`/`own` self. - return adapt(m.raw, m.params, m.results, where, [this, ...args]); - }, + value: methodFn, }); } for (const s of spec.statics) { const js = camelCase(s.member); const where = `${className}.${js} (static)`; + const wrapped = wrapExport(s.raw, s.params, s.results, s.async, where); + const staticFn = (...args: unknown[]) => wrapped(...args); + const payload = syncPayloadOf(wrapped); + if (payload !== undefined) markSyncCallable(staticFn, payload); Object.defineProperty(cls, js, { configurable: true, writable: true, - value: (...args: unknown[]) => - adapt(s.raw, s.params, s.results, where, args), + value: staticFn, }); } return cls; diff --git a/runtime/src/embedder/sync.ts b/runtime/src/embedder/sync.ts new file mode 100644 index 0000000..0edcace --- /dev/null +++ b/runtime/src/embedder/sync.ts @@ -0,0 +1,328 @@ +// `sync()` — the explicit synchronous view of a WIT-sync export (contracts/ +// embedder-api.md §"Functions and async", amendment A25, 2026-08-30). +// +// Placement (A22): application machinery exported from +// `@polyengine/runtime/embedder`, 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. Unlike the boolean brands in +// `@polyengine/protocol`'s `brands.ts` (whose payload is always `true`), this +// brand carries a PAYLOAD describing the callable's synchronous form — the +// dispatch shapes below are what `instantiate.ts` / `resources.ts` attach at +// wrap time and what this module reads back. + +/** The registry symbol. `Symbol.for` per A9: N runtime copies agree on it + * without sharing modules. */ +export const SYNC_CALLABLE: unique symbol = Symbol.for( + "polyengine.syncCallable/1", +); + +/** + * The brand payload, keyed by what the branded value is. + * + * - `"free"` — a lifted export function (plain export, interface member, or + * resource static): `fn` is the fully-wrapped synchronous form. + * - `"method"` — a guest-resource prototype method: `fn` takes the resource + * instance as its first argument (the `borrow`/`own` self param the + * lifted function already declares). + * - `"async"` — an async-typed export: carries no synchronous form, named so + * `sync()` can report the real reason. + */ +export type SyncPayload = + | { kind: "free"; fn: (...args: unknown[]) => unknown } + | { kind: "method"; fn: (self: unknown, ...args: unknown[]) => unknown } + | { kind: "async" }; + +/** + * Stamp `payload` on `target` under the brand: non-enumerable, non-writable, + * matching `@polyengine/protocol`'s `defineBrand` (protocol/src/brands.ts) — + * implemented locally since the runtime does not add application-tier + * vocabulary to the protocol package (A22). + * + * @internal — written by `instantiate.ts` and `resources.ts` at wrap/ + * class-build time; not part of the public `sync()` surface. + */ +export function markSyncCallable(target: object, payload: SyncPayload): void { + Object.defineProperty(target, SYNC_CALLABLE, { + value: payload, + enumerable: false, + writable: false, + configurable: false, + }); +} + +/** + * Read the brand payload off `target`, or `undefined` if unbranded. + * Structural, like `hasBrand`: accepts a payload minted by any copy. + * @internal + */ +export function syncPayloadOf(target: unknown): SyncPayload | undefined { + if (target === null) return undefined; + const t = typeof target; + if (t !== "object" && t !== "function") return undefined; + return (target as Record)[SYNC_CALLABLE]; +} + +/** Own, function-valued, branded members of `proto`'s prototype chain + * (stopping at `Object.prototype`), nearest wins. Used to recognize a + * guest-resource INSTANCE: its class's prototype carries `"method"`-branded + * data properties (`resources.ts` `buildGuestResourceClass`). */ +function protoBrandedMembers(proto: object): Map { + const out = new Map(); + for ( + let o: object | null = proto; + o !== null && o !== Object.prototype; + o = Object.getPrototypeOf(o) + ) { + for (const key of Object.getOwnPropertyNames(o)) { + if (out.has(key) || key === "constructor") continue; + const d = Object.getOwnPropertyDescriptor(o, key); + if (d === undefined || typeof d.value !== "function") continue; + const p = syncPayloadOf(d.value); + if (p !== undefined) out.set(key, p); + } + } + return out; +} + +/** Own, function-valued, branded static members of a guest-resource class. */ +function ownBrandedStatics(cls: object): Map { + const out = new Map(); + for (const key of Object.getOwnPropertyNames(cls)) { + if (key === "prototype" || key === "name" || key === "length") continue; + const d = Object.getOwnPropertyDescriptor(cls, key); + if (d === undefined || typeof d.value !== "function") continue; + const p = syncPayloadOf(d.value); + if (p !== undefined) out.set(key, p); + } + return out; +} + +function isResourceInstance(v: object): boolean { + if (typeof v === "function") return false; // a class, not an instance + const proto = Object.getPrototypeOf(v); + if (proto === null || proto === Object.prototype) return false; + return protoBrandedMembers(proto).size > 0; +} + +// deno-lint-ignore ban-types +function isResourceClass(v: Function): boolean { + return ownBrandedStatics(v).size > 0; +} + +function asyncMessage(name: string): string { + return `sync(): '${name}' is an async-typed WIT export; async exports ` + + `have no synchronous form`; +} + +function methodMessage(name: string): string { + return `sync(): '${name}' is a resource method; call sync(instance) ` + + `instead of sync(fn) — a bare method function has no receiver to bind`; +} + +/** Views are stable: `sync(x) === sync(x)` for the same target. */ +const views = new WeakMap(); + +function memoView(key: object, build: () => unknown): unknown { + const cached = views.get(key); + if (cached !== undefined) return cached; + const view = build(); + views.set(key, view); + return view; +} + +/** A view member that reports its real reason (async) only when accessed — + * so an unrelated sync member of the same record/class/instance stays usable + * (see the CONTRACT note on record recursion below). */ +function throwingMember(view: object, key: string, message: string): void { + Object.defineProperty(view, key, { + enumerable: true, + configurable: true, + get(): never { + throw new TypeError(message); + }, + }); +} + +function instanceView(instance: object): unknown { + return memoView(instance, () => { + const proto = Object.getPrototypeOf(instance) as object; + const members = protoBrandedMembers(proto); + const view: Record = {}; + for (const [key, p] of members) { + if (p.kind === "method") { + const fn = p.fn; + view[key] = (...a: unknown[]) => fn(instance, ...a); + } else if (p.kind === "async") { + throwingMember(view, key, asyncMessage(key)); + } + // A "free"-kind branded proto member should not occur (methods are + // always branded "method" by `buildGuestResourceClass`); nothing to do + // if it somehow did — the instance view only ever exposes methods + // (statics are not reachable from an instance; §"Functions and async"). + } + return view; + }); +} + +function classView(cls: object): unknown { + return memoView(cls, () => { + const statics = ownBrandedStatics(cls); + const view: Record = {}; + for (const [key, p] of statics) { + if (p.kind === "free") { + view[key] = p.fn; + } else if (p.kind === "async") { + throwingMember(view, key, asyncMessage(key)); + } + } + return view; + }); +} + +/** + * Map one record MEMBER by the `sync(record)` recursion rule: a branded + * function or a nested resource class/instance/record maps recursively; + * anything else (including an unbranded function) passes through unchanged. + * + * CONTRACT (contracts/embedder-api.md §"Functions and async" A25, the + * `sync(record)` bullet): the bullet says a record's members are "mapped by + * these same rules, recursively" — read most literally, an async-typed + * member nested in a record should behave exactly as `sync(asyncFn)` does at + * top level, i.e. throw. But applying that EAGERLY while building the + * parent's view would make one unrelated async export in a real component's + * exports record (a normal mix — see contracts/embedder-api.md's own async + + * sync export examples) poison `sync(exports)` entirely, defeating the + * per-use adapter's whole purpose. The conservative reading kept here defers + * that failure to the point the caller actually reaches for the async + * member (`throwingMember`), never for members the caller never touches — + * every failure the contract mandates still happens, just lazily. + */ +function mapMember(v: unknown): unknown { + if (typeof v === "function") { + const p = syncPayloadOf(v); + if (p !== undefined) { + if (p.kind === "free") return p.fn; + if (p.kind === "method") throw new TypeError(methodMessage(v.name)); + throw new TypeError(asyncMessage(v.name)); + } + if (isResourceClass(v)) return classView(v); + return v; // unbranded function: pass through unchanged + } + if (v !== null && typeof v === "object") { + if (isResourceInstance(v)) return instanceView(v); + return recordView(v); // a nested (interface) record + } + return v; // primitives, null: pass through unchanged +} + +function recordView(rec: object): unknown { + return memoView(rec, () => { + const view: Record = {}; + for (const key of Object.keys(rec)) { + const d = Object.getOwnPropertyDescriptor(rec, key); + if (d === undefined) continue; + if (!("value" in d)) { + // An accessor-backed own member (not expected on a runtime-built + // exports record today, but nothing here assumes data properties + // only): forward reads/writes to the underlying record unmapped. + Object.defineProperty(view, key, { + enumerable: true, + configurable: true, + get: () => (rec as Record)[key], + }); + continue; + } + const value = d.value; + // Lazy: `mapMember` runs (and can throw, for an async member) only + // when the caller actually reads this key — see the CONTRACT note on + // `mapMember` above. + Object.defineProperty(view, key, { + enumerable: true, + configurable: true, + get: () => mapMember(value), + }); + } + return view; + }); +} + +/** `Promise`-returning functions synchronize to `R`; records map + * recursively; everything else passes through. Type-level refusal of an + * async export is not attempted (the contract only requires the runtime + * error) — `Sync` stays structural. + * + * CONTRACT: the naive `F extends Record` branch (checked + * before this fix) only matches object-LITERAL type aliases — named + * interfaces (generated `*Exports`) and class instance types (generated + * resource classes, e.g. `Counter`) have no implicit index signature and + * are not assignable to it, so they fell through to the `: F` passthrough + * and stayed Promise-shaped. Ordering matters: a non-Promise function type + * (e.g. a resource's `drop(): void`, or `[Symbol.dispose]`) must be checked + * and passed through BEFORE the generic `object` branch, or `{ [K in keyof + * F]: ... }` would try to map over a function's call signature (losing it) + * instead of leaving the function itself alone. */ +export type Sync = F extends (...a: infer A) => Promise + ? (...a: A) => R + : F extends (...a: never[]) => unknown + ? F // non-Promise functions (e.g. `drop(): void`) pass through unchanged + : F extends object + ? { [K in keyof F]: Sync } // interfaces, class instances, records + : F; + +/** + * The synchronous form of a WIT-sync export (contracts/embedder-api.md + * §"Functions and async", amendment A25). + * + * - `sync(fn)` — a lifted export function (plain export, interface member, + * or resource static): returns the synchronous form `(...args) => T`. + * - `sync(instance)` — a guest-resource wrapper: a view whose members call + * the synchronous forms with `instance` as receiver. + * - `sync(cls)` — a guest-resource class: a view of synchronous statics + * (constructors are already synchronous; `new` the class itself). + * - `sync(record)` — an exports record or nested interface record: a view + * with every member mapped by these same rules, recursively; non-branded + * members pass through unchanged. + * - Views are stable: `sync(x) === sync(x)`. + * - An async-typed export, a bare resource-method function, or anything + * unbranded throws `TypeError`. + */ +export function sync Promise>( + target: F, +): Sync; +export function sync(target: T): Sync; +/** Fallback for a non-branded/primitive target — always throws at runtime + * (see the dispatch above); typed loosely so a caller passing an arbitrary + * value (as opposed to a known export/record/instance/class shape) still + * type-checks, matching the runtime's willingness to name the mistake. */ +export function sync(target: unknown): unknown; +export function sync(target: unknown): unknown { + if (typeof target === "function") { + const p = syncPayloadOf(target); + if (p !== undefined) { + if (p.kind === "free") return p.fn; + if (p.kind === "method") { + throw new TypeError(methodMessage(target.name || "")); + } + throw new TypeError(asyncMessage(target.name || "")); + } + if (isResourceClass(target)) return classView(target); + throw new TypeError( + `sync(): '${ + target.name || "" + }' is not a sync-callable export (unbranded function)`, + ); + } + if (target === null || typeof target !== "object") { + throw new TypeError( + `sync(): expected a lifted export function, guest-resource instance/` + + `class, or exports record; got ${ + target === null ? "null" : typeof target + }`, + ); + } + if (isResourceInstance(target)) return instanceView(target); + return recordView(target); +} diff --git a/runtime/src/exec/boundary.ts b/runtime/src/exec/boundary.ts index 8e6ee48..0d1d914 100644 --- a/runtime/src/exec/boundary.ts +++ b/runtime/src/exec/boundary.ts @@ -45,6 +45,7 @@ import { Store, storeQuiescent, Subtask, + SyncEntryBusy, WaitableSet, SubtaskState, Task, @@ -1254,28 +1255,35 @@ function takeHostFailure(store: Store): unknown { * needs genuine wasm-frame suspension: `needsJspi`, at the precise point. */ /** - * The plain-entered variant of a `[constructor]` export in jspi mode, - * attached to the promising-wrapped lifted function under this symbol. + * The plain-entered variant of a **sync-typed** lifted export in jspi mode, + * attached to the promising-wrapped lifted function under this symbol + * (contracts/embedder-api.md §"Functions and async", amendment A25). * - * A WIT constructor is surfaced as a JS class constructor - * (contracts/embedder-api.md §"Resources"), and a JS constructor cannot - * await — but in jspi mode every promising-wrapped entry returns a Promise - * even when the activation completes without suspending (jspi pin (e)). So - * constructor exports carry a second lifted function whose ENTRY is plain - * (unwrapped): a constructor that completes synchronously — the - * overwhelmingly common case; WIT constructors are always sync-typed — - * returns its rep synchronously through it. + * In jspi mode every promising-wrapped entry returns a Promise even when the + * activation completes without suspending (jspi pin (e)). Some host contexts + * cannot use a Promise no matter how promptly it resolves, so each sync-typed + * export carries a second lifted function whose ENTRY is plain (unwrapped): + * a guest activation that completes synchronously — the overwhelmingly common + * case for sync-typed WIT — delivers its results synchronously through it. * - * The cost is confined to genuinely-suspending constructors, which no JS - * host can surface as `new` anyway: a blocking built-in reached through the - * plain entry signals `NeedsJspi` (a capability error, instance left - * enterable), and a Suspending-wrapped host import reached from the - * unwrapped frame fails as a trap. Both name the constructor rather than - * silently deadlocking. + * Two consumers, one mechanism: + * + * * **resource constructors** — a WIT constructor is surfaced as a JS class + * constructor (§"Resources") and a JS constructor cannot await, so the + * embedder layer reads this symbol unconditionally for `[constructor]` + * exports; + * * **the embedder `sync()` adapter** (A25) — the explicit per-use + * synchronous view of any sync-typed export. + * + * The cost is confined to genuinely-suspending activations: a blocking + * built-in reached through the plain entry signals `NeedsJspi` (a capability + * error, instance left enterable), and a `Suspending`-wrapped host import + * reached from the unwrapped frame fails as a trap. Both name the export + * rather than silently deadlocking. A call made while the instance has + * hop-parked activations refuses with `SyncEntryBusy` before entering + * (`refuseOnEntryHops` below). */ -export const CONSTRUCTOR_SYNC_ENTRY: unique symbol = Symbol( - "polyengine.constructorSyncEntry", -); +export const SYNC_ENTRY: unique symbol = Symbol("polyengine.syncEntry"); export function createLiftedFunction(input: { name: string; @@ -1319,6 +1327,22 @@ export function createLiftedFunction(input: { * Promise, which it can never advance, and declares a bogus deadlock. */ allowAsyncCompletion?: boolean; + /** + * Refuse — synchronously, before entering — a call made while the instance + * has HOP-parked activations, instead of deferring it (amendment A25, + * failure-ladder arm 2). + * + * Set for the `SYNC_ENTRY` variant, which is built with + * `suspensionMode: "plain"` inside a *jspi-mode* instantiation: the + * hop-quiescence gate below is keyed on this function's own mode and so is + * dead for that variant, yet the hazard it exists to prevent is the + * instance's, not the entry's — a hop-parked activation's pending lift + * reads memory a fresh guest turn would mutate (see the gate's comment). + * A synchronous caller cannot be deferred, so it refuses instead. The + * refusal is pre-enter, hence non-poisoning: nothing was entered, so there + * is nothing to poison — the same structural safety as `entryRefusal`. + */ + refuseOnEntryHops?: boolean; }): (...args: ComponentValue[]) => unknown { const { name, @@ -1667,6 +1691,14 @@ export function createLiftedFunction(input: { }; return (...hostArgs: ComponentValue[]): unknown => { + // A25 arm 2: the synchronous variant refuses rather than deferring, and + // does so FIRST — before the arity check's sibling logic reaches + // `invokeNow` — because the refusal must be pre-enter to stay + // non-poisoning. See `refuseOnEntryHops` above for why the mode-keyed + // gate below cannot cover this variant. + if (input.refuseOnEntryHops && entryHopThreads(store, inst).length > 0) { + throw new SyncEntryBusy(name); + } if (hostArgs.length !== ft.params.length) { throw new TypeError( `${name}: expected ${ft.params.length} argument(s), got ${hostArgs.length}`, diff --git a/runtime/src/exec/executor.ts b/runtime/src/exec/executor.ts index 077f326..7b16a0a 100644 --- a/runtime/src/exec/executor.ts +++ b/runtime/src/exec/executor.ts @@ -42,7 +42,7 @@ import type { } from "../plan/format.ts"; import type { LoadedPlan, LoadedType } from "../plan/loader.ts"; import { - CONSTRUCTOR_SYNC_ENTRY, + SYNC_ENTRY, type CoreFn, createDtorEntry, createLiftedFunction, @@ -869,20 +869,23 @@ class Executor { syncCallStack: this.syncCallStack, allInstances: () => this.componentInstances.values(), }); - // Constructor exports additionally carry a plain-entered variant - // (see CONSTRUCTOR_SYNC_ENTRY): in jspi mode the promising-wrapped - // entry above necessarily returns a Promise, which a JS class - // constructor cannot await. Deliberately NOT noteEntry()-recorded — - // this is the one documented exception to the bridge invariant - // (entries wrapped iff imports wrapped), safe because a - // synchronously-completing activation never reaches the Suspending - // seam. - if ( - this.suspensionMode === "jspi" && - exp.name.startsWith("[constructor]") - ) { + // Every SYNC-TYPED export additionally carries a plain-entered + // variant (see SYNC_ENTRY, contracts/embedder-api.md amendment A25): + // in jspi mode the promising-wrapped entry above necessarily returns + // a Promise, which some host contexts cannot use however promptly it + // resolves — a JS class constructor cannot await it at all, and the + // embedder's `sync()` adapter exists to ask for the synchronous form + // of any sync-typed export. Async-typed exports have no synchronous + // form by definition and get none. + // + // Deliberately NOT noteEntry()-recorded — this is the documented + // exception to the bridge invariant (entries wrapped iff imports + // wrapped), safe because a synchronously-completing activation never + // reaches the Suspending seam. A25 extends the exception from + // constructors to all sync entries. + if (this.suspensionMode === "jspi" && !ft.async) { (value as unknown as Record)[ - CONSTRUCTOR_SYNC_ENTRY + SYNC_ENTRY ] = createLiftedFunction({ name: `${path} (sync entry)`, ft, @@ -893,6 +896,12 @@ class Executor { trapState: this.trapState, syncCallStack: this.syncCallStack, allInstances: () => this.componentInstances.values(), + // A25 arm 2: a synchronous caller cannot be deferred by the + // hop-quiescence gate, so it refuses (SyncEntryBusy) instead. + // This deliberately changes constructor behaviour: the + // constructor sync entry previously bypassed the gate + // entirely, a latent lift-corruption window. + refuseOnEntryHops: true, }); } return { kind: "value", value }; diff --git a/runtime/src/task/scheduler.ts b/runtime/src/task/scheduler.ts index 86573de..6d0467c 100644 --- a/runtime/src/task/scheduler.ts +++ b/runtime/src/task/scheduler.ts @@ -106,6 +106,33 @@ export function needsJspi(what: string): never { throw new NeedsJspi(what); } +/** + * Failure raised when a synchronous entry into an instance would race a + * pending lift (contracts/embedder-api.md amendment A25, failure-ladder arm + * 2). + * + * In jspi mode a promising-wrapped entry settles through a microtask hop even + * when nothing suspended, and the hop-quiescence gate (exec/boundary.ts) + * defers Promise-surface calls that would enter during that window. A + * synchronous caller — a resource constructor, or the embedder's `sync()` + * adapter — cannot be deferred, so it refuses instead. + * + * Deliberately *not* a `Trap`, and deliberately raised BEFORE the instance is + * entered: nothing was entered, so there is nothing to poison. The refusal is + * transient — the instance stays enterable, and the call succeeds on retry + * once the in-flight activity settles, or immediately through the + * Promise-shaped surface, which defers rather than refusing. + */ +export class SyncEntryBusy extends Error { + constructor(what: string) { + super( + `sync entry refused: ${what} (the component instance has activity in ` + + `flight; retry once it settles, or use the Promise-shaped call)`, + ); + this.name = "SyncEntryBusy"; + } +} + /** * Failure raised where a capability scheduled for a later M2 phase is * required. Same rationale as `NeedsJspi`: never a `Trap`. diff --git a/runtime/tests/bindgen/usage/sync_usage.ts b/runtime/tests/bindgen/usage/sync_usage.ts new file mode 100644 index 0000000..e6f0fcf --- /dev/null +++ b/runtime/tests/bindgen/usage/sync_usage.ts @@ -0,0 +1,90 @@ +// Hand-written usage sample for `@polyengine/runtime/embedder`'s `Sync` +// type and `sync()` adapter (contracts/embedder-api.md §"Functions and +// async", amendment A25) — pins how the sync VIEW type maps generated +// facade shapes: plain export functions, an exports-record interface, +// and a resource class. Runtime behavior of `sync()` is already covered +// by runtime/tests/embedder/sync_adapter_test.ts; this file is +// TYPE-only (`deno check`). + +import { bind } from "../generated/values.ts"; +import type { ValuesExports } from "../generated/values.ts"; +import { bind as bindResources } from "../generated/resources.ts"; +import type { Counter, ResourcesExports } from "../generated/resources.ts"; +import { bind as bindFutureUser } from "../generated/future-user.ts"; +import type { FutureUserExports } from "../generated/future-user.ts"; +import type { EmbedderInstance } from "../../../src/embedder/mod.ts"; +import { sync } from "../../../src/embedder/mod.ts"; +import type { Sync } from "../../../src/embedder/mod.ts"; +import type { Future } from "@polyengine/protocol"; +import type { Equal, Expect } from "./type_assert.ts"; + +export function useSync(instance: EmbedderInstance) { + const exports: ValuesExports = bind(instance); + + // sync(fn) on a plain Promise-returning export: strips the Promise, + // keeps the parameter list (A25 `sync(fn)` bullet). + const syncEchoBool = sync(exports.echoBool); + type _SyncEchoBoolIsPlain = Expect< + Equal boolean> + >; + const echoed: boolean = syncEchoBool(true); // not a Promise: no `await` + void echoed; + + // Sync on the whole exports interface: every member is mapped + // recursively, same as `sync(record)` at runtime — this is the DEFECT + // fixed in sync.ts (a named interface has no implicit index signature, + // so the naive `F extends Record` branch used to miss + // it and leave the view Promise-shaped). + type _SyncedValuesExports = Sync; + type _SyncedEchoBoolIsPlain = Expect< + Equal<_SyncedValuesExports["echoBool"], (v: boolean) => boolean> + >; + type _SyncedEchoU64IsPlain = Expect< + Equal<_SyncedValuesExports["echoU64"], (v: bigint) => bigint> + >; + + return { syncEchoBool }; +} + +export function useSyncResources(instance: EmbedderInstance) { + const exports: ResourcesExports = bindResources(instance); + + // Sync: a class-instance type (not an object literal) is the + // other half of the defect this fixture pins — methods map Promise -> + // T, and a non-Promise member ([Symbol.dispose]/`drop`, if present on + // the generated fixture) passes through unchanged rather than being + // destructured by the mapped-type's call-signature branch. + type _SyncedCounter = Sync; + type _SyncedIncrementIsPlain = Expect< + Equal<_SyncedCounter["increment"], () => bigint> + >; + type _SyncedGetIsPlain = Expect< + Equal<_SyncedCounter["get"], () => bigint> + >; + // `[Symbol.dispose]` is already non-Promise-returning (`() => void`): + // Sync's function-passthrough branch (checked before the object/ + // mapped-type branch) must leave it exactly as-is, not attempt to map + // over its call signature. + type _SyncedDisposeUnchanged = Expect< + Equal< + _SyncedCounter[typeof Symbol.dispose], + Counter[typeof Symbol.dispose] + > + >; + + void exports; +} + +export function useSyncFutureUser(instance: EmbedderInstance) { + const exports: FutureUserExports = bindFutureUser(instance); + + // A Future-returning export is already an eager handle (not + // Promise>, per future_user_usage.ts) — CORRECT per A25 that + // Sync passes it through unchanged: the sync form of a handle-valued + // result IS the eager handle, there is nothing further to strip. + type _SyncedMakeFutureUnchanged = Expect< + Equal, (x: number) => Future> + >; + + void exports; +} diff --git a/runtime/tests/embedder/casing_test.ts b/runtime/tests/embedder/casing_test.ts new file mode 100644 index 0000000..c78a230 --- /dev/null +++ b/runtime/tests/embedder/casing_test.ts @@ -0,0 +1,73 @@ +// `parseLeafName` unit tests: the mangled export/import name grammar +// (contracts/embedder-api.md §"Naming and casing") plus the unknown-bracket +// refusal mandated by the getters/setters pre-ruling (§"Getters and setters +// (pre-ruling, 2026-08-30 — not yet implementable)", final paragraph): +// "the runtime refuses unknown bracket forms in mangled names loudly at +// instantiation (rather than misbinding them as plain names…)". +// +// The known forms are already pinned end-to-end against real fixtures in +// `version_test.ts`; this file is the focused unit suite for the parser +// itself, in particular the negative space `version_test.ts` doesn't cover. + +import { assertEq } from "../support/asserts.ts"; +import { parseLeafName } from "../../src/embedder/casing.ts"; + +function throws(f: () => unknown): unknown { + try { + f(); + return undefined; + } catch (e) { + return e; + } +} + +Deno.test("parseLeafName: known forms still parse", () => { + assertEq(parseLeafName("make-counter"), { + form: "plain", + name: "make-counter", + }); + assertEq(parseLeafName("[constructor]counter"), { + form: "constructor", + resource: "counter", + }); + assertEq(parseLeafName("[method]counter.increment"), { + form: "method", + resource: "counter", + member: "increment", + }); + assertEq(parseLeafName("[static]counter.merge"), { + form: "static", + resource: "counter", + member: "merge", + }); +}); + +Deno.test("parseLeafName: unknown bracket forms are refused, not misbound", () => { + for ( + const raw of [ + "[get]foo", + "[set]foo", + "[method][get]r.p", + "[static][set]r.p", + "[weird]x", + "[async]f", + ] + ) { + const e = throws(() => parseLeafName(raw)); + assertEq(e instanceof Error, true, `${raw}: expected a throw, got ${e}`); + assertEq( + String((e as Error).message).includes(raw), + true, + `${raw}: the raw name must appear in the message, got: ${ + (e as Error).message + }`, + ); + } +}); + +Deno.test("parseLeafName: malformed method/static (no dot) is also refused", () => { + // `[method]counter` (no `.member`) matches MANGLED but not the dot-split; + // it falls through to the same refusal as an unknown bracket tag. + const e = throws(() => parseLeafName("[method]counter")); + assertEq(e instanceof Error, true, `expected a throw, got ${e}`); +}); diff --git a/runtime/tests/embedder/resources_test.ts b/runtime/tests/embedder/resources_test.ts index add35f9..c656ed4 100644 --- a/runtime/tests/embedder/resources_test.ts +++ b/runtime/tests/embedder/resources_test.ts @@ -167,7 +167,7 @@ Deno.test({ // jspi mode promising-wraps every lifted entry, so the entry returns a // Promise even when the activation never suspends — which a JS class // constructor cannot await. Constructor exports carry a plain-entered - // variant for exactly this (exec/boundary.ts CONSTRUCTOR_SYNC_ENTRY); + // variant for exactly this (exec/boundary.ts SYNC_ENTRY); // this pins `new` working under forced jspi, method calls included // (the polymorph-iroh endpoint's `new EndpointOptions(identity)` is the // consumer shape that found the gap). diff --git a/runtime/tests/embedder/sync_adapter_test.ts b/runtime/tests/embedder/sync_adapter_test.ts new file mode 100644 index 0000000..6d36538 --- /dev/null +++ b/runtime/tests/embedder/sync_adapter_test.ts @@ -0,0 +1,194 @@ +// `sync()` — the A25 synchronous adapter for WIT-sync exports (contracts/ +// embedder-api.md §"Functions and async", amendment A25). +// +// Fixtures: `examples/guests/build/values.component.wasm` (plain sync/ +// fallible exports), `examples/guests/build/resources.component.wasm` +// (guest-implemented resource — constructor/method/static), and +// `crates/translator-shim/testdata/async-lift.wasm` (a single async-typed +// export, callback ABI, eager `task.return` — exercises the "async has no +// synchronous form" refusal without needing a genuine suspension). + +import { assertEq } from "../support/asserts.ts"; +import { + caught, + guest, + haveFixture, + instantiateFixture, + testdata, +} from "./support.ts"; +import { ComponentException } from "@polyengine/protocol"; +import { sync } from "../../src/embedder/mod.ts"; + +const readyValues = await haveFixture(guest("values")); +const readyResources = await haveFixture(guest("resources")); +const readyAsync = await haveFixture(testdata("async-lift")); + +const IFACE = "polyengine:resources/counters"; + +Deno.test({ + name: "sync(): a plain sync export returns its value synchronously, " + + "matching the Promise surface", + ignore: !readyValues, + fn: async () => { + const { exports } = await instantiateFixture(guest("values")); + const syncEcho = sync(exports.echoU64); + const result = syncEcho(7n); + assertEq( + result instanceof Promise, + false, + "the sync form must not return a thenable", + ); + assertEq(result, 7n); + assertEq(await exports.echoU64(7n), 7n, "matches the Promise surface"); + }, +}); + +Deno.test({ + name: "sync(): a fallible export throws ComponentException synchronously", + ignore: !readyValues, + fn: async () => { + const { exports } = await instantiateFixture(guest("values")); + const syncEcho = sync(exports.echoResult); + let caughtErr: unknown; + let hopped = false; + Promise.resolve().then(() => { + hopped = true; + }); + try { + syncEcho({ kind: "err", value: "boom" }); + } catch (e) { + caughtErr = e; + } + assertEq( + hopped, + false, + "no microtask elapsed between the call and the throw", + ); + assertEq(caughtErr instanceof ComponentException, true, `got: ${caughtErr}`); + assertEq((caughtErr as ComponentException).payload, "boom"); + // Drain the queued microtask so it doesn't leak into a later test. + await new Promise((r) => queueMicrotask(() => r(undefined))); + }, +}); + +Deno.test({ + name: "sync(): an async-typed export throws TypeError naming async", + ignore: !readyAsync, + fn: async () => { + const { exports } = await instantiateFixture(testdata("async-lift")); + assertEq(typeof exports.f, "function"); + // The default surface stays Promise-shaped for the async export too. + assertEq(await exports.f(41), 42); + const err = await caught(() => sync(exports.f)); + assertEq(err instanceof TypeError, true, `got: ${err}`); + assertEq( + String(err).includes("async"), + true, + `expected the async-export reason: ${err}`, + ); + }, +}); + +Deno.test({ + name: "sync(): an unbranded function or a primitive throws TypeError", + ignore: !readyValues, + fn: async () => { + await instantiateFixture(guest("values")); // establishes fixture readiness + assertEq( + (await caught(() => sync(function plain() {}))) instanceof TypeError, + true, + ); + assertEq((await caught(() => sync(42))) instanceof TypeError, true); + assertEq((await caught(() => sync(null))) instanceof TypeError, true); + assertEq((await caught(() => sync("x"))) instanceof TypeError, true); + }, +}); + +Deno.test({ + name: "sync(): resource instance/class views (plain mode)", + ignore: !readyResources, + fn: async () => { + const { exports } = await instantiateFixture(guest("resources")); + const c = exports[IFACE]; + const a = new c.Counter(5n); // constructors are unaffected by sync() + assertEq(a instanceof c.Counter, true); + + const view = sync(a); + assertEq(sync(a) === view, true, "sync(instance) is memoized"); + assertEq(view.get(), 5n); + assertEq(view.increment(), 6n); + assertEq(await a.get(), 6n, "matches the Promise surface's own view"); + + // A bare prototype method function cannot supply a receiver. + const bareMethod = c.Counter.prototype.increment; + const err = await caught(() => sync(bareMethod)); + assertEq(err instanceof TypeError, true, `got: ${err}`); + assertEq( + String(err).includes("sync(instance)"), + true, + `expected the sync(instance) hint: ${err}`, + ); + + // Statics view. + const b = new c.Counter(10n); + const staticsView = sync(c.Counter); + assertEq(sync(c.Counter) === staticsView, true, "sync(cls) is memoized"); + const merged = staticsView.merge(a, b); + assertEq(merged instanceof c.Counter, true); + assertEq(await merged.get(), 16n); + + merged.drop(); + }, +}); + +Deno.test({ + name: "sync(): resource instance/class views (jspi mode — SYNC_ENTRY path)", + ignore: !readyResources, + fn: async () => { + // jspi mode promising-wraps every lifted entry, so the default Promise + // surface is unavoidably async even for a completing-synchronously + // activation; `sync()` routes through the plain-entered `SYNC_ENTRY` + // variant instead (exec/boundary.ts) and must behave identically. + const { exports } = await instantiateFixture(guest("resources"), {}, { + jspi: true, + }); + const c = exports[IFACE]; + const a = new c.Counter(2n); + const view = sync(a); + assertEq(view.increment(), 3n); + assertEq(view.get(), 3n); + assertEq(await a.get(), 3n); + a.drop(); + }, +}); + +Deno.test({ + name: "sync(): record view recurses, passes through non-branded members, " + + "is memoized", + ignore: !readyValues || !readyResources, + fn: async () => { + const { exports } = await instantiateFixture(guest("values")); + const view = sync(exports); + assertEq(sync(exports) === view, true, "sync(record) is memoized"); + assertEq(view.echoU64(1n), 1n, "a branded member maps to its sync form"); + + const resInst = await instantiateFixture(guest("resources")); + const resView = sync(resInst.exports); + const iface = resView[IFACE]; + assertEq(typeof iface.makeCounter, "function", "recurses into interfaces"); + const counter = iface.makeCounter(3n); + assertEq(counter instanceof resInst.exports[IFACE].Counter, true); + counter.drop(); + }, +}); + +Deno.test({ + name: "sync(): the default export surface stays Promise-shaped", + ignore: !readyValues, + fn: async () => { + const { exports } = await instantiateFixture(guest("values")); + const p = exports.echoU64(9n); + assertEq(p instanceof Promise, true); + assertEq(await p, 9n); + }, +}); diff --git a/runtime/tests/jspi/sync_entry_test.ts b/runtime/tests/jspi/sync_entry_test.ts new file mode 100644 index 0000000..b1f2a2a --- /dev/null +++ b/runtime/tests/jspi/sync_entry_test.ts @@ -0,0 +1,234 @@ +// The generalized SYNC_ENTRY mechanism (contracts/embedder-api.md +// §"Functions and async", amendment A25). +// +// In jspi mode every lifted export's core entry is `promising`-wrapped, so +// the entry returns a Promise even when the guest completed without +// suspending (jspi pin (e)/(j)). A25 generalizes the constructor-only +// plain-entered variant to EVERY sync-typed export: each carries a second +// lifted function, under the `SYNC_ENTRY` symbol, whose entry is plain, so a +// synchronously-completing activation delivers its results synchronously. +// (The embedder-facing `sync()` adapter that consumes it is a separate +// track; these pins are on the kernel half.) +// +// Two properties are pinned here: +// +// 1. **attachment** — sync-typed exports carry it, async-typed exports do +// not (an async WIT function has no synchronous form by definition); +// 2. **hop-window refusal** (A25 failure-ladder arm 2) — a plain entry +// taken while the instance has HOP-parked activations would race the +// pending result LIFT of that activation, the corruption window +// `hop_atomicity_test.ts` documents. The Promise surface *defers* there +// (the hop-quiescence gate); a synchronous caller cannot be deferred, so +// it REFUSES with `SyncEntryBusy` — thrown before the instance is +// entered, hence non-poisoning, so the instance stays enterable and the +// call succeeds on retry once the hop settles. +// +// Property 2 also changes constructor behaviour deliberately: the constructor +// sync entry previously bypassed the hop gate entirely. Constructing a hop +// window around a `new R(...)` needs a guest that both parks an activation +// and exposes a resource constructor, which no existing fixture does; the +// refusal lives in `createLiftedFunction` and is reached identically by both +// consumers, so it is pinned here through the generic sync entry. +import { assert, assertEquals } from "./asserts.ts"; +import { Translator } from "../../src/shim/mod.ts"; +import { instantiateComponent } from "../../src/exec/mod.ts"; +import { SYNC_ENTRY } from "../../src/exec/boundary.ts"; +import { SyncEntryBusy } from "../../src/task/scheduler.ts"; +import { planNeedsSuspension } from "../../src/jspi/bridge.ts"; +import { isSupported } from "../../src/jspi/mechanics.ts"; + +const root = new URL("../../../", import.meta.url); + +async function readIfPresent(rel: string): Promise { + try { + return await Deno.readFile(new URL(rel, root)); + } catch { + return null; + } +} + +// Same skip-when-absent discipline as the neighbours: the shim is a build +// artifact, not a checked-in one. +const shimWasm = await readIfPresent( + "target/wasm32-unknown-unknown/release/translator_shim.wasm", +); +if (shimWasm === null) { + console.warn( + "SKIP sync entry: missing translator_shim.wasm " + + "(cargo build -p translator-shim --release --target wasm32-unknown-unknown)", + ); +} + +const hopWasm = await Deno.readFile( + new URL("./fixtures/hop-atomicity.wasm", import.meta.url), +); +const asyncWasm = await Deno.readFile( + new URL("./fixtures/fact-callback-suspend.wasm", import.meta.url), +); + +type Fn = (...args: unknown[]) => unknown; + +function syncEntryOf(fn: unknown, where: string): Fn { + const entry = (fn as Record)[SYNC_ENTRY]; + assert( + typeof entry === "function", + `${where}: expected a SYNC_ENTRY variant, got ${Deno.inspect(entry)}`, + ); + return entry as Fn; +} + +/** The value `tick` builds on every call (fixture layout: two inner lists). */ +function assertTickValue(actual: unknown, where: string): void { + assert( + Array.isArray(actual), + `${where}: expected an array, got ${Deno.inspect(actual)}`, + ); + const outer = actual as unknown[]; + assertEquals(outer.length, 2, `${where}: outer list length`); + const expected = [[1, 2, 3], [4, 5]]; + for (let i = 0; i < 2; i++) { + const inner = outer[i] as ArrayLike; + assertEquals( + Array.from(inner).join(","), + expected[i].join(","), + `${where}: inner[${i}] bytes`, + ); + } +} + +/** The hop-atomicity fixture, instantiated in jspi mode (raw exec surface: + * the SYNC_ENTRY symbol rides the lifted function itself). */ +async function hopInstance() { + const translator = await Translator.create(shimWasm!); + const { plan, adapters } = translator.translate(hopWasm); + // The fixture's plan has no blocking declaration of its own (see its + // header); jspi is requested explicitly here rather than through a + // `suspending()`-marked import, since this suite drives the raw exec + // surface rather than the embedder facade. + assert( + !planNeedsSuspension(plan), + "fixture's plan must NOT need suspension on its own", + ); + return await instantiateComponent({ + plan, + componentBytes: hopWasm, + adapters, + imports: { "test:hop/gate": { wait: () => 7 } }, + jspi: true, + }); +} + +Deno.test({ + name: + "A25: a sync-typed non-constructor export carries SYNC_ENTRY in jspi " + + "mode, and it answers synchronously", + ignore: shimWasm === null || !isSupported(), + fn: async () => { + const handle = await hopInstance(); + const tick = handle.exports.tick as Fn; + const clobber = handle.exports.clobber as Fn; + + // The promising-wrapped default surface is still Promise-shaped: A25 adds + // a view, it does not change the default. + const promised = tick(); + assert( + promised instanceof Promise, + "the default entry must stay Promise-shaped in jspi mode", + ); + assertTickValue(await promised, "default entry"); + + // Every sync-typed export, not just `[constructor]` ones. + const tickSync = syncEntryOf(tick, "tick"); + const clobberSync = syncEntryOf(clobber, "clobber"); + + const value = tickSync(); + assert( + !(value instanceof Promise) && + typeof (value as { then?: unknown })?.then !== "function", + `sync entry must not return a thenable, got ${Deno.inspect(value)}`, + ); + assertTickValue(value, "sync entry"); + + // `clobber` overwrites the results area, and `tick` rewrites it, so the + // instance self-heals: a synchronous round trip through both proves the + // plain entry completes fully inside its bracket rather than leaving the + // instance mid-call. + assertEquals(clobberSync(), 1, "clobber sync entry result"); + assertTickValue(tickSync(), "sync entry after clobber"); + }, +}); + +Deno.test({ + name: + "A25 arm 2: a SYNC_ENTRY call during a hop window refuses with " + + "SyncEntryBusy, non-poisoningly", + ignore: shimWasm === null || !isSupported(), + fn: async () => { + const handle = await hopInstance(); + const tick = handle.exports.tick as Fn; + const tickSync = syncEntryOf(tick, "tick"); + const clobberSync = syncEntryOf(handle.exports.clobber, "clobber"); + + // Open the hop: the core call has returned but the result LIFT has not + // run yet, and the reentrance bracket is already released. This is the + // exact window in which `clobber` used to corrupt `tick`'s pending lift + // (hop_atomicity_test.ts); the Promise surface now defers into it, and a + // synchronous entry must refuse rather than defer. + const pending = tick(); + assert(pending instanceof Promise, "the pending promise IS the hop"); + + let refused: unknown = null; + try { + clobberSync(); + } catch (e) { + refused = e; + } + assert( + refused instanceof SyncEntryBusy, + `expected SyncEntryBusy, got ${Deno.inspect(refused)}`, + ); + // Branded by name, per A25 ("e.name === 'SyncEntryBusy'"). + assertEquals((refused as Error).name, "SyncEntryBusy"); + assert( + (refused as Error).message.includes("clobber"), + `the refusal must name the export: ${(refused as Error).message}`, + ); + // The refusal happens BEFORE entering, so the in-flight lift is + // untouched: the pending call still yields the correct value. + assertTickValue(await pending, "tick across a refused sync entry"); + + // Non-poisoning, both surfaces: the instance is enterable again once the + // hop has settled. + assertTickValue(await (tick() as Promise), "default entry after refusal"); + assertEquals(clobberSync(), 1, "sync entry after refusal"); + assertTickValue(tickSync(), "sync entry after refusal (tick)"); + }, +}); + +Deno.test({ + name: "A25: an async-typed export carries no SYNC_ENTRY", + ignore: shimWasm === null || !isSupported(), + fn: async () => { + const translator = await Translator.create(shimWasm!); + const { plan, adapters } = translator.translate(asyncWasm); + // This fixture needs suspension on its own (it imports + // `waitable-set.wait`), so the instantiation is genuinely jspi-mode — + // the only mode in which SYNC_ENTRY is attached at all. + assert( + planNeedsSuspension(plan), + "fixture must need suspension: it imports waitable-set.wait", + ); + const handle = await instantiateComponent({ + plan, + componentBytes: asyncWasm, + adapters, + imports: { gate: () => Promise.resolve() }, + }); + const go = handle.exports.go as Fn; + assertEquals( + (go as unknown as Record)[SYNC_ENTRY], + undefined, + "an async-lifted export has no synchronous form and must carry none", + ); + }, +});