A host import's subtask carries a no-op on_cancel
(runtime/src/exec/boundary.ts, loweredAsyncImport):
// A host import is a plain JS function and offers no cancellation
// channel — there is nothing to forward a request to.
subtask.onCancel = () => {};
The consequence is that a guest can never actually cancel an in-flight host
import. canon_subtask_cancel calls on_cancel, re-checks resolved(), and —
finding the subtask still unresolved — either blocks (the synchronous form) or
answers BLOCKED (the async form) until the host promise settles on its own
schedule. The guest task is pinned for the entire remaining duration of the
call it just tried to abandon.
This is legal per the reference (a callee may decline to cancel promptly), and
the comment says so. It is also, in practice, "cancellation is not implemented
for host imports", which is a capability gap rather than a policy choice.
Measured
With examples/guests/cancel-import (added in #240) and a host sleep import
backed by setTimeout:
| t |
event |
| 0 ms |
detached guest task starts sleep(4000), polls it once — subtask STARTED, in flight |
| 312 ms |
guest DROPS the future → wit-bindgen issues the synchronous subtask.cancel |
| — |
the frame parks; on_cancel did nothing, so hasPendingEvent() stays false |
| 4010 ms |
the host promise resolves naturally; only now does subtask.cancel return |
So the cancel cost the guest 3.7 s of the 4 s it was cancelling.
Why it matters
This is the substantive half of #239. #240 fixes the store-wide wedge that
shape produced, but the guest-side stall survives it. It is what makes
polymorph-iroh's relay redial ship with an unbounded dial and a documented "do
not add a timeout here" constraint: a 10 s timeout race would still pin the
endpoint pump for 10 s after the timeout fires, which is worse than no timeout.
Every guest with background work is affected — a timeout race, a retry loop, a
shutdown path — because dropping an in-flight import future is wit-bindgen's
specified cancellation path (WaitableOperation's Drop impl issues
subtask.cancel synchronously, and its comment explains that synchronous is
the only sound option in Rust).
What a fix has to decide
The reference's embedding API does give the host a cancellation channel:
Store.invoke (definitions.py) is on_cancel = f(on_start, on_resolve, caller), i.e. a host callee is expected to return one. wasmtime supplies it
naturally — a host import is a Rust future, and cancelling drops it.
A JS Promise has no such channel, so the runtime's only available move is to
stop waiting: resolve the subtask CANCELLED_BEFORE_RETURNED and discard
whatever the promise eventually produces. That is a contract question, not a
scheduler one:
- Is discarding acceptable by default? The host operation keeps running;
its side effects still happen. Resolving as CANCELLED tells the guest the
callee honoured the request, which is a stronger claim than "we looked away".
- Should the embedder be able to opt in explicitly — an
AbortSignal
passed to marked imports, or a cancel hook alongside suspending() — with
the stop-waiting behaviour as the default for imports that declare nothing?
(contracts/embedder-api.md already has precedent for per-declaration
markers and for optional source-side cancel hooks on stream producers.)
- Bookkeeping. The async-lower continuation currently runs
onResolve
unconditionally, which would assert on a subtask already resolved as
cancelled; lenders (subtask.unwindLenders) and the pendingHostCalls
registration both need a defined disposition on the cancelled path.
- Divergence record. Whatever is chosen,
docs/architecture.md §1's parity
policy wants it stated against the reference rather than left implicit in a
comment.
Scope note: fact_calls.ts already forwards cancellation properly for
component-to-component subtasks (task.requestCancellation). This is only
about the host-import arm.
A host import's subtask carries a no-op
on_cancel(
runtime/src/exec/boundary.ts,loweredAsyncImport):The consequence is that a guest can never actually cancel an in-flight host
import.
canon_subtask_cancelcallson_cancel, re-checksresolved(), and —finding the subtask still unresolved — either blocks (the synchronous form) or
answers
BLOCKED(the async form) until the host promise settles on its ownschedule. The guest task is pinned for the entire remaining duration of the
call it just tried to abandon.
This is legal per the reference (a callee may decline to cancel promptly), and
the comment says so. It is also, in practice, "cancellation is not implemented
for host imports", which is a capability gap rather than a policy choice.
Measured
With
examples/guests/cancel-import(added in #240) and a hostsleepimportbacked by
setTimeout:sleep(4000), polls it once — subtask STARTED, in flightsubtask.cancelon_canceldid nothing, sohasPendingEvent()stays falsesubtask.cancelreturnSo the cancel cost the guest 3.7 s of the 4 s it was cancelling.
Why it matters
This is the substantive half of #239. #240 fixes the store-wide wedge that
shape produced, but the guest-side stall survives it. It is what makes
polymorph-iroh's relay redial ship with an unbounded dial and a documented "do
not add a timeout here" constraint: a 10 s timeout race would still pin the
endpoint pump for 10 s after the timeout fires, which is worse than no timeout.
Every guest with background work is affected — a timeout race, a retry loop, a
shutdown path — because dropping an in-flight import future is wit-bindgen's
specified cancellation path (
WaitableOperation'sDropimpl issuessubtask.cancelsynchronously, and its comment explains that synchronous isthe only sound option in Rust).
What a fix has to decide
The reference's embedding API does give the host a cancellation channel:
Store.invoke(definitions.py) ison_cancel = f(on_start, on_resolve, caller), i.e. a host callee is expected to return one. wasmtime supplies itnaturally — a host import is a Rust future, and cancelling drops it.
A JS Promise has no such channel, so the runtime's only available move is to
stop waiting: resolve the subtask
CANCELLED_BEFORE_RETURNEDand discardwhatever the promise eventually produces. That is a contract question, not a
scheduler one:
its side effects still happen. Resolving as CANCELLED tells the guest the
callee honoured the request, which is a stronger claim than "we looked away".
AbortSignalpassed to marked imports, or a
cancelhook alongsidesuspending()— withthe stop-waiting behaviour as the default for imports that declare nothing?
(
contracts/embedder-api.mdalready has precedent for per-declarationmarkers and for optional source-side
cancelhooks on stream producers.)onResolveunconditionally, which would assert on a subtask already resolved as
cancelled; lenders (
subtask.unwindLenders) and thependingHostCallsregistration both need a defined disposition on the cancelled path.
docs/architecture.md§1's paritypolicy wants it stated against the reference rather than left implicit in a
comment.
Scope note:
fact_calls.tsalready forwards cancellation properly forcomponent-to-component subtasks (
task.requestCancellation). This is onlyabout the host-import arm.