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
59 changes: 57 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ Use native `try/finally`, `using`, or `await using` for resources. Body-local cl

## Cancellation and deadlines

`signal()` returns the current Job's `AbortSignal`. Pass it to cancellable native APIs and check it after awaits and before irreversible work. Runner cannot force uncooperative code to stop.
`signal()` returns the current Job's `AbortSignal` and throws a plain `Error` outside an active execution. Pass it to cancellable native APIs and check it after awaits and before irreversible work. Runner cannot force uncooperative code to stop.

```ts
import { setTimeout } from "node:timers/promises";
Expand All @@ -96,6 +96,49 @@ await execute(async () => {
- A Job seeded with an external `signal` follows it: an abort before the body runs cancels the Job without running it; an abort during the body yields a cancelled result. The subscription itself is made lazily, the first time the Job's cancellation is observed — a `signal()` read or a child start — so a Job nobody observes registers no listener.
- Cancellation classification is strict. A rejection that _is_ the signal reason, or Node's `AbortError` with `code: "ABORT_ERR"` and that reason as `cause`, is expected cancellation. An application error is not cancellation merely because its `cause` references the reason.

### Stop waiting without stopping the work

`timeout()` limits the lifetime of its owned subtree. It is not a bound on how long a caller waits for independently owned work: cancellation is cooperative, and joining cancelled work can take longer than the deadline.

For external process handles, keep their lifetime under a longer-lived application owner and race their settlement promises against an observation timer. Pass cancellation to the observation timer, not to the processes. A process's domain settlement (for example, producing a result) can precede its exit and its owning Job's completion.

```ts
import { addAbortListener } from "node:events";
import { setTimeout as sleep } from "node:timers/promises";
import { peekState } from "@tiberjs/runner";

async function waitForFirst<T>(settlements: readonly Promise<T>[], ms: number) {
if (settlements.length === 0) throw new RangeError("At least one settlement is required.");
const observation = peekState()?.context.signal;
observation?.throwIfAborted();
const timer = new AbortController();
using registration = observation
? addAbortListener(observation, () => timer.abort(observation.reason))
: undefined;

try {
return await Promise.race([
...settlements.map((settlement) =>
settlement.then((value) => ({ kind: "settled" as const, value })),
),
sleep(ms, undefined, { signal: timer.signal }).then(() => ({ kind: "elapsed" as const })),
]);
} finally {
timer.abort(); // Clear a losing timer; the observation registration is disposed on scope exit.
}
}

// Workers expose settlement promises; this wait does not own or terminate them.
const outcome = await waitForFirst(
workers.map((worker) => worker.settled),
5_000,
);
```

After an early settlement, timeout, or aborted observation, the application owner still owns every process's shutdown and cleanup. If an adapter creates temporary process-event listeners to build settlement observations, dispose those registrations in the same `finally` scope; `Promise.race()` does not remove them or cancel losing operations. Install observers before processes can publish their settlement, and keep exit/failure reporting with the owner even after a caller stops observing.

Use one Job per process when it manages the process's actual lifetime. A separate settlement promise can report a milestone while that Job stays alive. `HandoffJob` is appropriate when one published value must wait for a consumer's response; it is a single exchange, not a recurring notification stream.

## Context

Context is the execution environment, not an ownership node. Bindings use identity-based keys and are immutable: a derived frame shadows the parent without changing it, so concurrent branches never see each other's values.
Expand Down Expand Up @@ -124,12 +167,24 @@ await execute({ values: [provide(Tenant, "outer")] }, async () => {
});
```

- `use(key)` returns the binding or `undefined`. `hasContext(key)` distinguishes absence from an explicit `undefined`. `requireContext(key)` throws `MissingContextError` when absent.
- Inside an active execution, `use(key)` returns the binding or `undefined`. `hasContext(key)` distinguishes absence from an explicit `undefined`. `requireContext(key)` throws `MissingContextError` when absent. Outside an active execution, all three throw a plain `Error`; `undefined` from `use()` does not mean there is no execution.
- Bound values are stored by reference; they are not cloned or frozen.
- A **seed** (`ExecutionSeed`) is what a Job is started with: `values` (entries to overlay, or a `ContextFrame` to replace inherited values), an `attachment`, an external `signal`, and an absolute `deadline`. Omitted fields inherit. An explicit `attachment`, including `undefined`, replaces the inherited one.
- `currentState()` exposes `{ job, context }` for the active call chain; `context` carries `values`, the Job's `signal`, the `deadline`, and the `attachment`. `currentAttachment()` reads the attachment directly. `job.context` is available after activation and is not a cursor for later `withContext()` calls.
- `ContextFrame.from(entries)` builds an independent frame; `frame.withEntries(entries)` derives one.

