← 回到 infra 主页

DSH 上游 issue #9 草案 · 2026-09-30

关停期投影注销竞态:一次可预期的清理异常把整个 web 服务打死

关停时 ctx 被 dispose,把 inbox 投影的注册从 registrations 里删掉;而**仍在飞的** AbortSignal.onAbort 随后走 cancel → clear → current(),读到一个 undefined 就 throw; dsh-app-boot 的顶层 uncaughtException 又**一律** process.exit(1)。 ⇒ 一次**关停期**的清理异常 = 整个服务死亡、用户页面断开。今天实测复现 6 次 / 3 户, 每次换 UUID(时序竞态,与"某个坏会话"无关)。

一句话结论与影响面

第 1 节 · 症状与影响
项内容
症状dsh: fatal uncaught exception: Error: agent "<session-id>" cannot read inbox state: its projection registration is not active,进程随即消失
触发条件只在关停期:紧跟一条 user-serve 的 stop-cancel … reason=timeout(本仓的关停前置步骤)
影响该户 Web 服务**整体不可用**(页面断开、需重连);若与其他缺陷叠加,会表现为反复 502/重启
面本机 24 户中 3 户 / 6 次(今天现读);不是某户特有问题
优先级高 关停是**每天都会发生**的常规动作(升级、改配置、健康检查自愈),每次关停都是一次抽签

完整链条(5 环,逐行核到行号)

每一环都是从 app-alpha-0.2.0-rc.2 的出厂原件里逐行读出来的,不是推断。

① 注册(是 ctx.effect,随 ctx 一起被拆)
   dsh-agent-loop/lib/index.js:1559
     ctx.sessionProjections.register(inboxProjectionDefinition)
   实现 dsh-session-projection/lib/index.js:68-102
     register(definition) {
       …
       const dispose = this.ctx.effect(function* () { … }.bind(this),
                                       "sessionProjections.register()");
       return () => void dispose();
     }
   ⇒ disposer 在**该 fiber/ctx 被 dispose 时**运行,把该 key 从 registrations 里删掉。

② 读(缺 key 就返回 undefined,不抛)
   dsh-session-projection/lib/index.js:127-131
     stateOf(session, key) {
       const registration = this.registrations.get(key);
       if (registration === void 0) return void 0;      ← ★ 关键返回
       …
     }

③ 关停:ctx 被 dispose ⇒ ① 的 disposer 运行 ⇒ registrations 里没有 "inbox" 了。

④ 仍在飞的 abort(在拆完之后才跑)
   dsh-subagent-in-process-driver/lib/index.js:200   AbortSignal.onAbort
     → dsh-agent-loop/lib/index.js:817                ReactLoopAgent.cancel
     → dsh-agent-loop/lib/index.js:94                 ReactLoopInbox.clear
     → dsh-agent-loop/lib/index.js:85                 get nextStep
     → dsh-agent-loop/lib/index.js:175                current() → stateOf 得到 undefined
     → dsh-agent-loop/lib/index.js:177                ★ throw new Error(… "its projection registration is not active")

⑤ 升级成致命(顶层策略)
   dsh-app-boot/lib/index.js:3811  report(err, "fatal uncaught exception") → proc.exit(1)

抛点与读点原文

// dsh-agent-loop/lib/index.js:174-179
/** Read the current durable projection state. */
current() {
  const state = this.projections.stateOf(this.session, "inbox");
  if (state === void 0)
    throw new Error(`agent "${this.session.id}" cannot read inbox state: its projection registration is not active`);
  return state;
}

// dsh-agent-loop/lib/index.js:92-96
/** Durably cancel all pending input, clearing next-step before next-turn. */
clear() {
  this.splice("next-step", 0, this.nextStep.length, []);   // ← nextStep ⇒ current() ⇒ 上面那个 throw
  this.splice("next-turn", 0, this.nextTurn.length, []);
}

顶层处理器原文

// dsh-app-boot/lib/index.js:3802-3818
const report = (err, label) => {
  … 打印 "<label>" …
  if (release === void 0) { proc.exit(1); return; }
  (async () => { … await Promise.race([release(), timeout(FAIL_LOUD_RELEASE_TIMEOUT_MS)]); … })();
  proc.exit(1);                                   // ★ 无论如何都是 exit(1)
};
const onException = (err) => { report(err, "fatal uncaught exception"); };
proc.on("uncaughtException", onException);        // ★ 顶层注册
注意 stateOf 与 current() 的策略反差:底层 stateOf 对"没有这个注册"是**宽容**的(返回 undefined), 而上层 current() 把它升级成 throw。关停期出现"没有这个注册"是预期状态, 不是异常状态 ⇒ 这一层 throw 才是缺陷所在。

