From 3f6ac979bcc59f0b331b7cdec9d3717aa4b47ad1 Mon Sep 17 00:00:00 2001 From: miinhho Date: Wed, 16 Sep 2026 03:32:10 +0900 Subject: [PATCH] docs: clarify ambient access, observation waits, and module identity --- README.md | 59 +++++++++++++++++++++++++++++++-- src/execution/context/access.ts | 10 +++--- src/execution/context/key.ts | 1 + src/execution/state.ts | 2 ++ 4 files changed, 65 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 2913427..43a0d02 100644 --- a/README.md +++ b/README.md @@ -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"; @@ -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(settlements: readonly Promise[], 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. @@ -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. diff --git a/src/execution/context/access.ts b/src/execution/context/access.ts index 644a1c4..7ab582b 100644 --- a/src/execution/context/access.ts +++ b/src/execution/context/access.ts @@ -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(key: ContextKey): 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(key: ContextKey): 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(key: ContextKey): T { const values = currentState().context.values; const value = values.get(key.id) as T | undefined; @@ -53,12 +53,12 @@ export function withContext(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; } diff --git a/src/execution/context/key.ts b/src/execution/context/key.ts index 85ea7e9..b32188c 100644 --- a/src/execution/context/key.ts +++ b/src/execution/context/key.ts @@ -9,6 +9,7 @@ export interface ContextKey { /** A context binding used to derive a downstream execution. */ export type ContextEntry = readonly [ContextKey, unknown]; +/** Create a fresh Symbol identity; equal descriptions do not make keys interchangeable. */ export function contextKey(description: string): ContextKey { return Object.freeze({ id: Symbol(description), description }); } diff --git a/src/execution/state.ts b/src/execution/state.ts index 3aabe9f..8552121 100644 --- a/src/execution/state.ts +++ b/src/execution/state.ts @@ -21,6 +21,7 @@ export function withoutExecution(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) { @@ -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(); }