症状
错误对“罪行”的描述非常精确,而罪行永远是一个顺序问题:ERR_STREAM_WRITE_AFTER_END 表示 end 已经终结了流,之后又来了写入。包装 res 的 write/end 方法打上时间戳日志,同一请求出现 END 先于 WRITE 就是完整诊断。常见嫌疑人:回调在成功和迟到错误时各执行一次、错误中间件响应后原处理器继续写、重试包装器重新执行了捕获旧 res 的闭包、缓存响应与正常流程竞态双双 end。一个请求同时产生 500 和看似正常的响应、或留下挂死套接字,都是双写的外显。
诊断思路
先包装响应方法拿到带时间戳的调用序列;再 grep 缺少 return 前缀的 callback 调用——未 return 的回调调用会落到下一行或 catch 块里的第二次调用;最后用延迟报错的依赖构造确定性复现。双路径共享响应是本质:成功与错误、原始与重试、缓存与网络,第二到达者注定写入已被终结的流。
根因
可写流在 end 完成后收到了 write 调用。HTTP 处理器里这几乎总意味着两条代码路径都认为自己拥有响应——第一条完成后流已终结,第二条再写入即触发错误。
修复与验证
让响应所有权单一且显式:每个写入点先检查 res.writableEnded 或 responded 标志,已终结则记录弃写日志(含状态码与内容预览)并返回;委托错误中间件后立即 return 阻断穿透;回调用 once 包装器在源头做成单次触发,让双重调用在结构上不可能。验证时构造两类触发场景:依赖在成功响应 2 秒后报错、重试重新执行持有旧 res 的处理器。修复后每请求日志必须恰好一个 END、零 ERR_STREAM_WRITE_AFTER_END、且有一条点名迟到写者的弃写警告;最后做套接字层检查——测试批次结束后 ESTAB 连接数回到基线,证明双终结的响应没有留下半开连接。
代码示例
Error [ERR_STREAM_WRITE_AFTER_END]: write after end
at _write (node:internal/streams/Writable:355:11)
const origEnd = res.end.bind(res);
res.end = (...args) => { console.log('END', Date.now()); return origEnd(...args); };
const origWrite = res.write.bind(res);
res.write = (...args) => { console.log('WRITE', Date.now()); return origWrite(...args); };
function sendOnce(res) {
let used = false;
return (status, body) => {
if (used || res.writableEnded) {
log.warn('discarded late write', { status, preview: String(body).slice(0, 200) });
return;
}
used = true;
res.status(status).json(body);
};
}
function once(fn) {
let called = false;
return (...args) => { if (called) return; called = true; fn(...args); };
}
doWork(input, once((err, data) => {
if (err) return next(err); // return stops the fall-through
send(data);
}));