Skip to content

Link an external cancellation source lazily, on first observation #23

Description

@miinhho

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:

  1. code inside the Job reads signal() / job.signal and reacts to it
  2. a child Job starts, so the cascade in Job.cancel() must reach it
  3. a HandoffJob suspended in offer() is released
  4. 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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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