Skip to content

jspi: a trapping cancellation delivery poisons via the settled-tail path, not synchronously in requestCancellation — intermediate-window parity unproven #212

Description

@lannbot

Summary

Found during the #158 mechanism-A fix (#207), split out per that PR's
"not in scope" note. Under jspi, a trapping cancellation delivery does not
take Task.requestCancellation's synchronous poisoning branch, because on
the SuspensionPoint arm the trap is delivered as a rejection of the
import's Promise rather than a synchronous throw.

Details

Task.requestCancellation (runtime/src/task/mod.ts:515-545) wraps the
delivery resume(CANCELLED_TRUE) in the bracket-break catch f7abf96
introduced: a trap escaping the resume skips leaveTo and poisons the
callee, matching definitions.py request_cancellation (delivery at line
531, no handler, so a Trap never reaches leave_to at 532).

That catch only ever fires on the generator-Thread arm, where
Thread.resume runs the body synchronously and a trap propagates as a
throw (pinned by task_test.ts "request_cancellation: a trap during
delivery poisons the callee").

On the jspi arm — the callee parked in a cancellable built-in whose
produce throws when handed cancelledSuspensionPoint.resume never
rethrows: #resumeInner's catch routes the trap to #fail(e) (the
import-promise rejection the engine turns back into a wasm trap) and
returns normally. So requestCancellation sees no throw, leaveTo runs,
and the callee instance is left enterable and unpoisoned at that
moment
; the callee's activation then unwinds asynchronously, its
awaitValue rejects, and poisoning finally lands via the settled-tail
failure path (Thread.resumeWith's catch) several microtasks later.

Pinned explicitly by the "guest-shaped" test in
runtime/tests/resume_claim_discipline_test.ts (state cancel-delivered,
trap delivered to the parked promise, isInstancePoisoned === false
immediately after delivery).

Why it may matter (and may not)

The end state converges with the reference: the callee ends up poisoned,
the trap reaches the caller. What diverges is the intermediate window —
in definitions.py the poisoning is atomic with the delivery; here there
are several microtasks in which:

  • the callee instance answers mayEnterFrom(null) === true and could
    admit a host entry or a sibling delivery into a half-unwound instance;
  • Store.tick may schedule other threads of the callee instance ahead of
    the unwind's settled tail.

Whether any observable behavior can actually diverge (a call admitted in
the window that the reference would refuse) needs either a demonstration
or an argument that the window is harmless — e.g. that the unwinding
activation's tail is always serviced before any admission point, or that
admission into the window is indistinguishable from a legal schedule.
This is the same displacement pattern the runtime uses deliberately
elsewhere (resumeWith's bracket, #66 retirement), so the answer may well
be "working as designed" — but it is currently undocumented and unproven
either way.

Suggested resolution

Either:

  1. demonstrate a divergent observable and close the window (e.g. mark the
    instance poisoned/pending-poison at the delivery site when produce
    throws, before #fail), or
  2. rule the displacement conforming (schedule nondeterminism), document
    it at requestCancellation's catch and #resumeInner's trap arm, and
    flip the pinning assertion's comment from "current behavior" to
    "ruled".

Provenance

Found during the #158 investigation (2026-08-21/22); pinned in #207;
design discussion in #158's comments.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions