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:
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -20,6 +20,9 @@
|
||||
{
|
||||
"path": "../../util/brand"
|
||||
},
|
||||
{
|
||||
"path": "../../util/timeout"
|
||||
},
|
||||
{
|
||||
"path": "../../bash/bash"
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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)).
|
||||
|
||||
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": []
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../util/timeout"
|
||||
},
|
||||
{
|
||||
"path": "../web"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user