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
45 changes: 43 additions & 2 deletions contracts/embedder-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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 |
Expand Down
14 changes: 14 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
40 changes: 40 additions & 0 deletions examples/guests/cancel-import/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions examples/guests/cancel-import/wit/world.wit
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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;
}
2 changes: 1 addition & 1 deletion protocol/deno.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@polyengine/protocol",
"version": "0.2.2",
"version": "0.2.3",
"exports": {
".": "./src/mod.ts"
},
Expand Down
13 changes: 13 additions & 0 deletions protocol/src/brands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand Down
126 changes: 126 additions & 0 deletions protocol/src/defer_cancel.ts
Original file line number Diff line number Diff line change
@@ -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<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(
"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);
}
3 changes: 3 additions & 0 deletions protocol/src/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
export {
defineBrand,
defineRealmLocal,
DEFER_CANCEL,
DROPPED,
ERROR_CONTEXT,
FUTURE,
Expand Down Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions protocol/tests/brands_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const EXPECTED: Record<string, symbol> = {
"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,
Expand Down
Loading
Loading