Node.js 还有 pending Promise,进程却退出了
pending Promise本身不会维持Node运行。检查完成生产方、循环等待与资源unref,修正真实任务生命周期,并区分ESM顶层await的退出行为。
脚本已经进入异步函数,也执行到了 await,Promise 却一直 pending,随后 Node 进程结束,后面的日志没有出现。先查是谁负责完成这个 Promise,以及进程里是否还有实际工作。Promise 的等待状态本身不会让 Node 一直运行。
这一点适用于普通异步调用。ESM 顶层 await 的未完成等待还有专门的退出行为,不能把两者混成“pending 时总是以 0 退出”。
await 没有替你启动产生结果的工作
下面是独立的 CommonJS 示意文件 pending.cjs,Promise 没有任何完成路径:
async function main() {
console.log('starting');
await new Promise(() => {});
console.log('finished');
}
main().catch(console.error);
执行到 await 后,main 暂停了,但没有定时器、网络操作等继续推动它完成。在没有其他保持进程活动的工作时,Node 可以退出;继续添加 await 也不会让那个空 executor 自动调用 resolve。
EventEmitter 也容易形成这种情况:代码注册了等待某个事件的回调,却没有启动实际生产事件的任务。一个普通 EventEmitter 对象和它的监听函数不会凭空产生事件。应沿代码检查生产方是否已启动、事件名是否一致、resolve 或 reject 是否能到达,以及取消或失败路径是否漏掉了完成通知。
还要排除循环等待。某个 Promise 的完成需要先结束自己的回调,而回调又 await 同一个 Promise,就无法靠调整进程退出方式解决。
让实际任务拥有完整的生命周期
已有文件、网络或子进程任务时,确保返回的 Promise 对应真实完成事件,并由调用方等待。只把一个不会触发的回调包装成 Promise,或只等待“已经开始”而不是“已经完成”的事件,都不够。
定时任务同样有保持进程活动的规则。默认的 setTimeout、setImmediate 具有引用;调用 unref 后,如果没有其他活动维持事件循环,进程可以在回调前退出。后台维护任务允许这样退出时,这是有意的行为;业务必须完成的任务则要重新检查是否错误使用了 unref。
不要添加一个永久 setInterval 让脚本挂着。这可能掩盖永远不会完成的工作,还使部署和关闭流程更难判断。业务需要超时时,应设置有限期限、明确失败结果,并在任务结束后清理定时器;超时也不等于外部操作自动撤销。
排错时可以在已有脚本里暂时记录 beforeExit:
process.on('beforeExit', (code) => {
console.error('beforeExit', code);
});
beforeExit 表示事件循环已没有额外工作可调度。这段只记录信息;不要在诊断回调里反复创建异步工作,否则观察代码本身会改变退出时机。显式 process.exit 或未捕获异常导致的退出不属于这个事件的触发条件,还需结合退出码与日志判断。
顶层 await 不能照普通 main 的退出码解释
在已核对的 Node.js 24.10.0 行为中,ESM 顶层 await 尚未完成、又没有工作维持进程时,可以产生退出码 13;显式设置退出码等条件也会影响最终值。不要因为普通 main 示例没有报错,就认定所有异步等待退出都是正常完成。
如果故障发生在同步 require 加载异步 ESM 的阶段,检查ERR_REQUIRE_ASYNC_MODULE 与顶层 await。那是模块加载方式的问题,和这里已经进入任务、却没有完成生产方的情形不同。
修正后从原脚本或 CI 入口检查任务完成记录、成功结果、失败与取消路径,再核对退出状态。若外部操作早已提交,只是完成通知丢失,重跑整项任务可能重复操作,应先查外部结果。代码恢复到旧版本也不会撤销已经发生的副作用。


