Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,11 @@ Runner must not import server, HTTP, WebSocket, gRPC, broker, scheduler, or opti
- `TaskGroup` is an immutable nested declaration, not a lifetime owner. Submitted leaves belong directly to the Supervisor's Job.
- `execute()` and `fork()` create real Jobs. There is no separate Task handle or compatibility lifecycle layer.
- Context frames are independent of ownership. `withContext()` changes the call-chain environment without creating a Job.
- Compose support state without duplicating ownership: `FailureSet` stores errors, `CancellationBindings` releases registrations, and `GroupRunner` applies declaration policy. None owns a Job lifetime, creates an AbortController, or stores independent completion state.
- Compose support state without duplicating ownership: `FailureSet` stores errors, `Cancellation` holds one Job's incoming cancellation (its signal, followed sources, and deadline, each created on demand), and `GroupRunner` applies declaration policy. None owns a Job lifetime or stores independent completion state, and every delivered cancellation goes through the owning Job's `cancel()` so cascades stay the Job's.
- Do not restore DI, EventBus, tracing, or legacy APIs to this core.
- Context is immutable. Derive it with `provide(...)`; never introduce a mutable request-style bag.
- Cancellation and deadlines are live state. Recheck them after awaits and before commitment points.
- An external cancellation source is linked lazily: reading `job.signal`, `signal()`, or `context.signal`, and starting a child, are the observation points that subscribe. Internal bookkeeping reads the controller's signal and never subscribes. Already-aborted sources are honored by synchronous rechecks before the body, after it, and before a handoff offer.
- An external cancellation source is linked lazily: reading `job.signal`, `signal()`, or `context.signal`, and starting a child, are the observation points that subscribe. Job bookkeeping asks its `Cancellation` and never subscribes. Already-aborted sources are honored by synchronous rechecks before the body, after it, and before a handoff offer.
- Preserve native error identity and `cause`; use `AggregateError` when independent operation and cleanup failures both matter.
- Cancellation classification accepts the original signal reason or a Node-style `AbortError` with `code: "ABORT_ERR"` and matching `cause`. An ordinary application error remains a failure even when its cause is the cancellation reason.

Expand Down
50 changes: 41 additions & 9 deletions src/execution/context/frame.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,41 @@
import type { ContextEntry } from "./key.js";