`signal()`, `deadline()`, `currentState()`, `currentAttachment()`, and `withContext()` also require an active execution. Integrations that intentionally work both inside and outside Jobs can use `peekState()`, which returns `undefined` outside an execution. For example, `peekState()?.context.signal` safely obtains an optional cancellation signal.

### Key identity and duplicate module loading

Bindings are indexed by `key.id`, a fresh `Symbol` created by each `contextKey()` call. The description is diagnostic text, not a lookup name: two keys described as `"config"` are different, even if their generic types match. Import the same exported key wherever it is provided or read.

Loading a key-definition module twice creates different symbols. This can happen when a test runner's transformed module graph and Node's native imports each evaluate the module, or when consumers mix package imports and source paths. A value bound using one copy will be absent when read using the other. Keep providers and consumers on the same module graph and package entry point. Description-based fallback is intentionally unsupported because unrelated bindings can share a description and have incompatible types.

Loading runner itself twice is a separate problem: each copy creates its own `AsyncLocalStorage`, so its ambient APIs do not see the other copy's execution state. Sharing key symbols alone cannot fix that; use one runtime instance. Any application-defined `Symbol.for()` keys require a shared, namespaced contract (including a version when types change), and only share identity within the same symbol registry.

When an injected adapter or test binding is required, read it with `requireContext()` rather than silently choosing a side-effecting default when it is absent. This makes missing bindings fail before an unexpected adapter creates files or accesses external resources.

## Supervisor

A `Supervisor` manages a Job the caller supplies, submits work to it, and applies a failure policy. It creates no hidden owner: `supervisor.job` is the owner, and every submission is a direct child of it.
Expand Down
10 changes: 5 additions & 5 deletions src/execution/context/access.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,17 @@ export class MissingContextError extends Error {
}
}

/** Return the value bound to `key` in the current execution, if present. */
/** Return the binding or undefined when absent. Throws Error outside an active execution. */
export function use<T>(key: ContextKey<T>): T | undefined {
return currentState().context.values.get(key.id) as T | undefined;
}

/** Whether the active frame binds `key`, including an explicit undefined value. */
/** Whether the frame binds `key`, including undefined. Throws Error outside an active execution. */
export function hasContext<T>(key: ContextKey<T>): boolean {
return currentState().context.values.has(key.id);
}

/** Return a required binding, throwing only when the key is absent. */
/** Return the binding; throws MissingContextError when absent, or Error outside an execution. */
export function requireContext<T>(key: ContextKey<T>): T {
const values = currentState().context.values;
const value = values.get(key.id) as T | undefined;
Expand Down Expand Up @@ -53,12 +53,12 @@ export function withContext<T>(entries: readonly ContextEntry[], handler: () =>
);
}

/** The currently executing Job's cancellation signal. */
/** The current Job's cancellation signal. Throws Error outside an active execution. */
export function signal(): AbortSignal {
return currentState().context.signal;
}

/** The current execution's deadline (epoch millis), if any. */
/** The deadline (epoch millis), if any. Throws Error outside an active execution. */
export function deadline(): number | undefined {
return currentState().context.deadline;
}
1 change: 1 addition & 0 deletions src/execution/context/key.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export interface ContextKey<T> {
/** A context binding used to derive a downstream execution. */
export type ContextEntry = readonly [ContextKey<unknown>, unknown];

/** Create a fresh Symbol identity; equal descriptions do not make keys interchangeable. */
export function contextKey<T>(description: string): ContextKey<T> {
return Object.freeze({ id: Symbol(description), description });
}
Expand Down
2 changes: 2 additions & 0 deletions src/execution/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export function withoutExecution<T>(fn: () => T): T {
return storage.exit(fn);
}

/** Return the active execution state, throwing Error when there is no active execution. */
export function currentState(): RuntimeState {
const state = storage.getStore();
if (!state) {
Expand All @@ -32,6 +33,7 @@ export function currentState(): RuntimeState {
return state;
}

/** Return the active execution state, or undefined outside an execution. */
export function peekState(): RuntimeState | undefined {
return storage.getStore();
}
Expand Down