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:
40
packages/util/timeout/README.md
Normal file
40
packages/util/timeout/README.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# dsh-timeout
|
||||
|
||||
The **timing-and-classification** half of a timeout — a zero-dependency library of pure functions (no runtime harness deps) 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 signal it hands out only *notifies*; actually stopping the work stays in each capability, because that mechanism differs — bash SIGKILLs an OS process group, web tears down a `fetch` socket — and no shared layer can own all of them. This is the boundary the [RFC](../../../docs/rfc/implemented/architecture/2026-07-06-timeout-deadline-library.md) draws: share the timing/classification, keep the hard kill local.
|
||||
|
||||
It is a **library, not a service or plugin**: no `ctx`, registers nothing, holds no state, 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.
|
||||
|
||||
## Surface
|
||||
|
||||
```ts
|
||||
import { clampTimeout, deadline, timeoutOf, TimeoutReason } from '@deepseek-ai/dsh-timeout'
|
||||
```
|
||||
|
||||
| Export | Role |
|
||||
|---|---|
|
||||
| `clampTimeout(requested, def, max, name?)` | Validate the caller's optional positive-finite hint, fill from `def`, cap at `max`. Throws (with `name`) on a non-positive/non-finite hint. |
|
||||
| `deadline(upstream, timeoutMs, code)` | Fuse `upstream` cancellation with a timeout into one `AbortSignal` (`AbortSignal.any`); the timeout carries a `TimeoutReason`. `[Symbol.dispose]` clears the timer. |
|
||||
| `timeoutOf(signal \| { reason })` | Recover the `TimeoutReason` from an aborted signal/error, else `undefined` — the timeout-vs-cancel classifier. |
|
||||
| `TimeoutReason` | The internal reason (`code` + `timeoutMs`) stamped on a timeout abort. Not a public error — providers translate it into their own error/field. |
|
||||
|
||||
## The `timeoutMs <= 0` sentinel
|
||||
|
||||
`0` is the **internal** "no timeout" value for backend-owned background work (bash `start()`): `deadline()` arms no timer and forwards only `upstream`; with no upstream either, it returns a never-aborting signal plus a no-op disposer, so every caller keeps one call shape. External request hints validate as **positive finite** via `clampTimeout` before they reach `deadline`, so `0` is never a model-/plugin-facing "disable timeout" value.
|
||||
|
||||
## Usage shape
|
||||
|
||||
```ts ignore-check
|
||||
// Scope-lifetime consumer (foreground bash, one fetch): `using` disposes the timer.
|
||||
using d = deadline(upstream, timeoutMs, 'BASH_TIMEOUT')
|
||||
const outcome = await runWork({ signal: d.signal }) // work listens on d.signal and terminates itself
|
||||
const timedOut = timeoutOf(d.signal) !== undefined // classify the first abort
|
||||
const aborted = d.signal.aborted && !timedOut // mutually exclusive: timeout won, or cancel did
|
||||
```
|
||||
|
||||
The signal only *notifies* — the caller MUST attach its own termination (`d.signal.addEventListener('abort', kill)`, or hand `d.signal` to `fetch`). Racing a promise against a timer would resolve the tool-call while the child process or socket leaks on; handing out a signal forces a real termination path to exist.
|
||||
|
||||
## What does NOT get a timeout
|
||||
|
||||
Local file `read`/`write`/`edit` take no `timeoutMs`: a syscall is best-effort-abortable at most, a timeout could not force `fsync`/`rename` to stop, and adding one would be an implicit default that violates explicit-over-implicit. See [`fs/`](../../fs/README.md).
|
||||
30
packages/util/timeout/package.json
Normal file
30
packages/util/timeout/package.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-timeout",
|
||||
"description": "Zero-dependency timeout/deadline primitive: clampTimeout, deadline, timeoutOf, TimeoutReason (timing + classification only, no termination)",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
149
packages/util/timeout/src/index.ts
Normal file
149
packages/util/timeout/src/index.ts
Normal 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
|
||||
}
|
||||
160
packages/util/timeout/tests/timeout.spec.ts
Normal file
160
packages/util/timeout/tests/timeout.spec.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { clampTimeout, deadline, timeoutOf, TimeoutReason } from '@deepseek-ai/dsh-timeout'
|
||||
|
||||
describe('TimeoutReason', () => {
|
||||
it('is an Error carrying the code and elapsed ms', () => {
|
||||
const reason = new TimeoutReason('BASH_TIMEOUT', 100)
|
||||
expect(reason).toBeInstanceOf(Error)
|
||||
expect(reason.name).toBe('TimeoutReason')
|
||||
expect(reason.code).toBe('BASH_TIMEOUT')
|
||||
expect(reason.timeoutMs).toBe(100)
|
||||
expect(reason.message).toBe('BASH_TIMEOUT after 100ms')
|
||||
})
|
||||
})
|
||||
|
||||
describe('clampTimeout', () => {
|
||||
it('fills the default when the hint is absent', () => {
|
||||
expect(clampTimeout(undefined, 120_000, 600_000)).toBe(120_000)
|
||||
})
|
||||
|
||||
it('caps the hint at max', () => {
|
||||
expect(clampTimeout(999_999, 120_000, 600_000)).toBe(600_000)
|
||||
})
|
||||
|
||||
it('keeps a valid hint under the cap', () => {
|
||||
expect(clampTimeout(5_000, 120_000, 600_000)).toBe(5_000)
|
||||
})
|
||||
|
||||
it('caps the default itself when the default exceeds max', () => {
|
||||
// min(def, max) applies even with no hint — a misconfigured backend never
|
||||
// exceeds its own cap.
|
||||
expect(clampTimeout(undefined, 900_000, 600_000)).toBe(600_000)
|
||||
})
|
||||
|
||||
it('rejects a non-finite hint with the caller-provided name', () => {
|
||||
expect(() => clampTimeout(Number.NaN, 100, 200, 'bash-local: request.timeoutMs'))
|
||||
.toThrow(/bash-local: request\.timeoutMs must be a positive finite number/)
|
||||
expect(() => clampTimeout(Number.POSITIVE_INFINITY, 100, 200))
|
||||
.toThrow(/timeoutMs must be a positive finite number/)
|
||||
})
|
||||
|
||||
it('rejects a non-positive hint', () => {
|
||||
expect(() => clampTimeout(0, 100, 200)).toThrow(/must be a positive finite number/)
|
||||
expect(() => clampTimeout(-1, 100, 200)).toThrow(/must be a positive finite number/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deadline — timeout arm', () => {
|
||||
afterEach(() => { vi.useRealTimers() })
|
||||
|
||||
it('aborts on timeout with a TimeoutReason after the elapsed ms', () => {
|
||||
vi.useFakeTimers()
|
||||
using d = deadline(undefined, 100, 'BASH_TIMEOUT')
|
||||
expect(d.signal.aborted).toBe(false)
|
||||
vi.advanceTimersByTime(100)
|
||||
expect(d.signal.aborted).toBe(true)
|
||||
const reason = timeoutOf(d.signal)
|
||||
expect(reason).toBeInstanceOf(TimeoutReason)
|
||||
expect(reason?.code).toBe('BASH_TIMEOUT')
|
||||
expect(reason?.timeoutMs).toBe(100)
|
||||
})
|
||||
|
||||
it('[Symbol.dispose] clears the timer so no abort fires afterward', () => {
|
||||
vi.useFakeTimers()
|
||||
const d = deadline(undefined, 100, 'BASH_TIMEOUT')
|
||||
d[Symbol.dispose]()
|
||||
vi.advanceTimersByTime(1_000)
|
||||
expect(d.signal.aborted).toBe(false)
|
||||
expect(timeoutOf(d.signal)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('deadline — fuse with upstream', () => {
|
||||
it('aborts on upstream cancellation, classified as NOT a timeout', () => {
|
||||
const upstream = new AbortController()
|
||||
using d = deadline(upstream.signal, 60_000, 'BASH_TIMEOUT')
|
||||
upstream.abort('user cancelled')
|
||||
expect(d.signal.aborted).toBe(true)
|
||||
expect(timeoutOf(d.signal)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('cancel wins when it fires before the timeout', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const upstream = new AbortController()
|
||||
using d = deadline(upstream.signal, 100, 'BASH_TIMEOUT')
|
||||
upstream.abort('user cancelled') // fires first, before the 100ms timer
|
||||
vi.advanceTimersByTime(200)
|
||||
expect(d.signal.aborted).toBe(true)
|
||||
// AbortSignal.any adopts the FIRST source's reason: cancel won, so no
|
||||
// TimeoutReason even though the timer later elapsed.
|
||||
expect(timeoutOf(d.signal)).toBeUndefined()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('timeout wins when it fires before upstream cancellation', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const upstream = new AbortController()
|
||||
using d = deadline(upstream.signal, 100, 'WEB_FETCH_TIMEOUT')
|
||||
vi.advanceTimersByTime(100) // timer fires first
|
||||
upstream.abort('too late')
|
||||
expect(timeoutOf(d.signal)?.code).toBe('WEB_FETCH_TIMEOUT')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('forwards a pre-aborted upstream signal immediately', () => {
|
||||
const upstream = new AbortController()
|
||||
upstream.abort('already gone')
|
||||
using d = deadline(upstream.signal, 60_000, 'BASH_TIMEOUT')
|
||||
expect(d.signal.aborted).toBe(true)
|
||||
expect(timeoutOf(d.signal)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('deadline — timeoutMs <= 0 (no-timeout sentinel)', () => {
|
||||
afterEach(() => { vi.useRealTimers() })
|
||||
|
||||
it('arms no timer and forwards only the upstream signal', () => {
|
||||
vi.useFakeTimers()
|
||||
const upstream = new AbortController()
|
||||
using d = deadline(upstream.signal, 0, 'BASH_TIMEOUT')
|
||||
vi.advanceTimersByTime(1_000_000)
|
||||
expect(d.signal.aborted).toBe(false) // no timer ever armed
|
||||
upstream.abort('kill')
|
||||
expect(d.signal.aborted).toBe(true)
|
||||
expect(timeoutOf(d.signal)).toBeUndefined() // never a timeout
|
||||
})
|
||||
|
||||
it('returns a never-aborting signal with a no-op disposer when there is no upstream', () => {
|
||||
vi.useFakeTimers()
|
||||
const d = deadline(undefined, 0, 'BASH_TIMEOUT')
|
||||
expect(() => { d[Symbol.dispose]() }).not.toThrow()
|
||||
vi.advanceTimersByTime(1_000_000)
|
||||
expect(d.signal.aborted).toBe(false)
|
||||
expect(timeoutOf(d.signal)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('treats a negative timeout the same as zero', () => {
|
||||
const d = deadline(undefined, -5, 'BASH_TIMEOUT')
|
||||
expect(d.signal.aborted).toBe(false)
|
||||
d[Symbol.dispose]()
|
||||
})
|
||||
})
|
||||
|
||||
describe('timeoutOf', () => {
|
||||
it('classifies a bare reason carrier that holds a TimeoutReason', () => {
|
||||
const reason = new TimeoutReason('WEB_FETCH_TIMEOUT', 50)
|
||||
expect(timeoutOf({ reason })).toBe(reason)
|
||||
})
|
||||
|
||||
it('returns undefined for a non-timeout reason', () => {
|
||||
expect(timeoutOf({ reason: new Error('other') })).toBeUndefined()
|
||||
expect(timeoutOf({ reason: 'user cancelled' })).toBeUndefined()
|
||||
expect(timeoutOf({})).toBeUndefined()
|
||||
})
|
||||
})
|
||||
11
packages/util/timeout/tsconfig.json
Normal file
11
packages/util/timeout/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": []
|
||||
}
|
||||
Reference in New Issue
Block a user