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
9 changes: 9 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ Runner must not import server, HTTP, WebSocket, gRPC, broker, scheduler, or opti
- 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.
- 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 Expand Up @@ -69,6 +70,14 @@ must fail.
- Use logical paragraph breaks. Extract helpers for real responsibilities, not line-count targets.
- Remove obsolete exports and call paths instead of retaining compatibility shims.

## Review before handing off

Every change ends with these three passes; they are not optional cleanup.

- Responsibility: each module, class, and function owns one thing and its name says which. A helper that only forwards, a field that duplicates state held elsewhere, or a method exposed only so another internal caller can reach it is a smell to remove, not to document.
- Comments: a comment states an invariant, an ownership rule, or a non-obvious reason. Delete comments that narrate the code, repeat a name, or describe behavior the change removed. A stale comment is a bug.
- Documentation: `README.md` describes the current contract. When behavior, ownership, options, or a lifecycle rule changes, update the prose and examples in the same change, and remove text that describes the old model.

## Tests and verification

- Test public behavior, ownership, transitions, cancellation, cleanup ordering, and simultaneous failures.
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ await exchange; // body and descendants have settled

- `offer()` and `resume()` each accept one call; a second throws `TypeError`.
- Cancellation rejects a pending `offer()` with its reason. An offer after cancellation still reaches `receive()` but rejects for the body. A later `resume()` is discarded.
- An offer consults the Job's external sources before suspending: one that already aborted rejects the offer. A source that aborts during the suspension releases it only if the body observed `signal()` first; otherwise the consumer's `resume()` ends the suspension and the Job's result still reports the cancellation.
- A body that returns without offering fails. A Job that closes without offering rejects `receive()` with its failure.
- `receive()` rejects self/ancestor observation synchronously.

