feat(timeout): add dsh-timeout and converge bash + web_fetch onto it

Timeout timing/classification was re-implemented three ways across the
tool-bearing capabilities, with the fusion of timeout+cancel and the
timeout-vs-cancel reason recovery being the error-prone parts. Extract that
shared half into a zero-dependency @deepseek-ai/dsh-timeout library
(clampTimeout/deadline/timeoutOf/TimeoutReason) and leave the non-shareable
hard-kill in each capability, per the timeout-library RFC.

bash: run() owns the deadline; runBash drops its killTimer and no longer
classifies (SpawnSpec/SpawnOutcome lose timeoutMs/timedOut/aborted), so the
public timedOut/aborted booleans become mutually-exclusive first-abort
classifications. web_fetch: the hand-rolled controller/timer/listener/
signal.reason dance is replaced by provider-owned deadline/timeoutOf, keeping
the WEB_FETCH_TIMEOUT / WEB_ABORTED contract. fs stays timeout-free (README
states why).
This commit is contained in:
Dudu-0223
2026-07-06 16:23:52 +08:00
parent 9c0cd392b9
commit 8615e019d3
23 changed files with 638 additions and 91 deletions

View File

@@ -0,0 +1,149 @@
/**
* 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". It owns NO termination: the returned {@link deadline} signal only
* NOTIFIES; actually stopping the work (SIGKILL a process group, tear down a
* fetch socket, …) stays in each capability's implementation, because that
* mechanism differs per capability and no shared layer can own all of them.
*
* This is deliberately a library, not a cordis service or plugin: it takes no
* `ctx`, registers nothing, holds no cross-call state, and emits no events. A
* "timeout service" would have to understand how to stop every capability's
* work — exactly the knowledge a microkernel keeps out of shared layers.
*
* The four exports and their division of labor:
* - {@link clampTimeout} — validate a caller's optional positive hint, fill the
* backend default, cap at the backend max (pure arithmetic + the shared
* positive-finite request contract).
* - {@link deadline} — fuse upstream cancellation with a timeout into one
* `AbortSignal`, the timeout carrying an identifiable {@link TimeoutReason};
* `[Symbol.dispose]` clears the timer.
* - {@link timeoutOf} — classify an aborted signal (or error): a
* {@link TimeoutReason} means the timeout fired, anything else (or nothing)
* means it did not.
* - {@link TimeoutReason} — the internal classification reason; providers
* translate it into their own public error/result shape before returning.
*
* @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. The shared positive-finite request contract:
* a supplied `requested` must be a positive finite number or this throws —
* `0` is NOT a caller-facing "disable timeout" value (that sentinel is internal
* to {@link deadline}). A missing `requested` falls back to `def`.
*
* @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
}
/**
* 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). It is
* `AbortSignal.any([upstream, <timeout>])` — the single primitive that fuses
* two abort sources — with the reason and a disposable timer added on top.
*
* `timeoutMs <= 0` is the INTERNAL "no timeout" sentinel for backend-owned
* background work: arm no timer and forward only the upstream signal; with no
* upstream either, return a never-aborting signal so callers keep one call
* shape. External request hints validate as positive finite via
* {@link clampTimeout} before reaching here, so `0` never arrives from a model
* or plugin.
*
* The returned object's `[Symbol.dispose]` clears the timer — use `using` for a
* scope-lifetime consumer, or call it manually for an event-lifetime one. The
* signal only NOTIFIES; the caller must attach its own termination (kill the
* process group, abort the fetch, …).
*
* @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. No timer, so dispose is a
// no-op — the empty method keeps the one call shape for every caller.
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`. This is the classification half: a provider
* calls it on the deadline signal after an abort to decide whether the cause
* was its timeout (translate to the capability's timeout error/field) or an
* ordinary upstream cancellation (`undefined` → the cancel path).
*
* @param x An {@link AbortSignal} or any `{ reason }` carrier (e.g. a caught abort error).
* @returns The {@link TimeoutReason} when the abort was a timeout, else `undefined`.
*/
export function timeoutOf(x: AbortSignal | { reason?: unknown }): 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
return reason instanceof TimeoutReason ? reason : undefined
}