Skip to content

runtime, protocol: guest cancellation of host imports discards by default (A23) - #242

Merged
lannbot merged 1 commit into
mainfrom
host-import-cancel-discard
Aug 23, 2026
Merged

runtime, protocol: guest cancellation of host imports discards by default (A23)#242
lannbot merged 1 commit into
mainfrom
host-import-cancel-discard

Conversation

@lannbot

@lannbot lannbot commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator

Fixes #241. Implements contract amendment A23.

The defect

A guest cancelling an in-flight async-typed host import — subtask.cancel,
which wit-bindgen issues whenever guest code drops an import's future (a
timeout race, a retry loop, a shutdown path) — stalled until the import
completed naturally: the lowered import's on_cancel was a permanent no-op.
Measured with the #240 fixture: cancel issued at t=312ms, returned at
t=4010ms of a 4000ms timer. #240 fixed the store-wide wedge this shape
produced; this PR fixes the stall itself. It is what forced polymorph-iroh
to ship an unbounded relay dial with a documented "do not add a timeout
here" constraint.

What A23 says (contracts/embedder-api.md, in this diff)

The reference leaves a host callee's cancellation behavior to the embedding
(Store.invoke returns the callee's on_cancel, definitions.py:572);
wasmtime hosts get real cancellation because dropping a Rust future is one.
A JS Promise offers no abort channel, so the runtime answers on the host's
behalf:

  • Default — discard. 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 promise's eventual settlement is discarded — never
    lowered, rejections unreported (the guest renounced the call), and the
    call deregistered from deadlock accounting. The host operation itself is
    not interrupted: discard is about delivery, not execution. This is a
    reference-legal host behavior, not a divergence.
  • Opt-out — deferCancel(). New @polyengine/protocol export, sibling
    of suspending() (same direct-call + stage-3 decorator spellings, same
    loud refusals, brand polyengine.deferCancel/1). A marked import runs to
    completion: cancel answers BLOCKED / parks, and the guest observes
    RETURNED with the real result. Mark imports with commit points — a
    flush, a commit, anything where "cancelled" would let the guest believe
    nothing happened while the write lands. Inert on sync-typed imports by
    construction: their parks mint no subtask handle, so they can never be
    cancelled at all.

An embedder-supplied abort channel (telling the host its result was
discarded, so it can stop a socket connect etc.) is deliberately out of
A23's scope and stays open on #241.

Implementation notes

  • The discard slots into canon_subtask_cancel unchanged: it sets
    cancellationRequested before invoking on_cancel, the resolve arms the
    delivery-time SUBTASK event thunk, and finish() consumes it — event
    payload, resolveDelivered, and lender release (the parks: cleanup-only-in-produce siblings of #102 (hasSyncWaiter, numWaiting, sync host-import lenders) #106 class) all ride
    the existing machinery. CANCELLED_BEFORE_STARTED is unreachable for
    host imports (onStart runs synchronously before the host fn).
  • Both async-arm settle continuations gain if (subtask.resolved()) return
    guards: a discarded call's late settlement previously would have run
    onResolve into its STARTED assert (value arm) or parked the rejection
    (error arm) on store.hostFailure, poisoning an unrelated later call.
  • The conventions layer's four dispatcher arms re-wrap the embedder's
    function, which silently dropped brands on the wrapper; they now relay
    both marks through one relayMarks helper (a dropped deferCancel is a
    discarded commit — the exact failure the brand exists to prevent). The
    interface-member arm is pinned end-to-end by the new cancel-defer-ifc
    probe.
  • Contract text fix riding along: the A1 bullet still claimed suspending
    is "re-exported unchanged from the embedder surface" — stale since A22
    removed the courtesy re-exports. Both A1 and A23 now say
    protocol-direct, citing A22. deferCancel is accordingly NOT added to
    the embedder surface.

Tests

  • runtime/tests/host_import_cancel_test.ts: discard via both cancel forms
    (state returned synchronously, no park, hasSyncWaiter untouched), event
    delivery + lender release, pendingHostCalls deregistration, late value
    and late rejection both inert, subtask.drop after discard, and the
    deferCancel path (BLOCKED, then the real result on natural settle).
  • async_lower_test.ts: the old "cancel returns BLOCKED" test is now the
    deferCancel pin; the second-cancel trap is split into its two
    reference-guard arms (resolveDelivered at :2473 checked before
    cancellationRequested at :2475 — each guard now has exactly one
    reachable path and its own test).
  • Fixture (examples/guests/cancel-import): new cancel-inflight /
    cancel-defer / cancel-defer-ifc probes, timed end-to-end at the raw
    exec layer AND the embedder conventions layer (1200ms timer: discard
    asserts <400ms, defer asserts >=800ms).
  • Negative controls: with the discard neutralized, every discard test fails
    on its bound while the deferCancel tests keep passing; with the settle
    guards removed, exactly the two late-settle tests fail on the poisoned
    store. Restores verified by md5.

Versioning and labels

Protocol is additive → 0.2.2 → 0.2.3 (the byte-identity guard requires the
bump; version-guard local reports "0.2.3 is not published — a pending
bump"). Conventions goldens byte-identical. No breaking/* label: the
pre-A23 behavior was uncontracted and defective in both directions (#239's
wedge, #241's stall) — stated here explicitly so the cut-time label sweep
sees a decision, not an omission.

Gates

just gates — exit 0 on the full battery (runtime 666/0, protocol 65/0,
conformance 1257/0 with no expectation changes, sched-seeds both seeds,
sm/node/bun pinned shells, chromium + firefox all realms, smoke-tls,
smoke-c0, npm/publish checks, version-guard).

…ault (A23, #241)

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.
@lannbot
lannbot enabled auto-merge August 23, 2026 17:01
@lannbot
lannbot merged commit c7b9fbf into main Aug 23, 2026
4 checks passed
@lannbot
lannbot deleted the host-import-cancel-discard branch August 23, 2026 17:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A guest cannot cancel an in-flight host import: the subtask's on_cancel is a no-op

2 participants