Skip to content

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

Description

@lannbot

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions