DSH 上游 issue #9 草案 · 2026-09-30
关停时 ctx 被 dispose,把 inbox 投影的注册从
registrations 里删掉;而**仍在飞的** AbortSignal.onAbort 随后走
cancel → clear → current(),读到一个 undefined 就 throw;
dsh-app-boot 的顶层 uncaughtException 又**一律** process.exit(1)。
⇒ 一次**关停期**的清理异常 = 整个服务死亡、用户页面断开。今天实测复现 6 次 / 3 户,
每次换 UUID(时序竞态,与"某个坏会话"无关)。
| 项 | 内容 |
|---|---|
| 症状 | 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 次(今天现读);不是某户特有问题 |
| 优先级 | 高 关停是**每天都会发生**的常规动作(升级、改配置、健康检查自愈),每次关停都是一次抽签 |
每一环都是从 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 才是缺陷所在。全部取自生产 web.log,每次都紧跟一条关停前置的 stop-cancel … reason=timeout。
| 户 | 次数 | 被点名的 session UUID(各不相同) |
|---|---|---|
| zyj2 | 3 | 80be3f1a-…(09-29 23:45:53)、d9d75543-…(09-30 07:11)、f48b7300-…(09-30 08:26) |
| mch1 | 2 | 6347a3a5-…(09-30 00:38:22)、8b64ff5a-7fe7-4be2-a84e-06de983b87af(09-30 06:48:18) |
| mch8 | 1 | 0939450d-…(09-30 07:46:28) |
这一节是对本页初版的更正:初版把两者写成"timeout 先发生、崩溃紧随",容易被读成因果。
| 判词 | 含义 | 与 fatal 的关系 |
|---|---|---|
reason=timeout | 预算(1.5 s)在 GET /?token= 或首个 session/list 上就用尽了 —— session/cancel 的 rejection 被 () => null 吞掉、轮询错误被 break 吞掉,都只会产出 cancel-unconfirmed ⇒ 超时 = 我们的 cancel 根本没发出去 | 6/6 次 fatal 的前一行都是它 |
reason=cancel-unconfirmed | cancel 确实送达了,但没在预算内 settle | 实测 5 次,0 次崩溃 |
SIGTERM → interrupt(0) → fiber.dispose() → dsh-agent-loop:1678 abort.abort("disposed")
→ 同步派发到仍挂着的 driver:200 onAbort → child.cancel({kind:"parent"})(不带 keepInbox)
→ clear() → current() → throw。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);
}
传了 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 |
keepInbox),
但只在个别调用点打了补丁,没有修在 cancel()/clear()/current() 里 ⇒
剩下 8 处继续走危险支。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。driver:200 的无 try 回调**
(后者即使重构回移也仍然需要——顶层 fail-loud 依然会把它升级成进程死亡)。| 环 | 现状 | 若改掉 |
|---|---|---|
| 关停期清理**不幂等** | 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) ——
同一个仓里两个组件对"未捕获异常"采取了相反策略。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 独有的新代码。我们一度假设「崩溃留下一个半截会话,之后每次启动都试图恢复它并永久等待」。已用 36 席、4,801 个会话文件推翻。
DSH_HOME 原样复制一份,实例照样启动并 3/3 打印启动 URL。interruptedTurnClosers,而它们的 closer 从未落盘;也没有任何进程持有它们的 fd/lock。session/list 议题是两件独立的事:那件是接口不返回,本件是关停期把进程打死。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 输入)。ReactLoopInbox.current()(或 clear())在投影未激活时
应返回空 state / no-op,而不是 throw。语义上「关停时读不到 pending 输入」的正确答案是「没有待处理输入」。ReactLoopAgent.cancel() 在投影注销之前完成(或让
AbortSignal.onAbort 的订阅随同一 effect 一起注销,避免"拆完还回调")。dsh-app-boot 的 onException 目前一律
proc.exit(1);建议在关停阶段(已开始 dispose)把未捕获异常降级为**记录 + 继续收尾**,
并保留一个"关停期异常计数"以便观测。参考同仓 gateway.mjs 的 kept-alive 策略。src-master 的 inbox.ts 确已去掉 throw 路径(见 §4.6),
把它回移到 0.2.0 线即可同时覆盖 hasPending/locate 等其它读点
(稳定通道 0.1.5 上的同类崩溃就是从 hasPending 进的)。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
app-alpha-0.2.0-rc.2(node v24.16.0)。以下是我们没有证明的,请以上游复核为准。
Error.stackTraceLimit ⇒ 无法从日志判定是哪一个 AbortController.abort() 触发的(处置链里的 agent 自有 abort、ownership teardown、profile-boot 的 shutdown 信号三者之一)。"是 dispose 链而非我们的 cancel RPC"是由判词语义(timeout vs cancel-unconfirmed)推出的,不是直接捕获。report() 里 release === void 0 的早退分支(同样 exit(1))没有逐行确认它在哪种装配下成立。src-master 的 inbox 重构是取证席的静态读数,本页作者没有独立复核 master 源码;也可能主干另有取舍。