function entryMap(entries: readonly ContextEntry[]): Map<PropertyKey, unknown> {
/** What a frame needs from its own bindings; `Map` satisfies it for many, one object for one. */
interface Bindings {
has(key: PropertyKey): boolean;
get(key: PropertyKey): unknown;
keys(): Iterable<PropertyKey>;
}

/** The common case — one binding per frame — without a hash table. */
class SingleBinding implements Bindings {
constructor(
private readonly key: PropertyKey,
private readonly value: unknown,
) {}

has(key: PropertyKey): boolean {
return key === this.key;
}

get(key: PropertyKey): unknown {
return key === this.key ? this.value : undefined;
}

*keys(): Iterable<PropertyKey> {
yield this.key;
}
}

function bindingsOf(entries: readonly ContextEntry[]): Bindings {
if (entries.length === 1) {
const [key, value] = entries[0]!;
return new SingleBinding(key.id, value);
}
const values = new Map<PropertyKey, unknown>();
for (const [key, value] of entries) {
values.set(key.id, value);
}

return values;
}

Expand All @@ -19,9 +49,9 @@ function entryMap(entries: readonly ContextEntry[]): Map<PropertyKey, unknown> {
*/
export class ContextFrame {
readonly #parent: ContextFrame | null;
readonly #own: ReadonlyMap<PropertyKey, unknown>;
readonly #own: Bindings;

private constructor(parent: ContextFrame | null, own: ReadonlyMap<PropertyKey, unknown>) {
private constructor(parent: ContextFrame | null, own: Bindings) {
this.#parent = parent;
this.#own = own;
}
Expand All @@ -31,7 +61,7 @@ export class ContextFrame {

/** Create a root frame containing the supplied context bindings. */
static from(entries: readonly ContextEntry[]): ContextFrame {
return entries.length === 0 ? ContextFrame.empty : new ContextFrame(null, entryMap(entries));
return entries.length === 0 ? ContextFrame.empty : new ContextFrame(null, bindingsOf(entries));
}

get(key: PropertyKey): unknown {
Expand Down Expand Up @@ -62,18 +92,20 @@ export class ContextFrame {
if (keys.length === 0) {
return this;
}

if (keys.length === 1) {
const key = keys[0]!;
return new ContextFrame(this, new SingleBinding(key, values[key]));
}
const own = new Map<PropertyKey, unknown>();
for (const key of keys) {
own.set(key, (values as Record<PropertyKey, unknown>)[key]);
own.set(key, values[key]);
}

return new ContextFrame(this, own);
}

/** Return a child frame containing the supplied context bindings. */
withEntries(entries: readonly ContextEntry[]): ContextFrame {
return entries.length === 0 ? this : new ContextFrame(this, entryMap(entries));
return entries.length === 0 ? this : new ContextFrame(this, bindingsOf(entries));
}

keys(): IterableIterator<PropertyKey> {
Expand Down
47 changes: 0 additions & 47 deletions src/job/cancellation-bindings.ts

This file was deleted.

159 changes: 159 additions & 0 deletions src/job/cancellation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import { addAbortListener } from "node:events";
import { isCancellation } from "./abort.js";
import { scheduleDeadline } from "./deadline.js";

/** The Job whose subtree a delivered cancellation must reach. */
export interface CancellationTarget {
cancel(reason: unknown): void;
}

const DEADLINE_EXCEEDED = "Job deadline exceeded";

/**
* One Job's incoming cancellation: its own signal, the external sources it
* follows, and the deadline it owns. Everything here is created on demand.
*
* - The `AbortController` exists only once something aborts or reads the signal.
* - External sources are recorded by `follow()` and subscribed to by `observe()`,
* the first time the Job's cancellation becomes observable. `recheck()` honors a
* source that aborted while nothing observed, without subscribing.
* - The target's `cancel()` receives every delivery, so cascades stay the Job's.
*
* It owns no lifetime and no completion state.
*/
export class Cancellation {
private controller: AbortController | undefined;
private pendingInherited: AbortSignal | undefined;
private pendingExternal: AbortSignal | undefined;
private links: Map<AbortSignal, Disposable | undefined> | undefined;
private timer: Disposable | undefined;

constructor(private readonly target: CancellationTarget) {}

get aborted(): boolean {
return this.controller?.signal.aborted === true;
}

get reason(): unknown {
return this.controller?.signal.reason;
}

/** The signal only once it has been created; `undefined` means "not aborted". */
get current(): AbortSignal | undefined {
return this.controller?.signal;
}

/** The Job's signal; reading it subscribes to the followed sources. */
get signal(): AbortSignal {
if (this.pendingInherited !== undefined || this.pendingExternal !== undefined) {
this.observe();
}
return (this.controller ??= new AbortController()).signal;
}

throwIfAborted(): void {
this.controller?.signal.throwIfAborted();
}

isCancellation(error: unknown): boolean {
return this.controller !== undefined && isCancellation(error, this.controller.signal);
}

abort(reason: unknown): void {
(this.controller ??= new AbortController()).abort(reason);
}

/**
* Record the sources this Job follows. An already-aborted source cancels now;
* a live one is subscribed to only when the Job's cancellation is observed.
*/
follow(inherited: AbortSignal | undefined, external: AbortSignal | undefined): void {
const own = this.controller?.signal;
if (inherited !== undefined && inherited !== own) {
this.pendingInherited = inherited;
}
if (external !== undefined && external !== own) {
this.pendingExternal = external;
}
this.recheck();
}

/** Honor a followed source that aborted while nothing observed, without subscribing. */
recheck(): void {
if (this.aborted) {
return;
}
const inherited = this.pendingInherited;
if (inherited?.aborted) {
this.target.cancel(inherited.reason);
return;
}
const external = this.pendingExternal;
if (external?.aborted) {
this.target.cancel(external.reason);
}
}

/** Subscribe to the followed sources now that the Job's cancellation is observable. */
observe(): void {
const inherited = this.pendingInherited;
const external = this.pendingExternal;
this.pendingInherited = undefined;
this.pendingExternal = undefined;
if (this.aborted) {
return;
}
if (inherited !== undefined) {
this.link(inherited);
}
// Linking an already-aborted inherited source cancels synchronously.
if (external !== undefined && !this.aborted) {
this.link(external);
}
}

/** Own a deadline; the target is cancelled with a `TimeoutError` when it elapses. */
deadline(at: number): void {
this.timer = scheduleDeadline(at, () => {
this.target.cancel(new DOMException(DEADLINE_EXCEEDED, "TimeoutError"));
});
}

/** Release subscriptions and the timer. The signal, if any, keeps its state. */
dispose(): void {
this.timer?.[Symbol.dispose]();
this.timer = undefined;
for (const subscription of this.links?.values() ?? []) {
subscription?.[Symbol.dispose]();
}
this.links = undefined;
this.pendingInherited = undefined;
this.pendingExternal = undefined;
}

private link(source: AbortSignal): void {
const links = (this.links ??= new Map());
if (links.has(source)) {
return;
}
if (source.aborted) {
links.set(source, undefined);
this.target.cancel(source.reason);
return;
}

let delivered = false;
const deliver = (): void => {
if (!delivered) {
delivered = true;
this.target.cancel(source.reason);
}
};
const subscription = addAbortListener(source, deliver);
links.set(source, subscription);
// Listener installation can itself reenter source cancellation.
if (source.aborted) {
deliver();
}
}
}
Loading