fix: scope timeoutOf by deadline code so nesting composes (codex round 2)

Round 2 P2: timeoutOf() accepted ANY TimeoutReason, so under nesting — when
the upstream handed to deadline() is itself a deadline (the RFC's named
tools/execute middleware follow-up) and its outer timer fires first —
AbortSignal.any preserves the outer reason and the inner bash/web would report
the outer timeout as their own (timedOut / WEB_FETCH_TIMEOUT) though their local
timer never expired. Add an optional code to timeoutOf; bash and web pass their
own code, so a foreign timeout falls through to the upstream-cancel path.
This commit is contained in:
Dudu-0223
2026-07-06 17:07:11 +08:00
parent b4ba84a1a9
commit 760bc9aa6a
6 changed files with 59 additions and 21 deletions

View File

@@ -16,7 +16,7 @@ import { clampTimeout, deadline, timeoutOf, TimeoutReason } from '@deepseek-ai/d
|---|---|
| `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. |
| `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
@@ -28,13 +28,15 @@ import { clampTimeout, deadline, timeoutOf, TimeoutReason } from '@deepseek-ai/d
```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
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/`](../../fs/README.md).