
一个每 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 只在临时压制噪音时有意义,长期挂在生产上等于把故障藏起来。