症状
只有当有人“截短”你的输出时崩溃才出现:node emit.js | head -5 报 Error: write EPIPE,堆栈落在 afterWriteDispatched。head 拿够五行就退出,而写入方要等某次异步 write 完成时才发现管道已关闭。终端输出永不触发,因为 tty 读者不会提前退出。错误对象带 code EPIPE 与 syscall write,堆栈指向 process.stdout——一个 Socket 而非普通文件——即确认走的是管道写入路径。
诊断思路
先复现精确的管道形态:head 只取五行而 emit.js 写得更多,后续每次写入都在与已关闭的管道赛跑;再把 stderr 合并观察堆栈位置,确认错误在 stdout 这个 Socket 上;最后 grep 现有代码里是否处理过 EPIPE 或监听过 stdout 的 error 事件——没有监听时,“向已关闭管道写入”的拒绝默认升级为未捕获异常并杀死进程。grep -m1、less -F、断开的日志套接字都是同一模式的不同读者。
根因
下游读者在 Node 进程仍有排队写入时关闭了管道。process.stdout 的写入是异步的,已关闭管道的拒绝会在事件循环的后续轮次以 error 事件(code EPIPE)送达;未挂错误处理器时该事件成为未捕获异常。Windows 上等价的信号路径不同,但最终同样表现为 EPIPE。
修复与验证
把“消费者拿够了”变成显式的干净停机:给 process.stdout 挂 error 处理器,EPIPE 时以退出码 0 结束(这不是失败),其他错误照常抛出;生成器类程序在每次写入前检查 process.stdout.destroyed,读者离开就提前跳出,不再与管道赛跑;stderr 同样要挂处理器,日志管道也会提前关闭。长驻服务写日志套接字时,错误处理器应触发重连而不是退出。验证跑三个经典管道:head、grep -m1、wc -l 都必须退出码 0 且无堆栈;对照用例同样重要——管道接 cat 全量消费时必须一行不少,证明 EPIPE 路径没有截断正常输出;再在 CI 脚本里用 set -o pipefail 断言退出码,让回归当场失败。
代码示例
node emit.js | head -5
Error: write EPIPE
at afterWriteDispatched (node:internal/stream_base_commons:162:15)
process.stdout.on('error', (err) => {
if (err.code === 'EPIPE') {
process.exit(0); // consumer had enough; not a failure
}
throw err;
});
for await (const row of source) {
if (process.stdout.destroyed) break; // reader is gone
write(row);
}