Skip to content

runtime, protocol: abortable() — per-call AbortSignal for host imports (A24) - #243

Merged
lannbot merged 1 commit into
mainfrom
abortable-imports
Aug 23, 2026
Merged

runtime, protocol: abortable() — per-call AbortSignal for host imports (A24)#243
lannbot merged 1 commit into
mainfrom
abortable-imports

Conversation

@lannbot

@lannbot lannbot commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator

Closes #241. Implements contract amendment A24 — the advisory abort channel left open by #242.

The gap

A23 (#242) made a guest's cancellation of an in-flight host import return
promptly by discarding the result — but discard is about delivery, not
execution. The host operation keeps running: a discarded socket connect
keeps connecting, a discarded timer keeps its callback armed. For the
motivating consumer (polymorph-iroh's relay dial timeout) that's one
leaked connect per timeout, in a redial loop.

What A24 adds (contracts/embedder-api.md, in this diff)

abortable(fn) — the third per-declaration mark alongside suspending()
and deferCancel() (same direct-call + stage-3 decorator spellings, same
loud refusals, brand polyengine.abortable/1): every call to a marked
import receives a fresh AbortSignal appended after its WIT-declared
parameters, and the runtime aborts it when — and only when — that call's
subtask is discarded by a guest cancellation.

dial: abortable((addr: string, signal: AbortSignal) =>
  fetch(url, { signal }))

Design points:

  • Per-call, not per-declaration. A function-level cancel() hook (the
    shape floated on A guest cannot cancel an in-flight host import: the subtask's on_cancel is a no-op #241) cannot say WHICH in-flight call died when several
    overlap; a per-call signal is right by construction, and it is the
    vocabulary the platform already accepts everywhere (fetch, sockets,
    timers).
  • Signature-unconditional, abort-discard-only. A marked function
    always receives a signal — stable arity — but the signal only ever fires
    on a guest-initiated discard. Inert where discard cannot happen:
    sync-typed imports (no subtask handle), deferCancel imports
    (cancellation never discards), eager resolves, natural completion.
    Instance teardown deliberately does not abort (future amendment
    material).
  • Deferred one microtask. The discard runs synchronously inside
    canon_subtask_cancel, i.e. inside a live guest activation; host abort
    listeners must not execute there (the issue-Guest callback invoked with null context slot (wit-bindgen async_support.rs:578) — polymorph-tls webcrypto-composed target #24 attribution class, plus
    re-entrancy). Promise.resolve().then, not queueMicrotask — the
    bare-engine-shell precedent from the jspi sentinels. Ordering pinned by
    test: the guest observes CANCELLED_BEFORE_RETURNED first, the host
    observes the abort a tick later, and an abort-provoked AbortError
    rejection lands on A23's resolved-subtask guards — discarded like any
    other late settlement.

The fix the review process earned

The conventions facade (#wrapImportFn) rebuilt host argument lists from
WIT arity, silently dropping anything past it — A24 would have been dead
through instantiate(), the only public entry point, with the signal
parameter forever undefined. The implementing agent caught it; trailing
runtime-minted extras are now forwarded verbatim (the loop is empty for
every unmarked import), and the embedder-layer abort test isolates exactly
that hunk: with the forwarding removed it fails loudly (the import body
traps on signal.addEventListener) while the raw-exec-layer test still
passes.

Tests

Protocol brand suite (independence: all three marks on one function);
host_import_cancel_test.ts +6 (signal arity and position, unmarked-import
arity control, the microtask deferral pinned from both sides —
aborted === false synchronously after the cancel returns, true one
tick later — AbortError inertness, deferCancel composition never aborts,
natural completion never aborts); fixture sleep-abort/cancel-abort/
run-abortable probes timed end-to-end at both layers, with the host
clearing its timer on abort. Three negative controls (abort removed;
deferral made synchronous; facade forwarding removed) each fail exactly
the tests that own them, verified with md5-checked restores.

Versioning and labels

Protocol changes ride the pending unpublished 0.2.3 (byte-identity guard
vacuous — no further bump). Goldens byte-identical. No breaking/* label:
additive protocol surface; the runtime hands marked imports an argument
they asked for and changes nothing for unmarked ones (pinned by the arity
control test).

Gates

just gates — exit 0: runtime 676/0, protocol 73/0, conformance 1257/0
(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 (reports protocol 0.2.3 as a pending bump).

…s (A24, #241)

A23's discard is a statement about delivery: the guest stops waiting, but
the host operation keeps running — a discarded socket connect keeps
connecting, a discarded timer keeps its callback armed. Amendment A24
(contracts/embedder-api.md) closes the loop with the platform's own
cancellation vocabulary.

`abortable(fn)` — third mark in the suspending()/deferCancel() family,
same two spellings, same refusals, brand `polyengine.abortable/1` — opts
an import into receiving a fresh AbortSignal appended after its
WIT-declared parameters on every call:

    dial: abortable((addr, signal) => fetch(url, { signal }))

The runtime aborts that signal when, and only when, the call's subtask is
discarded by a guest cancellation. The mark controls the signature
unconditionally (a marked function always receives a signal, so its arity
is stable); the abort is discard-only, so the signal never fires on
sync-typed imports, deferCancel imports, eager resolves, or natural
completion. The abort is deferred one microtask past the cancel built-in
(`Promise.resolve().then`, the bare-shell-safe spelling): host listeners
must not run inside a live guest activation, and any settlement the abort
provokes (an AbortError rejection) lands on A23's resolved-subtask guards
and is discarded like any other late settlement — the ordering "guest
observes CANCELLED_BEFORE_RETURNED first, host observes the abort a tick
later" is pinned by test.

The conventions facade needed a real fix to carry this: #wrapImportFn
rebuilt the host argument list from WIT arity, silently dropping anything
the executor appended — the signal would have been undefined through
instantiate(), the only public entry point. Trailing runtime-minted extras
are now forwarded verbatim (no toHost conversion; the loop is empty for
every unmarked import), and the embedder-layer abort test is the
regression pin that isolates exactly that hunk.

Tests: protocol brand suite (all three marks on one function, each
predicate sees only its own); host_import_cancel_test.ts pins signal
arity/position, the microtask deferral (aborted === false synchronously
after the cancel returns, true one tick later), AbortError-rejection
inertness, the deferCancel composition (five ticks, never aborted), and
natural completion; the cancel-import fixture gains sleep-abort /
cancel-abort / run-abortable, timed end-to-end at both the raw exec layer
and the embedder conventions layer with a host that clears its timer on
abort. Negative controls: abort block removed, deferral made synchronous,
and facade forwarding removed each fail exactly the tests that own them.

Protocol rides the pending 0.2.3 (unpublished, so the byte-identity guard
is vacuous — no further bump). Conventions goldens byte-identical; no
breaking/* label: additive protocol surface, and the runtime change only
gives marked imports an argument they asked for.
@lannbot
lannbot enabled auto-merge August 23, 2026 18:04
@lannbot
lannbot merged commit 9312672 into main Aug 23, 2026
3 checks passed
@lannbot
lannbot deleted the abortable-imports branch August 23, 2026 18:07
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