From 2d6411968a16486bc41d53658895a34e7e701675 Mon Sep 17 00:00:00 2001 From: Lann Martin Date: Sun, 23 Aug 2026 13:00:26 -0400 Subject: [PATCH] runtime, protocol: guest cancellation of host imports discards by default (A23, #241) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A guest cancelling an in-flight async-typed host import (`subtask.cancel`; wit-bindgen's drop-to-cancel path) used to stall until the import completed naturally: the lowered import's `on_cancel` was a permanent no-op, so the cancel blocked for the full remaining duration of the call the guest had just abandoned (measured: cancel at t=312ms, return at t=4010ms of a 4000ms timer). The reference leaves a host callee's cancellation behavior to the embedding (`Store.invoke`, definitions.py line 572) — wasmtime hosts hand back a future whose drop IS cancellation; a JS Promise has no such channel, so the runtime must answer on the host's behalf, and "accept and ignore" was the worst available answer. Amendment A23 (contracts/embedder-api.md): the default is now the reference's prompt-cancel host, `on_cancel = () => on_resolve(None)` — the subtask resolves CANCELLED_BEFORE_RETURNED immediately, both cancel forms return without blocking, and the host call's eventual settlement is DISCARDED: the value is never lowered, a rejection is not reported (the guest renounced the call), and the call is deregistered from deadlock accounting. The host operation itself is not interrupted — discard is a statement about delivery, not execution. The settle continuations gain resolved-subtask guards; without them a discarded call's late settlement ran `onResolve` into its STARTED assert (or parked the rejection) on `store.hostFailure`, poisoning an unrelated later call. `deferCancel()` — new in @polyengine/protocol (0.2.3), sibling of `suspending()` with the same two spellings and the same loud refusals — opts an import back into run-to-completion for operations with commit points: cancel answers BLOCKED / parks, and the guest observes RETURNED with the real result. Inert on sync-typed imports by construction (their parks mint no subtask handle, so they can never be cancelled at all). The conventions layer's four dispatcher arms now relay both marks through one `relayMarks` helper: each arm re-wraps the embedder's function, and a brand left on the original is invisible to the executor — for deferCancel that would have been a silently discarded commit, exactly what the brand exists to prevent. Tests: host_import_cancel_test.ts pins the discard (both cancel forms, event delivery, lender release, deadlock deregistration, late-settle inertness both arms, drop-after-discard) and the deferCancel path; async_lower_test.ts's second-cancel trap is split into its two reference-guard arms (resolveDelivered before cancellationRequested); the cancel-import fixture gains cancel-inflight / cancel-defer / cancel-defer-ifc probes, timed end-to-end through both the raw exec layer and the embedder conventions layer (the -ifc leg pins the interface-member relay arm). Every new test was verified to fail against the neutralized implementation. Not breaking: the pre-A23 behavior was uncontracted and defective (#239, #241), the conventions goldens are byte-identical, and the protocol change is additive — hence no breaking/* label; protocol rides 0.2.2 -> 0.2.3. --- contracts/embedder-api.md | 45 ++- docs/architecture.md | 14 + examples/guests/cancel-import/src/lib.rs | 40 +++ examples/guests/cancel-import/wit/world.wit | 24 ++ protocol/deno.json | 2 +- protocol/src/brands.ts | 13 + protocol/src/defer_cancel.ts | 126 +++++++ protocol/src/mod.ts | 3 + protocol/tests/brands_test.ts | 1 + protocol/tests/defer_cancel_test.ts | 121 +++++++ runtime/src/embedder/instantiate.ts | 43 ++- runtime/src/exec/boundary.ts | 74 +++- runtime/src/exec/executor.ts | 9 + runtime/src/intrinsics/async_builtins.ts | 7 +- runtime/src/jspi/suspending.ts | 14 +- .../async_lower_onresolve_failure_test.ts | 1 + runtime/tests/async_lower_test.ts | 65 +++- runtime/tests/embedder/cancel_import_test.ts | 137 +++++++ runtime/tests/host_import_cancel_test.ts | 337 ++++++++++++++++++ .../integration/e2e_cancel_import_test.ts | 147 ++++++-- runtime/tests/park_state_settle_test.ts | 1 + runtime/tests/realloc_may_leave_test.ts | 4 + 22 files changed, 1170 insertions(+), 58 deletions(-) create mode 100644 protocol/src/defer_cancel.ts create mode 100644 protocol/tests/defer_cancel_test.ts create mode 100644 runtime/tests/embedder/cancel_import_test.ts create mode 100644 runtime/tests/host_import_cancel_test.ts diff --git a/contracts/embedder-api.md b/contracts/embedder-api.md index acdb744..6c8665d 100644 --- a/contracts/embedder-api.md +++ b/contracts/embedder-api.md @@ -356,8 +356,9 @@ class PeerTrappedError extends Error { // A7: a stream/future op whose peer ins import is typed to return `T` synchronously. Returning a Promise from a sync-typed import parks the calling **wasm frame** and is a *declared* capability (amendment A1): wrap the function in `suspending()` (defined - in `@polyengine/protocol` since A9; re-exported unchanged from the embedder - surface). The marker + in `@polyengine/protocol` since A9; imported from there directly — the + A9-era embedder-surface re-export was removed by A22, whose rule is that + the runtime's exported surface is application-only). The marker - is per-declaration — only marked imports are handed to wasm as `WebAssembly.Suspending`, so unmarked imports keep the plain calling convention and sync-only components keep their zero-cost pin; @@ -406,6 +407,45 @@ class PeerTrappedError extends Error { // A7: a stream/future op whose peer ins half of a host stream/future still hangs until the embedder acts (never a trap — see Streams and futures), and a settlement-time failure surfaces on the next call into the instance, as before. +- **Guest cancellation of an in-flight host import discards by default** + (amendment A23, 2026-08-23, polyengine#241). A guest may cancel an + in-flight async-typed import (`subtask.cancel`; wit-bindgen reaches it + by dropping the import's future — its specified cancellation path). A + JS host function offers no cancellation channel, so the runtime answers + on its behalf, and the default answer is the reference's prompt-cancel + host — `on_cancel = () => on_resolve(None)`, the shape `Store.invoke` + expects a callee to hand back (definitions.py line 572): the subtask + resolves `CANCELLED_BEFORE_RETURNED` immediately, both cancel forms + return without blocking, and the host call's eventual settlement is + **discarded** — the value is never lowered, a rejection is not reported + anywhere (the guest renounced the call; there is no addressee), and the + call stops counting as guest-wakeable for deadlock detection. The host + operation itself is NOT interrupted: a Promise cannot be aborted from + outside, so its side effects still run to completion. Discard is a + statement about delivery, not about execution. (An embedder-supplied + abort channel — notifying the host that its result was discarded — is + deliberately out of A23's scope; see polyengine#241.) +- **`deferCancel()` opts an import out of discard** (A23): a marked import + must run to completion — a cancellation request is accepted and ignored, + the async cancel form answers `BLOCKED`, the sync form parks under jspi + (on a non-JSPI engine it is refused at the call site, `NeedsJspi`, per + the A1 engine floor), and the + guest observes `RETURNED` with the real result when the promise settles + (the pre-A23 behavior, now per-declaration). Mark imports with a commit + point — a flush, a commit, anything where "cancelled" would let the + guest believe nothing happened while the write lands. Two spellings, one + brand (`polyengine.deferCancel/1`), exactly as `suspending()`: the + direct call (`flush: deferCancel(fn)` — the only form available in + record literals) and a stage-3 method decorator (`@deferCancel` on + instance or static methods, with the same loud refusals of non-method + positions and of the legacy `experimentalDecorators` convention; + constructors are never markable). Defined in `@polyengine/protocol` and + imported from there directly, like `suspending()` (A22: the runtime's + exported surface is application-only). The mark is tolerated + and inert on sync-typed imports: their parks never mint a subtask + handle, so they cannot be cancelled at all and the no-discard guarantee + holds vacuously. Independent of `suspending()`; both brands may sit on + one function. ## Resources @@ -912,6 +952,7 @@ equivalent of a semver major: | `polyengine.invalidHandle/1` | `InvalidHandleError.prototype` | resource-wrapper misuse | | `polyengine.streamProducer/1` | `StreamProducerError.prototype` | producer-side failures | | `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.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/docs/architecture.md b/docs/architecture.md index d76df07..0d73e5c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -380,6 +380,20 @@ the site (`runtime/src/intrinsics/async_builtins.ts`, the determinacy park in `createSubtaskCancel`); regression pinned across seeds by `runtime/tests/cancel_bracket_race_test.ts`. +**Host-import cancellation resolves promptly by default (A23).** The +reference leaves a host callee's `on_cancel` to the embedding +(`Store.invoke`, definitions.py line 572); wasmtime hosts hand back a +future whose drop *is* cancellation. A JS Promise offers no such channel, +so polyengine's lowered host imports answer with the reference's +prompt-cancel shape — `on_cancel = () => on_resolve(None)` — resolving the +subtask CANCELLED_BEFORE_RETURNED and discarding the promise's eventual +settlement (never lowered, rejections unreported, deregistered from +deadlock accounting). This is a reference-legal host behavior, not a +divergence; the per-declaration `deferCancel()` brand +(contracts/embedder-api.md A23) restores run-to-completion for imports +with commit points. The host operation itself is never interrupted — only +delivery is cancelled. + Named divergence (2026-08-20, [#165](https://github.com/polymorph-components/polyengine/issues/165), adjudicated-accept): **`enter-sync-call` checks the callee's reentrance gate but does not take it.** A FACT sync guest→guest call performs the diff --git a/examples/guests/cancel-import/src/lib.rs b/examples/guests/cancel-import/src/lib.rs index c402572..15f3b97 100644 --- a/examples/guests/cancel-import/src/lib.rs +++ b/examples/guests/cancel-import/src/lib.rs @@ -23,6 +23,18 @@ //! `Drop` impl on `WaitableOperation` (`rt/async_support/waitable.rs`) issues //! the component model's `subtask.cancel`, synchronously, because "that's the //! only way for this to be sound" in Rust. +//! +//! `cancel-inflight`/`cancel-defer`/`cancel-defer-ifc` extend this corpus for +//! amendment A23 (contracts/embedder-api.md; polyengine#241): the guest-side +//! shape (poll once, drop, return) is identical across all three — what +//! differs is which import the drop targets, and hence what the HOST does +//! with the cancel. `sleep` (undecorated) gets the A23 default: discard, the +//! export returns promptly. `sleep-defer`/`timers.sleep-defer` are branded +//! `deferCancel()` host-side (protocol/src/defer_cancel.ts): the cancel +//! parks until the import resolves naturally, so the export returns after +//! ~ms — the pre-A23 behavior, opted back in per-declaration. The `timers` +//! variant additionally proves the conventions layer's `relayMarks` carries +//! the brand across an interface-member wrapper, not just a bare one. wit_bindgen::generate!({ world: "cancel-import", @@ -87,6 +99,34 @@ impl Guest for Component { block(ms); } + /// A23 probe over the undecorated (discard-by-default) import: poll once, + /// drop, return. No detached task needed — the point is how fast THIS + /// export call itself returns. + async fn cancel_inflight(ms: u64) { + let mut f = Box::pin(sleep(ms)); + // `Box::pin`, not `pin!`: dropping a `Pin<&mut _>` drops the + // pointer, not the future, and the cancellation never happens (see + // the module doc's `start_poll_drop` note). + let _ = futures::poll!(f.as_mut()); + drop(f); + } + + /// Same shape over `sleep-defer` (branded `deferCancel` host-side): the + /// drop's cancel parks until the import resolves naturally. + async fn cancel_defer(ms: u64) { + let mut f = Box::pin(sleep_defer(ms)); + let _ = futures::poll!(f.as_mut()); + drop(f); + } + + /// Same shape over `timers.sleep-defer` — the interface-member brand + /// relay. + async fn cancel_defer_ifc(ms: u64) { + let mut f = Box::pin(timers::sleep_defer(ms)); + let _ = futures::poll!(f.as_mut()); + drop(f); + } + /// The health poll. Cheap, synchronous, and unrelated to everything above. fn ping() -> u32 { 42 diff --git a/examples/guests/cancel-import/wit/world.wit b/examples/guests/cancel-import/wit/world.wit index 7d1d01f..d0ad708 100644 --- a/examples/guests/cancel-import/wit/world.wit +++ b/examples/guests/cancel-import/wit/world.wit @@ -17,6 +17,17 @@ world cancel-import { /// parks the calling wasm frame mid-activation until the Promise settles. import block: func(ms: u64); + /// Host timer, async-typed, branded `deferCancel` host-side: the A23 + /// opt-out (contracts/embedder-api.md amendment A23). Cancelling it defers + /// to natural resolution — the pre-A23 behavior, now per-declaration. + import sleep-defer: async func(ms: u64); + + /// Interface-scoped sibling of `sleep-defer`, exercising the conventions + /// layer's brand relay for interface members (instantiate.ts relayMarks). + import timers: interface { + sleep-defer: async func(ms: u64); + } + /// Start `sleep`, poll it once so the subtask is genuinely in flight, wait, /// then DROP it from a detached task — a cancellation issued while the host /// is idle. The minimal form of the reported shape. @@ -33,6 +44,19 @@ world cancel-import { /// concurrent export calls are enough on their own. export block-for: async func(ms: u64); + /// A23 probe: start `sleep(ms)`, poll once (subtask in flight), DROP it, + /// return. Discard-by-default (A23) makes this return promptly; the host + /// times how long it takes. + export cancel-inflight: async func(ms: u64); + + /// Same shape over `sleep-defer`: the drop's synchronous cancel parks the + /// frame until the import resolves naturally, so this returns after ~ms — + /// the A23 opt-out pinned end to end. + export cancel-defer: async func(ms: u64); + + /// Same shape over `timers.sleep-defer` (interface-member brand relay). + export cancel-defer-ifc: async func(ms: u64); + /// Cheap health poll: the export whose failure surfaces the wedge. export ping: func() -> u32; } diff --git a/protocol/deno.json b/protocol/deno.json index 2e54450..a29f23f 100644 --- a/protocol/deno.json +++ b/protocol/deno.json @@ -1,6 +1,6 @@ { "name": "@polyengine/protocol", - "version": "0.2.2", + "version": "0.2.3", "exports": { ".": "./src/mod.ts" }, diff --git a/protocol/src/brands.ts b/protocol/src/brands.ts index 14dbd53..f2753bf 100644 --- a/protocol/src/brands.ts +++ b/protocol/src/brands.ts @@ -55,6 +55,19 @@ export const STREAM_PRODUCER: unique symbol = Symbol.for( export const SUSPENDING: unique symbol = Symbol.for( "polyengine.suspending/1", ); +/** + * The per-declaration cancel-discard opt-out (amendment A23). + * + * Unmarked host imports answer a guest `subtask.cancel` with the reference's + * prompt-cancel shape — `on_cancel = () => on_resolve(None)` — and DISCARD the + * promise's eventual settlement. A marked import runs to completion instead: + * the request is accepted and ignored, and the guest observes the real result. + * Unlike `polyengine.suspending/1` this brand is NOT mode evidence — it + * changes no calling convention, only what a cancellation does. + */ +export const DEFER_CANCEL: unique symbol = Symbol.for( + "polyengine.deferCancel/1", +); /** `Stream.prototype` — embedder stream handles (stateful: foreign = refused). */ export const STREAM: unique symbol = Symbol.for("polyengine.stream/1"); /** `Future.prototype` — embedder future handles (stateful: foreign = refused). */ diff --git a/protocol/src/defer_cancel.ts b/protocol/src/defer_cancel.ts new file mode 100644 index 0000000..1b4547e --- /dev/null +++ b/protocol/src/defer_cancel.ts @@ -0,0 +1,126 @@ +// The per-declaration cancel-discard opt-out (contracts/embedder-api.md +// §"Functions and async", amendment A23; polyengine#241). +// +// A guest may cancel an in-flight async-typed import (`subtask.cancel`; +// wit-bindgen reaches it by dropping the import's future). The reference +// leaves the answer to the embedding — `Store.invoke` takes the callee's +// `OnCancel` back from the callee itself (definitions.py line 572) — and a +// wasmtime host gets a real one for free, because dropping a Rust future IS +// cancellation. A JS Promise has no such channel, so polyengine answers on +// the host's behalf, and the DEFAULT answer is the reference's prompt-cancel +// host: `on_cancel = () => on_resolve(None)` (definitions.py canon_lower line +// ~2267). The subtask resolves CANCELLED_BEFORE_RETURNED at once, both cancel +// forms return without blocking, and the host call's eventual settlement is +// discarded — never lowered, a rejection reported nowhere (the guest +// renounced the call; there is no addressee), and no longer counted as +// guest-wakeable for deadlock detection. +// +// Discard is a statement about DELIVERY, not about EXECUTION: a Promise +// cannot be aborted from outside, so the host operation still runs to +// completion and its side effects still land. That is exactly the hazard this +// brand exists for. Mark an import whose body has a COMMIT POINT — a flush, a +// database write, a payment — where "cancelled" would let the guest believe +// nothing happened while the write is landing anyway. A marked import keeps +// the pre-A23 behavior, now per-declaration: the request is accepted and +// ignored, the async cancel form answers BLOCKED, the sync form parks, and the +// guest observes RETURNED with the real result. +// +// The mark is tolerated and INERT on sync-typed imports. That is vacuous +// truth, not a wart: a sync-typed import's A1 park never mints a subtask +// handle, so `subtask.cancel` cannot name it at all, and the no-discard +// guarantee holds because nothing can request the discard. +// +// Independent of `suspending()` — different questions, different brands, and +// both may sit on one function. There is deliberately no +// `anyDeferCancelImport` analogue: unlike A1's mark, this brand is not +// evidence for mode selection, so nothing needs to walk an imports record +// looking for it. It is read per-declaration at lowering time +// (exec/executor.ts `buildLoweredImport`). +// +// Layering: dependency-free apart from ./brands.ts (the protocol package as a +// whole imports nothing). + +import { DEFER_CANCEL, defineBrand, hasBrand } from "./brands.ts"; + +/** + * Declare that this host import must run to completion: a guest cancellation + * of an in-flight call is accepted and IGNORED rather than answered with the + * default A23 discard. + * + * Use it for imports with a commit point — anything whose side effects land + * regardless, where reporting CANCELLED_BEFORE_RETURNED to the guest would be + * an outright lie about what happened. A marked import's async cancel form + * answers BLOCKED, its sync form parks, and the guest sees RETURNED carrying + * the real result once the promise settles. + * + * Two forms, one brand (`polyengine.deferCancel/1`), exactly as + * `suspending()`: + * + * * **direct call** — `flush: deferCancel(() => …)` — the canonical form, + * and the only one available inside record literals; + * * **stage-3 method decorator** — `@deferCancel flush() { … }` on a + * provider class or a host-implemented resource class (methods and + * statics). + * + * The decorator form REFUSES anything it cannot mark, loudly: decorating a + * class, getter, setter, accessor or field throws at class-definition time (a + * silent no-op would surface as a discarded commit, arbitrarily far from the + * mistake), and the TypeScript-legacy `experimentalDecorators` calling + * convention throws with a pointer here — under that convention the decorator + * receives the PROTOTYPE, not the method, and marking it would both brand the + * wrong object and corrupt the property descriptor. Constructors are never + * markable (synchronous by the C2 amendment; the language reserves no + * constructor-decorator position anyway). + * + * Tolerated and inert on sync-typed imports (they can never be cancelled); + * independent of `suspending()`, and both marks may ride one function. + * + * The value is marked in place (functions are objects); the return is the + * same function, typed for insertion into an imports record or for method + * replacement. + */ +export function deferCancel( + fn: F, + context?: unknown, + legacyDescriptor?: unknown, +): F { + // TypeScript-legacy method decorator convention: (prototype, key, + // descriptor). Detectable because stage-3 contexts are objects with a + // string `kind`, never string/symbol property keys. + if ( + typeof context === "string" || typeof context === "symbol" || + legacyDescriptor !== undefined + ) { + throw new TypeError( + "deferCancel: legacy (experimentalDecorators) method decoration is not " + + "supported — the decorator would receive the prototype, not the " + + "method. Compile with stage-3 decorators (the default), or use the " + + "call form: `f: deferCancel(fn)`.", + ); + } + if (context !== undefined) { + const kind = (context as { kind?: unknown }).kind; + if (kind !== "method") { + throw new TypeError( + `deferCancel: cannot decorate a ${String(kind)} — only methods ` + + `(instance or static) can be marked cancel-deferring. Constructors ` + + `are synchronous by contract; for record-literal imports use the ` + + `call form: \`f: deferCancel(fn)\`.`, + ); + } + } + if (typeof fn !== "function") { + throw new TypeError( + `deferCancel: expected a function, got ${typeof fn}`, + ); + } + // Non-enumerable (A9 `defineBrand`): the mark must not show up in value + // walks of an imports record, and re-marking the same function is a no-op. + defineBrand(fn as unknown as object, DEFER_CANCEL); + return fn; +} + +/** Brand check (executor-side, read per-declaration at lowering time). */ +export function isDeferCancel(value: unknown): boolean { + return typeof value === "function" && hasBrand(value, DEFER_CANCEL); +} diff --git a/protocol/src/mod.ts b/protocol/src/mod.ts index 16e4d55..58f7368 100644 --- a/protocol/src/mod.ts +++ b/protocol/src/mod.ts @@ -16,6 +16,7 @@ export { defineBrand, defineRealmLocal, + DEFER_CANCEL, DROPPED, ERROR_CONTEXT, FUTURE, @@ -82,6 +83,8 @@ export { type ToCloneableOptions, } from "./cloneable.ts"; +export { deferCancel, isDeferCancel } from "./defer_cancel.ts"; + export { anySuspendingImport, isSuspending, suspending } from "./suspending.ts"; export { diff --git a/protocol/tests/brands_test.ts b/protocol/tests/brands_test.ts index 955c5e5..69c1da5 100644 --- a/protocol/tests/brands_test.ts +++ b/protocol/tests/brands_test.ts @@ -18,6 +18,7 @@ const EXPECTED: Record = { "polyengine.invalidHandle/1": brands.INVALID_HANDLE, "polyengine.streamProducer/1": brands.STREAM_PRODUCER, "polyengine.suspending/1": brands.SUSPENDING, + "polyengine.deferCancel/1": brands.DEFER_CANCEL, "polyengine.stream/1": brands.STREAM, "polyengine.streamWriter/1": brands.STREAM_WRITER, "polyengine.future/1": brands.FUTURE, diff --git a/protocol/tests/defer_cancel_test.ts b/protocol/tests/defer_cancel_test.ts new file mode 100644 index 0000000..6e6c82d --- /dev/null +++ b/protocol/tests/defer_cancel_test.ts @@ -0,0 +1,121 @@ +// The cancel-discard opt-out marker (contracts/embedder-api.md §"Functions +// and async", amendment A23; polyengine#241). +// +// What is pinned HERE is the vocabulary half: the mark is a process-global +// brand, so it is readable by any runtime copy and hand-rollable by a +// zero-import host module, and the decorator's loud refusals hold. The +// behavior the mark BUYS — default discard vs. run-to-completion — is pinned +// where the subtask machinery lives (runtime/tests/host_import_cancel_test.ts). + +import { assert, assertEquals, assertFalse, assertThrows } from "./assert.ts"; +import { deferCancel, isDeferCancel, isSuspending, suspending } from "../src/mod.ts"; + +Deno.test("A23: deferCancel() marks in place and the brand reads back", () => { + const fn = (a: number) => a; + const marked = deferCancel(fn); + assert(marked === fn, "the value is marked in place"); + assert(isDeferCancel(marked)); + assertFalse(isDeferCancel((a: number) => a)); + assertFalse(isDeferCancel({})); + assertFalse(isDeferCancel(undefined)); + assertFalse(isDeferCancel(null)); + // An object carrying the brand is not markable-as-an-import: the predicate + // is function-only, exactly like `isSuspending`. + assertFalse( + isDeferCancel({ [Symbol.for("polyengine.deferCancel/1")]: true }), + ); +}); + +Deno.test("A23: the mark is the process-global brand, not a module-local symbol", () => { + const fn = deferCancel(() => 1); + assertEquals( + (fn as unknown as Record)[ + Symbol.for("polyengine.deferCancel/1") + ], + true, + ); + // Hand-rolled: a zero-import host module can opt out of discard with + // nothing but the registry symbol (brands are markers, not gatekeepers). + const hand = Object.defineProperty( + () => 1, + Symbol.for("polyengine.deferCancel/1"), + { value: true }, + ); + assert(isDeferCancel(hand)); +}); + +Deno.test("A23: the mark is non-enumerable (invisible to imports-record walks)", () => { + const fn = deferCancel(() => 1); + assertEquals(Object.getOwnPropertySymbols(fn).length, 1); + assertEquals( + Object.propertyIsEnumerable.call(fn, Symbol.for("polyengine.deferCancel/1")), + false, + ); + // Re-marking is a no-op, not a TypeError on a non-configurable property. + deferCancel(fn); + assert(isDeferCancel(fn)); +}); + +Deno.test("A23: @deferCancel marks instance and static methods", () => { + class Provider { + @deferCancel + flush(): number { + return 1; + } + + @deferCancel + static commit(): number { + return 2; + } + } + // The brand authority for instance methods is the CLASS PROTOTYPE. + assert(isDeferCancel(Provider.prototype.flush)); + assert(isDeferCancel(Provider.commit)); +}); + +Deno.test("A23: the decorator refuses non-method positions at class-definition time", () => { + // A silent no-op would surface as a DISCARDED COMMIT — the guest told the + // write was cancelled while it lands anyway — arbitrarily far from the + // mistake. Refuse at class-definition time instead. + for (const kind of ["getter", "setter", "field", "class", "accessor"]) { + assertThrows( + () => deferCancel((() => 1) as CallableFunction, { kind }), + TypeError, + `cannot decorate a ${kind}`, + ); + } +}); + +Deno.test("A23: the legacy experimentalDecorators convention is refused with guidance", () => { + // Under that convention the decorator receives the PROTOTYPE, not the + // method: marking it would brand the wrong object AND corrupt the descriptor. + const e = assertThrows( + () => deferCancel((() => 1) as CallableFunction, "flush", { value: () => 1 }), + TypeError, + ); + assert(e.message.includes("experimentalDecorators")); + assert(e.message.includes("deferCancel(fn)")); +}); + +Deno.test("deferCancel(): a non-function is refused", () => { + assertThrows( + () => deferCancel({} as unknown as CallableFunction), + TypeError, + "expected a function", + ); +}); + +Deno.test("A23: independent of suspending() — both brands may ride one function", () => { + // Different questions (calling convention vs. cancellation answer), so + // neither predicate may see the other's mark, and marking one must not + // disturb the other. + const both = deferCancel(suspending(() => 1)); + assert(isDeferCancel(both)); + assert(isSuspending(both)); + assertEquals(Object.getOwnPropertySymbols(both).length, 2); + + const onlyDefer = deferCancel(() => 1); + assertFalse(isSuspending(onlyDefer)); + const onlySuspend = suspending(() => 1); + assertFalse(isDeferCancel(onlySuspend)); +}); diff --git a/runtime/src/embedder/instantiate.ts b/runtime/src/embedder/instantiate.ts index fc2a5fe..cc1111f 100644 --- a/runtime/src/embedder/instantiate.ts +++ b/runtime/src/embedder/instantiate.ts @@ -24,7 +24,12 @@ import { instantiateComponent, } from "../exec/mod.ts"; import { camelCase, parseLeafName, pascalCase } from "./casing.ts"; -import { isSuspending, suspending } from "../jspi/suspending.ts"; +import { + deferCancel, + isDeferCancel, + isSuspending, + suspending, +} from "../jspi/suspending.ts"; import { Translator } from "../shim/mod.ts"; import { copyCensus, isTrap, isComponentException } from "@polyengine/protocol"; import { NameCollisionError, ComponentException } from "./errors.ts"; @@ -50,6 +55,24 @@ import { import { ImportResolver } from "./version.ts"; import { type ElemCodec, Future, Stream } from "./streams.ts"; +/** + * Relay the per-declaration host-import marks from the embedder's function + * onto the wrapper the executor will actually receive, and return the + * wrapper. + * + * Every `#dispatcher` arm re-wraps the embedder's function in a closure, so a + * brand left on the original is INVISIBLE to `buildLoweredImport` — for A1 + * that surfaced as a `NeedsJspi`, for A23 (`deferCancel()`) it would be a + * silently discarded commit, which is precisely the failure the brand exists + * to prevent. Both marks are relayed by the same helper so a third one cannot + * be added to one arm and forgotten in the other three. + */ +function relayMarks(from: unknown, to: F): F { + if (isSuspending(from)) suspending(to); + if (isDeferCancel(from)) deferCancel(to); + return to; +} + /** Per-element codec for a `future` returned in function-result position. */ function elementCodec( element: ValType | null, @@ -578,9 +601,10 @@ class Facade { } return impl(...raw); }; - // A1 brand relay, layer 2 of 2 (see #dispatcher): the executor reads the - // brand off this wrapper, which is what lands in its hostImports record. - return isSuspending(dispatch) ? suspending(wrapper) : wrapper; + // A1/A23 brand relay, layer 2 of 2 (see #dispatcher): the executor reads + // the brands off this wrapper, which is what lands in its hostImports + // record. + return relayMarks(dispatch, wrapper); } /** A host-implemented resource type: register the class, own the mapping. */ @@ -634,8 +658,9 @@ class Facade { `${describe(fn)}); expected '${camelCase(m.name)}'`, ); } - // A1: the `suspending()` brand rides the dispatch closure so #wrapLeaf - // can relay it onto the value the executor actually receives. + // A1/A23: the `suspending()` and `deferCancel()` brands ride the + // dispatch closure so #wrapLeaf can relay them onto the value the + // executor actually receives. // // A2 receiver rule: an interface member is invoked with its containing // object as receiver (matching the static arm's `apply(cls)`), so a @@ -649,7 +674,7 @@ class Facade { const receiver = leaf.path.length === 0 ? undefined : provider; const dispatch: (args: unknown[]) => unknown = (args) => (fn as RawFn).apply(receiver, args); - return isSuspending(fn) ? suspending(dispatch) : dispatch; + return relayMarks(fn, dispatch); } const clsName = pascalCase(m.resource); // World-level member leaves resolved the class itself (`#provider`); @@ -701,7 +726,7 @@ class Facade { } return (fn as RawFn).apply(self, rest); }; - return isSuspending(protoFn) ? suspending(dispatch) : dispatch; + return relayMarks(protoFn, dispatch); } case "static": { const fn = (cls as Record)[camelCase(m.member)]; @@ -716,7 +741,7 @@ class Facade { // at wrap time. const dispatch: (args: unknown[]) => unknown = (args) => (fn as RawFn).apply(cls, args); - return isSuspending(fn) ? suspending(dispatch) : dispatch; + return relayMarks(fn, dispatch); } } } diff --git a/runtime/src/exec/boundary.ts b/runtime/src/exec/boundary.ts index df4f3d5..846e7ce 100644 --- a/runtime/src/exec/boundary.ts +++ b/runtime/src/exec/boundary.ts @@ -2110,8 +2110,15 @@ export function createLoweredImport(input: { mode: SuspensionMode; /** Host fn carries the `suspending()` brand (embedder-api.md A1). */ suspendable: boolean; + /** + * Host fn carries the `deferCancel()` brand (embedder-api.md A23): the + * import must run to completion, so a cancellation is accepted and ignored + * instead of taking the default discard. + */ + deferCancel: boolean; }): CoreFn { - const { name, ft, opts, hostFn, stats, mode, suspendable } = input; + const { name, ft, opts, hostFn, stats, mode, suspendable, deferCancel } = + input; const inst = opts.instance; const store = inst.store; @@ -2194,15 +2201,23 @@ export function createLoweredImport(input: { // definitions.py assigns the callee's `OnCancel` here: // `subtask.on_cancel = callee(on_start, on_resolve, caller = ...)` // - // A host import is a plain JS function and offers no cancellation - // channel — there is nothing to forward a request to. The faithful model - // is therefore a handler that *accepts and ignores* the request, which is - // exactly what the reference permits: `canon_subtask_cancel` (line 2469) - // calls `on_cancel` and then re-checks `subtask.resolved()`; a callee that - // declines to cancel promptly leaves the subtask unresolved, and the async - // form returns BLOCKED while the sync form waits. The subtask still - // resolves normally when the promise settles — cancellation is a request, - // not a guarantee. + // The `OnCancel` is the CALLEE's to supply: `Store.invoke` takes it back + // from the callee it invoked (`on_cancel = f(on_start, on_resolve, caller + // = None)`, definitions.py line 572), i.e. the reference expects the + // embedding to hand back the cancellation behaviour of whatever it is + // hosting. A wasmtime host gets a real one for free — dropping a Rust + // future IS cancellation. A JS Promise has no such channel, so polyengine + // answers on the host's behalf; amendment A23 makes the DEFAULT answer the + // reference's prompt-cancel host (`on_cancel = () => on_resolve(None)`), + // installed by the async arm below. + // + // The no-op assigned HERE is only the placeholder for paths where + // `subtask.cancel` is unreachable, so no answer can ever be demanded of + // it: an eagerly-resolving callee never mints a subtask handle (the + // fast-path return below is a bare state), and a sync-typed import's A1 + // park never mints one either. It is also the FINAL handler for a + // `deferCancel()`-branded import — accept and ignore, the pre-A23 + // behaviour, now per-declaration. // // Leaving `on_cancel` null instead made a *legal* `subtask.cancel` crash // with an internal AssertionError, which is neither reference behaviour @@ -2340,6 +2355,15 @@ export function createLoweredImport(input: { const promise = Promise.resolve(raw).then( (v) => { store.pendingHostCalls.delete(promise); + // A23: the subtask may already be resolved when the host promise + // settles — the discard `onCancel` below resolved it + // CANCELLED_BEFORE_RETURNED (the only pre-settle resolver on this + // arm). The value has no addressee, and `onResolve` would run + // straight into its `state === STARTED` assert ("on_resolve on a + // subtask that never started") and park that AssertionError on + // `store.hostFailure`, poisoning whatever unrelated embedder call + // came next. + if (subtask.resolved()) return; try { onResolve(toResults(v)); } catch (e) { @@ -2348,10 +2372,40 @@ export function createLoweredImport(input: { }, (e) => { store.pendingHostCalls.delete(promise); + // Same guard, different reason: a rejection of a RENOUNCED call is + // not a host failure. The guest cancelled and was told so; surfacing + // the rejection would fail an unrelated later call with the error of + // an operation nobody is waiting for. + if (subtask.resolved()) return; store.hostFailure = e; }, ); store.pendingHostCalls.add(promise); + if (!deferCancel) { + // A23 DISCARD (contracts/embedder-api.md §"Functions and async"; + // polyengine#241) — the reference's prompt-cancel host, + // `on_cancel = () => on_resolve(None)` (definitions.py canon_lower's + // null branch, line ~2267). + // + // This runs synchronously inside `canon_subtask_cancel`, which already + // set `cancellationRequested` before calling us (the assert in + // `onResolve`'s null branch relies on that ordering). `onResolve(null)` + // arms the SUBTASK event — a delivery-time thunk — and resolves + // CANCELLED_BEFORE_RETURNED, so the built-in's `finish()` tail consumes + // the event, `deliverResolve` releases the lenders (the #106 class, + // discharged exactly as a RETURNED delivery would), and BOTH cancel + // forms return the state without blocking. The null path lowers + // nothing, so there is no realloc re-entry from inside a built-in. + // + // The renounced call can no longer wake the guest, so it must stop + // counting as externally-wakeable for the driver's deadlock probe: + // deregister it NOW. (The settle continuation above also deletes; + // `Set.delete` is idempotent.) + subtask.onCancel = () => { + store.pendingHostCalls.delete(promise); + onResolve(null); + }; + } } else { onResolve(toResults(raw)); } diff --git a/runtime/src/exec/executor.ts b/runtime/src/exec/executor.ts index 7355d7a..a726fb8 100644 --- a/runtime/src/exec/executor.ts +++ b/runtime/src/exec/executor.ts @@ -17,6 +17,7 @@ import { assertModeConsistent, type SuspendingImport, chooseMode, + isDeferCancel, isSuspending, planNeedsSuspension, suspendingImport, @@ -1236,6 +1237,13 @@ class Executor { const ft = this.funcType(decl.type, `import '${label}'`); const opts = this.resolveOptions(decl.options); const suspendable = isSuspending(value); + // A23 (contracts/embedder-api.md §"Functions and async"): does this import + // opt out of cancel-discard? Unlike `suspendable` above, this needs no + // executor-state detour — the brand is consumed by `createLoweredImport` + // itself (it only decides which `onCancel` the lowered import installs, not + // whether the CoreFn gets wrapped), so nothing downstream has to read a + // brand off a replaced function identity. + const deferCancel = isDeferCancel(value); // The Suspending-wrap decision is taken in `importValue`, which sees the // trampoline only AFTER `createTrampoline`'s trap-recording wrapper has // replaced this function's identity — a brand on the CoreFn would die @@ -1252,6 +1260,7 @@ class Executor { stats: this.stats, mode: this.suspensionMode, suspendable, + deferCancel, }); } diff --git a/runtime/src/intrinsics/async_builtins.ts b/runtime/src/intrinsics/async_builtins.ts index b3b5a31..7f282cd 100644 --- a/runtime/src/intrinsics/async_builtins.ts +++ b/runtime/src/intrinsics/async_builtins.ts @@ -543,8 +543,11 @@ export function createSubtaskCancel( // rule (fact_calls.ts). A callee with a pending (undeliverable) // cancel sits parked non-cancellably, which is determinate, so the // genuine BLOCKED answer is still immediate. Host-import subtasks - // carry no callee task: their onCancel is a no-op and their state - // cannot be mid-hop, so the pre-jspi immediate answer stands. + // carry no callee task, and their state cannot be mid-hop: the + // default (A23) onCancel resolves them before this branch is ever + // reached, and a `deferCancel` import's no-op onCancel leaves them + // simply unresolved — either way the pre-jspi immediate answer + // stands. // // NAMED DIVERGENCE (docs/architecture.md §6, #92): this park makes // the async built-in non-atomic — other ready threads may run while diff --git a/runtime/src/jspi/suspending.ts b/runtime/src/jspi/suspending.ts index ba8903f..a671cc8 100644 --- a/runtime/src/jspi/suspending.ts +++ b/runtime/src/jspi/suspending.ts @@ -11,6 +11,16 @@ // Layering: this module was import-free on purpose (jspi/ stays standalone); // A9 relaxes that to "imports `@polyengine/protocol` only" — the protocol package // is itself dependency-free, so jspi/ still pulls in no runtime machinery. -// The embedder surface re-exports `suspending` from `@polyengine/runtime/embedder`. -export { anySuspendingImport, isSuspending, suspending } from "@polyengine/protocol"; +// A23 (`deferCancel`/`isDeferCancel`) rides the same re-export: it is the +// other per-declaration host-import mark, it lives in the same dependency-free +// package, and `exec/executor.ts` reads both through `jspi/mod.ts`. +// (Host modules import both marks from `@polyengine/protocol` directly — +// the embedder surface stopped re-exporting the vocabulary at A22.) +export { + anySuspendingImport, + deferCancel, + isDeferCancel, + isSuspending, + suspending, +} from "@polyengine/protocol"; diff --git a/runtime/tests/async_lower_onresolve_failure_test.ts b/runtime/tests/async_lower_onresolve_failure_test.ts index ac80956..bb71d7b 100644 --- a/runtime/tests/async_lower_onresolve_failure_test.ts +++ b/runtime/tests/async_lower_onresolve_failure_test.ts @@ -93,6 +93,7 @@ Deno.test( stats: newStats(), mode: "plain", suspendable: false, + deferCancel: false, }) as (...args: number[]) => unknown; const task = new Task(FT, TASK_OPTS, inst, () => [], () => {}); diff --git a/runtime/tests/async_lower_test.ts b/runtime/tests/async_lower_test.ts index 817e526..a9dc893 100644 --- a/runtime/tests/async_lower_test.ts +++ b/runtime/tests/async_lower_test.ts @@ -35,6 +35,9 @@ import { } from "../src/task/mod.ts"; import type { FuncType } from "../src/cabi/types.ts"; import { BLOCKED, createSubtaskCancel } from "../src/intrinsics/async_builtins.ts"; +// A23: the cancel-discard opt-out, read off the host function exactly as +// `executor.ts buildLoweredImport` reads it from the embedder's imports record. +import { deferCancel, isDeferCancel } from "../src/jspi/suspending.ts"; import { Trap } from "../src/cabi/mod.ts"; function assert(cond: boolean, msg: string): asserts cond { @@ -105,6 +108,9 @@ function mkFixture(hostFn: (...a: unknown[]) => unknown): Fixture { stats: newStats(), mode: "plain", suspendable: false, + // Mirrors `buildLoweredImport`: the brand is read off the host value, so a + // fixture whose host fn is wrapped in `deferCancel()` gets the opt-out. + deferCancel: isDeferCancel(hostFn), }) as (...args: number[]) => unknown; const task = new Task(FT, TASK_OPTS, inst, () => [], () => {}); @@ -233,6 +239,7 @@ Deno.test("sync lower of a Promise-returning host import needs JSPI", () => { opts, hostFn: () => Promise.resolve(1), stats: newStats(), + deferCancel: false, // Plain mode: the A1 park arm is jspi-only, so this stays the guard pin // for the no-JSPI path. The marked+jspi park itself is pinned by // tests/embedder/suspending_imports_test.ts. @@ -268,16 +275,21 @@ Deno.test("sync lower of a Promise-returning host import needs JSPI", () => { ); }); -Deno.test("async lower: subtask.cancel on a pending host call returns BLOCKED", async () => { +Deno.test("A23: deferCancel() opts a host import out of discard — subtask.cancel returns BLOCKED", async () => { // definitions.py `canon_subtask_cancel` (line 2469): the request is passed // to the callee's `on_cancel`; if the callee does not resolve promptly, the // async form returns BLOCKED and the subtask stays live. // - // A host import has no cancellation channel, so its `on_cancel` accepts and - // ignores the request (see boundary.ts). Before that handler existed, this - // legal call crashed with an internal AssertionError. + // That is what a `deferCancel()`-branded import does — accept and ignore + // (contracts/embedder-api.md amendment A23; polyengine#241). It was the + // behavior of EVERY host import before A23; it is now the opt-in for imports + // with a commit point, where reporting CANCELLED_BEFORE_RETURNED would lie + // about a write that lands anyway. The default is discard, pinned in + // tests/host_import_cancel_test.ts. let settle!: (v: number) => void; - const f = mkFixture(() => new Promise((r) => (settle = r))); + const f = mkFixture( + deferCancel(() => new Promise((r) => (settle = r))), + ); const packed = f.asGuest(() => f.call(1, 64)) as number; const [, subtaski] = unpackSubtaskResult(packed); const subtask = f.inst.handles.get(subtaski) as Subtask; @@ -290,8 +302,9 @@ Deno.test("async lower: subtask.cancel on a pending host call returns BLOCKED", assertEq(subtask.resolved(), false); assertEq(subtask.state, SubtaskState.STARTED); - // Cancellation is a request, not a guarantee: the host call still settles, - // and the subtask resolves RETURNED exactly as it would have. + // For a deferred import cancellation is a request, not a guarantee: the host + // call still settles, and the subtask resolves RETURNED exactly as it would + // have. settle(99); await new Promise((r) => setTimeout(r, 0)); assertEq(f.store.hostFailure, undefined); @@ -308,9 +321,15 @@ Deno.test("async lower: subtask.cancel on a pending host call returns BLOCKED", assertEq(subtask.resolveDelivered(), true); }); -Deno.test("async lower: a second subtask.cancel traps", () => { +Deno.test("async lower: a second subtask.cancel traps (deferCancel: already cancelled)", () => { // definitions.py line 2475: `trap_if(subtask.cancellation_requested)`. - const f = mkFixture(() => new Promise(() => {})); + // + // Reaching that trap needs a subtask that is still UNRESOLVED after its + // first cancel — under A23 only a `deferCancel()` import leaves one, since + // the default discard resolves and delivers on the first cancel (the + // resolveDelivered trap, pinned in the test below). Keeping this arm on the + // brand keeps the reference-parity property pinned rather than retiring it. + const f = mkFixture(deferCancel(() => new Promise(() => {}))); const packed = f.asGuest(() => f.call(1, 64)) as number; const [, subtaski] = unpackSubtaskResult(packed); const cancel = createSubtaskCancel({ async: true }, f.inst); @@ -327,3 +346,31 @@ Deno.test("async lower: a second subtask.cancel traps", () => { `unexpected message: ${raised}`, ); }); + +Deno.test("A23: a second subtask.cancel after a DISCARD traps on the delivered resolution", () => { + // definitions.py line 2473: `trap_if(subtask.resolve_delivered())`, checked + // BEFORE the cancellation_requested trap. The default (unbranded) import + // discards on the first cancel — resolving CANCELLED_BEFORE_RETURNED and + // delivering it through the built-in's `finish()` tail — so the second + // cancel names a subtask whose resolution is already delivered and hits this + // trap instead. Both orderings of the reference's guards stay pinned. + const f = mkFixture(() => new Promise(() => {})); + const packed = f.asGuest(() => f.call(1, 64)) as number; + const [, subtaski] = unpackSubtaskResult(packed); + const cancel = createSubtaskCancel({ async: true }, f.inst); + assertEq( + f.asGuest(() => cancel(subtaski)), + SubtaskState.CANCELLED_BEFORE_RETURNED, + ); + let raised: unknown; + try { + f.asGuest(() => cancel(subtaski)); + } catch (e) { + raised = e; + } + assert(raised instanceof Trap, `expected a Trap, got ${raised}`); + assert( + String(raised).includes("already delivered"), + `unexpected message: ${raised}`, + ); +}); diff --git a/runtime/tests/embedder/cancel_import_test.ts b/runtime/tests/embedder/cancel_import_test.ts new file mode 100644 index 0000000..bfe9b1c --- /dev/null +++ b/runtime/tests/embedder/cancel_import_test.ts @@ -0,0 +1,137 @@ +// Amendment A23 (contracts/embedder-api.md §"Functions and async"; +// polyengine#241) through the conventions facade: a guest cancelling an +// in-flight async-typed host import (`subtask.cancel`, reached by +// wit-bindgen's drop-to-cancel path) resolves CANCELLED_BEFORE_RETURNED +// promptly by default, discarding the host promise's eventual settlement. +// An import branded `deferCancel()` (protocol/src/defer_cancel.ts) opts out +// per-declaration: its cancel answers BLOCKED and the guest waits for the +// natural result, exactly the pre-A23 behavior. +// +// Fixture: `examples/guests/cancel-import` (extended for A23; the #239 +// corpus lives at the raw exec layer in +// tests/integration/e2e_cancel_import_test.ts). Three straight-line exports, +// each poll-once/drop/return over a different import: +// - cancel-inflight: bare `sleep` (undecorated) -> discard +// - cancel-defer: bare `sleep-defer` (deferCancel) -> defer +// - cancel-defer-ifc: `timers.sleep-defer` (deferCancel) -> defer +// The third is the one that would have caught a brand silently dropped by +// the conventions layer's interface-member relay (`instantiate.ts +// relayMarks`, reached at the `timers` namespace-object wrapper) — a +// regression there would make cancel-defer-ifc behave like the discard +// default instead of the deferred one. + +import { guest, haveFixture, instantiateFixture } from "./support.ts"; +import { deferCancel } from "@polyengine/protocol"; + +function delay(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} + +function assertTrue(cond: boolean, msg: string): void { + if (!cond) throw new Error(msg); +} + +const ready = await haveFixture(guest("cancel-import")); + +// Long enough that "returned promptly" (< 400ms) and "waited for natural +// resolution" (>= 800ms) are unambiguous on typical CI timing jitter. +const A23_SLOW = 1200; + +async function instantiateGuest() { + return await instantiateFixture(guest("cancel-import"), { + sleep: (ms: bigint) => delay(Number(ms)), + block: (_ms: bigint) => { + throw new Error("cancel-import A23 tests never call `block`"); + }, + "sleep-defer": deferCancel((ms: bigint) => delay(Number(ms))), + timers: { + "sleep-defer": deferCancel((ms: bigint) => delay(Number(ms))), + }, + }); +} + +Deno.test({ + name: + "A23: cancelInflight discards promptly — the bare-function relay preserves the ABSENCE of the brand", + ignore: !ready, + fn: async () => { + const c = await instantiateGuest(); + + const start = performance.now(); + await c.exports.cancelInflight(BigInt(A23_SLOW)); + const elapsed = performance.now() - start; + + assertTrue( + elapsed < 400, + `A23 regression: cancelInflight(${A23_SLOW}) took ${elapsed}ms (>= 400ms) ` + + `— an undecorated import's cancel must discard and resolve ` + + `CANCELLED_BEFORE_RETURNED promptly, not stall until the dropped ` + + `subtask's host promise settles naturally.`, + ); + + // The store must stay healthy through the conventions wrapper — no + // `store.hostFailure`-class wedge from the discard path. + await c.exports.ping(); + + // Leak hygiene: discard is about delivery, not execution — the dropped + // host timer still fires at ~A23_SLOW regardless (--trace-leaks runs). + await delay(A23_SLOW + 100); + }, +}); + +Deno.test({ + name: + "A23: cancelDefer stalls for natural resolution — the bare-function relay carries the brand", + ignore: !ready, + fn: async () => { + const c = await instantiateGuest(); + + const start = performance.now(); + await c.exports.cancelDefer(BigInt(A23_SLOW)); + const elapsed = performance.now() - start; + + assertTrue( + elapsed >= 800, + `A23 opt-out regression: cancelDefer(${A23_SLOW}) took only ${elapsed}ms ` + + `— a deferCancel()-branded import's cancel must answer BLOCKED and ` + + `wait for the natural result.`, + ); + assertTrue( + elapsed < 5000, + `cancelDefer(${A23_SLOW}) took ${elapsed}ms — expected it to complete`, + ); + + await c.exports.ping(); + }, +}); + +Deno.test({ + name: + "A23: cancelDeferIfc stalls for natural resolution — the INTERFACE-MEMBER relay carries the brand", + ignore: !ready, + fn: async () => { + const c = await instantiateGuest(); + + const start = performance.now(); + await c.exports.cancelDeferIfc(BigInt(A23_SLOW)); + const elapsed = performance.now() - start; + + assertTrue( + elapsed >= 800, + `A23 opt-out regression: cancelDeferIfc(${A23_SLOW}) took only ` + + `${elapsed}ms — a deferCancel()-branded INTERFACE-MEMBER import ` + + `(\`timers.sleep-defer\`) must answer BLOCKED and wait for the ` + + `natural result, exactly like a bare-function import. This is the ` + + `regression the implementation review flagged: the conventions ` + + `layer's interface-member wrapper (instantiate.ts relayMarks) ` + + `silently dropping the brand while the bare-function relay still ` + + `carries it.`, + ); + assertTrue( + elapsed < 5000, + `cancelDeferIfc(${A23_SLOW}) took ${elapsed}ms — expected it to complete`, + ); + + await c.exports.ping(); + }, +}); diff --git a/runtime/tests/host_import_cancel_test.ts b/runtime/tests/host_import_cancel_test.ts new file mode 100644 index 0000000..f6f5a0c --- /dev/null +++ b/runtime/tests/host_import_cancel_test.ts @@ -0,0 +1,337 @@ +// Guest cancellation of an in-flight HOST import (contracts/embedder-api.md +// §"Functions and async", amendment A23; polyengine#241). +// +// THE QUESTION A23 ANSWERS. `Store.invoke` takes the callee's `OnCancel` back +// from the callee itself — `on_cancel = f(on_start, on_resolve, caller = None)` +// (definitions.py line 572) — so the reference deliberately leaves a host +// callee's cancellation behaviour to the embedding. wasmtime hosts get a real +// one for free, because dropping a Rust future IS cancellation. A JS Promise +// offers no such channel, so polyengine answers on the host's behalf, and the +// DEFAULT answer is the reference's own prompt-cancel shape: +// `on_cancel = () => on_resolve(None)` (canon_lower's null branch, line ~2267). +// +// What that buys, and what it costs: +// +// * the subtask resolves CANCELLED_BEFORE_RETURNED at once, so BOTH cancel +// forms answer with a state instead of blocking or parking; +// * the host promise's eventual settlement is DISCARDED — never lowered, a +// rejection reported nowhere (the guest renounced the call; there is no +// addressee), and deregistered from deadlock accounting; +// * the host OPERATION is not interrupted — a Promise cannot be aborted from +// outside, so its side effects still land. Discard is about DELIVERY. +// +// The last point is the hazard `deferCancel()` exists for: an import with a +// commit point marks itself and keeps the pre-A23 run-to-completion behaviour. +// +// These tests drive `createLoweredImport` + `createSubtaskCancel` directly, +// the same way tests/async_lower_test.ts does, so each step of the reference's +// state machine is observable. + +import { assertEq } from "./support/asserts.ts"; +import { + createLoweredImport, + newStats, + type ResolvedOptions, +} from "../src/exec/boundary.ts"; +import { + ComponentInstanceState, + EventCode, + popCurrentThread, + pushCurrentThread, + Store, + Subtask, + SubtaskState, + Task, + type TaskOptions, + Thread, + unpackSubtaskResult, +} from "../src/task/mod.ts"; +import type { FuncType } from "../src/cabi/types.ts"; +import { BLOCKED, createSubtaskCancel } from "../src/intrinsics/async_builtins.ts"; +import { deferCancel, isDeferCancel } from "../src/jspi/suspending.ts"; + +function assert(cond: boolean, msg: string): asserts cond { + if (!cond) throw new Error(`assertion failed: ${msg}`); +} + +/** `func(x: u32) -> u32`, async-typed — the only shape that can be cancelled. */ +const FT: FuncType = { + params: [{ kind: "u32" }], + results: [{ kind: "u32" }], + async: true, +}; + +const TASK_OPTS: TaskOptions = { + async_: true, + callback: true, + stringEncoding: "utf8", + memory: null, +}; + +interface Fixture { + store: Store; + inst: ComponentInstanceState; + memory: WebAssembly.Memory; + call: (...args: number[]) => unknown; + asGuest(fn: () => T): T; +} + +function mkFixture(hostFn: (...a: unknown[]) => unknown): Fixture { + const store = new Store(); + const inst = new ComponentInstanceState(0, store); + const memory = new WebAssembly.Memory({ initial: 1 }); + const view = { + addrType: "i32" as const, + get bytes() { + return new Uint8Array(memory.buffer); + }, + get view() { + return new DataView(memory.buffer); + }, + get length() { + return memory.buffer.byteLength; + }, + ptrType: () => "i32" as const, + ptrSize: () => 4 as const, + }; + const opts: ResolvedOptions = { + stringEncoding: "utf8", + // deno-lint-ignore no-explicit-any + memory: view as any, + realloc: null, + postReturn: null, + callback: null, + async: true, + cancellable: false, + coreType: { params: ["i32", "i32"], results: ["i32"] }, + instance: inst, + }; + const call = createLoweredImport({ + name: "host-fn", + ft: FT, + opts, + hostFn, + stats: newStats(), + mode: "plain", + suspendable: false, + // Read off the host value exactly as `executor.ts buildLoweredImport` + // reads it from the embedder's imports record. + deferCancel: isDeferCancel(hostFn), + }) as (...args: number[]) => unknown; + + const task = new Task(FT, TASK_OPTS, inst, () => [], () => {}); + const thread = new Thread(task, (function* () {})()); + + return { + store, + inst, + memory, + call, + asGuest(fn: () => T): T { + pushCurrentThread(thread); + try { + return fn(); + } finally { + popCurrentThread(thread); + } + }, + }; +} + +/** A host promise this test settles by hand — the "still in flight" state. */ +function deferred(): { + promise: Promise; + resolve: (v: T) => void; + reject: (e: unknown) => void; +} { + let resolve!: (v: T) => void; + let reject!: (e: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + // The rejection arm is only settled inside a test that also handles it; the + // guard keeps an un-awaited rejection from becoming an unhandled-rejection + // failure of the whole test file. + promise.catch(() => {}); + return { promise, resolve, reject }; +} + +/** Start a call and hand back its subtask, mid-flight. */ +function inFlight(f: Fixture): { subtaski: number; subtask: Subtask } { + const packed = f.asGuest(() => f.call(1, 64)) as number; + const [state, subtaski] = unpackSubtaskResult(packed); + assertEq(state, SubtaskState.STARTED); + const subtask = f.inst.handles.get(subtaski) as Subtask; + assertEq(subtask.resolved(), false); + return { subtaski, subtask }; +} + +const flush = () => new Promise((r) => setTimeout(r, 0)); + +Deno.test("A23: the async cancel form discards and answers CANCELLED_BEFORE_RETURNED", () => { + // The headline: NOT BLOCKED. `canon_subtask_cancel` calls `on_cancel`, which + // is now the prompt-cancel host, so `subtask.resolved()` is already true when + // the built-in re-checks it — every parking branch is skipped and the tail + // is `finish()`, which returns the state (async_builtins.ts:490-579). + const d = deferred(); + const f = mkFixture(() => d.promise); + const { subtaski, subtask } = inFlight(f); + + const cancel = createSubtaskCancel({ async: true }, f.inst); + const rc = f.asGuest(() => cancel(subtaski)); + assert(typeof rc === "number", `expected a state, got ${typeof rc}`); + assert(rc !== BLOCKED, "the default host import cancels promptly"); + assertEq(rc, SubtaskState.CANCELLED_BEFORE_RETURNED); + assertEq(subtask.state, SubtaskState.CANCELLED_BEFORE_RETURNED); + assertEq(subtask.cancellationRequested, true); + // `finish()` consumed the SUBTASK event the null `onResolve` armed, and + // consuming it is what runs `deliverResolve` (subtask.ts:158-163). + assertEq(subtask.resolveDelivered(), true); + assertEq(subtask.hasPendingEvent(), false); +}); + +Deno.test("A23: the SYNC cancel form under jspi also answers synchronously (no park)", () => { + // The sync form's jspi arm parks on `hasPendingEvent` and sets + // `hasSyncWaiter` for the duration (SITE 5, async_builtins.ts). A prompt + // cancel never reaches it: the subtask is resolved before the park decision, + // so the built-in returns a NUMBER, not a thenable, and the flag never moved + // (a stuck flag would make a later `waitable.join` trap spuriously — #87). + const d = deferred(); + const f = mkFixture(() => d.promise); + const { subtaski, subtask } = inFlight(f); + + const cancel = createSubtaskCancel({ async: false }, f.inst, "jspi"); + const rc = f.asGuest(() => cancel(subtaski)); + assert(typeof rc === "number", `expected a number, got ${typeof rc}`); + assert( + !(rc !== null && typeof (rc as unknown as PromiseLike) === "object"), + "the sync form did not park", + ); + assertEq(rc, SubtaskState.CANCELLED_BEFORE_RETURNED); + assertEq(subtask.hasSyncWaiter, false); + assertEq(subtask.resolveDelivered(), true); +}); + +Deno.test("A23: a late value settle after a discard is inert (no host failure)", async () => { + // Pre-A23 this poisoned the store: the settle continuation called + // `onResolve` unconditionally, which ran into its `state === STARTED` assert + // ("on_resolve on a subtask that never started") and parked that + // AssertionError on `store.hostFailure` — surfacing on whatever unrelated + // embedder call came next. The `subtask.resolved()` guard in boundary.ts's + // async arm is what makes the renounced value a no-op. + const d = deferred(); + const f = mkFixture(() => d.promise); + const { subtaski, subtask } = inFlight(f); + f.asGuest(() => createSubtaskCancel({ async: true }, f.inst)(subtaski)); + + d.resolve(42); + await flush(); + + assertEq(f.store.hostFailure, undefined); + assertEq(f.store.pendingHostCalls.size, 0); + // Nothing was lowered: the retptr the guest supplied is untouched, and the + // resolution is still the cancellation. + assertEq(new DataView(f.memory.buffer).getUint32(64, true), 0); + assertEq(subtask.state, SubtaskState.CANCELLED_BEFORE_RETURNED); +}); + +Deno.test("A23: a late REJECTION after a discard is inert (not a host failure)", async () => { + // The guest renounced the call, so there is no addressee for the error. + // Pre-A23 the rejection landed on `store.hostFailure` unconditionally and + // failed the next call into the instance with the error of an operation + // nobody was waiting for. + const d = deferred(); + const f = mkFixture(() => d.promise); + const { subtaski } = inFlight(f); + f.asGuest(() => createSubtaskCancel({ async: true }, f.inst)(subtaski)); + + d.reject(new Error("the renounced host call failed")); + await flush(); + + assertEq(f.store.hostFailure, undefined); + assertEq(f.store.pendingHostCalls.size, 0); +}); + +Deno.test("A23: a discarded call stops counting for deadlock detection", () => { + // `pendingHostCalls` is the driver's "progress is still possible, just not + // this turn" evidence (`driveAsync`: `pendingHostCalls.size === 0` is a + // precondition of the deadlock verdict). A renounced call can no longer wake + // the guest, so leaving it registered would suppress a genuine deadlock + // verdict for as long as the host promise stays pending — here, forever. + const d = deferred(); + const f = mkFixture(() => d.promise); + const { subtaski } = inFlight(f); + assertEq(f.store.pendingHostCalls.size, 1); + + f.asGuest(() => createSubtaskCancel({ async: true }, f.inst)(subtaski)); + + // Deregistered at cancel time, while the underlying promise is still pending. + assertEq(f.store.pendingHostCalls.size, 0); +}); + +Deno.test("A23: a discard releases the subtask's lenders, exactly like a RETURNED delivery", () => { + // The #106 class: a subtask that breaks off without delivering its + // resolution leaves every handle it borrowed elevated forever, and later + // `resource.drop`s trap "handle still lent out" on a perfectly healthy + // instance. Discard is safe from that class *because* it goes through the + // ordinary delivery path — `onResolve(null)` resolves and the built-in's + // `finish()` consumes the event, which runs `deliverResolve`. + const d = deferred(); + const f = mkFixture(() => d.promise); + const { subtaski, subtask } = inFlight(f); + + const lendable = { numLends: 0 }; + subtask.addLender(lendable as unknown as Parameters[0]); + assertEq(lendable.numLends, 1); + + f.asGuest(() => createSubtaskCancel({ async: true }, f.inst)(subtaski)); + + assertEq(subtask.resolveDelivered(), true); + assertEq(lendable.numLends, 0); +}); + +Deno.test("A23: deferCancel() keeps run-to-completion — BLOCKED, then the real result", async () => { + // The opt-out, end-to-end at this layer: the marked import's `onCancel` stays + // the accept-and-ignore no-op, so the subtask is still unresolved when + // `canon_subtask_cancel` re-checks it and the async form answers BLOCKED + // (definitions.py line 2486). The settle then takes the ordinary path. + const d = deferred(); + const f = mkFixture(deferCancel(() => d.promise)); + const { subtaski, subtask } = inFlight(f); + + const rc = f.asGuest(() => createSubtaskCancel({ async: true }, f.inst)(subtaski)); + assertEq(rc, BLOCKED); + assertEq(subtask.resolved(), false); + assertEq(subtask.state, SubtaskState.STARTED); + + d.resolve(7); + await flush(); + + assertEq(f.store.hostFailure, undefined); + assertEq(subtask.state, SubtaskState.RETURNED); + assertEq(new DataView(f.memory.buffer).getUint32(64, true), 7); + // The guest observes RETURNED through the pending SUBTASK event, carrying + // the real result — as if the cancellation had never been requested. + assertEq(subtask.resolveDelivered(), false); + const [code, index, payload] = subtask.getPendingEvent(); + assertEq(code, EventCode.SUBTASK); + assertEq(index, subtaski); + assertEq(payload, SubtaskState.RETURNED); + assertEq(subtask.resolveDelivered(), true); +}); + +Deno.test("A23: subtask.drop succeeds after a discard", () => { + // `Subtask.drop` traps unless the resolution was DELIVERED (definitions.py + // `Subtask.drop`, line 912). Discard delivers, so the guest's ordinary + // epilogue — cancel, then drop the handle — works with no special casing. + const d = deferred(); + const f = mkFixture(() => d.promise); + const { subtaski, subtask } = inFlight(f); + f.asGuest(() => createSubtaskCancel({ async: true }, f.inst)(subtaski)); + + const removed = f.inst.handles.remove(subtaski) as Subtask; + assert(removed === subtask, "the handle table returned our subtask"); + removed.drop(); + assertEq([...f.inst.handles].length, 0); +}); diff --git a/runtime/tests/integration/e2e_cancel_import_test.ts b/runtime/tests/integration/e2e_cancel_import_test.ts index 82c9c9c..e4a35b6 100644 --- a/runtime/tests/integration/e2e_cancel_import_test.ts +++ b/runtime/tests/integration/e2e_cancel_import_test.ts @@ -23,7 +23,7 @@ import { assertEq } from "../support/asserts.ts"; import { Translator } from "../../src/shim/mod.ts"; import { instantiateComponent } from "../../src/exec/mod.ts"; -import { suspending } from "@polyengine/protocol"; +import { deferCancel, suspending } from "@polyengine/protocol"; const root = new URL("../../../", import.meta.url); @@ -61,6 +61,15 @@ async function instantiate() { // wasm frame mid-activation until the Promise settles — the #239 A1 // park shape. block: suspending((ms: bigint) => delay(Number(ms))), + // A23 opt-out (contracts/embedder-api.md amendment A23): branding an + // async-typed import `deferCancel` keeps the pre-A23 run-to-completion + // behavior on cancel, per-declaration. + "sleep-defer": deferCancel((ms: bigint) => delay(Number(ms))), + // Interface-scoped sibling, exercising the raw executor's brand read at + // an interface-member leaf (`buildLoweredImport` path walk). + timers: { + "sleep-defer": deferCancel((ms: bigint) => delay(Number(ms))), + }, }; return await instantiateComponent({ plan, @@ -77,6 +86,10 @@ const WEDGE_ASSERTION = "driveAsync: a resumed-activation claim was never released " + "(the activation neither parked, finished, nor trapped)"; +function assertTrue(cond: boolean, msg: string): void { + if (!cond) throw new Error(msg); +} + async function assertPing(e: Exports, where: string): Promise { let value: unknown; try { @@ -176,15 +189,16 @@ Deno.test( // t=0: sleep(hold) starts (S1), polled once so it is genuinely // in flight. // t=dropAfter: the task wakes and DROPS S1's future -> `subtask.cancel`. - // This runtime's host-import subtasks have a no-op - // `on_cancel` (runtime/src/exec/boundary.ts, search - // `subtask.onCancel = () => {}`), so the cancel does NOT - // return promptly — it parks the guest until S1 resolves - // NATURALLY, i.e. at t=hold. - // t=hold: the cancel returns; the task then runs its tail - // `sleep(hold)`. - // t=2*hold: the detached task finally ends. - // With hold=SLOW=1000, dropAfter=100: the detached task ends at t=2000. + // `sleep` is undecorated, so this gets the A23 default + // (contracts/embedder-api.md amendment A23): discard. + // The subtask resolves CANCELLED_BEFORE_RETURNED at once + // and the cancel returns PROMPTLY, at t=dropAfter — it no + // longer waits for S1 to resolve naturally. + // t=dropAfter: the task then runs its tail `sleep(hold)`. + // t=dropAfter+hold: the detached task finally ends. + // With hold=SLOW=1000, dropAfter=100: the detached task ends at t=1100. + // (S1's host timer still fires at t=hold=1000 regardless — discard is + // about delivery, not execution — but nothing observes it.) const dropAfter = 100; await e["start-poll-drop"](BigInt(SLOW), BigInt(dropAfter)); @@ -200,9 +214,9 @@ Deno.test( await assertPing(e, `poll ${i} after start-poll-drop's cancel point`); } - // Remaining guest time: 2*SLOW - 500 = 1500ms. Wait 1650ms (150ms - // margin) so the detached task has provably ended before the test - // returns. + // Remaining guest time under A23: dropAfter + hold - 500 = 1100 - 500 = + // 600ms. The old pre-A23 wait (1650ms) still comfortably covers that — + // over-waiting is fine, kept as-is rather than trimmed. await delay(1650); }, ); @@ -219,13 +233,13 @@ Deno.test( // `futures::future::select`. // t=fast: the fast sleep wins the select; the still-in-flight slow // future (the loser) is dropped at the end of its scope -> - // `subtask.cancel`. Same no-op-`on_cancel` defect as - // start-poll-drop above: the cancel parks the guest until - // the loser resolves NATURALLY, at t=slow. - // t=slow: the cancel returns; the task then runs its tail - // `sleep(slow)`. - // t=2*slow: the detached task finally ends. - // With slow=SLOW=1000, fast=100: the detached task ends at t=2000. + // `subtask.cancel`. `sleep` is undecorated, so this gets the + // A23 default: discard, resolving CANCELLED_BEFORE_RETURNED + // at once — the cancel returns PROMPTLY at t=fast, no longer + // waiting for the loser to resolve naturally. + // t=fast: the task then runs its tail `sleep(slow)`. + // t=fast+slow: the detached task finally ends. + // With slow=SLOW=1000, fast=100: the detached task ends at t=1100. const fast = 100; await e["start-race-drop"](BigInt(SLOW), BigInt(fast)); @@ -234,9 +248,96 @@ Deno.test( await assertPing(e, `poll ${i} while start-race-drop is racing/dropping`); } - // 5 polls * 50ms = 250ms elapsed. Remaining guest time: 2*SLOW - 250 = - // 1750ms. Wait 1900ms (150ms margin) so the detached task has provably - // ended before the test returns. + // 5 polls * 50ms = 250ms elapsed. Remaining guest time under A23: + // fast + slow - 250 = 1100 - 250 = 850ms. The old pre-A23 wait (1900ms) + // still comfortably covers that — kept as-is rather than trimmed. await delay(1900); }, ); + +// --------------------------------------------------------------------------- +// A23 (contracts/embedder-api.md amendment A23; polyengine#241): the +// per-declaration cancel-discard opt-out, proved at the raw exec layer. +// `cancel-inflight`/`cancel-defer`/`cancel-defer-ifc` are a straight-line +// export (no detached task, no ping-polling): poll `sleep`/`sleep-defer` +// once, drop it, return. What's under test is how long THIS export call +// itself takes. +// --------------------------------------------------------------------------- + +// Long enough that "returned promptly" (< 400ms) and "waited for natural +// resolution" (>= 800ms) are unambiguous on typical CI timing jitter. +const A23_SLOW = 1200; + +Deno.test( + "cancel-import A23: discard-by-default returns promptly, not after natural resolution", + async () => { + const component = await instantiate(); + const e = component.exports as Exports; + + const start = performance.now(); + await e["cancel-inflight"](BigInt(A23_SLOW)); + const elapsed = performance.now() - start; + + assertTrue( + elapsed < 400, + `A23 regression: cancel-inflight(${A23_SLOW}) took ${elapsed}ms (>= 400ms) — ` + + `an undecorated import's cancel must discard and resolve ` + + `CANCELLED_BEFORE_RETURNED promptly (contracts/embedder-api.md ` + + `amendment A23), not stall until the dropped subtask's host promise ` + + `settles naturally. A regression here means the discard path in ` + + `createLoweredImport's async arm (runtime/src/exec/boundary.ts) has ` + + `been lost and cancellation is back to run-to-completion for every ` + + `import.`, + ); + + // Leak hygiene: discard is about DELIVERY, not EXECUTION — the dropped + // host timer still fires at ~A23_SLOW regardless. Outlive it before the + // test returns (the sanitizer runs with --trace-leaks). + await delay(A23_SLOW + 100); + }, +); + +Deno.test( + "cancel-import A23: deferCancel() opt-out still runs the cancelled import to completion", + async () => { + const component = await instantiate(); + const e = component.exports as Exports; + + const start = performance.now(); + await e["cancel-defer"](BigInt(A23_SLOW)); + const elapsed = performance.now() - start; + + assertTrue( + elapsed >= 800, + `A23 opt-out regression: cancel-defer(${A23_SLOW}) took only ${elapsed}ms ` + + `— an import branded deferCancel() (protocol/src/defer_cancel.ts) must ` + + `keep the pre-A23 behavior: cancelling it answers BLOCKED and the ` + + `guest waits for the natural result, so this export should not ` + + `return before the host timer it dropped actually settles.`, + ); + assertTrue( + elapsed < 5000, + `cancel-defer(${A23_SLOW}) took ${elapsed}ms — expected it to complete, ` + + `not hang indefinitely`, + ); + }, +); + +Deno.test( + "cancel-import A23: ping is healthy after both discard and deferCancel cancellations", + async () => { + // A fresh instance isn't the point here — the point is that neither + // cancellation path (discard, nor deferCancel-parked) leaves the store + // in a bad state (`store.hostFailure`-class wedge) for a THIRD call on + // the same instance to trip over. + const component = await instantiate(); + const e = component.exports as Exports; + + await e["cancel-inflight"](BigInt(A23_SLOW)); + await e["cancel-defer"](BigInt(A23_SLOW)); + await assertPing(e, "after cancel-inflight + cancel-defer"); + + // Outlive cancel-inflight's discarded host timer before returning. + await delay(A23_SLOW + 100); + }, +); diff --git a/runtime/tests/park_state_settle_test.ts b/runtime/tests/park_state_settle_test.ts index 5a43f8f..3eb29d5 100644 --- a/runtime/tests/park_state_settle_test.ts +++ b/runtime/tests/park_state_settle_test.ts @@ -281,6 +281,7 @@ function mkImportWorld(input: { stats: newStats(), mode: input.mode, suspendable: input.suspendable, + deferCancel: false, }) as (...args: number[]) => unknown; return { ...w, rt, handle, handleIndex, call }; } diff --git a/runtime/tests/realloc_may_leave_test.ts b/runtime/tests/realloc_may_leave_test.ts index 2e884dd..d2957cc 100644 --- a/runtime/tests/realloc_may_leave_test.ts +++ b/runtime/tests/realloc_may_leave_test.ts @@ -149,6 +149,7 @@ Deno.test("#147: a host-entry realloc that lowers an import traps", () => { stats: newStats(), mode: "plain", suspendable: false, + deferCancel: false, }) as () => unknown; // The guest's realloc reaches out of the component while lowering. h.duringRealloc = () => void importCall(); @@ -190,6 +191,7 @@ Deno.test("#147: import-result lowering runs realloc inside the may_leave window stats: newStats(), mode: "plain", suspendable: false, + deferCancel: false, }) as (retptr: number) => unknown; // Driven from inside a lifted export's core function: that is the guest, @@ -228,6 +230,7 @@ Deno.test("#147: an import-result realloc that lowers an import traps", () => { stats: newStats(), mode: "plain", suspendable: false, + deferCancel: false, }) as () => unknown; const importCall = createLoweredImport({ name: "returns-string", @@ -237,6 +240,7 @@ Deno.test("#147: an import-result realloc that lowers an import traps", () => { stats: newStats(), mode: "plain", suspendable: false, + deferCancel: false, }) as (retptr: number) => unknown; h.duringRealloc = () => void inner();