From 9af6e0f73ad08ce23bb76574e26c33dcd5370640 Mon Sep 17 00:00:00 2001 From: jkooe <62494645+jkooe@users.noreply.github.com> Date: Sat, 17 Oct 2026 21:23:20 +0800 Subject: [PATCH] =?UTF-8?q?fix(agent):=20stdout=20=E6=96=AD=E7=AE=A1?= =?UTF-8?q?=E5=AE=B9=E9=94=99=20=E2=80=94=E2=80=94=20=E4=BF=AE=E3=80=8C?= =?UTF-8?q?=E9=80=80=E5=87=BA=E6=8E=A7=E5=88=B6=E5=8F=B0=E5=90=8E=E8=A2=AB?= =?UTF-8?q?=E6=8E=A7=E7=AB=AF=E5=81=87=E6=AD=BB=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 真机现象(两次踩到,都能复现) 退出/重启控制台后,被控端**端口仍然 OPEN,但所有 RPC 全部超时**(连握手都不回), 持续数分钟不自愈,只能再重开一次控制台。外部看像是「进程活着,但哑了」。 ## 根因 被控端由控制台 spawn 时,stdout/stderr 是**管道**而非终端。控制台一退出 (尤其是本就要支持「保留被控端」这个场景),管道**读端消失**,而 agent 仍在往死管道写日志: - Node 对未监听 'error' 的 stream 会把 `EPIPE` **抛成未捕获异常** → 进程退出; - 写端在无读者时还可能**阻塞**,把事件循环一起卡住 —— 端口保住了(OS 层 socket 还在), 但 JS 再也不执行。这正是「端口 OPEN 却全员超时」的形态。 全仓此前**没有任何** stdout/stderr 错误处理,grep 可证。 ## 做法 - 给 `process.stdout` / `process.stderr` 挂 'error' 监听,吞掉 EPIPE / DESTROYED; - 一旦判定通道已断,**短路后续写**(包一层 console.log / console.error), 不再往死管道里堆字节; - 任何写异常都只置标志、不抛 —— 日志通道故障不该打死常驻服务。 日志不丢:审计走文件(audit.log),控制台在时照常收管道输出。 宁可少一行 stdout,也不能让常驻服务因为「没人看日志」而假死。 ## 与「复用已运行实例」的关系 上一提交做了「下次启动复用孤儿被控端」,但如果孤儿本身已被断管卡死, 复用就只是把一个哑掉的进程认领回来。**这条修复才让复用真正有意义。** --- apps/agent/src/index.ts | 58 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/apps/agent/src/index.ts b/apps/agent/src/index.ts index 2645d4b..ed9cec3 100644 --- a/apps/agent/src/index.ts +++ b/apps/agent/src/index.ts @@ -69,6 +69,64 @@ process.on('unhandledRejection', (reason: unknown) => { ); }); +// --------------------------------------------------------------------------- +// stdout / stderr 断管容错(2026-10-17 真机两次踩到) +// --------------------------------------------------------------------------- +// +// ## 现象 +// 端口仍然 `OPEN`,但**所有** RPC 都超时(握手都不回)。持续数分钟不自愈, +// 只能靠重开控制台恢复。 +// +// ## 根因 +// 被控端由控制台 spawn 时,stdout/stderr 是**管道**而非终端。用户退出控制台 +// (尤其选「保留被控端」)后,管道**读端消失**,而 agent 仍在往这个死管道写日志: +// - Node 对未监听 'error' 的 stream 会把 `EPIPE` **抛成未捕获异常** → 进程退出; +// - 更糟的是写端在无读者时可能**阻塞**,把事件循环一起卡住 —— +// 于是端口保住了(OS 层 socket 还在),但 JS 再也不执行了。 +// 这解释了为什么「退出控制台后被控端还在,却谁都连不上」:它活着,但哑了。 +// +// ## 做法 +// ① 监听 'error' 吞掉 EPIPE,不让它升级成未捕获异常; +// ② 一旦判定通道已断,就**短路后续写**,不再往死管道里堆字节。 +// +// 日志不会因此丢失:审计走文件(`audit.log`),控制台在时照常收到管道输出。 +// 宁可少一行 stdout,也不能让常驻服务因为「没人看日志」而假死。 +let stdoutDead = false; +let stderrDead = false; + +function markDead(e: NodeJS.ErrnoException, isErr: boolean): void { + if (e.code === 'EPIPE' || e.code === 'ERR_STREAM_DESTROYED' || e.code === 'ERR_STREAM_WRITE_AFTER_END') { + if (isErr) stderrDead = true; + else stdoutDead = true; + return; + } + // 其他写错误也一律吞掉:日志通道的故障不该打死被控端 + if (isErr) stderrDead = true; + else stdoutDead = true; +} + +process.stdout.on('error', (e: NodeJS.ErrnoException) => markDead(e, false)); +process.stderr.on('error', (e: NodeJS.ErrnoException) => markDead(e, true)); + +const rawLog = console.log.bind(console); +const rawErr = console.error.bind(console); +console.log = (...a: unknown[]): void => { + if (stdoutDead) return; + try { + rawLog(...a); + } catch { + stdoutDead = true; + } +}; +console.error = (...a: unknown[]): void => { + if (stderrDead) return; + try { + rawErr(...a); + } catch { + stderrDead = true; + } +}; + process.on('uncaughtException', (err: unknown) => { reportProcessError( '未捕获异常',