Node 守护进程日志转发踩坑记:stdio 管道、UTF-8 截断,和一个字符串按值传参的故事

给 Node 守护进程加日志转发,结果每行都冒出 undefined?

1. 背景:一个"自己写的小守护进程"

我写了一个零依赖的 Node.js 内网 Web 服务,本地开发靠一个"热重载守护进程"跑:监听服务文件变化自动重启、进程崩溃自动拉起、每 30 秒检查端口兜底。改动代码后刷新页面就能生效,不需要手动杀进程。

骨架代码(分层自愈):

js 复制代码
// 守护进程骨架:mtime 轮询 + 崩溃自动拉起 + 端口自检
function start() {
  child = cp.spawn(NODE, [SERVER], { cwd: DIR, stdio: 'ignore' });
  child.on('exit', function (code) {
    log('server.js 退出 code=' + code);
    setTimeout(start, 2000); // 崩溃后 2 秒拉起
  });
}
setInterval(function () { /* 检测文件变化 → 重启 */ }, 1000);
setInterval(function () { /* 端口不通 → 强制重启 */ }, 30000);

本节要点 :写守护进程时,"服务能不能自己活过来"比"功能本身"更重要。这里埋一个伏笔:stdio: 'ignore'

2. 教训:控制台关闭后的 EPIPE 日志风暴

事故:

  • 现象:关掉启动服务的控制台窗口后,守护进程的 stdout 写不出去,抛 EPIPE;这个错误在流上异步抛,try/catch 抓不到,冒泡成 uncaughtException;如果异常处理器里再 console.log,就会无限自我放大,日志被刷爆、守护失效。
  • 防护代码:
js 复制代码
let stdoutBroken = false;
process.stdout.on('error', () => { stdoutBroken = true; });
process.stderr.on('error', () => { stdoutBroken = true; });

function log(msg) {
  if (!stdoutBroken) {
    try { process.stdout.write(msg + '\n'); } catch (e) { stdoutBroken = true; }
  }
  try { fs.appendFileSync(LOG_FILE, msg + '\n', 'utf8'); } catch (e) {}
}

process.on('uncaughtException', function (e) {
  if (inErrorHandler) return;   // 递归防护
  inErrorHandler = true;
  try { log('uncaughtException: ' + e.message); } catch (x) {}
  inErrorHandler = false;
});

要点 :日志系统第一条铁律------写日志本身不能成为崩溃源。控制台坏了就降级只写文件,异常处理器里绝不能再抛。

3. 改进一:子进程日志转发

痛点:stdio: 'ignore' 意味着业务进程的 console.log 全部丢失 。一旦业务出问题,watch.log 里没有任何线索,只能靠业务日志(而业务日志常常没有)。

方案:

js 复制代码
child = cp.spawn(NODE, [SERVER], { cwd: DIR, stdio: ['ignore', 'pipe', 'pipe'] });

function pipeStream(stream, tag) {
  if (!stream) return;
  var buf = '';
  stream.on('data', function (d) {
    var s = buf + d.toString();
    buf = '';
    var idx;
    while ((idx = s.indexOf('\n')) >= 0) {
      var line = s.slice(0, idx).trim();
      s = s.slice(idx + 1);
      if (line) log(tag + line);       // log() 自带 EPIPE 防护
    }
    if (s.length > 65536) { /* 超长无换行兜底 */ }
    buf = s;
  });
  stream.on('error', function () {});  // 子进程退出后管道 error 忽略
}
pipeStream(child.stdout, '[server] ');
pipeStream(child.stderr, '[server-err] ');

要点

  • 转发要按行缓冲 ,否则 chunk 边界会把一行切成两半(data 事件一次收到的字节数是不确定的)
  • [server] / [server-err] 前缀区分来源
  • 转发进 log() 是安全的------它自带 EPIPE 防护

本节结尾留个悬念 :第一次写完这个函数,跑起来一看------每一行都多了个诡异的 undefined 前缀。

4. 改进二:日志按行截断(UTF-8 安全)

痛点:日志超过阈值后截断,原来的写法是 buf.slice(-256 * 1024)(按字符串/字节切),可能在多字节字符中间半行处切断 → 乱码、可读性差。

方案------保留窗口内找第一个换行,从完整行开始切:

js 复制代码
const st = fs.statSync(LOG_FILE);
if (st.size > 512 * 1024) {
  const buf = fs.readFileSync(LOG_FILE, 'utf8');
  const maxKeep = 256 * 1024;
  let start = buf.length > maxKeep ? buf.length - maxKeep : 0;
  const nl = buf.indexOf('\n', start);
  if (nl >= 0) start = nl + 1;
  fs.writeFileSync(LOG_FILE, buf.slice(start), 'utf8');
}