真实复现(6 次 / 3 户)

全部取自生产 web.log,每次都紧跟一条关停前置的 stop-cancel … reason=timeout。

第 3 节 · 现读(2026-09-30 10:1x +08:00)
户次数被点名的 session UUID(各不相同)
zyj2380be3f1a-…(09-29 23:45:53)、d9d75543-…(09-30 07:11)、f48b7300-…(09-30 08:26)
mch126347a3a5-…(09-30 00:38:22)、8b64ff5a-7fe7-4be2-a84e-06de983b87af(09-30 06:48:18)
mch810939450d-…(09-30 07:46:28)
"每次换 UUID"是这条结论的地基:如果它与某个特定会话绑定,我们会看到同一个 UUID 反复出现; 实测 3 户 6 次、UUID 互不相同 ⇒ 它是时序竞态,不是数据问题。 补充(45 席取证):这些 UUID 是子会话,且三次 zyj2 崩溃都属于同一个父会话 (该父会话先后 spawn 过 1225 个子会话)⇒ "换 UUID"的机制就是"每次 subagent run 换一个 child session id"。

谁触发的 abort —— 不是我们的 stop-cancel

这一节是对本页初版的更正:初版把两者写成"timeout 先发生、崩溃紧随",容易被读成因果。

第 3.5 节 · 两种判词的含义完全不同
判词含义与 fatal 的关系
reason=timeout预算(1.5 s)在 GET /?token= 或首个 session/list 上就用尽了 —— session/cancel 的 rejection 被 () => null 吞掉、轮询错误被 break 吞掉,都只会产出 cancel-unconfirmed ⇒ 超时 = 我们的 cancel 根本没发出去6/6 次 fatal 的前一行都是它
reason=cancel-unconfirmedcancel 确实送达了,但没在预算内 settle实测 5 次,0 次崩溃
因此这 6 次的 abort 来自 DSH 自己的关停链: SIGTERM → interrupt(0) → fiber.dispose() → dsh-agent-loop:1678 abort.abort("disposed") → 同步派发到仍挂着的 driver:200 onAbort → child.cancel({kind:"parent"})(不带 keepInbox) → clear() → current() → throw。
同一份 dispose 里还有一条"被吞掉"的兄弟路径:dsh-agent-loop:1688 的 machine.cancel({kind:"disposed"}) 被 :1682-1694 的 try/catch 包着 ⇒ 它抛了也只是一行日志。 只有 driver:200 这条(AbortSignal 监听器内、无 try)会变成 uncaughtException —— 这也解释了为什么同一个缺陷只在这一条路径上致命。

代码里已有逃生口,崩溃路径没走

