症状
脚本完成工作、打印最后一行,然后什么都没有——shell 永不返回。CI 步骤在真实工作完成后仍被记为卡死,docker run 挂着不退出,也没有任何错误日志。Node 只在事件循环排空时退出,所以直接问循环它还持有什么:在“工作完成”之后打印 process._getActiveHandles(),每个句柄类型都是一份自白——Timeout 意味着未清理的 interval,Socket 通常属于数据库连接池或 keep-alive agent,ChildProcess 意味着未等待的 spawn。把句柄映射回创建点,生命周期缺口就具体化了。
诊断思路
先在收尾处枚举活跃句柄与活跃请求;再按类型定位所有者:Timers 对应 setInterval、Socket 对应池与 agent、ChildProcess 对应子进程残留;最后 grep 创建点找没有对应关闭点的资源——createPool、createClient、subscribe、setInterval 出现却找不到 end/clear/unref 的地方就是缺口。
根因
Node 在事件循环无事可做时退出。仍被引用的句柄——连接池、未清理的定时器、运行中的子进程——都算“有事可做”,于是脚本工作早已完成,进程却一直活着。
修复与验证
在 main 结束时按依赖顺序显式收尾:停掉消费者、pool.end() 关闭池化套接字、redis.quit()、clearInterval、await worker.terminate(),再让退出码自然传播。必须存在但不应决定生命周期的句柄(心跳、指标上报)用 unref 反转与循环的关系:循环排空时忽略它们。绝不要 unref 一个还有真实工作在途的句柄——写到一半的 socket 被 unref,进程退出时载荷就被截断了。规则是:存活信号可以 unref,在途工作必须 await。验证时用 timeout 10 node job.js 做墙钟护栏,断言远在时限内返回;在退出路径打印句柄列表要求为空(或只含已文档化的 unref 存活句柄);最后验证退出码传播——失败的运行必须非零退出,否则修挂起却吞错误码,等于把可见故障换成静默故障。
代码示例
import { inspect } from 'node:util';
process.on('exit', () => {});
setTimeout(() => {
console.log('work done');
console.log(inspect(process._getActiveHandles(), { depth: 1 }));
// [Timeout, Socket, Socket, ChildProcess]
}, 100);
async function main() {
await runJob();
await consumer.stop();
await pool.end(); // closes pooled sockets
await redis.quit();
clearInterval(metricsTimer);
await worker.terminate();
}
main()
.then(() => process.exitCode = 0)
.catch((err) => { console.error(err); process.exitCode = 1; });
const heartbeat = setInterval(ping, 30_000);
heartbeat.unref(); // loop ignores it when otherwise empty