Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions contracts/embedder-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 |
Expand Down
9 changes: 8 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 23 additions & 0 deletions examples/guests/cancel-import/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions examples/guests/cancel-import/wit/world.wit
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
150 changes: 150 additions & 0 deletions protocol/src/abortable.ts
Original file line number Diff line number Diff line change
@@ -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<F extends CallableFunction>(
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);
}
15 changes: 15 additions & 0 deletions protocol/src/brands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand Down
3 changes: 3 additions & 0 deletions protocol/src/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
// `@polyengine/runtime/embedder` re-exports all of it unchanged.

export {
ABORTABLE,
defineBrand,
defineRealmLocal,
DEFER_CANCEL,
Expand Down Expand Up @@ -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";
Expand Down
Loading
Loading