fix(agent): stdout 断管容错 —— 修「退出控制台后被控端假死」

## 真机现象(两次踩到,都能复现)
退出/重启控制台后,被控端**端口仍然 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,也不能让常驻服务因为「没人看日志」而假死。

## 与「复用已运行实例」的关系
上一提交做了「下次启动复用孤儿被控端」,但如果孤儿本身已被断管卡死,
复用就只是把一个哑掉的进程认领回来。**这条修复才让复用真正有意义。**
This commit is contained in:
jkooe committed 2026-10-17 21:23:20 +08:00
1 parent 00659165b1
commit 9af6e0f73a
1 file changed
+58
+58
View File
@@ -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(
'未捕获异常',