diff --git a/contracts/embedder-api.md b/contracts/embedder-api.md index 6c8665d..e19e1eb 100644 --- a/contracts/embedder-api.md +++ b/contracts/embedder-api.md @@ -446,6 +446,31 @@ class PeerTrappedError extends Error { // A7: a stream/future op whose peer ins 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. +- **`abortable()` hands an import a per-call `AbortSignal`, aborted on + discard** (amendment A24, 2026-08-23, polyengine#241). A23's discard is + a statement about delivery; the host operation itself keeps running — a + discarded socket connect keeps connecting, a discarded timer keeps its + callback armed. `abortable(fn)` opts an import into the platform's own + cancellation vocabulary: every call receives a fresh `AbortSignal` + appended after the WIT-declared parameters + (`dial: abortable((addr, signal) => fetch(url, { signal }))`), and the + runtime aborts that signal when — and only when — the call's subtask is + discarded by a guest cancellation. The mark controls the SIGNATURE + unconditionally (a marked function always receives a signal, so its + arity is stable); the abort is discard-only. Ordering: the abort is + scheduled on a microtask after the cancel built-in returns, never + synchronously inside it — host listeners must not run inside a live + guest activation — so the guest observes `CANCELLED_BEFORE_RETURNED` + first and the host observes the abort a tick later. Any settlement the + abort provokes (an `AbortError` rejection, a partial value) lands on + A23's resolved-subtask guards and is discarded like any other late + settlement. Two spellings, one brand (`polyengine.abortable/1`), exactly + as the other marks; defined in `@polyengine/protocol` and imported from + there directly (A22). Inert wherever discard cannot happen — sync-typed + imports (no subtask handle), `deferCancel` imports (cancellation never + 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). ## Resources @@ -953,6 +978,7 @@ equivalent of a semver major: | `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.abortable/1` | the marked function (A24) | imports receiving a per-call AbortSignal | | `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 0d73e5c..32530d4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -392,7 +392,14 @@ 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. +delivery is cancelled. Amendment A24 closes that gap for hosts that can be +stopped: an import marked `abortable()` receives a fresh `AbortSignal` +appended after its WIT-declared parameters on every call, and the runtime +aborts it a microtask after the discard — never synchronously inside +`canon_subtask_cancel`, so a host abort listener never runs inside a live +guest activation. Whatever settlement the abort provokes (typically an +`AbortError` rejection) arrives with the subtask already resolved and lands +on A23's resolved-subtask guards, discarded like any other late settlement. 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 diff --git a/examples/guests/cancel-import/src/lib.rs b/examples/guests/cancel-import/src/lib.rs index 15f3b97..0709396 100644 --- a/examples/guests/cancel-import/src/lib.rs +++ b/examples/guests/cancel-import/src/lib.rs @@ -35,6 +35,13 @@ //! ~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. +//! +//! `cancel-abort`/`run-abortable` extend the corpus again for amendment A24 +//! (contracts/embedder-api.md; polyengine#241): `sleep-abort` is branded +//! `abortable()` host-side (protocol/src/abortable.ts), so the host is +//! handed a per-call `AbortSignal` it can use to actually stop the work a +//! discarded cancellation would otherwise leave running. The guest-side +//! shape is identical to `cancel_inflight`: poll once, drop, return. wit_bindgen::generate!({ world: "cancel-import", @@ -127,6 +134,22 @@ impl Guest for Component { drop(f); } + /// A24 probe (contracts/embedder-api.md amendment A24): mirrors + /// `cancel_inflight` exactly, but over `sleep-abort` — the host-side + /// `abortable()`-branded import — so the host receives the discard as an + /// `AbortSignal` firing, not merely as an uncollected promise. + async fn cancel_abort(ms: u64) { + let mut f = Box::pin(sleep_abort(ms)); + let _ = futures::poll!(f.as_mut()); + drop(f); + } + + /// A24 control: run `sleep-abort` to natural completion, no cancellation + /// anywhere — the host's `AbortSignal` must never fire on this path. + async fn run_abortable(ms: u64) { + sleep_abort(ms).await; + } + /// 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 d0ad708..58124a0 100644 --- a/examples/guests/cancel-import/wit/world.wit +++ b/examples/guests/cancel-import/wit/world.wit @@ -57,6 +57,18 @@ world cancel-import { /// Same shape over `timers.sleep-defer` (interface-member brand relay). export cancel-defer-ifc: async func(ms: u64); + /// Host timer, async-typed, branded `abortable` host-side (A24): the host + /// receives a per-call AbortSignal after the WIT params and clears its + /// timer when the guest's cancellation discards the call. + import sleep-abort: async func(ms: u64); + + /// A24 probe: start `sleep-abort(ms)`, poll once (in flight), DROP it, + /// return. Discard makes this prompt; the host observes the abort. + export cancel-abort: async func(ms: u64); + + /// Control: run `sleep-abort(ms)` to natural completion (no cancel). + export run-abortable: async func(ms: u64); + /// Cheap health poll: the export whose failure surfaces the wedge. export ping: func() -> u32; } diff --git a/protocol/src/abortable.ts b/protocol/src/abortable.ts new file mode 100644 index 0000000..815d2d3 --- /dev/null +++ b/protocol/src/abortable.ts @@ -0,0 +1,150 @@ +// The per-declaration abort-on-discard mark (contracts/embedder-api.md +// §"Functions and async", amendment A24; polyengine#241). +// +// A23 answered "what does a guest cancellation of an in-flight host import +// DO?" with the reference's prompt-cancel host: the subtask resolves +// CANCELLED_BEFORE_RETURNED and the promise's eventual settlement is +// discarded. That is a statement about DELIVERY only — the host operation +// itself keeps running. A discarded socket connect keeps connecting, a +// discarded timer keeps its callback armed, a discarded fetch keeps streaming +// bytes nobody will ever read. +// +// A24 is the third mark in the family (`suspending()` A1, `deferCancel()` +// A23), and it closes exactly that gap by handing the host the platform's own +// cancellation vocabulary. Every call of a marked import receives a fresh +// `AbortSignal` appended AFTER the WIT-declared parameters: +// +// dial: abortable((addr, signal) => fetch(url, { signal })) +// +// and the runtime aborts that signal when — and only when — the call's subtask +// is discarded by a guest cancellation. +// +// Two properties are worth stating separately because they are easy to +// conflate: +// +// * the mark controls the SIGNATURE unconditionally. A marked function +// always receives a signal, on every call, including calls that can never +// be discarded. Arity is a property of the declaration, not of the run. +// * the ABORT is discard-only. The signal fires for a guest-initiated +// cancellation that took the A23 discard, and for nothing else. Instance +// teardown does not abort in-flight calls (future amendment material). +// +// Ordering: the abort is scheduled on a microtask AFTER the cancel built-in +// returns, never synchronously inside it — host abort listeners must not run +// inside a live guest activation. So the guest observes +// CANCELLED_BEFORE_RETURNED first, and the host observes the abort a tick +// later. Any settlement the abort provokes (an `AbortError` rejection, a +// partial value) lands on A23's resolved-subtask guards and is discarded like +// any other late settlement — it is not a host failure. +// +// INERT wherever a discard cannot happen, and this is vacuous truth rather +// than a wart: a sync-typed import's A1 park never mints a subtask handle, so +// `subtask.cancel` cannot name it; a `deferCancel()`-marked import's +// cancellation is accepted and ignored, so it never discards; a call that +// resolves eagerly is over before a handle exists. In all three the signal is +// minted and simply never fires. Mark GENUINELY-ASYNC operations — the ones +// with something to stop. +// +// Independent of the other two brands: different questions (calling +// convention / what a cancellation answers / whether the host is told), and +// all three may sit on one function. Like A23 and unlike A1 this brand is not +// mode evidence, so there is no `anyAbortableImport` analogue — 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 { ABORTABLE, defineBrand, hasBrand } from "./brands.ts"; + +/** + * Declare that this host import takes a per-call `AbortSignal`, appended after + * its WIT-declared parameters, which the runtime aborts when a guest + * cancellation discards the call. + * + * Use it for genuinely-async operations that CAN be stopped — a fetch, a dial, + * a timer, a long poll — so that a guest's cancellation stops the work instead + * of merely stopping its delivery (A23 discards the result; the operation runs + * on). + * + * The mark controls the SIGNATURE unconditionally: a marked function receives + * a signal on every call, so its arity is stable even on paths where the + * signal can never fire. The ABORT is discard-only, and deferred one microtask + * past the guest's cancel built-in, so a host listener never runs inside a + * live guest activation. Whatever the abort provokes — typically an + * `AbortError` rejection — is discarded as a late settlement, not reported as + * a host failure. + * + * Two forms, one brand (`polyengine.abortable/1`), exactly as `suspending()` + * and `deferCancel()`: + * + * * **direct call** — `dial: abortable((addr, signal) => …)` — the canonical + * form, and the only one available inside record literals; + * * **stage-3 method decorator** — `@abortable dial(addr, signal) { … }` 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 `signal` parameter that is forever + * `undefined`, 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, on `deferCancel()` imports, and + * on calls that resolve eagerly — the signal is minted and never fires. + * Independent of the other marks; all three 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. The signal parameter is deliberately NOT reflected in this + * type: the conventions facade is untyped at runtime, and bindgen owns the + * compile-time shape of a marked import. + */ +export function abortable( + 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( + "abortable: 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: abortable(fn)`.", + ); + } + if (context !== undefined) { + const kind = (context as { kind?: unknown }).kind; + if (kind !== "method") { + throw new TypeError( + `abortable: cannot decorate a ${String(kind)} — only methods ` + + `(instance or static) can be marked abortable. Constructors are ` + + `synchronous by contract; for record-literal imports use the call ` + + `form: \`f: abortable(fn)\`.`, + ); + } + } + if (typeof fn !== "function") { + throw new TypeError( + `abortable: 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, ABORTABLE); + return fn; +} + +/** Brand check (executor-side, read per-declaration at lowering time). */ +export function isAbortable(value: unknown): boolean { + return typeof value === "function" && hasBrand(value, ABORTABLE); +} diff --git a/protocol/src/brands.ts b/protocol/src/brands.ts index f2753bf..7f4c102 100644 --- a/protocol/src/brands.ts +++ b/protocol/src/brands.ts @@ -68,6 +68,21 @@ export const SUSPENDING: unique symbol = Symbol.for( export const DEFER_CANCEL: unique symbol = Symbol.for( "polyengine.deferCancel/1", ); +/** + * The per-declaration abort-on-discard mark (amendment A24). + * + * A marked host import receives a fresh `AbortSignal` appended after its + * WIT-declared parameters on EVERY call (the mark controls the signature + * unconditionally), and the runtime aborts that signal — one microtask after + * the guest's cancel built-in returns — when a guest cancellation discards the + * call under A23. Like `polyengine.deferCancel/1` and unlike + * `polyengine.suspending/1` this brand is NOT mode evidence: it changes no + * calling convention the runtime must plan for, only what the host is told + * when its result is thrown away. + */ +export const ABORTABLE: unique symbol = Symbol.for( + "polyengine.abortable/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/mod.ts b/protocol/src/mod.ts index 58f7368..0a557bf 100644 --- a/protocol/src/mod.ts +++ b/protocol/src/mod.ts @@ -14,6 +14,7 @@ // `@polyengine/runtime/embedder` re-exports all of it unchanged. export { + ABORTABLE, defineBrand, defineRealmLocal, DEFER_CANCEL, @@ -83,6 +84,8 @@ export { type ToCloneableOptions, } from "./cloneable.ts"; +export { abortable, isAbortable } from "./abortable.ts"; + export { deferCancel, isDeferCancel } from "./defer_cancel.ts"; export { anySuspendingImport, isSuspending, suspending } from "./suspending.ts"; diff --git a/protocol/tests/abortable_test.ts b/protocol/tests/abortable_test.ts new file mode 100644 index 0000000..1cdbd86 --- /dev/null +++ b/protocol/tests/abortable_test.ts @@ -0,0 +1,132 @@ +// The abort-on-discard marker (contracts/embedder-api.md §"Functions and +// async", amendment A24; 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 — a per-call `AbortSignal`, aborted a microtask after +// an A23 discard — is pinned where the subtask machinery lives +// (runtime/tests/host_import_cancel_test.ts). + +import { assert, assertEquals, assertFalse, assertThrows } from "./assert.ts"; +import { + abortable, + deferCancel, + isAbortable, + isDeferCancel, + isSuspending, + suspending, +} from "../src/mod.ts"; + +Deno.test("A24: abortable() marks in place and the brand reads back", () => { + const fn = (a: number) => a; + const marked = abortable(fn); + assert(marked === fn, "the value is marked in place"); + assert(isAbortable(marked)); + assertFalse(isAbortable((a: number) => a)); + assertFalse(isAbortable({})); + assertFalse(isAbortable(undefined)); + assertFalse(isAbortable(null)); + // An object carrying the brand is not markable-as-an-import: the predicate + // is function-only, exactly like `isSuspending`/`isDeferCancel`. + assertFalse(isAbortable({ [Symbol.for("polyengine.abortable/1")]: true })); +}); + +Deno.test("A24: the mark is the process-global brand, not a module-local symbol", () => { + const fn = abortable(() => 1); + assertEquals( + (fn as unknown as Record)[ + Symbol.for("polyengine.abortable/1") + ], + true, + ); + // Hand-rolled: a zero-import host module can opt into the signal with + // nothing but the registry symbol (brands are markers, not gatekeepers). + const hand = Object.defineProperty( + () => 1, + Symbol.for("polyengine.abortable/1"), + { value: true }, + ); + assert(isAbortable(hand)); +}); + +Deno.test("A24: the mark is non-enumerable (invisible to imports-record walks)", () => { + const fn = abortable(() => 1); + assertEquals(Object.getOwnPropertySymbols(fn).length, 1); + assertEquals( + Object.propertyIsEnumerable.call(fn, Symbol.for("polyengine.abortable/1")), + false, + ); + // Re-marking is a no-op, not a TypeError on a non-configurable property. + abortable(fn); + assert(isAbortable(fn)); +}); + +Deno.test("A24: @abortable marks instance and static methods", () => { + class Provider { + @abortable + dial(): number { + return 1; + } + + @abortable + static connect(): number { + return 2; + } + } + // The brand authority for instance methods is the CLASS PROTOTYPE. + assert(isAbortable(Provider.prototype.dial)); + assert(isAbortable(Provider.connect)); +}); + +Deno.test("A24: the decorator refuses non-method positions at class-definition time", () => { + // A silent no-op would surface as a `signal` parameter that is forever + // `undefined` — the host quietly never learning its work was renounced — + // arbitrarily far from the mistake. Refuse at class-definition time instead. + for (const kind of ["getter", "setter", "field", "class", "accessor"]) { + assertThrows( + () => abortable((() => 1) as CallableFunction, { kind }), + TypeError, + `cannot decorate a ${kind}`, + ); + } +}); + +Deno.test("A24: 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( + () => abortable((() => 1) as CallableFunction, "dial", { value: () => 1 }), + TypeError, + ); + assert(e.message.includes("experimentalDecorators")); + assert(e.message.includes("abortable(fn)")); +}); + +Deno.test("abortable(): a non-function is refused", () => { + assertThrows( + () => abortable({} as unknown as CallableFunction), + TypeError, + "expected a function", + ); +}); + +Deno.test("A24: the three marks are independent — each predicate sees only its own", () => { + // Three different questions (calling convention / what a cancellation + // answers / whether the host is told), so no predicate may see another's + // mark, and marking one must not disturb the others. + const all = abortable(deferCancel(suspending(() => 1))); + assert(isAbortable(all)); + assert(isDeferCancel(all)); + assert(isSuspending(all)); + assertEquals(Object.getOwnPropertySymbols(all).length, 3); + + const onlyAbort = abortable(() => 1); + assertFalse(isDeferCancel(onlyAbort)); + assertFalse(isSuspending(onlyAbort)); + + const onlyDefer = deferCancel(() => 1); + assertFalse(isAbortable(onlyDefer)); + const onlySuspend = suspending(() => 1); + assertFalse(isAbortable(onlySuspend)); +}); diff --git a/protocol/tests/brands_test.ts b/protocol/tests/brands_test.ts index 69c1da5..2271fa2 100644 --- a/protocol/tests/brands_test.ts +++ b/protocol/tests/brands_test.ts @@ -19,6 +19,7 @@ const EXPECTED: Record = { "polyengine.streamProducer/1": brands.STREAM_PRODUCER, "polyengine.suspending/1": brands.SUSPENDING, "polyengine.deferCancel/1": brands.DEFER_CANCEL, + "polyengine.abortable/1": brands.ABORTABLE, "polyengine.stream/1": brands.STREAM, "polyengine.streamWriter/1": brands.STREAM_WRITER, "polyengine.future/1": brands.FUTURE, diff --git a/runtime/src/embedder/instantiate.ts b/runtime/src/embedder/instantiate.ts index cc1111f..0b2d9d7 100644 --- a/runtime/src/embedder/instantiate.ts +++ b/runtime/src/embedder/instantiate.ts @@ -25,7 +25,9 @@ import { } from "../exec/mod.ts"; import { camelCase, parseLeafName, pascalCase } from "./casing.ts"; import { + abortable, deferCancel, + isAbortable, isDeferCancel, isSuspending, suspending, @@ -70,6 +72,7 @@ import { type ElemCodec, Future, Stream } from "./streams.ts"; function relayMarks(from: unknown, to: F): F { if (isSuspending(from)) suspending(to); if (isDeferCancel(from)) deferCancel(to); + if (isAbortable(from)) abortable(to); return to; } @@ -825,6 +828,16 @@ class Facade { const args = ft.params.map((p, i) => toHost(raw[i] as ComponentValue, p, o, scope) ); + // CONTRACT (A24): anything the executor appended PAST the WIT-declared + // params is a runtime-minted extra, not a component value — today + // exactly the `abortable()` signal `createLoweredImport` adds for a + // marked import. It is forwarded verbatim (no `toHost` conversion: it + // has no `ValType` and must reach the host as the platform object it + // is). Without this the facade would silently drop the signal and a + // marked import's `signal` parameter would be forever `undefined` — + // the failure the mark exists to prevent. The slice is empty for every + // unmarked import, so no existing path changes shape. + for (let i = ft.params.length; i < raw.length; i++) args.push(raw[i]); let out: unknown; try { out = dispatch(args); diff --git a/runtime/src/exec/boundary.ts b/runtime/src/exec/boundary.ts index 846e7ce..d756ef8 100644 --- a/runtime/src/exec/boundary.ts +++ b/runtime/src/exec/boundary.ts @@ -2116,9 +2116,25 @@ export function createLoweredImport(input: { * instead of taking the default discard. */ deferCancel: boolean; + /** + * Host fn carries the `abortable()` brand (embedder-api.md A24): every call + * receives a fresh `AbortSignal` appended after the WIT-declared params, and + * the runtime aborts it when — and only when — the call is discarded by a + * guest cancellation. + */ + abortable: boolean; }): CoreFn { - const { name, ft, opts, hostFn, stats, mode, suspendable, deferCancel } = - input; + const { + name, + ft, + opts, + hostFn, + stats, + mode, + suspendable, + deferCancel, + abortable, + } = input; const inst = opts.instance; const store = inst.store; @@ -2223,8 +2239,20 @@ export function createLoweredImport(input: { // with an internal AssertionError, which is neither reference behaviour // nor a sanctioned incompleteness signal. subtask.onCancel = () => {}; + // A24 (contracts/embedder-api.md §"Functions and async"): a marked import + // is handed a fresh `AbortSignal` after its WIT-declared parameters. The + // mark controls the SIGNATURE UNCONDITIONALLY — a marked function receives + // a signal on every call, including the paths where it can never fire + // (sync-typed, eager resolve, `deferCancel`) — so the host's arity is a + // property of its declaration, not of how a particular call happened to + // go. `new AbortController()` is evaluated only for marked imports, which + // keeps bare engine shells with no `AbortController` off this path for the + // whole unmarked corpus. + const controller = abortable ? new AbortController() : null; const args = onStart(); - const raw = hostFn(...args); + const raw = controller === null + ? hostFn(...args) + : hostFn(...args, controller.signal); const toResults = (v: unknown): ComponentValue[] => ft.results.length === 0 ? [] : [v as ComponentValue]; @@ -2404,6 +2432,30 @@ export function createLoweredImport(input: { subtask.onCancel = () => { store.pendingHostCalls.delete(promise); onResolve(null); + if (controller !== null) { + // A24: tell the host its result was discarded, so it can stop the + // underlying operation — clear a timer, abort a fetch, close a + // dial. Reachable only from this arm by construction: a + // `deferCancel()` import never discards, so its signal never + // fires. + // + // Deferred one microtask. This closure runs SYNCHRONOUSLY inside + // `canon_subtask_cancel`, i.e. inside a live guest activation, and + // host abort listeners must not execute there — that is the + // issue-#24 attribution class, plus arbitrary re-entrancy into a + // guest mid-built-in. `Promise.resolve().then`, not + // `queueMicrotask`: the latter does not exist in bare engine + // shells (see jspi/bridge.ts's SENTINEL_TICK note). + // + // The resulting order is: the guest observes + // CANCELLED_BEFORE_RETURNED first, the host observes the abort a + // tick later. Any settlement the abort provokes (typically an + // `AbortError` rejection) arrives at the settle continuation above + // with the subtask already resolved, so it lands on the A23 + // resolved-subtask guards and is discarded like any other late + // settlement — never a `store.hostFailure`. + Promise.resolve().then(() => controller.abort()); + } }; } } else { diff --git a/runtime/src/exec/executor.ts b/runtime/src/exec/executor.ts index a726fb8..077f326 100644 --- a/runtime/src/exec/executor.ts +++ b/runtime/src/exec/executor.ts @@ -17,6 +17,7 @@ import { assertModeConsistent, type SuspendingImport, chooseMode, + isAbortable, isDeferCancel, isSuspending, planNeedsSuspension, @@ -1244,6 +1245,12 @@ class Executor { // whether the CoreFn gets wrapped), so nothing downstream has to read a // brand off a replaced function identity. const deferCancel = isDeferCancel(value); + // A24 (same section): does this import want a per-call `AbortSignal`? + // Read exactly like `deferCancel` above and for the same reason — the + // brand is consumed inside `createLoweredImport`, which mints the + // controller and appends the signal itself, so no function identity is + // replaced downstream of the read. + const abortable_ = isAbortable(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 @@ -1261,6 +1268,7 @@ class Executor { mode: this.suspensionMode, suspendable, deferCancel, + abortable: abortable_, }); } diff --git a/runtime/src/jspi/suspending.ts b/runtime/src/jspi/suspending.ts index a671cc8..4c6ad38 100644 --- a/runtime/src/jspi/suspending.ts +++ b/runtime/src/jspi/suspending.ts @@ -12,14 +12,17 @@ // A9 relaxes that to "imports `@polyengine/protocol` only" — the protocol package // is itself dependency-free, so jspi/ still pulls in no runtime machinery. -// 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 — +// A23 (`deferCancel`/`isDeferCancel`) and A24 (`abortable`/`isAbortable`) +// ride the same re-export: they are the other per-declaration host-import +// marks, they live in the same dependency-free package, and +// `exec/executor.ts` reads all three through `jspi/mod.ts`. +// (Host modules import the marks from `@polyengine/protocol` directly — // the embedder surface stopped re-exporting the vocabulary at A22.) export { + abortable, anySuspendingImport, deferCancel, + isAbortable, isDeferCancel, isSuspending, suspending, diff --git a/runtime/tests/async_lower_onresolve_failure_test.ts b/runtime/tests/async_lower_onresolve_failure_test.ts index bb71d7b..568bcf0 100644 --- a/runtime/tests/async_lower_onresolve_failure_test.ts +++ b/runtime/tests/async_lower_onresolve_failure_test.ts @@ -94,6 +94,7 @@ Deno.test( mode: "plain", suspendable: false, deferCancel: false, + abortable: 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 a9dc893..5470c24 100644 --- a/runtime/tests/async_lower_test.ts +++ b/runtime/tests/async_lower_test.ts @@ -111,6 +111,7 @@ function mkFixture(hostFn: (...a: unknown[]) => unknown): Fixture { // 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), + abortable: false, }) as (...args: number[]) => unknown; const task = new Task(FT, TASK_OPTS, inst, () => [], () => {}); @@ -240,6 +241,7 @@ Deno.test("sync lower of a Promise-returning host import needs JSPI", () => { hostFn: () => Promise.resolve(1), stats: newStats(), deferCancel: false, + abortable: 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. diff --git a/runtime/tests/embedder/cancel_import_test.ts b/runtime/tests/embedder/cancel_import_test.ts index bfe9b1c..5d4927e 100644 --- a/runtime/tests/embedder/cancel_import_test.ts +++ b/runtime/tests/embedder/cancel_import_test.ts @@ -21,7 +21,7 @@ // default instead of the deferred one. import { guest, haveFixture, instantiateFixture } from "./support.ts"; -import { deferCancel } from "@polyengine/protocol"; +import { abortable, deferCancel } from "@polyengine/protocol"; function delay(ms: number): Promise { return new Promise((r) => setTimeout(r, ms)); @@ -47,9 +47,54 @@ async function instantiateGuest() { timers: { "sleep-defer": deferCancel((ms: bigint) => delay(Number(ms))), }, + // Never exercised by the A23 tests below, but the plan requires every + // declared import to be provided — abortable() only controls arity, + // not whether the import must be present. + "sleep-abort": abortable((ms: bigint, _signal: AbortSignal) => + delay(Number(ms)) + ), }); } +// A24 (contracts/embedder-api.md amendment A24; polyengine#241) through the +// conventions facade. Scoped per instantiation: each test gets its own +// counters/flags so they can't bleed into one another. +function instantiateAbortableGuest() { + let abortsObserved = 0; + let signalWellFormed = false; + const instancePromise = instantiateFixture(guest("cancel-import"), { + sleep: (ms: bigint) => delay(Number(ms)), + block: (_ms: bigint) => { + throw new Error("cancel-import A24 tests never call `block`"); + }, + "sleep-defer": deferCancel((ms: bigint) => delay(Number(ms))), + timers: { + "sleep-defer": deferCancel((ms: bigint) => delay(Number(ms))), + }, + // A24: this is the regression pin for the conventions facade's + // trailing-arg forwarding (instantiate.ts "CONTRACT (A24)"). Without + // that forwarding loop, `signal` arrives `undefined` here and this + // listener wire-up throws on `signal.addEventListener` — the test fails + // loudly either way. + "sleep-abort": abortable((ms: bigint, signal: AbortSignal) => { + signalWellFormed = signal instanceof AbortSignal; + return new Promise((resolve, reject) => { + const t = setTimeout(resolve, Number(ms)); + signal.addEventListener("abort", () => { + clearTimeout(t); + abortsObserved++; + reject(new DOMException("sleep-abort discarded", "AbortError")); + }); + }); + }), + }); + return { + instancePromise, + getAbortsObserved: () => abortsObserved, + getSignalWellFormed: () => signalWellFormed, + }; +} + Deno.test({ name: "A23: cancelInflight discards promptly — the bare-function relay preserves the ABSENCE of the brand", @@ -135,3 +180,85 @@ Deno.test({ await c.exports.ping(); }, }); + +// --------------------------------------------------------------------------- +// A24 (contracts/embedder-api.md amendment A24; polyengine#241) through the +// conventions facade: this is the regression pin for `instantiate.ts`'s +// "CONTRACT (A24)" trailing-arg forwarding hunk. Without it, the host's +// `signal` parameter is `undefined`, the abort listener never wires up, and +// `abortsObserved` stays 0 — see the negative control in the dispatch report. +// --------------------------------------------------------------------------- + +const A24_SLOW = 1200; + +Deno.test({ + name: + "A24: cancelAbort's AbortSignal fires on discard, well-formed, through the facade", + ignore: !ready, + fn: async () => { + const { instancePromise, getAbortsObserved, getSignalWellFormed } = + instantiateAbortableGuest(); + const c = await instancePromise; + + const start = performance.now(); + await c.exports.cancelAbort(BigInt(A24_SLOW)); + const elapsed = performance.now() - start; + + assertTrue( + elapsed < 400, + `A24 regression: cancelAbort(${A24_SLOW}) took ${elapsed}ms (>= 400ms) ` + + `— an abortable()-branded import's cancel must still discard ` + + `promptly, the same as an unmarked import.`, + ); + + // The abort lands a microtask after the cancel built-in returns + // (contracts/embedder-api.md amendment A24) — flush a few ticks. + await delay(20); + assertTrue( + getAbortsObserved() === 1, + "A24 regression: cancelAbort's AbortSignal never fired through the " + + "conventions facade after its subtask was discarded. Without " + + "instantiate.ts's CONTRACT (A24) trailing-arg forwarding, the " + + "host's `signal` parameter is undefined and this listener never " + + "wires up in the first place — the facade silently drops the " + + "signal the mark exists to deliver.", + ); + assertTrue( + getSignalWellFormed(), + "A24 regression: the import received something other than a real " + + "AbortSignal through the conventions facade — `signal instanceof " + + "AbortSignal` was false.", + ); + + // Inert through the real composition: the store must stay healthy. + await c.exports.ping(); + + // Leak hygiene: the abort listener clears the timer on discard — no + // stray `setTimeout` to outlive here. + }, +}); + +Deno.test({ + name: + "A24: runAbortable completes naturally through the facade, never aborts", + ignore: !ready, + fn: async () => { + const { instancePromise, getAbortsObserved } = instantiateAbortableGuest(); + const c = await instancePromise; + + const start = performance.now(); + await c.exports.runAbortable(150n); + const elapsed = performance.now() - start; + + assertTrue( + elapsed >= 100, + `runAbortable(150) took only ${elapsed}ms — expected it to await ` + + `sleep-abort to natural completion (no cancellation on this path)`, + ); + assertTrue( + getAbortsObserved() === 0, + "A24 regression: the AbortSignal fired on a call that ran to " + + "natural completion with no guest cancellation anywhere.", + ); + }, +}); diff --git a/runtime/tests/host_import_cancel_test.ts b/runtime/tests/host_import_cancel_test.ts index f6f5a0c..2597415 100644 --- a/runtime/tests/host_import_cancel_test.ts +++ b/runtime/tests/host_import_cancel_test.ts @@ -48,7 +48,12 @@ import { } 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"; +import { + abortable, + deferCancel, + isAbortable, + isDeferCancel, +} from "../src/jspi/suspending.ts"; function assert(cond: boolean, msg: string): asserts cond { if (!cond) throw new Error(`assertion failed: ${msg}`); @@ -117,6 +122,9 @@ function mkFixture(hostFn: (...a: unknown[]) => unknown): Fixture { // Read off the host value exactly as `executor.ts buildLoweredImport` // reads it from the embedder's imports record. deferCancel: isDeferCancel(hostFn), + // A24 likewise: the mark is read off the host value, and it is what makes + // `createLoweredImport` append a fresh `AbortSignal` to every call. + abortable: isAbortable(hostFn), }) as (...args: number[]) => unknown; const task = new Task(FT, TASK_OPTS, inst, () => [], () => {}); @@ -335,3 +343,162 @@ Deno.test("A23: subtask.drop succeeds after a discard", () => { removed.drop(); assertEq([...f.inst.handles].length, 0); }); + +// --------------------------------------------------------------------------- +// A24: the per-call AbortSignal (contracts/embedder-api.md §"Functions and +// async"; polyengine#241) +// --------------------------------------------------------------------------- +// +// A23's discard is about DELIVERY: the host operation runs on, and a +// discarded dial keeps dialing. `abortable()` hands the host the platform's +// own cancellation vocabulary — every call of a marked import gets a fresh +// `AbortSignal`, and the runtime aborts it when, and only when, the call is +// discarded. Two properties these tests exist to keep apart: the SIGNATURE is +// unconditional (a marked import always receives a signal), the ABORT is +// discard-only and DEFERRED one microtask past the cancel built-in — host +// listeners must never run inside a live guest activation. + +/** Record what the host actually received, call by call. */ +function recorder(result: () => unknown) { + const seen: { count: number; last: unknown } = { count: 0, last: undefined }; + const fn = function (...a: unknown[]) { + seen.count = a.length; + seen.last = a[a.length - 1]; + return result(); + }; + return { seen, fn }; +} + +Deno.test("A24: a marked import receives WIT arity + 1, the extra arg an unaborted AbortSignal", () => { + // The signature is the mark's unconditional half: `FT` declares one param, + // so a marked host fn is called with two — the lifted `u32` and the signal. + const d = deferred(); + const r = recorder(() => d.promise); + const f = mkFixture(abortable(r.fn)); + inFlight(f); + + assertEq(r.seen.count, FT.params.length + 1); + assert( + r.seen.last instanceof AbortSignal, + `expected an AbortSignal, got ${typeof r.seen.last}`, + ); + // Nothing has been cancelled, so the signal is inert at call time. + assertEq((r.seen.last as AbortSignal).aborted, false); +}); + +Deno.test("A24: an UNMARKED import receives exactly WIT arity (no stray signal)", () => { + // The control for the test above: the mark is what appends the signal, so + // an unmarked import's arity must not move. A stray trailing argument would + // land on a host implementation that declared an optional parameter and + // silently change its behaviour. + const d = deferred(); + const r = recorder(() => d.promise); + const f = mkFixture(r.fn); + inFlight(f); + + assertEq(r.seen.count, FT.params.length); + assertEq(r.seen.last, 1); +}); + +Deno.test("A24: a discard aborts the signal — one microtask LATER, never inside the built-in", async () => { + // The ordering guarantee. `onCancel` runs synchronously inside + // `canon_subtask_cancel`, i.e. inside a live guest activation; running host + // abort listeners there is the issue-#24 attribution class plus arbitrary + // re-entrancy. So the guest sees CANCELLED_BEFORE_RETURNED first and the + // host sees the abort a tick later. + const d = deferred(); + const r = recorder(() => d.promise); + const f = mkFixture(abortable(r.fn)); + const { subtaski } = inFlight(f); + const signal = r.seen.last as AbortSignal; + + const rc = f.asGuest(() => + createSubtaskCancel({ async: true }, f.inst)(subtaski) + ); + assertEq(rc, SubtaskState.CANCELLED_BEFORE_RETURNED); + // SYNCHRONOUSLY after the built-in returned: still unaborted. This is the + // assertion that pins the deferral rather than merely the abort. + assertEq(signal.aborted, false); + + await Promise.resolve(); + assertEq(signal.aborted, true); +}); + +Deno.test("A24: an AbortError rejection provoked by the abort is inert", async () => { + // The composition with A23's guards: the host reacts to the abort by + // rejecting, and that rejection belongs to a call the guest renounced. It + // reaches the settle continuation with the subtask already resolved, so it + // is discarded like any other late settlement — never `store.hostFailure`, + // which would fail whatever unrelated embedder call came next. + const d = deferred(); + const r = recorder(() => d.promise); + const f = mkFixture(abortable(r.fn)); + const { subtaski, subtask } = inFlight(f); + const signal = r.seen.last as AbortSignal; + signal.addEventListener("abort", () => { + d.reject(new DOMException("the dial was aborted", "AbortError")); + }); + + f.asGuest(() => createSubtaskCancel({ async: true }, f.inst)(subtaski)); + await flush(); + + assertEq(signal.aborted, true); + assertEq(f.store.hostFailure, undefined); + assertEq(f.store.pendingHostCalls.size, 0); + assertEq(subtask.state, SubtaskState.CANCELLED_BEFORE_RETURNED); +}); + +Deno.test("A24: deferCancel + abortable — cancellation never discards, so the signal never fires", async () => { + // The inert composition the contract calls out. A `deferCancel()` import's + // cancellation is accepted and ignored (BLOCKED, then the real result), so + // no discard ever happens and the signal — minted, because the signature is + // unconditional — stays unaborted for the life of the call. + const d = deferred(); + const r = recorder(() => d.promise); + const f = mkFixture(abortable(deferCancel(r.fn))); + const { subtaski, subtask } = inFlight(f); + const signal = r.seen.last as AbortSignal; + assertEq(r.seen.count, FT.params.length + 1); + + const rc = f.asGuest(() => + createSubtaskCancel({ async: true }, f.inst)(subtaski) + ); + assertEq(rc, BLOCKED); + for (let i = 0; i < 5; i++) await Promise.resolve(); + assertEq(signal.aborted, false); + + d.resolve(7); + await flush(); + + assertEq(signal.aborted, false); + assertEq(f.store.hostFailure, undefined); + assertEq(subtask.state, SubtaskState.RETURNED); + assertEq(new DataView(f.memory.buffer).getUint32(64, true), 7); + const [code, index, payload] = subtask.getPendingEvent(); + assertEq(code, EventCode.SUBTASK); + assertEq(index, subtaski); + assertEq(payload, SubtaskState.RETURNED); +}); + +Deno.test("A24: no cancellation, no abort — a marked import settles normally", async () => { + // Discard-only, stated positively: an ordinary call of a marked import runs + // to its natural settlement with the signal untouched, and delivers. + const d = deferred(); + const r = recorder(() => d.promise); + const f = mkFixture(abortable(r.fn)); + const { subtaski, subtask } = inFlight(f); + const signal = r.seen.last as AbortSignal; + + d.resolve(9); + await flush(); + + assertEq(signal.aborted, false); + assertEq(f.store.hostFailure, undefined); + assertEq(f.store.pendingHostCalls.size, 0); + assertEq(subtask.state, SubtaskState.RETURNED); + assertEq(new DataView(f.memory.buffer).getUint32(64, true), 9); + const [code, index, payload] = subtask.getPendingEvent(); + assertEq(code, EventCode.SUBTASK); + assertEq(index, subtaski); + assertEq(payload, SubtaskState.RETURNED); +}); diff --git a/runtime/tests/integration/e2e_cancel_import_test.ts b/runtime/tests/integration/e2e_cancel_import_test.ts index e4a35b6..c54662b 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 { deferCancel, suspending } from "@polyengine/protocol"; +import { abortable, deferCancel, suspending } from "@polyengine/protocol"; const root = new URL("../../../", import.meta.url); @@ -51,7 +51,12 @@ function delay(ms: number): Promise { return new Promise((r) => setTimeout(r, ms)); } +// Scoped per instantiation (per test), per the dispatch: A24's `abortable()` +// import must observe the abort exactly once per discard and never on a +// natural-completion path, and giving each instance its own counter keeps +// concurrently-run tests from bleeding into one another. async function instantiate() { + let abortsObserved = 0; const imports = { // Plain async import: a Promise settles through the task core with no // JSPI involved. @@ -70,13 +75,30 @@ async function instantiate() { timers: { "sleep-defer": deferCancel((ms: bigint) => delay(Number(ms))), }, + // A24 (contracts/embedder-api.md amendment A24): branding an async-typed + // import `abortable()` hands the host a per-call `AbortSignal` appended + // after the WIT-declared `ms` param, aborted one microtask after a guest + // cancellation discards the call. The listener clears the timer, so a + // discard never leaves a stray `setTimeout` running — no trailing wait + // needed for it in the tests below. + "sleep-abort": abortable((ms: bigint, signal: AbortSignal) => + new Promise((resolve, reject) => { + const t = setTimeout(resolve, Number(ms)); + signal.addEventListener("abort", () => { + clearTimeout(t); + abortsObserved++; + reject(new DOMException("sleep-abort discarded", "AbortError")); + }); + }) + ), }; - return await instantiateComponent({ + const component = await instantiateComponent({ plan, componentBytes: guestWasm, adapters, imports, }); + return { component, getAbortsObserved: () => abortsObserved }; } // deno-lint-ignore no-explicit-any @@ -119,7 +141,7 @@ const SLOW = 1000; Deno.test( "cancel-import #239: two concurrent export calls — blockFor + ping polls", async () => { - const component = await instantiate(); + const { component } = await instantiate(); const e = component.exports as Exports; // Start a slow, mid-frame-parking export call WITHOUT awaiting it: this @@ -145,7 +167,7 @@ Deno.test( Deno.test( "cancel-import #239: detached task parks mid-frame, no export call outstanding", async () => { - const component = await instantiate(); + const { component } = await instantiate(); const e = component.exports as Exports; // `start-block` spawns a detached task and returns almost immediately @@ -181,7 +203,7 @@ Deno.test( Deno.test( "cancel-import #239: detached task cancels an in-flight import (subtask.cancel)", async () => { - const component = await instantiate(); + const { component } = await instantiate(); const e = component.exports as Exports; // `start-poll-drop(hold, dropAfter)` spawns a detached task and returns @@ -224,7 +246,7 @@ Deno.test( Deno.test( "cancel-import #239: detached task races two imports, drops the loser", async () => { - const component = await instantiate(); + const { component } = await instantiate(); const e = component.exports as Exports; // `start-race-drop(slow, fast)` spawns a detached task and returns @@ -271,7 +293,7 @@ const A23_SLOW = 1200; Deno.test( "cancel-import A23: discard-by-default returns promptly, not after natural resolution", async () => { - const component = await instantiate(); + const { component } = await instantiate(); const e = component.exports as Exports; const start = performance.now(); @@ -300,7 +322,7 @@ Deno.test( Deno.test( "cancel-import A23: deferCancel() opt-out still runs the cancelled import to completion", async () => { - const component = await instantiate(); + const { component } = await instantiate(); const e = component.exports as Exports; const start = performance.now(); @@ -330,7 +352,7 @@ Deno.test( // 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 { component } = await instantiate(); const e = component.exports as Exports; await e["cancel-inflight"](BigInt(A23_SLOW)); @@ -341,3 +363,84 @@ Deno.test( await delay(A23_SLOW + 100); }, ); + +// --------------------------------------------------------------------------- +// A24 (contracts/embedder-api.md amendment A24; polyengine#241): the +// `abortable()` mark hands the host a per-call `AbortSignal`, aborted one +// microtask after a guest cancellation discards the call — proved at the raw +// exec layer against a real wit-bindgen guest. +// --------------------------------------------------------------------------- + +// Long enough that "returned promptly" (< 400ms) and "ran to natural +// completion" (>= 100ms) are unambiguous on typical CI timing jitter. +const A24_SLOW = 1200; + +Deno.test( + "cancel-import A24: abortable() import observes the abort when its call is discarded", + async () => { + const { component, getAbortsObserved } = await instantiate(); + const e = component.exports as Exports; + + const start = performance.now(); + await e["cancel-abort"](BigInt(A24_SLOW)); + const elapsed = performance.now() - start; + + assertTrue( + elapsed < 400, + `A24 regression: cancel-abort(${A24_SLOW}) took ${elapsed}ms (>= 400ms) — ` + + `an abortable()-branded import's cancel must still discard promptly ` + + `(A23), the same as an unmarked import; A24 only adds the signal.`, + ); + + // The abort is scheduled a microtask after the cancel built-in returns + // (contracts/embedder-api.md amendment A24), not synchronously inside + // it — flush a few ticks before checking that the host actually + // observed it. A regression here means the host never learned its + // result was discarded and the dropped timer just kept running + // unaborted (the exact gap A24 closes over plain A23 discard). + await delay(20); + assertEq( + getAbortsObserved(), + 1, + "A24 regression: the abortable()-branded import's AbortSignal never " + + "fired after its subtask was discarded by the guest's cancellation " + + "— cancel-import's `sleep-abort` should have had its AbortSignal " + + "aborted exactly once.", + ); + + // The AbortError rejection this provokes is a late settlement on an + // already-cancelled subtask (A23's resolved-subtask guards) — it must + // stay inert through the real composition, not surface as a store + // failure that wedges a later call. + await assertPing(e, "after cancel-abort's discard+abort"); + + // Leak hygiene: the abort listener clears the timer on discard, so + // there is no stray `setTimeout` to outlive here (unlike A23's plain + // discard, whose dropped timer keeps running to natural completion). + }, +); + +Deno.test( + "cancel-import A24: abortable() import run to natural completion never aborts", + async () => { + const { component, getAbortsObserved } = await instantiate(); + const e = component.exports as Exports; + + const start = performance.now(); + await e["run-abortable"](150n); + const elapsed = performance.now() - start; + + assertTrue( + elapsed >= 100, + `run-abortable(150) took only ${elapsed}ms — expected it to await ` + + `sleep-abort to natural completion (no cancellation on this path)`, + ); + assertEq( + getAbortsObserved(), + 0, + "A24 regression: the abortable()-branded import's AbortSignal fired " + + "on a call that ran to natural completion with no guest " + + "cancellation anywhere — the signal must fire only on discard.", + ); + }, +); diff --git a/runtime/tests/park_state_settle_test.ts b/runtime/tests/park_state_settle_test.ts index 3eb29d5..938b9eb 100644 --- a/runtime/tests/park_state_settle_test.ts +++ b/runtime/tests/park_state_settle_test.ts @@ -282,6 +282,7 @@ function mkImportWorld(input: { mode: input.mode, suspendable: input.suspendable, deferCancel: false, + abortable: 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 d2957cc..f17d5e2 100644 --- a/runtime/tests/realloc_may_leave_test.ts +++ b/runtime/tests/realloc_may_leave_test.ts @@ -150,6 +150,7 @@ Deno.test("#147: a host-entry realloc that lowers an import traps", () => { mode: "plain", suspendable: false, deferCancel: false, + abortable: false, }) as () => unknown; // The guest's realloc reaches out of the component while lowering. h.duringRealloc = () => void importCall(); @@ -192,6 +193,7 @@ Deno.test("#147: import-result lowering runs realloc inside the may_leave window mode: "plain", suspendable: false, deferCancel: false, + abortable: false, }) as (retptr: number) => unknown; // Driven from inside a lifted export's core function: that is the guest, @@ -231,6 +233,7 @@ Deno.test("#147: an import-result realloc that lowers an import traps", () => { mode: "plain", suspendable: false, deferCancel: false, + abortable: false, }) as () => unknown; const importCall = createLoweredImport({ name: "returns-string", @@ -241,6 +244,7 @@ Deno.test("#147: an import-result realloc that lowers an import traps", () => { mode: "plain", suspendable: false, deferCancel: false, + abortable: false, }) as (retptr: number) => unknown; h.duringRealloc = () => void inner();