Files
deepseek-harness/docs/rfc/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md
Ziya 2565133af3 docs(i18n): RFC tree batch — 146 bilingual pairs via the committed pipeline
implemented(除 4 篇超长文档随后补)、proposed、rejected 全树配对;
同一流水线 + 二遍校验(paraphrase-back + 仓库上下文一致性)产出。
docs/rfc/implemented/AGENTS.md 与其 CLAUDE.md 符号链接列入排除
(agent 指令文件,与根 AGENTS.md 同策略)。
2026-07-15 23:25:06 -07:00

10 KiB
Raw Blame History

RFC:共享的超时/截止时间原语,hard-kill 留给各能力自行实现

English | 中文

Status: implemented

问题

超时处理在各个承载工具的能力之间逐渐分化,而这种分化并非表面的——同一套逻辑被三种方式各自重新实现,每种都带着自己微妙的正确性负担。

  • bash(packages/bash/bash-local/src/run.ts)在进程管道内部有一套完整、正确的超时实现:一个经配置钳位的 timeoutMs,两个独立触发器——用于超时的 killTimer 和用于上游取消的 onAbort 监听器——各自调用同一个 kill() 闭包,该闭包对进程组执行 SIGTERM→宽限期→SIGKILL 升级,以及两个正交的结果布尔值(timedOut、aborted)各自独立锁存。
  • web_fetch(packages/web/web-fetch-local/src/provider.ts)有一套正确但手工搭建的超时:它构造一个 AbortController,接入 setTimeout(() => controller.abort(new WebError(…, 'WEB_FETCH_TIMEOUT'))),手动添加和移除上游信号监听器,在 finally 中清除定时器,并在 translateAbortOrNetwork 辅助函数中从 signal.reason 恢复超时原因——因为 reader 只抛出裸 AbortError。
  • web_search(packages/web/tool-web/src/search.ts)完全没有超时:WebSearchRequest(packages/web/web/src/types.ts)不携带 timeoutMs 字段,各提供方的 search() 只转发 exec.signal。(web_search 在本 RFC 中保持无超时——见「后果」。)

每个新的外部进程或网络工具都要重新推导同样四件事:钳位请求值、启动定时器、将超时与上游取消融合、在出口处区分「超时」与「被取消」——而融合和原因恢复恰恰是最容易出微妙错误的部分(web_fetch 的 signal.reason 舞步就是证据)。与此同时,各能力执行的终止动作不可归约地不同:bash 杀的是 OS 进程组(工作运行在子进程中,在本运行时之外,只能通过信号触达),而 web 中止的是进程内的 fetch(undici 拆掉 socket)。不存在一种单一机制能停止所有这些工作。

决策

@deepseek-ai/dsh-timeout 位于 packages/util/(与 dsh-brand 同级),拥有超时的计时与分类这一半;终止那一半——hard kill——留在各能力的实现中。它是一个纯函数库,不是 Cordis 服务或插件:不接收 ctx、不注册任何东西、不持有跨调用状态、不发射事件。刻意不设中央「超时服务」——那样的服务必须知道如何停止每个能力的工作,而这正是微内核要排除在共享层之外的知识,也是 Codex 的 ExecExpiration 作用域仅限于 exec 家族所示范的。

库的对外接口

三个函数加一个 reason 类型:

/** The internal reason attached to a timeout abort, so consumers can classify it after the fact. */
export class TimeoutReason extends Error {
  override name = 'TimeoutReason'

  constructor(readonly code: string, readonly timeoutMs: number) {
    super(`${code} after ${timeoutMs}ms`)
  }
}

/** Validate/fill a caller's optional positive hint from the backend's default, then cap at its max. */
export function clampTimeout(
  requested: number | undefined,
  def: number,
  max: number,
  name = 'timeoutMs',
): number

/**
 * Build a deadline signal that aborts on upstream cancellation OR on timeout,
 * with the timeout carrying a `TimeoutReason`. `timeoutMs <= 0` means "no
 * timeout" (background tasks): forward only the upstream signal, arm no timer.
 * The returned object's `[Symbol.dispose]` clears the timer — `using` for a
 * scope-lifetime consumer, a manual call for an event-lifetime one.
 */
export function deadline(
  upstream: AbortSignal | undefined,
  timeoutMs: number,
  code: string,
): { signal: AbortSignal; [Symbol.dispose](): void }

/** Recover the TimeoutReason from an aborted signal (or error); `code` scopes the match to this deadline's timer. */
export function timeoutOf(x: AbortSignal | { reason?: unknown }, code?: string): TimeoutReason | undefined

deadline 通过 AbortSignal.any 将上游信号与定时器融合,附加一个类型化的 TimeoutReason,并暴露可 dispose(资源释放)的定时器清理。非正数超时是内部的无超时哨兵,用于后端自有的后台任务;外部提示经 clampTimeout 后必须为正有限值。既无定时器也无上游信号时,函数返回一个永不中止的信号,但具有相同的 disposal 形状。提供方将超时原因翻译为 seam 特定的结果。timeoutOf(signal, code) 通过 code 限定分类范围,使外层嵌套的 deadline 被视为上游取消而非内层能力的超时。

分工

