侧边栏壁纸
博主头像
一笑痕

人生若只如初见,
是可喜亦或者是可悲?

  • 累计撰写 129 篇文章
  • 累计收到 7 条评论

一个没接住的 Promise,Node 直接结束了进程

2026-9-26 / 0 评论 / 12 阅读

一个没接住的 Promise,Node 直接结束了进程

一个每 100 毫秒打一次点的进程,跑到第 6 个点的时候调了一次上游,上游超时。之后 stdout 没有第 7 个点,进程在 644 毫秒时退出,退出码 1。整个日志里没有任何一处写着「未处理的拒绝」,只有一段看起来像启动失败的堆栈。

复现用的两个文件

子进程按模式走不同的分支:默认分支调用一个 async 函数但既不 await 也不 catch,handler 分支装一个 process.on("unhandledRejection"),await 分支在调用处接了 catch。父进程负责换启动参数重跑,收集退出码、存活时间与 stderr。

// _ur-child.mjs —— 被观察的进程
const mode = process.argv[2];
let ticks = 0;
if (mode === "handler") {
  process.on("unhandledRejection", (e) => {
    console.log("handler 收到:", e.message);
  });
}
const timer = setInterval(() => {
  ticks++;
  console.log("tick", ticks, "at", Date.now() - t0, "ms");
  if (ticks === 6) {
    if (mode === "await") {
      // 有 await 的路径:错误会被 catch 到
      (async () => {
        try { await Promise.reject(new Error("upstream timeout")); }
        catch (e) { console.log("catch 收到:", e.message); }
      })();
    } else {
      // 孤立拒绝:调用方没有 await,也没有 .catch
      (async () => { throw new Error("upstream timeout"); })();
    }
  }
  if (ticks >= 25) { clearInterval(timer); console.log("自己退出,码 0"); process.exit(0); }
}, 100);
const t0 = Date.now();
// 父进程:换启动参数重跑,收退出码与 stderr
function run(mode, extraArgs = []) {
  return new Promise((resolve) => {
    const t0 = Date.now();
    const p = spawn(process.execPath, [...extraArgs, CHILD, mode], { stdio: ["ignore", "pipe", "pipe"] });
    let out = "", err = "";
    p.stdout.on("data", (d) => (out += d));
    p.stderr.on("data", (d) => (err += d));
    p.on("exit", (code, signal) => resolve({ code, signal, out, err, ms: Date.now() - t0 }));
  });
}

默认跑法:停在第六个点

模式: nohandler (默认)
  最后一行打点: tick 6 at 612 ms
  存活时间: 644 ms
  退出码: 1 信号: null
  stdout 行数: 6
  stderr 第一行: file:///Users/yxh/blog/tools/_ur-child.mjs:21
  stderr 里的报错: Error: upstream timeout
  stderr 末尾: Node.js v26.8.1
  stderr 出现 UnhandledPromiseRejection: false
  stderr 总行数: 11

存活 644 毫秒,退出码 1,第 6 个点之后再没有输出。注意最后两行统计:stderr 一共 11 行,但里面没有出现 UnhandledPromiseRejection 这个字符串。

