104 lines
4.3 KiB
TypeScript
104 lines
4.3 KiB
TypeScript
/**
|
|
* Shared timeout arithmetic, signal fusion, and classification. The library
|
|
* only notifies through abort signals; each capability still owns the mechanism
|
|
* that stops its work and translates timeout reasons into public outcomes.
|
|
* @module @deepseek-ai/dsh-timeout
|
|
*/
|
|
|
|
/**
|
|
* Internal abort reason carrying a capability-owned code and elapsed deadline.
|
|
* Providers translate it through {@link timeoutOf} before returning to callers.
|
|
*/
|
|
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, use the backend default, then cap
|
|
* it. Supplied values must be positive and finite; zero is not a public
|
|
* disable-timeout sentinel.
|
|
*
|
|
* @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 ?? def, max)`.
|
|
*/
|
|
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
|
|
}
|
|
|
|
/**
|
|
* Fuse upstream cancellation with an identifiable timeout. `timeoutMs <= 0` is
|
|
* the internal no-timer sentinel; the returned disposer clears an armed timer.
|
|
* The signal only notifies, so callers must stop their own work.
|
|
*
|
|
* @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 a timeout reason from a reason-bearing object. Supplying `code`
|
|
* distinguishes this deadline from a nested upstream deadline; a foreign code
|
|
* follows the ordinary cancellation path.
|
|
*
|
|
* @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
|
|
}
|