Files
deepseek-harness/packages/util/timeout/README.md
2026-07-12 02:12:36 +08:00

4.4 KiB

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

Model Experience

Context surface What the model sees Token effect
None directly This library only creates and classifies abort signals. It registers no prompt, schema, or message; consumers decide whether a timeout becomes a marker, a structured error, or no model-visible change. Zero direct tokens. It can indirectly cap or replace a consumer's result when that consumer renders a timeout.

Surface

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 }, code?) Recover the TimeoutReason from an aborted signal/error, else undefined — the timeout-vs-cancel classifier. Pass code to match only THIS deadline's timer (see nesting below).
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

// 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, 'BASH_TIMEOUT') !== undefined  // classify the first abort, scoped to OUR code
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.

Pass your own code to timeoutOf so classification composes under nesting: when the upstream you were handed is itself a deadline signal (a future tools/execute middleware arming a per-call deadline), AbortSignal.any preserves the outer TimeoutReason if the outer timer fires first. Scoping to your code makes a foreign timeout read as an ordinary upstream cancel — the correct classification from your capability's view — instead of your own timeout firing when your local timer never expired.

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/.