关注点 负责方
校验请求提示并钳位 default/max dsh-timeout(clampTimeout)——纯算术加共享的正有限请求契约
启动定时器、到期中止、携带 reason、与上游取消融合 dsh-timeout(deadline)
清除定时器 dsh-timeout([Symbol.dispose])
中止后分类首个 abort reason dsh-timeout(timeoutOf)
实际终止工作 各能力的实现
default/max 值 各能力的配置
超时 code 字符串 各能力(WEB_FETCH_TIMEOUT ≠ BASH_TIMEOUT)

信号只通知;终止始终是监听者的职责,而监听者因能力而异。bash 自己写 addEventListener('abort', kill),因为 OS 进程活在本运行时之外,没有别的东西会杀它;web 把 d.signal 交给 fetch,undici 拆掉 socket。这也是文件 read/write/edit 不接受 timeoutMs 的原因:本地系统调用至多只能尽力中止,超时无法强制 fsync/rename 停下,加一个超时等于引入一个违反「显式优于隐式」的隐式默认值。两个参考 agent 出于同样的理由都不给文件 I/O 设超时。

各能力如何消费

  • web_fetch——工具层保持校验并转发;提供方手工搭建的 controller + setTimeout + 手动监听器 + finally + signal.reason 恢复被提供方自有的 deadline/timeoutOf 取代。上游信号已预先中止时仍立即抛出 WEB_ABORTED;否则 fetch 使用融合后的 d.signal 运行,translateAbortOrNetwork 根据信号分类抛出的错误(timeoutOf → WEB_FETCH_TIMEOUT,否则已中止 → WEB_ABORTED,否则网络 → WEB_PROVIDER_ERROR)。公开的错误码契约不变,TimeoutReason 永远不会作为公开错误跨越 web seam。
  • bash——resolve() 将请求钳位为显式规格。前台 run() 创建 deadline 并将其信号传给进程执行,后者既有的 abort 监听器执行进程组 kill。执行器将首个 abort 分类为超时或取消。后台启动保持无超时,仅转发上游取消。

后果

  • runBash 的结果不再独立锁存 timedOut 和 aborted;超时与用户中止在进程关闭前竞争时,现在报告单一的首个 abort 原因,而非两者同时为 true。统一的 SIGTERM→宽限期→SIGKILL 终止路径不变,seam 类型 BashRunResult 保留两个布尔值(现在互斥),因此 dsh-tool-bash 的结果渲染不受影响。
  • SpawnSpec.timeoutMs 与 SpawnOutcome.timedOut/aborted 被移除,而非作为始终为零/始终为 false 的残留保留:runBash 不再拥有定时器、执行器拥有分类逻辑后,它们无处被读取。这是与字面提案形状(向 runBash 传 timeoutMs: 0)的唯一偏差;在逐文件覆盖率门禁下,一个始终为 0 且无人读取的字段是死代码。
  • web_fetch 去掉了自建的 controller/timer/listener/reason-recovery;分类器现在基于 deadline 信号(timeoutOf + aborted)而非抛出错误的形状来判断,这在请求阶段的 reject-with-reason 和读取阶段的裸 AbortError 两种情况下都是健壮的。
  • AbortSignal.any 与 using/Symbol.dispose 在此首次进入本仓库(Node ≥ 24 基线,已满足)。

不在本 RFC 范围内,列出以标明边界:web_search 可以在其 tool-schema/快照覆盖率规划完成后获得可选的面向模型的 timeout_ms;未来基于 ripgrep 的文件系统发现工具可以在存在后消费同样的提供方自有 deadline 形状;tools/execute waterfall(瀑布式事件)中间件可以通过驱动 exec.signal 为每次工具调用设置默认 deadline——那将是一个消费本库的插件,仍然只做通知,hard kill 仍是各能力自己的事。

曾考虑的替代方案

统一的超时插件 / ctx.timeout 服务。 基于微内核理由否决。一个能停止任何工具工作的服务必须理解每个能力的终止机制(进程组 SIGKILL、socket 拆除、系统调用边界检查)——这正是架构所禁止的「内核知道太多」。Codex 的 ExecExpiration 作用域仅限于 exec 家族,正是因为它驱动的 kill(killpg)是进程家族特有的;MCP 和 model-stream 各自保有自己的。不存在一个连贯的中间层能为所有东西拥有终止权,因此共享部分只能是纯计时/分类那一半——一个库,而非服务。

每个工具各自实现超时,不共享代码(之前的现状,也是 Claude Code 的选择)。 否决,因为它已经在产生分化和重复的正确性负担:web_fetch 手工搭建的 controller/reason 逻辑正是未来每个网络/进程工具都要重新推导的,而融合 + signal.reason 恢复是容易出错的部分。Claude Code 容忍完全重复;本仓库有一条统一的共享中止通道(每次 execute 上的 exec.signal),使一个小型共享原语严格更干净,因此成本/收益不同。

用 withTimeout(promise, ms) 包装器代替信号工厂。 否决,因为让 promise 与定时器竞争只是在 deadline 时 resolve 工具调用的 promise,而不停止底层工作——子进程或 fetch socket 会泄漏。发出信号并要求能力去监听,才能强制一条真正的终止路径存在。这与「dispose 必须达到静止态,而非仅仅请求它」的防御性规则一致。

保留 bash 独立的超时和取消触发器。 否决,因为一个 deadline 信号移除了自建定时器并标准化了分类。竞争的原因报告先到达的那个 abort,而既有的 SIGTERM→SIGKILL 终止路径不变。