runtime, protocol: guest cancellation of host imports discards by default (A23) - #242
Merged
Conversation
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_cancelwas 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.invokereturns the callee'son_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:
on_cancel = () => on_resolve(None): the subtask resolvesCANCELLED_BEFORE_RETURNEDimmediately, both cancel forms return withoutblocking, 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.
deferCancel(). New@polyengine/protocolexport, siblingof
suspending()(same direct-call + stage-3 decorator spellings, sameloud refusals, brand
polyengine.deferCancel/1). A marked import runs tocompletion: cancel answers
BLOCKED/ parks, and the guest observesRETURNEDwith the real result. Mark imports with commit points — aflush, 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
canon_subtask_cancelunchanged: it setscancellationRequestedbefore invokingon_cancel, the resolve arms thedelivery-time SUBTASK event thunk, and
finish()consumes it — eventpayload,
resolveDelivered, and lender release (the parks: cleanup-only-in-produce siblings of #102 (hasSyncWaiter, numWaiting, sync host-import lenders) #106 class) all ridethe existing machinery.
CANCELLED_BEFORE_STARTEDis unreachable forhost imports (
onStartruns synchronously before the host fn).if (subtask.resolved()) returnguards: a discarded call's late settlement previously would have run
onResolveinto itsSTARTEDassert (value arm) or parked the rejection(error arm) on
store.hostFailure, poisoning an unrelated later call.function, which silently dropped brands on the wrapper; they now relay
both marks through one
relayMarkshelper (a droppeddeferCancelis adiscarded commit — the exact failure the brand exists to prevent). The
interface-member arm is pinned end-to-end by the new
cancel-defer-ifcprobe.
suspendingis "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.
deferCancelis accordingly NOT added tothe embedder surface.
Tests
runtime/tests/host_import_cancel_test.ts: discard via both cancel forms(state returned synchronously, no park,
hasSyncWaiteruntouched), eventdelivery + lender release,
pendingHostCallsderegistration, late valueand late rejection both inert,
subtask.dropafter discard, and thedeferCancelpath (BLOCKED, then the real result on natural settle).async_lower_test.ts: the old "cancel returns BLOCKED" test is now thedeferCancelpin; the second-cancel trap is split into its tworeference-guard arms (
resolveDeliveredat :2473 checked beforecancellationRequestedat :2475 — each guard now has exactly onereachable path and its own test).
examples/guests/cancel-import): newcancel-inflight/cancel-defer/cancel-defer-ifcprobes, timed end-to-end at the rawexec layer AND the embedder conventions layer (1200ms timer: discard
asserts <400ms, defer asserts >=800ms).
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: thepre-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).