那 11 行 stderr 长什么样

  --- stderr 全文 ---
  | file:///Users/yxh/blog/tools/_ur-child.mjs:21
  |       (async () => { throw new Error("upstream timeout"); })();
  |                            ^
  |
  | Error: upstream timeout
  |     at file:///Users/yxh/blog/tools/_ur-child.mjs:21:28
  |     at Timeout._onTimeout (file:///Users/yxh/blog/tools/_ur-child.mjs:21:61)
  |     at listOnTimeout (node:internal/timers:685:17)
  |     at process.processTimers (node:internal/timers:618:7)
  |
  | Node.js v26.8.1

堆栈指向的正是那行孤立调用,at Timeout._onTimeout 说明它是在定时器回调里发起的。对运维来说,这段日志的形状跟「进程启动时崩了」几乎没有区别:没有请求上下文,没有任务名,第一行就是文件路径与行号。

同一份代码,三种策略

模式: nohandler (启动参数 --unhandled-rejections=warn)
  最后一行打点: tick 25 at 2548 ms
  存活时间: 2603 ms
  退出码: 0 信号: null
  stdout 行数: 26
  stderr 第一行: (node:43632) UnhandledPromiseRejectionWarning: Error: upstream timeout
  stderr 里的报错: (node:43632) UnhandledPromiseRejectionWarning: Error: upstream timeout
  stderr 末尾: (node:43632) UnhandledPromiseRejectionWarning: Unhandled promise rejection. This error originated either by throwing inside of an async function without a catch block, or by rejecting a promise which was not handled with .catch(). To terminate the node process on unhandled promise rejection, use the CLI flag `--unhandled-rejections=strict` (see https://nodejs.org/api/cli.html#cli_unhandled_rejections_mode). (rejection id: 1)
  stderr 出现 UnhandledPromiseRejection: true
  stderr 总行数: 7

带上 --unhandled-rejections=warn 之后,进程活到第 25 个点、自己退出,退出码 0。stderr 里出现了 UnhandledPromiseRejectionWarning,还顺带告诉了一件事:要终止进程得用 --unhandled-rejections=strict。也就是说默认模式在这台机器上等价于 strict。

模式: nohandler (启动参数 --unhandled-rejections=none)
  最后一行打点: tick 25 at 2546 ms
  存活时间: 2604 ms
  退出码: 0 信号: null
  stdout 行数: 26
  stderr 第一行: (空)
  stderr 里的报错: (无)
  stderr 末尾: (空)
  stderr 出现 UnhandledPromiseRejection: false
  stderr 总行数: 0

--unhandled-rejections=none 这一档最安静:同样的孤立拒绝,stderr 一行都没有,进程照常跑完。故障在监控里是完全隐形的,上游超时那笔调用就这么算了。

同一个拒绝事件,三种策略给出三种结局,代码一个字没改。装 handler 的那一档与 await 那一档都活到 25 个点、退出码 0,区别只在 handler 会在控制台留一行 handler 收到: upstream timeout。

排查顺序

先把排查顺序写下来。第一步确认进程是不是自杀的:退出码 1 加上 stdout 在某个点之后断掉,基本可以排除被 OOM killer 干掉或收到信号。信号那一栏是 null,说明不是被 kill 的。第二步去 stderr 里找错误对象本身,别搜关键词,unhandled 在默认模式下根本不会出现在文本里,搜它只会得到空结果。第三步按堆栈里的 at Timeout._onTimeout、at process.processTimers 这层回到定时器注册处,找到那个没被 await 的调用。

这段堆栈的误导性在于它既没有任务名也没有请求上下文:第一眼容易往启动流程上想,而进程起来之后一直好好的,出事发生在第 600 毫秒,跟启动没有任何关系。要我只挑一个改进点,就是在 handler 里带上当前任务名,让下一次崩掉的日志自己说清楚是谁出的事。

定策略

定时任务这类长驻进程,「带病继续跑」通常比退出更危险:状态可能已经不一致,后续任务还会用着坏掉的内存。队列消费者和后台轮询是同一类角色。给这类进程装 handler 的价值不在于救活它,而在于把上下文写进日志再决定退出:

process.on("unhandledRejection", (reason) => {
  logger.error({ task: currentTaskName, reason }, "unhandled rejection");
  gracefulShutdown(1);          // 让编排层重启,不要带着不确定状态接着跑
});

服务类进程可以更宽松一点,但要保证每个请求处理器都在自己的 try/catch 或框架的错误管道里,让孤立拒绝只出现在真正的 bug 路径上。--unhandled-rejections=none 只在临时压制噪音时有意义,长期挂在生产上等于把故障藏起来。

    🤞 分享