/** * The timing-and-classification half of a timeout — a zero-dependency library of pure * functions shared by every capability that clamps a caller's timeout hint, arms a deadline, * and later has to tell "timed out" apart from "cancelled". * @module @deepseek-ai/dsh-timeout */ /** * The internal reason attached to a timeout abort so consumers can classify it * after the fact. It carries the failing `code` (each capability's own string — * `BASH_TIMEOUT`, `WEB_FETCH_TIMEOUT`, …) and the `timeoutMs` that elapsed. * * It is an INTERNAL classification reason, not a public error: providers * translate it into their seam-specific error code or result field (via * {@link timeoutOf}) before returning to callers. Native `AbortSignal.timeout()` * yields a fixed `TimeoutError` indistinguishable across timeout kinds; this * type is identifiable and carries the code/duration. */ export class TimeoutReason extends Error { override name = 'TimeoutReason' /** * @param code Capability-owned timeout code (e.g. `BASH_TIMEOUT`). * @param timeoutMs The deadline that elapsed, in milliseconds. */ constructor(readonly code: string, readonly timeoutMs: number) { super(`${code} after ${timeoutMs}ms`) } } /** * Validate a caller's optional timeout hint, fill it from the backend default, then cap at * the backend max. * * @param requested The caller's optional hint; validated when present. * @param def The backend default applied when `requested` is absent. * @param max The backend upper bound the result is capped to. * @param name Field name used in the thrown message (so the caller sees which input was * bad). * @returns The effective timeout in milliseconds: `min(requested ?? */ export function clampTimeout( requested: number | undefined, def: number, max: number, name = 'timeoutMs', ): number { if (requested !== undefined && (!Number.isFinite(requested) || requested <= 0)) { throw new Error(`${name} must be a positive finite number`) } return Math.min(requested ?? def, max) } /** A deadline signal plus the cleanup that clears its timer (dispose-once). */ export interface Deadline { /** Aborts on upstream cancellation OR on timeout (the timeout carries a {@link TimeoutReason}). */ readonly signal: AbortSignal /** Clear the timer. Safe to call once; `using` calls it at scope exit. */ [Symbol.dispose](): void } /** * Build a deadline signal that aborts on upstream cancellation OR on timeout, with the * timeout carrying an identifiable {@link TimeoutReason} (unlike native * `AbortSignal.timeout()`, whose fixed `TimeoutError` is opaque). * * @param upstream The caller's cancellation signal, if any, fused into the result. * @param timeoutMs Deadline in milliseconds; `<= 0` means "no timeout" (arm no timer). * @param code Capability-owned code stamped onto the timeout's {@link TimeoutReason}. * @returns The fused {@link Deadline} (signal + timer cleanup). */ export function deadline( upstream: AbortSignal | undefined, timeoutMs: number, code: string, ): Deadline { if (timeoutMs <= 0) { // No timeout (background work): forward only the upstream signal, or a never-aborting one // when there is no upstream. return { signal: upstream ?? new AbortController().signal, [Symbol.dispose]() {} } } const timer = new AbortController() const id = setTimeout(() => { timer.abort(new TimeoutReason(code, timeoutMs)) }, timeoutMs) return { // AbortSignal.any adopts the reason of whichever source aborts FIRST, so a // race resolves to a single cause: timeoutOf() reads TimeoutReason only // when the timeout won, and upstream-wins leaves an ordinary abort reason. signal: upstream !== undefined ? AbortSignal.any([upstream, timer.signal]) : timer.signal, [Symbol.dispose]() { clearTimeout(id) }, } } /** * Recover the {@link TimeoutReason} from an aborted signal (or any object with a `reason`), * else `undefined`. * * @param x An {@link AbortSignal} or any `{ reason }` carrier (e.g. a caught abort error). * @param code When provided, only a {@link TimeoutReason} with this exact `code` matches. * @returns The matching {@link TimeoutReason}, else `undefined`. */ export function timeoutOf(x: AbortSignal | { reason?: unknown }, code?: string): TimeoutReason | undefined { // AbortSignal.reason is typed `any`; pin it to `unknown` so no `any` leaks and // the instanceof narrows cleanly for both a signal and a bare reason carrier. const reason: unknown = x.reason if (!(reason instanceof TimeoutReason)) return undefined return code === undefined || reason.code === code ? reason : undefined }