Context
A Job seeded with an external signal links to it eagerly in prepare():
new Job(body, { signal: request.signal });
// prepare(): cancellation.link(externalSignal, cancel) -> addAbortListener(...)
// complete(): cancellation[Symbol.dispose]() -> removeEventListener(...)
The server seeds every HTTP exchange this way so that client disconnect cancels the exchange Job. That is the right ownership rule, but the registration is paid by every Job whether or not anything can ever observe the cancellation.
Measured on the server's /echo path after #22 (one HandoffJob per request, no listener in the server itself):
| runtime |
addAbortListener + dispose |
share of per-request cost |
| Bun |
~0.9 µs (addEventListener+removeEventListener alone is ~0.72 µs) |
~7% of 12.8 µs |
| Node 24 |
addAbortListener 5.4% self in the CPU profile, plus add/remove listener frames |
~9% |
The cost is the platform's AbortSignal listener registration, not the runner's bookkeeping, so it cannot be made cheaper; it can only be avoided.
Problem
Cancellation from an external source has an effect only through these paths:
- code inside the Job reads
signal() / job.signal and reacts to it
- a child Job starts, so the cascade in
Job.cancel() must reach it
- a
HandoffJob suspended in offer() is released
- the Job's final result reports the cancellation
For a Job that never reads its signal and never starts a child, (1) and (2) cannot happen. (4) needs only a synchronous source.aborted check at completion, not a listener. (3) is the one path that genuinely needs an out-of-band wake-up, and it is discussed below.
Today the listener is registered before any of those paths exist, so the common trivial Job pays for a capability it never uses.
Proposal
Make the external link lazy: register on the external source the first time the Job's cancellation becomes observable, otherwise never.
Observation points that establish the link:
job.signal (public getter) and the ambient signal() accessor
context.signal on the Job's execution context (same identity, same rule)
- child
start() — it already reads parent.signal.throwIfAborted()
Synchronous checks that need no listener:
- before the body runs: an already-aborted external source still cancels the Job before its body, so
execute({ signal: AbortSignal.abort(reason) }, body) keeps rejecting without calling body
- after the body returns: an external abort that happened while the body ran still produces a cancelled result, exactly as with the eager link
HandoffJob.offer(): an already-aborted external source rejects the offer immediately
Internal bookkeeping (prepare, perform, recordFailure, cancel, reconcileFailure) reads the controller's signal directly and never establishes the link; only external observation does.
The offer() question
A HandoffJob suspended in offer() cannot read anything, so with a lazy link an external abort during the suspension is not delivered unless the signal was observed before the offer. cancel()/close() (direct, cascaded, deadline) still release the handoff, as #22 specified.
Two options:
- A.
offer() is an observation point. Semantics identical to today. The server's exchange always offers, so it keeps paying the listener on every request; the optimization then helps only plain Jobs.
- B.
offer() checks the source synchronously but does not link. A consumer that called receive() owes a resume(); while it holds the value, external cancellation reaches the producer only if the Job observed its signal before suspending. The server already reports disconnect through resume(aborted) from the transport, so it never needs the link for this path.
B is what makes the change worthwhile for the server, and it is a real narrowing of the runner's contract for HandoffJob. It should be an explicit decision, documented in AGENTS.md and README.
Non-goals
- No change to parent/child cascade: children still cancel through
Job.cancel(), never through listeners on the parent's signal.
- No polling, timers, or
AbortSignal.any composition.
- No change to deadline scheduling.
- No transport-specific knowledge in the runner.
Acceptance criteria
- A Job seeded with an external signal whose body never observes cancellation and starts no child registers no listener on that source (
getEventListeners(source, "abort") is empty during and after its run).
- The first
signal() / job.signal / child start() registers exactly one listener, and it is removed at completion.
- An external source aborted before start, or during the body, still yields a cancelled result and never runs / keeps running the body past its next observation point.
- The
offer() decision (A or B) is tested and documented.
- Existing cancellation, supervisor, task-group, and handoff tests pass; the reentrant registration test in
job-cancellation.test.ts is restated at the new registration point.
- Server request-path benchmark shows the listener frames gone from the profile with no behavioral change in its tests.
Related: #22
Context
A Job seeded with an external signal links to it eagerly in
prepare():The server seeds every HTTP exchange this way so that client disconnect cancels the exchange Job. That is the right ownership rule, but the registration is paid by every Job whether or not anything can ever observe the cancellation.
Measured on the server's
/echopath after #22 (oneHandoffJobper request, no listener in the server itself):addAbortListener+ disposeaddEventListener+removeEventListeneralone is ~0.72 µs)addAbortListener5.4% self in the CPU profile, plus add/remove listener framesThe cost is the platform's
AbortSignallistener registration, not the runner's bookkeeping, so it cannot be made cheaper; it can only be avoided.Problem
Cancellation from an external source has an effect only through these paths:
signal()/job.signaland reacts to itJob.cancel()must reach itHandoffJobsuspended inoffer()is releasedFor a Job that never reads its signal and never starts a child, (1) and (2) cannot happen. (4) needs only a synchronous
source.abortedcheck at completion, not a listener. (3) is the one path that genuinely needs an out-of-band wake-up, and it is discussed below.Today the listener is registered before any of those paths exist, so the common trivial Job pays for a capability it never uses.
Proposal
Make the external link lazy: register on the external source the first time the Job's cancellation becomes observable, otherwise never.
Observation points that establish the link:
job.signal(public getter) and the ambientsignal()accessorcontext.signalon the Job's execution context (same identity, same rule)start()— it already readsparent.signal.throwIfAborted()Synchronous checks that need no listener:
execute({ signal: AbortSignal.abort(reason) }, body)keeps rejecting without callingbodyHandoffJob.offer(): an already-aborted external source rejects the offer immediatelyInternal bookkeeping (
prepare,perform,recordFailure,cancel,reconcileFailure) reads the controller's signal directly and never establishes the link; only external observation does.The
offer()questionA
HandoffJobsuspended inoffer()cannot read anything, so with a lazy link an external abort during the suspension is not delivered unless the signal was observed before the offer.cancel()/close()(direct, cascaded, deadline) still release the handoff, as #22 specified.Two options:
offer()is an observation point. Semantics identical to today. The server's exchange always offers, so it keeps paying the listener on every request; the optimization then helps only plain Jobs.offer()checks the source synchronously but does not link. A consumer that calledreceive()owes aresume(); while it holds the value, external cancellation reaches the producer only if the Job observed its signal before suspending. The server already reports disconnect throughresume(aborted)from the transport, so it never needs the link for this path.B is what makes the change worthwhile for the server, and it is a real narrowing of the runner's contract for
HandoffJob. It should be an explicit decision, documented in AGENTS.md and README.Non-goals
Job.cancel(), never through listeners on the parent's signal.AbortSignal.anycomposition.Acceptance criteria
getEventListeners(source, "abort")is empty during and after its run).signal()/job.signal/ childstart()registers exactly one listener, and it is removed at completion.offer()decision (A or B) is tested and documented.job-cancellation.test.tsis restated at the new registration point.Related: #22