DEBUG.WIKI
V.PS:让项目靠近用户,亚太、欧洲、北美 KVM VPS
IPPeak:从目标地区访问网页,住宅代理、SEO 监测、广告验证
ZgoCloud:多地区部署,按需选节点;日本、美国、香港、德国

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 入口检查任务完成记录、成功结果、失败与取消路径,再核对退出状态。若外部操作早已提交,只是完成通知丢失,重跑整项任务可能重复操作,应先查外部结果。代码恢复到旧版本也不会撤销已经发生的副作用。