DeepSeek Harness Desktop 报 read ECONNRESET?守卫插件与 supervisor
这不是网络问题,是 DeepSeek Harness 的管道错误没人接。 host 子进程因未捕获的 read ECONNRESET 按 fail-loud 策略退出,Electron 壳既不会重启它,也没有在界面上把失败说清楚,于是表现为「窗口还在、端口全断」(来源)。
DeepSeek Harness Desktop host 退出的三个信号
判定三件事同时成立就够了:host 日志里有 fatal uncaught exception、127.0.0.1 端口连不上、Electron 主进程仍在。
- 打开 Desktop 日志目录(Windows 在
%APPDATA%下的 dsh-desktop logs),找最新的crash-*.log。预期能看到dsh: fatal uncaught exception: Error: read ECONNRESET,堆栈里带WriteWrap.onWriteComplete与ChildProcess._handle.onexit。 - 在终端里请求本地端口。预期连接被拒绝,而不是超时。
- 看任务管理器或活动监视器:Electron 主进程还在,dsh host 子进程不见了。预期符合「壳活、后端死」。
- 看界面有没有弹出原生恢复对话框。预期多数情况下没有——启动期失败才会走
fatal帧那条路,运行期未捕获异常不走。 - 确认发生时机:通常在正常使用中、有活跃会话或插件运行时,不是空闲挂机。预期这就是「偶发但反复」的来源。
如果窗口直接消失、连日志都没有,先看 DeepSeek Harness host 静默退出排查。
DeepSeek Harness Desktop host 为什么退出
一句话:管道错误没人接,就会按 fail-loud 退出;而 Desktop 的壳既不会重启它,也不会把失败说清楚。
为什么不能在 fatal handler 里兜
installFailLoud 是故意装的:未捕获异常意味着抛错那一刻的状态不可信,继续跑等于把崩溃换成错误输出。要修的是「异常根本不该到这一层」。
同一棵树里已经有正确做法:某实验性运行时会给子进程的 stdio 挂一个空 error 监听,注释写明「子进程早死时管道会发 ECONNRESET/EPIPE,吞掉它们,权威失败信号是 close 事件」。也就是说,仓库里已经承认这类错误应该被就地消费,只是子进程层的 stdout/stderr 收集器没跟上(据讨论,社区核对源码)。
所以方案的方向是:别改退出策略,改监听的存活时间。
根因:监听被 settle 移走,start() 不再拉起新 child
两处叠加:observeOutputStream 在首次 end/close/error 后移除全部三个监听,此后管道零监听;而 Desktop 的 start() 对已存在的 child 直接返回,永久 no-op。
- 第一处:
observeOutputStream注册了once('end')、once('close')、once('error'),settle在第一个事件到达时把三者一起移除。普通退出会先触发 end/close,于是从那一刻起管道没有任何 error 监听。 - 第二处:错误恰恰在子进程退出之后才完成,堆栈里的
ChildProcess._handle.onexit就是证据。此时 error 事件无人接收,抛到进程级。 - 第三处:
apps/desktop的start()只在没有 child 时创建;child 已存在就直接返回原 promise。host 死后没有任何路径创建替代进程。 - 结果就是:一次管道错误 → 进程退出 → 服务全断 → 只能手动重启应用。
路径一:给 DeepSeek Harness 装 stdio-error-guard 插件
这是改动最小的一条:插件包住 subprocess 服务,给每个 child 的 stdio 留一个常驻 error 监听,错误就地消费,退出策略一点不动。 它只是 DSH插件 生态里的一种补丁做法,接管不了宿主自己的退出策略。
- 安装插件:
dsh plugin add @argszero/dsh-stdio-error-guard。预期插件出现在已安装列表里。 - 确认它注入的是
subprocess服务(插件配置里inject含该项)。预期宿主加载后没有 pending 行。 - 重启 Desktop,正常使用一段时间。预期不再出现
fatal uncaught exception: Error: read ECONNRESET。 - 人为制造一次子进程早死:跑一个立刻退出的命令,再触发一次输出读取。预期管道错误被吞掉,host 不掉线。
- 记住它的边界:它只消费错误,不负责让失败变响、也不重启已经死掉的 host。预期你不会在崩溃后指望它把服务拉回来。
- 如果插件装好后宿主启动报注入缺失,先确认当前版本的 subprocess 服务名仍存在,再决定重装还是等插件更新。预期能分清是插件过期还是装错了包。
路径二:自己在 DeepSeek Harness 里保留常驻 error 监听
如果你不想引入第三方包,就在自己的 subprocess 包装层里做同一件事:每个 child 的 stdout/stderr 上挂一个 on('error'),并保证它在整个生命周期内不被移除。
- 拿到 subprocess 服务返回的 child 对象后,立刻给
stdout、stderr(以及需要时的 stdin)各挂一个监听。预期监听数量在创建后即为 1。 - 监听体只做记录,不要 throw,也不要在里面做重活。预期 error 不再升级为未捕获异常。
- 用一次性监听会重蹈覆辙——
once触发一次就没了。必须用on,并确认没有其他代码在 child 关闭时把它移除。 - 权威失败信号仍然看
child.on('close'),退出码与已捕获输出照旧判定。预期不会因吞掉管道错误而漏报失败。 - 在 child 关闭后打印
stdout.listenerCount('error')做自检。预期仍为 1,而不是 0。 - 用一段会立刻退出的命令验证:先关闭子进程再触发写入,预期宿主继续运行。
- 在日志里给被吞掉的错误留一行 trace(进程 id、错误码、时间)。预期既不升级异常,也不会把问题埋到完全看不见。
路径三:外层 supervisor 重启 DeepSeek Harness host
前两条只能降低崩溃概率,不能保证 100% 不死;要解决「死了没人管」,就得在 host 外面放一个守护者,按退避策略重启并暴露状态。 只想在界面里手动重启、不写守护进程的话,可以装 dsh-process-panel,但它不解决监听被移除的根因。
- 写一个 supervisor 进程,负责启动 host 子进程并监听它的
exit。预期 host 退出时 supervisor 能拿到退出码与最后一段 stderr。 - 设置重启退避(例如指数退避)与最大重启次数。预期连续崩溃不会变成高频重启风暴。
- 重启前清理旧监听端口,避免新 host 因端口占用起不来。预期重启后端口立即可用。
- 把每次重启写入一个持久日志文件(时间、退出码、最后 64 KiB stderr)。预期事后能区分「崩溃」与「正常退出」。
- 在 UI 或外部探针上暴露 host 状态。预期用户能看到「后端正在重启」,而不是对着假活窗口猜。
- 压测:人为 kill 掉 host,预期 supervisor 在退避窗口内拉起,端口恢复,mobile-relay 也跟着恢复。
DeepSeek Harness Desktop 守卫的验证与取舍
别只看「这次没崩」,要直接量管道上还有没有监听者——这是唯一能区分「运气好」与「真的修了」的证据。
- 在 child 关闭后打印
stdout.listenerCount('error')。预期为 1。 - 手动
stdout.emit('error', new Error('ECONNRESET'))。预期进程不退出。 - 对比未装守卫的情况:同一操作会让 host 以非零码退出。预期差异明确。
- 跑一次 1-2 小时的常规使用,再查日志。预期没有新的 fatal 记录。
先决定你要的是别崩还是能回来
三条路线解决的不是同一个问题:前两条降低崩溃概率,第三条保证崩了也能恢复服务,预算和风险都不一样。
| 路径 | 目标 | 适用场景 | 代价 |
|---|---|---|---|
| 插件守卫 | 别崩 | 个人机器、无法改宿主 | 只挡升级,不修根因 |
| 常驻监听 | 别崩(自己维护) | 有自己的 subprocess 包装层 | 要保证监听不被别处移除 |
| supervisor | 崩了能回来 | 无人值守节点、长期运行 | 会中断会话,需要退避 |
这套方案不承诺什么
- 不要在 fatal handler 里加 try/catch 续跑,那是与设计对抗。
- 插件只挡升级,不修根因;上游的正确修法是在子进程层保留常驻监听。
- supervisor 重启会中断会话,必须配合退避,否则故障时体验更差。
- 只有启动期的失败才会给壳发
fatal帧,运行期崩溃不会弹窗,别把「没弹窗」当成「没崩」。 - 以上位置与机制来自讨论中的社区核对源码与实测,上游未修,升级后需重新确认。
- 错误也可能出现在写方向(
write ECONNRESET),处理方式完全一致,不要另写一套监听。
常见问题
DeepSeek Harness 的 host 子进程死了,Electron 主进程没死。stdio 管道的 error 事件在监听被移除后落到 Node 的未捕获路径,fail-loud 处理器按设计退出;壳这边没有 respawn,所以只剩一个连不上后端的窗口。
因为 DeepSeek Harness 的 fail-loud 是刻意设计的失败策略:未捕获异常意味着抛错点的状态不可信,续跑会把可见崩溃变成静默错误输出。正确位置在更下面一层——让管道 error 始终有监听者,不让它升级到进程级。
它能阻止 DeepSeek Harness 的 ECONNRESET 升级成致命异常,让 host 继续活着,因此端口不会断。它不能重建已经死掉的 host,也不能把失败写进 stderr 之外的持久日志,那部分要靠外层守护进程。
DeepSeek Harness 的 supervisor 重启会中断正在跑的会话与工具调用,且如果崩溃是确定性的,会变成反复重启。适合无人值守节点配合退避与重启次数上限使用,不能替代修掉未捕获异常的来源。
在 DeepSeek Harness 的 child 关闭后打印 stdout.listenerCount('error'),正常情况下应该是 1 而不是 0。为 0 说明用了 once 或者被 settle 一并移除,需要改成进程生命周期内的 on 监听。
相关术语
- fail-loud
- fail-loud 是 DSH 对未捕获异常的既有策略:记录诊断、释放资源、以非零码退出,绝不尝试恢复失败的操作。它由 installFailLoud 安装,属于有意设计而非缺陷。— https://github.com/deepseek-ai/deepseek-harness/discussions/9904
- ECONNRESET
- ECONNRESET 表示连接被对端重置,在子进程场景里常见于子进程已退出、管道写入或读取才完成。它本身是普通 I/O 错误,但若没有监听者就会变成未捕获异常。— https://nodejs.org/api/errors.html#common-system-errors
- observeOutputStream
- observeOutputStream 是子进程层给 stdout/stderr 挂一次性监听的函数,分别监听 end、close、error。三者中任意一个先触发,settle 会把三个监听全部移除。— https://github.com/deepseek-ai/deepseek-harness/discussions/9904
来源
- Desktop host 进程因未捕获的 ECONNRESET 异常退出后 web 服务不自恢复· deepseek-ai/deepseek-harness Discussions
- @argszero/dsh-stdio-error-guard· argszero