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

@@ -23,6 +23,7 @@
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-bash": "^0.0.1",
"@deepseek-ai/dsh-timeout": "^0.0.1",
"cordis": "^4.0.0-rc.6"
},
"dependencies": {
@@ -30,6 +31,7 @@
},
"devDependencies": {
"@deepseek-ai/dsh-bash": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
"cordis": "^4.0.0-rc.6"
}
}

View File

@@ -18,6 +18,7 @@ import { Context } from 'cordis'
import z from 'schemastery'
import { BashExecutor, BashTaskId } from '@deepseek-ai/dsh-bash'
import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskRead, OwnerToken } from '@deepseek-ai/dsh-bash'
import { clampTimeout, deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
import { DEFAULT_GRACE_MS, runBash } from './run.ts'
import type { RunInternals, RunningBash } from './run.ts'
@@ -114,8 +115,12 @@ export class LocalBashExecutor extends BashExecutor {
* values and never re-default.
*/
resolve(request: BashExecRequest): BashExecSpec {
if (request.timeoutMs !== undefined) assertPositiveFinite('request.timeoutMs', request.timeoutMs)
const timeoutMs = Math.min(request.timeoutMs ?? this.config.timeoutMs, this.config.maxTimeoutMs)
const timeoutMs = clampTimeout(
request.timeoutMs,
this.config.timeoutMs,
this.config.maxTimeoutMs,
'bash-local: request.timeoutMs',
)
return {
command: request.command,
workdir: request.workdir ?? this.config.cwd ?? process.cwd(),
@@ -132,29 +137,38 @@ export class LocalBashExecutor extends BashExecutor {
}
async run(spec: BashExecSpec): Promise<BashRunResult> {
// One fused deadline drives both the timeout and upstream cancellation;
// runBash listens on d.signal and runs the SIGTERM→grace→SIGKILL kill.
// `using` clears the timer across the awaited process lifetime.
using d = deadline(spec.signal, spec.timeoutMs, 'BASH_TIMEOUT')
const outcome = await runBash({
command: spec.command,
cwd: spec.workdir,
timeoutMs: spec.timeoutMs,
maxOutputBytes: this.config.maxOutputBytes,
graceMs: this.config.graceMs,
signal: spec.signal,
signal: d.signal,
stdin: spec.stdin,
env: spec.env,
}, this.internals).done
return { ...outcome, timeoutMs: spec.timeoutMs }
// Classify the FIRST abort reason: a TimeoutReason means the timeout cut the
// command short; any other abort is upstream cancellation. Mutually
// exclusive by construction — the fused signal reports one cause, not two
// independently-latched facts.
const timedOut = timeoutOf(d.signal) !== undefined
const aborted = d.signal.aborted && !timedOut
return { ...outcome, timedOut, aborted, timeoutMs: spec.timeoutMs }
}
start(spec: BashExecSpec): BashTask {
// No timeout for background tasks (matches Claude Code, which detaches
// the timeout when backgrounding); callers stop tasks via kill() — or
// via spec.signal, which the seam contract honors for background runs
// too (runBash wires it to the group kill). spec.timeoutMs is ignored
// here by design.
// too (runBash wires it to the group kill). No deadline is created here,
// so spec.timeoutMs is ignored by design — background tasks stay
// timeout-free (see the timeout-library RFC).
const running = runBash({
command: spec.command,
cwd: spec.workdir,
timeoutMs: 0,
maxOutputBytes: this.config.maxOutputBytes,
graceMs: this.config.graceMs,
signal: spec.signal,
@@ -174,8 +188,10 @@ export class LocalBashExecutor extends BashExecutor {
stdoutOffset: 0,
stderrOffset: 0,
done: running.done.then((outcome) => {
// Abort-killed tasks report as killed, not completed.
if (task.status === 'running') task.status = outcome.aborted ? 'killed' : 'completed'
// Abort-killed tasks report as killed, not completed. Background runs
// forward only the upstream signal (no timeout), so its aborted state
// is the authoritative "was this cancelled" signal.
if (task.status === 'running') task.status = spec.signal?.aborted === true ? 'killed' : 'completed'
task.exitCode = outcome.exitCode
task.signal = outcome.signal
this.notifyTaskDone(task)

View File

@@ -6,6 +6,12 @@
* Everything here is deliberately free of Cordis concepts so it can be unit
* tested in isolation; `LocalBashExecutor` owns lifecycle and configuration.
*
* runBash owns NO timing: it kills the process group when its `spec.signal`
* fires and does not distinguish a timeout from a cancel. The executor fuses
* timeout + upstream cancellation into that one signal via
* `@deepseek-ai/dsh-timeout`'s `deadline`, and classifies the outcome from the
* signal afterward — the timing/classification half is shared, the kill is not.
*
* Design notes (surveyed against Claude Code, OpenCode, Codex, and pi — see
* the package README): spawn-per-call with `detached: true` so the child
* leads its own process group; kills target the group (`kill(-pid)`) so
@@ -69,13 +75,17 @@ export function childEnv(extra?: Record<string, string>): NodeJS.ProcessEnv {
export interface SpawnSpec {
command: string
cwd: string
/** Kill the process group after this many milliseconds. 0 = no timeout. */
timeoutMs: number
/** Per-stream in-memory cap; overflow spills to disk (tail kept in memory). */
maxOutputBytes: number
/** Grace period between the SIGTERM and the SIGKILL escalation on a kill. */
graceMs: number
/** Abort signal — kills the process group when fired. */
/**
* Abort signal — kills the process group when it fires. The executor owns
* timing: `run()` passes a fused timeout/cancel deadline signal (see
* `@deepseek-ai/dsh-timeout`), `start()` passes the bare upstream signal.
* runBash only listens and kills; it does NOT classify why (the executor
* reads the signal's reason afterward).
*/
signal?: AbortSignal | undefined
/**
* Bytes to write to the child's stdin, then close it. Absent (or empty)
@@ -92,12 +102,15 @@ export interface SpawnSpec {
env?: Record<string, string> | undefined
}
/** Raw outcome of one closed process (before result shaping). */
/**
* Raw outcome of one closed process (before result shaping). Deliberately
* carries NO timeout/cancel classification: runBash kills on abort but does not
* decide why — the executor's `run()`/`start()` reads the deadline signal it
* owns to classify `timedOut`/`aborted` (see the package README).
*/
export interface SpawnOutcome {
exitCode: number | null
signal: NodeJS.Signals | null
timedOut: boolean
aborted: boolean
stdout: CollectedOutput
stderr: CollectedOutput
}
@@ -318,9 +331,6 @@ export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningB
child.stdout.on('data', (chunk: Buffer) => { stdout.push(chunk) })
child.stderr.on('data', (chunk: Buffer) => { stderr.push(chunk) })
let timedOut = false
let aborted = false
let killTimer: NodeJS.Timeout | undefined
let graceTimer: NodeJS.Timeout | undefined
// pid is undefined when the spawn itself fails (bad cwd, missing binary);
@@ -333,17 +343,12 @@ export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningB
graceTimer = setTimeout(() => { killGroup(pid, 'SIGKILL') }, spec.graceMs)
}
if (spec.timeoutMs > 0) {
killTimer = setTimeout(() => {
timedOut = true
kill()
}, spec.timeoutMs)
}
const onAbort = (): void => {
aborted = true
kill()
}
// runBash owns no timer: the executor's `run()` fuses timeout+cancel into one
// deadline signal (`@deepseek-ai/dsh-timeout`) and passes it here; we only
// listen and run the SIGTERM→grace→SIGKILL kill. Whether the abort was a
// timeout or an upstream cancel is classified by the executor from that
// signal, not tracked here.
const onAbort = (): void => { kill() }
spec.signal?.addEventListener('abort', onAbort, { once: true })
// Write stdin and close it, but ONLY when the caller supplied bytes — with no
@@ -376,14 +381,11 @@ export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningB
resolve({
exitCode,
signal,
timedOut,
aborted,
stdout: stdout.finalize(),
stderr: stderr.finalize(),
})
})
function cleanup(): void {
if (killTimer !== undefined) clearTimeout(killTimer)
if (graceTimer !== undefined) clearTimeout(graceTimer)
spec.signal?.removeEventListener('abort', onAbort)
}

View File

@@ -101,6 +101,8 @@ describe('LocalBashExecutor.run', () => {
const { bash } = await setup({ timeoutMs: 60_000 })
const result = await bash.run(bash.resolve({ command: 'sleep 60', timeoutMs: 100 }))
expect(result.timedOut).toBe(true)
// Mutually exclusive: a timeout classifies as timedOut, never also aborted.
expect(result.aborted).toBe(false)
expect(result.timeoutMs).toBe(100)
})
@@ -111,6 +113,20 @@ describe('LocalBashExecutor.run', () => {
setTimeout(() => { controller.abort() }, 50)
const result = await pending
expect(result.aborted).toBe(true)
// Mutually exclusive: an upstream cancel classifies as aborted, never also timedOut.
expect(result.timedOut).toBe(false)
})
it('classifies a self-killed command as neither timed out nor aborted', async () => {
// The command kills itself (SIGTERM) with no timeout and no upstream abort:
// the deadline signal never fires, so both classifications are false — the
// fused-signal classification reports the cause that cut the command short,
// and here nothing the executor owns did.
const { bash } = await setup({ timeoutMs: 60_000 })
const result = await bash.run(bash.resolve({ command: 'kill -TERM $$' }))
expect(result.signal).toBe('SIGTERM')
expect(result.timedOut).toBe(false)
expect(result.aborted).toBe(false)
})
it('rejects on spawn failure (bad workdir)', async () => {

View File

@@ -26,7 +26,6 @@ function spec(command: string, overrides: Partial<Parameters<typeof runBash>[0]>
return {
command,
cwd: process.cwd(),
timeoutMs: 0,
maxOutputBytes: 64_000,
graceMs: 3_000,
...overrides,
@@ -61,8 +60,6 @@ describe('runBash', () => {
const result = await runBash(spec('echo hello')).done
expect(result.exitCode).toBe(0)
expect(result.signal).toBeNull()
expect(result.timedOut).toBe(false)
expect(result.aborted).toBe(false)
expect(result.stdout.text).toBe('hello\n')
expect(result.stdout.truncated).toBe(false)
expect(result.stderr.text).toBe('')
@@ -97,11 +94,16 @@ describe('runBash', () => {
expect(result.stdout.text.trim()).toMatch(/\/tmp$/)
})
it('kills with SIGTERM on timeout', async () => {
it('kills the process group with SIGTERM when the signal fires', async () => {
// runBash owns no timer: it kills on abort. The executor drives the timeout
// by firing this signal via a deadline (see executor.spec.ts); here we
// assert the kill itself lands as SIGTERM.
const controller = new AbortController()
const start = Date.now()
const result = await runBash(spec('sleep 60', { timeoutMs: 100 })).done
const running = runBash(spec('sleep 60', { signal: controller.signal }))
setTimeout(() => { controller.abort('deadline') }, 100)
const result = await running.done
expect(Date.now() - start).toBeLessThan(5_000)
expect(result.timedOut).toBe(true)
expect(result.signal).toBe('SIGTERM')
expect(result.exitCode).toBeNull()
})
@@ -134,7 +136,6 @@ describe('runBash', () => {
const running = runBash(spec('sleep 60', { signal: controller.signal }))
setTimeout(() => { controller.abort('user cancelled') }, 50)
const result = await running.done
expect(result.aborted).toBe(true)
expect(result.signal).toBe('SIGTERM')
})
@@ -211,7 +212,6 @@ describe('stdin and extra env (set by in-process plugins)', () => {
const big = 'x'.repeat(1024 * 1024)
const result = await runBash(spec('exit 7', { stdin: big })).done
expect(result.exitCode).toBe(7)
expect(result.aborted).toBe(false)
})
})
@@ -339,11 +339,11 @@ describe('abort edge cases', () => {
.toThrow(/aborted before spawn: aborted/)
})
it('reports an externally self-killed command without the timeout marker', async () => {
it('reports the terminating signal of an externally self-killed command', async () => {
// runBash reports the raw signal; whether it counts as timeout/cancel is the
// executor's classification (a self-kill is neither) — see executor.spec.ts.
const result = await runBash(spec('kill -TERM $$')).done
expect(result.signal).toBe('SIGTERM')
expect(result.timedOut).toBe(false)
expect(result.aborted).toBe(false)
})
})
@@ -396,10 +396,9 @@ describe('review fixes: env scrubbing and spill hardening', () => {
it('honors AbortSignal on background-style runs (no timeout)', async () => {
const controller = new AbortController()
const running = runBash(spec('sleep 60', { timeoutMs: 0, signal: controller.signal }))
const running = runBash(spec('sleep 60', { signal: controller.signal }))
setTimeout(() => { controller.abort() }, 50)
const result = await running.done
expect(result.aborted).toBe(true)
expect(result.signal).toBe('SIGTERM')
})
})

View File

@@ -20,6 +20,9 @@
{
"path": "../../util/brand"
},
{
"path": "../../util/timeout"
},
{
"path": "../../bash/bash"
}

View File

@@ -10,3 +10,8 @@ The filesystem stack: a provider seam (text IO + atomic mutation with an optiona
| `tool-fs/` | Model-facing `read`/`write`/`edit` tools AND the executor (reads via `ctx.fs`, owns read windowing, dispatches `fs/*`) | (registers on `ctx.tools`) |
The interface lives at `fs/fs/`. A sandboxed, remote, or project-scoped filesystem backend can replace `fs-local` without touching the seam, the policy gate, or the model-facing tool schemas. The policy (`fs-policy/`) is a plugin that participates only through the `fs/*` event gate, not a service the tool injects — so dropping it gracefully loses the policy and leaves the unconstrained bare provider rather than breaking the tool. A deployment that loads `tool-fs/` is expected to also load it.
## No timeouts on file IO
`read`/`write`/`edit` take **no** `timeoutMs`, and the provider seam arms no deadline — unlike bash and web, which consume [`@deepseek-ai/dsh-timeout`](../util/timeout/README.md). A local syscall is best-effort-abortable at most: a timeout could not force an in-progress `fsync`/`rename` to stop, so a deadline here would be a knob that cannot deliver on its promise. Adding one would also be an implicit default in the exact place explicit-over-implicit forbids. Both reference agents (Claude Code, Codex) leave file IO untimed for the same reason; cancellation still propagates through the tool-execution signal for best-effort abort at syscall boundaries.

View File

@@ -5,5 +5,8 @@ Zero-dependency primitives shared across the other groups. A package lands here
| Package | Role |
|---|---|
| `brand/` | The type-only `Branded<B>` nominal-typing primitive (no runtime code, no harness deps) |
| `timeout/` | The timing/classification half of a timeout — `clampTimeout`/`deadline`/`timeoutOf`/`TimeoutReason` (pure functions, no harness deps); termination stays in each capability |
`dsh-brand` is the canonical case: it owns ONLY the `Branded<B>` helper, so a capability package can brand the ids it owns (`dsh-bash`'s `BashTaskId`/`OwnerToken`, `dsh-session`'s `SessionId`, …) by depending on `dsh-brand` alone, without pulling in an unrelated package just to reach `Branded`.
`dsh-timeout` follows the same shape for the timeout family: `dsh-bash` and `dsh-web-fetch-local` each fuse a caller's cancellation with a deadline and later classify "timed out" vs "cancelled" by depending on `dsh-timeout` alone. It deliberately owns only the timing/classification half — the *termination* (SIGKILL a process group, tear down a fetch socket) stays in each capability, because no shared layer can own every capability's kill (see [the timeout-library RFC](../../docs/rfc/implemented/architecture/2026-07-06-timeout-deadline-library.md)).

View 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).

View 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"
}
}

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
}

View 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()
})
})

View File

@@ -0,0 +1,11 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": []
}

View File

@@ -22,6 +22,7 @@
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-timeout": "^0.0.1",
"@deepseek-ai/dsh-web": "^0.0.1",
"cordis": "^4.0.0-rc.6"
},
@@ -29,6 +30,7 @@
"schemastery": "^3.18.0"
},
"devDependencies": {
"@deepseek-ai/dsh-timeout": "workspace:^",
"@deepseek-ai/dsh-web": "workspace:^",
"cordis": "^4.0.0-rc.6"
}

View File

@@ -21,6 +21,7 @@
import { WebError } from '@deepseek-ai/dsh-web'
import type { WebFetchBody, WebFetchProvider, WebFetchRequest, WebFetchResult, WebProviderStatus } from '@deepseek-ai/dsh-web'
import { clampTimeout, deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
import { classifyContentType, decoderForCharset, isSameOrigin, parseCharset, validateFetchUrl } from './policy.ts'
/** Resolved provider limits (the plugin's schemastery Config supplies defaults). */
@@ -56,35 +57,25 @@ export class LocalFetchProvider implements WebFetchProvider {
}
async fetch(request: WebFetchRequest, exec?: { readonly signal?: AbortSignal }): Promise<WebFetchResult> {
const timeoutMs = request.timeoutMs !== undefined
? Math.min(request.timeoutMs, this.limits.maxTimeoutMs)
: this.limits.timeoutMs
if (exec?.signal?.aborted) throw new WebError('web fetch aborted', 'WEB_ABORTED')
const timeoutMs = clampTimeout(request.timeoutMs, this.limits.timeoutMs, this.limits.maxTimeoutMs)
// One controller drives both the caller's abort and our own timeout, so the
// network request and the streaming read both stop on either.
const controller = new AbortController()
const onAbort = (): void => { controller.abort() }
if (exec?.signal !== undefined) {
if (exec.signal.aborted) throw new WebError('web fetch aborted', 'WEB_ABORTED')
exec.signal.addEventListener('abort', onAbort, { once: true })
}
const timer = setTimeout(() => { controller.abort(new WebError('web fetch timed out', 'WEB_FETCH_TIMEOUT')) }, timeoutMs)
try {
return await this.followAndRead(request.url, controller)
} finally {
clearTimeout(timer)
if (exec?.signal !== undefined) exec.signal.removeEventListener('abort', onAbort)
}
// One deadline signal fuses the caller's abort with our own timeout, so the
// network request and the streaming read both stop on either. The timeout
// abort carries a TimeoutReason we recover afterward to classify the cause
// (translateAbortOrNetwork), instead of hand-rolling a controller + timer +
// reason-recovery dance.
using d = deadline(exec?.signal, timeoutMs, 'WEB_FETCH_TIMEOUT')
return await this.followAndRead(request.url, d.signal)
}
/** Follow same-origin redirects up to the hop cap, then read the final response. */
private async followAndRead(initialUrl: string, controller: AbortController): Promise<WebFetchResult> {
private async followAndRead(initialUrl: string, signal: AbortSignal): Promise<WebFetchResult> {
let currentUrl = validateFetchUrl(initialUrl, this.limits.maxUrlLength)
let redirectsFollowed = 0
for (;;) {
const response = await this.requestOnce(currentUrl, controller)
const response = await this.requestOnce(currentUrl, signal)
if (isRedirectStatus(response.status)) {
// The redirect budget is enforced BEFORE this hop's target is resolved
@@ -127,20 +118,20 @@ export class LocalFetchProvider implements WebFetchProvider {
continue
}
return await this.readBody(response, currentUrl, controller.signal)
return await this.readBody(response, currentUrl, signal)
}
}
private async requestOnce(url: URL, controller: AbortController): Promise<Response> {
private async requestOnce(url: URL, signal: AbortSignal): Promise<Response> {
try {
return await fetch(url, {
method: 'GET',
redirect: 'manual',
headers: { 'user-agent': this.limits.userAgent, 'accept': 'text/html,application/xhtml+xml,text/*;q=0.9,application/json;q=0.8' },
signal: controller.signal,
signal,
})
} catch (error: unknown) {
throw translateAbortOrNetwork(error, controller.signal)
throw translateAbortOrNetwork(error, signal)
}
}
@@ -255,24 +246,17 @@ function resolveRedirect(location: string, base: URL): URL {
}
/**
* Translate a thrown fetch/stream error into a `WebError`. Our own
* `WEB_FETCH_TIMEOUT` (passed to `controller.abort(reason)`) and any other
* already-typed `WebError` pass through; an `AbortError` becomes `WEB_ABORTED`,
* UNLESS the abort was our timeout — the body-read reader surfaces a generic
* `AbortError` rather than the abort reason, so we recover the timeout's
* `WebError` from `signal.reason`; anything else is a transport/network failure
* (`WEB_PROVIDER_ERROR`).
* Translate a thrown fetch/stream error into a `WebError`, classified by the
* deadline signal rather than the error's shape (which differs by phase: the
* request-phase `fetch` rejects with the abort reason, while the read-phase
* reader surfaces a bare `AbortError`). `timeoutOf(signal)` recovering a
* `TimeoutReason` means our timeout fired (`WEB_FETCH_TIMEOUT`); any other abort
* is upstream cancellation (`WEB_ABORTED`); a throw with the signal NOT aborted
* is a transport/network failure (`WEB_PROVIDER_ERROR`).
*/
function translateAbortOrNetwork(error: unknown, signal?: AbortSignal): WebError {
if (error instanceof WebError) return error
if (error instanceof DOMException && error.name === 'AbortError') {
// A timeout abort carries its WebError as the signal reason; honor the
// WEB_FETCH_TIMEOUT contract instead of reporting a generic cancellation.
// (Node rejects WITH the reason — the WebError branch above — so this only
// fires on a runtime that surfaces a bare AbortError while reason is set.)
/* v8 ignore next */
if (signal?.reason instanceof WebError) return signal.reason
return new WebError('web fetch aborted', 'WEB_ABORTED', { cause: error })
}
function translateAbortOrNetwork(error: unknown, signal: AbortSignal): WebError {
const timeout = timeoutOf(signal)
if (timeout !== undefined) return new WebError('web fetch timed out', 'WEB_FETCH_TIMEOUT', { cause: timeout })
if (signal.aborted) return new WebError('web fetch aborted', 'WEB_ABORTED', { cause: error })
return new WebError(`web fetch failed: ${String(error)}`, 'WEB_PROVIDER_ERROR', { cause: error })
}

View File

@@ -17,6 +17,9 @@
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../util/timeout"
},
{
"path": "../web"
}