DeepSeek Harness 宿主 SIGTERM 后静默 exit 0:退出码与标记排查
kill -TERM 之后宿主安静地以 0 退出,这个 0 不是「一切正常」,而是 interrupt(0) 的设计结果。 profile-boot 把 SIGTERM 映射成主动清理再退出,SIGHUP 根本没有注册,warning 级错误只进内存 ring;于是崩溃、退出应用、被信号杀掉这三种事,从外部看完全一样(Discussion #10022)。
DeepSeek Harness 信号语义与缺失的外部证据
很多人以为加监听只是「顺便记一笔」,实际会改变进程对信号的反应。 两条 Node 行为必须先记住:
- 一个信号一旦有监听器,Node 就不再执行它的默认动作。裸写
process.on('SIGHUP', ...)会把「终止」变成「忽略」,宿主不再因挂断退出,看起来像卡住。 - Node 的信号句柄是 unref 的,不会让事件循环保持存活。一个只等信号的脚本,注册完就会立刻退出。
- 因此正确的记录器要写完后移除自己的监听,再对自身重新触发同一个信号,让进程按原来的方式死掉。
- 重新触发失败时,按 shell 的惯例用 129、130、143 表示对应信号,别伪造一个 0。
这三条不只影响 DSH,任何在 Node 里接管信号的插件都适用。区别在于 DSH 的 profile-boot 已经在 apps/cli/src/profile-boot.ts:307-308 把 SIGTERM 接到 interrupt(0),你的监听只是追加,不能改变它的顺序。
排查前先接受一个事实:默认安装下没有任何持久痕迹可查。 讨论里的报告者逐项确认过:
- 崩溃日志:
~/Library/Logs/DeepSeek Harness/与 macOS DiagnosticReports 都没有新条目。预期结果:查不到对应时间的记录。 - stderr:宿主没有把退出原因写到任何持久文件。预期结果:重定向日志里只有正常输出,没有
exiting: SIGTERM之类的行。 - 退出码:
interrupt(0)的宽限期是 5 秒(PROCESS_SHUTDOWN_TIMEOUT_MS),超时后强制退出,码仍是 0。预期结果:正常退出与被杀无法区分。 - 桌面壳行为:Electron 主进程在退出应用和 IPC 异常时都会给宿主子进程发 SIGTERM,所以每次退出都会走这条路径。预期结果:无法用「有没有 SIGTERM」判断是不是用户操作。
这也是为什么报告者的自动守护脚本在重启后消失,却没有任何记录能说明它死于什么。
DeepSeek Harness 一分钟对照实验:正常退出与信号退出
做一次对照,比读十段源码更快确认「退出码不可用」。 四步:
- 从桌面壳正常退出应用,记下时间并查宿主进程的退出码。预期结果:exit 0,日志目录没有新增。
- 重新启动后手动
kill -TERM <pid>,同样记录。预期结果:仍是 exit 0,仍然没有新增日志。 - 对比两次的时间戳、日志与 DiagnosticReports。预期结果:找不出任何可区分的信息。
- 再用
kill -KILL <pid>试一次。预期结果:shell 报告 137,这是唯一有区别的一次,但它说明不了被杀之前发生了什么。
第 1 步与第 2 步结果相同,就是「退出码不可用」最直接的证据。
DSH插件路径一:安装 host-exit-marker
这是改动最小的一条路,装完就有一份可读的退出账本;插件页和版本声明可以在 DSH Plugin Hub 核对。 步骤:
- 执行
dsh plugin add @argszero/dsh-host-exit-marker。预期结果:插件安装成功,~/.dsh/logs/下出现host-exit.log。 - 重启宿主,确认启动行已写入。预期结果:看到形如
start pid=… host=… node=… startedAt=…的一行。 - 手动
kill -TERM <pid>。预期结果:日志追加signal signal=SIGTERM pid=… uptime=… others=…,进程随后按原路径退出。 - 强制
kill -KILL <pid>后重启。预期结果:下一次启动补写died-without-exit,并用lastAlive把死亡时间定位到一个心跳周期内。
它用 prependListener 保证记录先于 profile-boot 的清理执行,并且不改变退出状态、关闭路径或其它监听器。据讨论,该插件 0.1.0 已发布到 npm,源码仓库可查。
DeepSeek Harness 路径二:自己注册信号并写文件
适合不想引入第三方插件、或需要把记录写到自有日志系统的人。 关键不是「加监听」,而是「加完还要还原」:
- 在插件启动时用
process.prependListener注册 SIGTERM 与 SIGHUP。预期结果:两个信号都能被你的处理函数先看到。 - 在处理函数里同步追加一行到
~/.dsh/logs/host-exit.log,带上时间、pid、信号名与 uptime。预期结果:即使进程马上退出,这一行也已经落盘。 - 写完移除自己的监听器,再
process.kill(process.pid, signal)重新触发。预期结果:进程按原默认动作退出,而不是被你的监听器「留住」。 - 用一个子进程测试 SIGHUP。预期结果:子进程确实因挂断而死,同时日志里有对应记录,证明没有把终止变成忽略。
不要只写 process.on 就结束:那会把 SIGHUP 变成忽略,宿主从此不怕挂断,问题只会更难查。
DeepSeek Harness 路径三:外层 supervisor 记录退出码
适合宿主由 systemd、launchd 或自研守护进程拉起的环境,不需要改 DSH。 做法:
- 把宿主作为前台子进程交给 supervisor 管理,不要让壳自己吞掉状态。预期结果:supervisor 能拿到真实的 wait status。
- 在 supervisor 里记录每次退出的时间、退出码与信号。预期结果:至少能区分「exit 0」与「signal 15」。
- 对异常退出做重启并计数。预期结果:短时间内反复重启会触发告警,而不是静默循环。
- 同时保留一份应用侧日志路径。预期结果:supervisor 记录「怎么死的」,应用日志记录「死前在做什么」。
supervisor 的局限很明确:面对 interrupt(0),它看到的仍然是 exit 0,只能证明进程结束了,证明不了原因。它和路径一互补,不能互相替代。
DeepSeek Harness 这套方案覆盖不到的地方
退出记录只解决「有没有发生」,不解决「为什么发生」。 四点边界:
- warning 级插件错误仍然只在 64 KB 内存 ring 里,插件读不到,需要宿主把 warning 镜像到滚动文件。
SIGKILL、原生崩溃、整机断电不会执行任何处理函数,只能靠心跳记录在下次启动时补写。- 记录器不能把退出变「响」,stderr 归宿主所有。
- 退出码不会因为装了插件而变化,别指望用它区分正常退出与信号。
排查顺序建议固定下来:先看 host-exit.log 有没有 signal 行,再看有没有 died-without-exit,最后才去翻应用日志。三条都没有,才说明问题在更外层。
| 路线 | 能看到什么 | 看不到什么 |
|---|---|---|
| host-exit-marker | 信号名、uptime、无处理函数的死亡 | 退出码、warning ring |
| 自写信号处理 | 自定义字段与落盘位置 | 原生崩溃、SIGKILL |
| 外层 supervisor | 退出码与信号、重启次数 | 宿主内部原因 |
| 三者叠加 | 原因、次数与时间线 | 内核级崩溃细节 |
三条路可以叠加:插件给原因,supervisor 给次数,两者合起来才能把「又没了」变成可追溯的时间线。
来源:Discussion #10022、argszero/dsh-host-exit-marker。其它启动与退出排查入口见 DSH 故障排查。
常见问题
从 DeepSeek Harness 的退出码判断不了。profile-boot 把 SIGTERM 映射成 interrupt(0),清理超时后主动 exit 0,所以正常退出和被信号终止在构造上完全一致。唯一可靠的办法是在进程离开前写一条持久记录,比如装 host-exit-marker 后看 ~/.dsh/logs/host-exit.log。
DeepSeek Harness 会卡住,是因为 Node 在某个信号有监听器时会停止执行它的默认动作。裸的 process.on('SIGHUP') 会把「终止」变成「忽略」,宿主于是不再因为挂断而退出,外部看到的就是一个像卡住的进程。正确的做法是写完记录后移除监听并重新对自身触发该信号。
DeepSeek Harness 插件的信号句柄是 unref 的,不会让事件循环保持存活。如果一个脚本唯一的待办就是等信号,它会在注册完之后立即退出。写文件的信号记录器需要在写完后重新抛出信号,或显式维持存活,不能只挂一个监听就结束。
DeepSeek Harness 的 host-exit-marker 不能解决 warning 级错误丢失的问题,它只负责在进程退出前后落一条持久记录。warning 级日志属于宿主的日志面,插件读不到也改不了。那一半需要宿主把 warning 镜像到 ~/.dsh/logs 下的滚动文件。
相关术语
- interrupt(0)
- interrupt(0) 是 profile-boot 收到 SIGTERM 后调用的退出路径,先执行 dispose,超出宽限期后主动以退出码 0 结束进程,因此信号退出与正常退出无法从退出码区分。— https://github.com/deepseek-ai/deepseek-harness/discussions/10022
- host-exit-marker
- host-exit-marker 是社区插件 @argszero/dsh-host-exit-marker,在 ~/.dsh/logs/host-exit.log 里按事件追加 start、signal、died-without-exit 三类记录。— https://github.com/argszero/dsh-host-exit-marker
- died-without-exit
- died-without-exit 是 host-exit-marker 在下一次启动时补写的记录,用心跳文件的最后存活时间标记那些没有机会执行任何处理函数的死亡。— https://github.com/argszero/dsh-host-exit-marker
- warning ring(64 KB 内存环)
- warning ring 是桌面宿主主进程用来暂存 warning 级插件错误的固定大小内存缓冲区,进程退出即丢失,不会写入磁盘。— https://github.com/deepseek-ai/deepseek-harness/discussions/10022
来源
- deepseek-harness Discussion #10022:Host-process observability,silent SIGTERM/SIGHUP exit with no persistent marker· deepseek-ai(GitHub Discussions)
- argszero/dsh-host-exit-marker 源码仓库· GitHub(argszero)