// dsh-agent-loop/lib/index.js:815-821
cancel(cause, options = {}) {
  if (!options.keepInbox) {          // ★ 逃生口:传 true 就跳过 clear(),也就不会读到已注销的投影
    this.inbox.clear();
    …
  }
  if (this.phase.kind !== "idle") this.phase.abort.abort(cause);
}
第 4.5 节 · 调用点分布(全树 10 处 .cancel({)
传了 keepInbox: true(安全)没传(危险支)
dsh-api-session-controller/lib/index.js:998(GUI 的 session/cancel)
dsh-agent-loop/lib/index.js:1906
dsh-subagent-in-process-driver/lib/index.js:200 ← 本 issue 的崩溃点
dsh-agent-loop:1688(但被 try/catch 包住)
dsh-subagent:1218 / 2255、dsh-goal-round-driver:352、dsh-agent:35、dsh-acp:871/954/996、dsh-deepseek-account:22
这说明代码库知道 "inbox 可能不可用"(否则不会设计出 keepInbox), 但只在个别调用点打了补丁,没有修在 cancel()/clear()/current() 里 ⇒ 剩下 8 处继续走危险支。

上游主干疑似已重构掉这条 throw —— 但没随 rc.2 发布

取证席在 src-master/packages/core/agent/src/inbox.ts 读到: :26 把 state 放成构造函数私有字段,:58-61 是同形的 clear(), mutate 直接走 this.state,没有任何 throw 路径; 而 rc.2 的 @deepseek-ai/dsh-agent 里 grep 不到 class Inbox ⇒ 这次重构没有进入 rc.2。
若此读数成立,上面的修法①(让关停期清理幂等)在上游主干里可能已经做过, 本 issue 的价值就集中在:把该重构回移到 0.2.0 线,以及**修掉 driver:200 的无 try 回调** (后者即使重构回移也仍然需要——顶层 fail-loud 依然会把它升级成进程死亡)。
⚠️ 这条是取证席的静态读数,本页作者没有独立复核 master 源码。

为什么这是设计缺陷(三环缺一不死服务)

第 4 节 · 三个必要条件,各自都可以独立修
环现状若改掉
关停期清理**不幂等**current() 对"投影已注销"直接 throw改成 no-op / 返回空 state ⇒ ④ 不再抛,链条断
abort 在 dispose **之后**才跑注销随 ctx dispose;AbortSignal.onAbort 仍在飞调 dispose 次序(先 cancel() 后注销)⇒ ④ 读到有效 state,链条断
顶层把**任何**未捕获异常当致命onException → proc.exit(1),不区分"运行期真异常"与"关停期异常"关停期异常降级为记录 ⇒ 即便 ④ 抛,服务也不死
同仓反证(不是我们的偏好,是同一个仓里的既有做法):本机部署的 ops/dsh/gateway/gateway.mjs:249-258 对同一事件用的是 uncaughtException (kept alive) / unhandledRejection (kept alive) —— 同一个仓里两个组件对"未捕获异常"采取了相反策略。
取证席用探针把"这句是谁打的"钉死了(node v24.16.0): ① 不装 handler 的裸抛 ⇒ stderr 是 Node 原生栈、EXIT=1; ② 装同形 handler ⇒ stderr 打出 fatal uncaught exception: …、EXIT=1。 两条路退出码都是 1,但文本不同 ⇒ 现场那句前缀可判定来自 dsh-app-boot 的 handler,而不是 Node 默认行为。
跨版本证据:稳定通道 app-0.1.5 上也出现了同一个不变式被打破的实例 (读点不同:dsh-subagent:712 hasPending)⇒ 读侧整体无防护,只是触发读点不同, 不是 rc.2 独有的新代码。
可复现性的诚实边界:我们在生产上观测到 6 次,但没有在受控环境里 主动复现过(需要构造"dispose 与 abort 交错"的时序)。上面的链条是静态逐行核出来的, 没有拿到「同一进程内 disposer 先跑、abort 后跑」的运行时证据 —— 那需要一个带跟踪的受控复现, 我们希望上游能据此复现或在内部单测里加一条交错用例。

被证伪的假设(别再走这条路)

我们一度假设「崩溃留下一个半截会话,之后每次启动都试图恢复它并永久等待」。已用 36 席、4,801 个会话文件推翻。

  1. 逐字节相同的副本启动正常:把含该半截会话的 DSH_HOME 原样复制一份,实例照样启动并 3/3 打印启动 URL。
  2. 崩溃后 2 秒就正常启动:某户 23:45:53 崩溃,23:45:55 就起来并打印了 URL。
  3. 那批半截会话从未被 resume 过:产品文档说明 resume 会补写 interruptedTurnClosers,而它们的 closer 从未落盘;也没有任何进程持有它们的 fd/lock。
  4. 半截是常态:子代理会话里 23–27% 本就是"末次 step 未闭合"的形态 ⇒ 半截不能当崩溃指纹。
因此本 issue 与本仓此前的 session/list 议题是两件独立的事:那件是接口不返回,本件是关停期把进程打死。

修法:上游三条 + 我们侧缓解

上游(按推荐度)

  1. ★ 最小可发布的一刀(1 行):dsh-subagent-in-process-driver/lib/index.js:200 的 child.cancel({ kind: "parent" }) 改成 child.cancel({ kind: "parent" }, { keepInbox: true }) (或就地包 try/catch)。理由:逃生口已经存在且被其它调用点用过(见 §4.5), 这一处漏传就是本 issue 的直接触发点;改完 clear() 根本不会被调用,throw 路径断掉, 且不影响语义(关停时本来就要丢弃 pending 输入)。
  2. 让关停期清理幂等:ReactLoopInbox.current()(或 clear())在投影未激活时 应返回空 state / no-op,而不是 throw。语义上「关停时读不到 pending 输入」的正确答案是「没有待处理输入」。
  3. 调整 dispose 次序:保证 ReactLoopAgent.cancel() 在投影注销之前完成(或让 AbortSignal.onAbort 的订阅随同一 effect 一起注销,避免"拆完还回调")。
  4. 区分关停期与运行期异常:dsh-app-boot 的 onException 目前一律 proc.exit(1);建议在关停阶段(已开始 dispose)把未捕获异常降级为**记录 + 继续收尾**, 并保留一个"关停期异常计数"以便观测。参考同仓 gateway.mjs 的 kept-alive 策略。
  5. 回移主干重构:若 src-master 的 inbox.ts 确已去掉 throw 路径(见 §4.6), 把它回移到 0.2.0 线即可同时覆盖 hasPending/locate 等其它读点 (稳定通道 0.1.5 上的同类崩溃就是从 hasPending 进的)。

我们侧(缓解,不冒充修法)

  • 减少关停次数:这个 race 只在关停时发生 ⇒ 每少一次关停就少一次抽签。 本机的关停前置是 user-serve.sh 的 stop-cancel;健康检查自愈与升级会触发它。
  • 让它在机读面可见:该异常目前只出现在 web.log,没有任何判定行/指标 ⇒ 需要一条巡检判据(含恢复侧,避免"告警只进不出")。

复跑命令(只读)

# 1) 逐户 fatal 次数 + 它前一行(看是否紧跟 stop-cancel)
sudo -n -u infra sudo -n grep -nB1 'fatal uncaught exception' /home/<user>/.dsh-alpha/logs/web.log

# 2) 该户 stop-cancel 各 reason 次数(分母)
sudo -n -u infra sudo -n grep -oE 'stop-cancel ok=(yes|no)( reason=[a-z-]+)?' \
  /home/<user>/.dsh-alpha/logs/web.log | sort | uniq -c

# 3) 链条原件(逐行核对,别只看本页)
sudo -n -u infra sudo -n sed -n '174,179p;92,96p;85p;817p' \
  /opt/dsh/app-<version>/node_modules/@deepseek-ai/dsh-agent-loop/lib/index.js
sudo -n -u infra sudo -n sed -n '68,102p;127,131p' \
  /opt/dsh/app-<version>/node_modules/@deepseek-ai/dsh-session-projection/lib/index.js
sudo -n -u infra sudo -n sed -n '3802,3818p' \
  /opt/dsh/app-<version>/node_modules/@deepseek-ai/dsh-app-boot/lib/index.js
sudo -n -u infra sudo -n sed -n '195,205p' \
  /opt/dsh/app-<version>/node_modules/@deepseek-ai/dsh-subagent-in-process-driver/lib/index.js
本页数字的采集时刻:2026-09-30 10:1x +08:00,内核 app-alpha-0.2.0-rc.2(node v24.16.0)。

诚实清单

以下是我们没有证明的,请以上游复核为准。

  1. 没有受控复现:链条是静态逐行核出来的,没有拿到「同一进程内 disposer 先跑、abort 后跑」的运行时时序证据。
  2. 日志栈恰好 10 帧 = Node 缺省 Error.stackTraceLimit ⇒ 无法从日志判定是哪一个 AbortController.abort() 触发的(处置链里的 agent 自有 abort、ownership teardown、profile-boot 的 shutdown 信号三者之一)。"是 dispose 链而非我们的 cancel RPC"是由判词语义(timeout vs cancel-unconfirmed)推出的,不是直接捕获。
  3. report() 里 release === void 0 的早退分支(同样 exit(1))没有逐行确认它在哪种装配下成立。
  4. src-master 的 inbox 重构是取证席的静态读数,本页作者没有独立复核 master 源码;也可能主干另有取舍。
  5. 「6 次 / 3 户」是本机 24 户、本日的现读;不等于全机队或历史总量。
  6. 半截假设的证伪来自 36 席取证(含逐字节副本对照),不是本页作者逐件复核的。
  7. 未验证:stable / master 通道是否同样受影响(同一份内核代码,理应一致,但没测)。