Expand Down Expand Up @@ -202,6 +203,8 @@ await execute({ values: [provide(Tenant, "outer")] }, async () => {

`signal()` returns the current Job's cancellation signal. Pass it to cancellable native APIs, and check it after awaits and before irreversible work. Runner cannot force uncooperative code to stop.

A Job seeded with an external `signal` subscribes to it lazily, the first time its own cancellation is observed: a `signal()` or `job.signal` read, or a child starting. A Job that never observes it registers no listener. An external source that aborted before the body starts, or while it runs, still yields a cancelled result; only the wake-up of code already suspended requires the subscription.

```ts
import { setTimeout } from "node:timers/promises";
import { execute, signal, timeout } from "@tiberjs/runner";
Expand Down
12 changes: 9 additions & 3 deletions src/execution/context/access.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,18 @@ export function withContext<T>(entries: readonly ContextEntry[], handler: () =>
if (entries.length === 0) {
return handler();
}
const { context } = state;
return runWith(
{
...state,
job: state.job,
context: {
...state.context,
values: state.context.values.withEntries(entries),
values: context.values.withEntries(entries),
// Forwarded, not copied: reading the signal stays the Job's observation point.
get signal() {
return context.signal;
},
deadline: context.deadline,
attachment: context.attachment,
},
},
handler,
Expand Down
1 change: 1 addition & 0 deletions src/job/handoff-job.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export class HandoffJob<Result, Offered, Resumed> extends Job<Result> {
}
const rendezvous = new HandoffState<Offered, Resumed>();
super(() => body(rendezvous), seed);
rendezvous.owner = this;
this.rendezvous = rendezvous;
this.attach(rendezvous);
}
Expand Down
10 changes: 10 additions & 0 deletions src/job/handoff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,16 @@ export interface OwnedHandoff {
settle(failure: unknown): void;
}

/** The Job whose cancellation state an offer consults before suspending. */
export interface CancellationOwner {
recheckCancellation(): void;
}

export class HandoffState<Offered, Resumed> implements Handoff<Offered, Resumed>, OwnedHandoff {
private readonly value = Promise.withResolvers<Offered>();
private readonly answer = Promise.withResolvers<Resumed>();
/** @internal Set by HandoffJob before activation. */
owner: CancellationOwner | undefined;
offered = false;
private resumed = false;
private received = false;
Expand All @@ -22,6 +29,9 @@ export class HandoffState<Offered, Resumed> implements Handoff<Offered, Resumed>
if (this.offered) {
throw new TypeError("Handoff.offer() may only be called once.");
}
// A source that already aborted releases this handoff before the body suspends;
// a later abort reaches it only through the Job's own cancellation.
this.owner?.recheckCancellation();
this.offered = true;
this.value.resolve(value);
if (this.released) {
Expand Down
149 changes: 108 additions & 41 deletions src/job/job.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { isCancellation } from "./abort.js";
import { CancellationBindings } from "./cancellation-bindings.js";
import { FailureSet } from "./failure-set.js";
import { peekState, runWith, withoutExecution } from "../execution/state.js";
import type { OwnedHandoff } from "./handoff.js";
import type { CancellationOwner, OwnedHandoff } from "./handoff.js";

export interface JobStartOptions {
readonly parent?: Job<unknown>;
Expand All @@ -22,7 +22,7 @@ export type JobResult<T> =
const CHILD_FAILED = new DOMException("A child job failed", "AbortError");

/** A cold, single-use execution that settles once its body and descendants finish. */
export class Job<T> implements PromiseLike<T>, AsyncDisposable {
export class Job<T> implements PromiseLike<T>, AsyncDisposable, CancellationOwner {
private readonly controller = new AbortController();
private readonly settled = Promise.withResolvers<JobResult<unknown>>();
private children: Set<Job<unknown>> | undefined;
Expand All @@ -34,6 +34,9 @@ export class Job<T> implements PromiseLike<T>, AsyncDisposable {
private propagateFailureToParent = false;
private failures: FailureSet | undefined;
private cancellation: CancellationBindings | undefined;
// External sources are linked only once this Job's cancellation becomes observable.
private pendingInherited: AbortSignal | undefined;
private pendingExternal: AbortSignal | undefined;
private closing: Promise<void> | undefined;
private handoff: OwnedHandoff | undefined;

Expand All @@ -57,7 +60,14 @@ export class Job<T> implements PromiseLike<T>, AsyncDisposable {
return this.executionContext;
}

/**
* This Job's cancellation signal. Reading it is the observation that links
* the Job to its external cancellation sources.
*/
get signal(): AbortSignal {
if (this.pendingInherited !== undefined || this.pendingExternal !== undefined) {
this.observeCancellation();
}
return this.controller.signal;
}

Expand All @@ -82,7 +92,7 @@ export class Job<T> implements PromiseLike<T>, AsyncDisposable {
return error;
}
const failure = failures.value;
return Object.is(error, failure) || Object.is(error, this.signal.reason)
return Object.is(error, failure) || Object.is(error, this.controller.signal.reason)
? failure
: combinedError([error, failure], "Job rejection and execution failed.");
}
Expand Down Expand Up @@ -148,17 +158,22 @@ export class Job<T> implements PromiseLike<T>, AsyncDisposable {
}

private isExternalCancellationSource(source: AbortSignal | undefined): source is AbortSignal {
return source !== undefined && source !== this.signal && source !== this.owner?.signal;
return (
source !== undefined &&
source !== this.controller.signal &&
source !== this.owner?.controller.signal
);
}

private prepare(inherited: ExecutionContext | undefined): void {
// Ownership is committed; seed access and abort delivery see a base context.
this.executionContext = {
values: inherited?.values ?? ContextFrame.empty,
signal: this.signal,
deadline: inherited?.deadline,
attachment: inherited?.attachment,
};
// The Job is already running: a seed accessor may start a child under it, and a
// running Job always has a context.
this.executionContext = new JobContext(
this,
inherited?.values ?? ContextFrame.empty,
inherited?.deadline,
inherited?.attachment,
);
const entries = this.seed?.values;
const externalSignal = this.seed?.signal;
const requestedDeadline = this.seed?.deadline;
Expand All @@ -172,33 +187,27 @@ export class Job<T> implements PromiseLike<T>, AsyncDisposable {
? inherited?.deadline
: Math.min(requestedDeadline, inherited?.deadline ?? Infinity);
const base = inherited?.values ?? ContextFrame.empty;
this.executionContext = {
values:
entries instanceof ContextFrame
? entries
: entries === undefined
? base
: base.withEntries(entries),
signal: this.signal,
this.executionContext = new JobContext(
this,
entries instanceof ContextFrame
? entries
: entries === undefined
? base
: base.withEntries(entries),
deadline,
attachment,
};
);
const inheritedSignal = inherited?.signal;
const receivesInherited = this.isExternalCancellationSource(inheritedSignal);
const receivesExternal = this.isExternalCancellationSource(externalSignal);
if (!this.signal.aborted && (receivesInherited || receivesExternal)) {
const cancellation = (this.cancellation ??= new CancellationBindings());
const cancel = (reason: unknown): void => this.cancel(reason);
if (receivesInherited) {
cancellation.link(inheritedSignal, cancel);
}
if (receivesExternal && !this.signal.aborted) {
cancellation.link(externalSignal, cancel);
}
if (this.isExternalCancellationSource(inheritedSignal)) {
this.pendingInherited = inheritedSignal;
}
if (this.isExternalCancellationSource(externalSignal)) {
this.pendingExternal = externalSignal;
}
this.recheckCancellation();
const ownerDeadline = this.owner?.context.deadline;
if (
!this.signal.aborted &&
!this.controller.signal.aborted &&
deadline !== undefined &&
(ownerDeadline === undefined || deadline < ownerDeadline)
) {
Expand All @@ -208,6 +217,46 @@ export class Job<T> implements PromiseLike<T>, AsyncDisposable {
}
}

/** Subscribe to the external sources now that this Job's cancellation is observable. */
private observeCancellation(): void {
const inherited = this.pendingInherited;
const external = this.pendingExternal;
this.pendingInherited = undefined;
this.pendingExternal = undefined;
const signal = this.controller.signal;
if (this.phase === "closed" || signal.aborted) {
return;
}
const cancellation = (this.cancellation ??= new CancellationBindings());
const cancel = (reason: unknown): void => this.cancel(reason);
if (inherited !== undefined) {
cancellation.link(inherited, cancel);
}
// Linking an already-aborted inherited source cancels synchronously.
if (external !== undefined && !signal.aborted) {
cancellation.link(external, cancel);
}
}

/**
* @internal Honor an external source that aborted while nothing observed this
* Job's cancellation, without subscribing to it.
*/
recheckCancellation(): void {
if (this.controller.signal.aborted) {
return;
}
const inherited = this.pendingInherited;
if (inherited?.aborted) {
this.cancel(inherited.reason);
return;
}
const external = this.pendingExternal;
if (external?.aborted) {
this.cancel(external.reason);
}
}

private async perform(inherited: ExecutionContext | undefined): Promise<void> {
let value: T | undefined;
let rejected = false;
Expand All @@ -217,7 +266,7 @@ export class Job<T> implements PromiseLike<T>, AsyncDisposable {
this.prepare(inherited);
value = await runWith({ job: this, context: this.context }, async () => {
try {
this.signal.throwIfAborted();
this.controller.signal.throwIfAborted();
const value = await this.body();
if (this.handoff && !this.handoff.offered) {
throw new TypeError("HandoffJob body completed without offering a value.");
Expand All @@ -229,15 +278,17 @@ export class Job<T> implements PromiseLike<T>, AsyncDisposable {
throw error;
}
});
this.signal.throwIfAborted();
// An external abort during the body is honored even if nothing observed it.
this.recheckCancellation();
this.controller.signal.throwIfAborted();
} catch (error) {
rejected = true;
rejection = error;
this.recordFailure(error);
this.cancel(error);
preserveCancellation =
isCancellation(error, this.signal) &&
this.signal.reason !== CHILD_FAILED &&
isCancellation(error, this.controller.signal) &&
this.controller.signal.reason !== CHILD_FAILED &&
!this.failures?.hasRecorded(error);
}
this.phase = "closing";
Expand All @@ -251,16 +302,16 @@ export class Job<T> implements PromiseLike<T>, AsyncDisposable {
this.rememberFailure(failure);
}
result = { ok: false, error: failure };
} else if (rejected || this.signal.aborted) {
result = { ok: false, error: rejected ? rejection : this.signal.reason };
} else if (rejected || this.controller.signal.aborted) {
result = { ok: false, error: rejected ? rejection : this.controller.signal.reason };
} else {
result = { ok: true, value };
}
this.complete(result);
}

private recordFailure(error: unknown): void {
if (this.failures?.recognizes(error) || isCancellation(error, this.signal)) {
if (this.failures?.recognizes(error) || isCancellation(error, this.controller.signal)) {
return;
}
const failures = (this.failures ??= new FailureSet("Job execution failed."));
Expand Down Expand Up @@ -295,10 +346,10 @@ export class Job<T> implements PromiseLike<T>, AsyncDisposable {
continue;
}
job.controller.abort(received);
job.handoff?.release(job.signal.reason);
job.handoff?.release(job.controller.signal.reason);
for (const child of job.children ?? []) {
jobs.push(child);
reasons.push(job.signal.reason);
reasons.push(job.controller.signal.reason);
}
}
}
Expand Down Expand Up @@ -414,6 +465,8 @@ export class Job<T> implements PromiseLike<T>, AsyncDisposable {
this.phase = "closed";
this.cancellation?.[Symbol.dispose]();
this.cancellation = undefined;
this.pendingInherited = undefined;
this.pendingExternal = undefined;
this.owner?.children?.delete(this);
if (this.handoff && !this.handoff.offered && !result.ok) {
this.handoff.settle(result.error);
Expand All @@ -425,3 +478,17 @@ export class Job<T> implements PromiseLike<T>, AsyncDisposable {
return this.close();
}
}

/** A Job's context: reading its signal is the Job's observation point. */
class JobContext implements ExecutionContext {
constructor(
private readonly job: Job<unknown>,
readonly values: ContextFrame,
readonly deadline: number | undefined,
readonly attachment: unknown,
) {}

get signal(): AbortSignal {
return this.job.signal;
}
}
Loading