单测思路:

js 复制代码
// 构造 512KB+ 的日志,每行含中文和 emoji
// 断言:截断后首行完整(以整行开头)、无 U+FFFD 替换符、末尾标记行保留

要点:日志截断的正确语义是"丢旧行",不是"丢半行"。写日志相关的代码,最好顺手写个 UTF-8 边界单测。

5. 意外:每一行都冒出 undefined(重头戏)

现象:

ini 复制代码
[10:03:10] [server] undefined[init] 数据库不可用,降级为文件存储
[10:03:10] [server] undefined[boot] 启动完成
[10:03:10] [server] undefined========================================
[10:03:10] [server] 服务已启动: https://localhost:xxxx

注意规律 :不是每行都有 undefined,而是"每个数据块的第一行"有。这个规律本身就是线索。

6. 排查实录:从"怀疑数据"到"最小复现"

这是全文最值得写的部分,按时间线展开:

6.1 怀疑业务输出 → 直接跑一次服务,抓原始 stdout → 干净。排除业务代码。

6.2 抓真实 chunk → 写个脚本 spawn 同一个服务,打印每段 data 的完整内容 → 干净。排除数据本身。

6.3 最小复现 → 用 node -e 输出几行日志 + 复制转发逻辑 → 干净?! 此时矛盾出现:同样的逻辑,为什么在守护进程里就有 undefined?

6.4 完整复刻 → 把守护进程的 log + 转发 + spawn 原样复制到独立脚本 → 还是干净?! 矛盾加深------逻辑看起来完全一样。

6.5 加调试 → 在真实守护进程里,把收到的原始 chunk 原样写进一个 debug 文件 → chunk 干净,但最终日志里有 undefined 。矛盾被压缩到一个点上:问题出在"写入端"

6.6 顿悟

js 复制代码
pipeStream(child.stdout, '[server] ', outBuf);   // outBuf 是字符串!

outBuf 是字符串,按值传入 函数;函数里访问 state.buf------字符串没有 .buf 属性,得到 undefined。于是:

js 复制代码
var s = state.buf + d.toString();   // undefined + "[init] ..." === "undefined[init] ..."

每个数据块的首行都被拼上了 undefined。修复:改成闭包持有缓冲:

js 复制代码
function pipeStream(stream, tag) {
  var buf = '';          // 闭包变量,每次调用独立
  stream.on('data', function (d) {
    var s = buf + d.toString();
    ...
  });
}

6.7 验证:日志干净、热重载正常、UTF-8 截断单测通过。

总结

  • 排查诡异 bug 的正确姿势是逐层缩小范围:怀疑数据 → 怀疑源 → 最小复现 → 在真实环境打印。每一步都让矛盾更聚焦。
  • 当"看起来完全一样的逻辑"在 A 环境正常、在 B 环境异常时,别急着怀疑魔法------检查参数是不是传错了类型 。字符串按值传参、对象按引用传参,state.buf 这种写法只对对象有效。

结尾

我是一位正在学习java的新手小白,我想把自己的经验记录下来。 这个小守护进程本身没什么了不起,但为了让它"活得久、坏得清楚",我踩的坑和攒的经验,也许能帮你少走几步。如果你也在写 Node 工具链,欢迎交流你的日志处理姿势。


相关推荐
coderCN2 小时前
Nodejs express+knex(ORM框架)
前端·node.js
coderCN2 小时前
Nodejs mysql2+express+yaml
node.js
烬羽2 小时前
我把"时间胶囊"部署上腾讯云宝塔,跨域从根源消失了
nginx·node.js·全栈
东风破_11 小时前
danci 2:创建的单词书到底存在哪里?从 Supabase 一路理解 ORM、Drizzle 和 RLS
数据库·后端·node.js
东风破_12 小时前
danci 项目(三):从 JSON 数据到 AI Coding,真实项目里的数据清洗、Prompt 和工程规范
前端·后端·node.js
东风破_12 小时前
danci 项目(一):从需求到架构,一个单词学习系统为什么会这样设计
前端·后端·node.js
__zRainy__18 小时前
Node系列 · Express:常用的中间件
中间件·node.js·express
时寒的笔记19 小时前
jsvmp04_某yuan宝日志还原rc4
node.js
coderCN20 小时前
Nodejs 第三十四章 数据库(表达式和函数、子查询和连表)
前端·node.js