一个 Node.js 命令行程序的『死亡』

故事

这是一个平平无奇的 Node.js 脚本,它帮你在未来的某一时刻做一些事情。

ts 复制代码
setTimeout(() => {
  // Do something in the future
}, 1000000)

随着故事越来越复杂,这个 Node.js 脚本规模越来越大,变成了一个 Node.js 命令行程序。

它会在启动时,做一些初始化,然后在一段时间后做一些事情,并且如果这个 Node.js 进程被突然终止(ctrl + c),你需要做一些清理工作,或者记录下中间状态等等。

ts 复制代码
// to-be.js

// Do something initialize

setTimeout(() => {
  // Do something in the future
}, 1000000)

process.on('SIGINT', () => {
  // Do something cleanup if this process is terminated
})

然而,你发现你刚才运行的这段脚本写错了 / 不想运行了等等,你希望结束它。于是,你疯狂地在 terminal 里按 ctrl + c,但是无事发生。

我们知道在 terminal 里按 ctrl + c 会给当前在前台运行的进程,发送一个 SIGINT 信号去终止它的运行。这里 Node.js 没有正常退出的原因是:Node.js 接收到 SIGINTSIGTERM 这两个信号后的默认处理行为退出当前的进程 ;如果你为这 2 个信号添加了自定义的回调函数 ,将会禁用这个默认行为(Node.js 不会退出)。

详情见 Node.js 文档 Signal events

暴力的 Workaround

然后,你就有了一个暴力的 workaround:

ts 复制代码
// not-to-be.js

setTimeout(() => {
  // Do something in the future
}, 1000000)

process.on('SIGINT', () => {
  // Do something cleanup if this process is terminated
  process.exit()
})

Okay,这确实可以解决很多问题。但是如果你有很多个回调函数,分别在不同的上下文下,做不同的清理工作呢?其中任何一个 process.exit 都会让进程突然暴毙,使得其他的清理回调函数没有执行。另一角度上说,滥用 process.exit 会给你的 Node.js 命令行程序的调试带来困难,有时你可能完全忘记了之前写过的 process.exit,然后你发现你的程序突然终止而抓耳挠腮,不知所措。如果你正在开发一个库,那么你更不能使用它,而是选择抛出异常等更加显式的方式终止。

『优雅』的解决方案?

所以,一个更加 『优雅』的解决方案是:

ts 复制代码
const timer = setTimeout(() => {
  // Do something in the future
}, 1000000)

process.on('SIGINT', () => {
  // Do something cleanup if this process is terminated
  clearTimeout(timer)
})

在接受到 SIGINT 信号后,手动清除之前启动的定时器。此时 Node.js 发现没有任何异步的任务正在 / 等待运行,那么它就会正常结束。这看起来更加 make sense。

但是,也会带来新的困难。随着你的 CLI 应用变得越来越复杂,你启动了更多的异步任务(更多的定时器,建立 TCP 服务器,运行子进程等等)。你可能忘记了哪些东西没有停止,导致上面这种『优雅』的解决方案,似乎有时候有一些不可靠,依赖于你是否给每一个任务都添加了对应的回收回调函数。

Why is node running

这里有一个辅助你 debug 的工具:why-is-node-running,或者它的替代品 why-is-node-still-running

在你的 Node.js 命令行程序的开始,引入的这个库在背后使用了 async_hooks API 监听了所有异步事件。下面是这个库的 demo:

js 复制代码
const log = require('why-is-node-running') // should be your first require
const net = require('net')

function createServer () {
  const server = net.createServer()
  setInterval(function () {}, 1000)
  server.listen(0)
}

createServer()
createServer()

setTimeout(function () {
  log() // logs out active handles that are keeping node running
}, 100)
txt 复制代码
There are 5 handle(s) keeping the process running

# Timeout
/home/maf/dev/node_modules/why-is-node-running/example.js:6  - setInterval(function () {}, 1000)
/home/maf/dev/node_modules/why-is-node-running/example.js:10 - createServer()

# TCPSERVERWRAP
/home/maf/dev/node_modules/why-is-node-running/example.js:7  - server.listen(0)
/home/maf/dev/node_modules/why-is-node-running/example.js:10 - createServer()

# Timeout
/home/maf/dev/node_modules/why-is-node-running/example.js:6  - setInterval(function () {}, 1000)
/home/maf/dev/node_modules/why-is-node-running/example.js:11 - createServer()

# TCPSERVERWRAP
/home/maf/dev/node_modules/why-is-node-running/example.js:7  - server.listen(0)
/home/maf/dev/node_modules/why-is-node-running/example.js:11 - createServer()

# Timeout
/home/maf/dev/node_modules/why-is-node-running/example.js:13 - setTimeout(function () {

@breadc/death

沿用[暴力的 Workaround](#暴力的 Workaround "#%E6%9A%B4%E5%8A%9B%E7%9A%84-Workaround"),但是我们可以搞一个集中的事件总线,用它作为 SIGINT 等终止信号的回调函数,提供更多复杂的回收功能。于是,我自己手搓 @breadc/death 这个库。

ts 复制代码
// 节选自 https://github.com/yjl9903/Breadc/blob/main/packages/death/src/death.ts

const emitter = new EventEmitter();

const handlers = {
  SIGINT: makeHandler('SIGINT')
};

function makeHandler(signal: NodeJS.Signals) {
  return async (signal: NodeJS.Signals) => {
    const listeners = emitter.listeners(signal);

    // Iterate all the listener by reverse
    for (const listener of listeners.reverse()) {
      await listener(signal);
    }

    // Remove listener to restore Node.js default behaviour
    // and avoid infinite loop
    process.removeListener('SIGINT', handlers.SIGINT);
    process.kill(process.pid, context.kill);    
  };
}

export function onDeath(callback: OnDeathCallback): () => void {
  process.on('SIGINT', handlers.SIGINT);
  emitter.addListener('SIGINT', callback);
  return () => {
    emitter.removeListener('SIGINT', callback)
  };
}

可以看到,我们这里用自己创建的 emitter 替换了 Node.js 内置的 process 上的事件总线。在调用自己的 onDeath 注册回调函数时,在本来的 process.on('SIGINT', ...) 注册的是我们自己的回调函数(重复注册同一个函数会保留多个,此处省略了相关处理),然后使用 emitter 自己维护回调函数。

在触发 SIGINT 信号后,我们会拿出一份所有回调函数的数组的拷贝,用逆序运行它们。可以理解为,这些回收资源的回调函数,往往要么顺序无关,要么可能需要按照它们分配的顺序进行回收,因此选择使用逆序。

最后,将我们这里 SIGINT 的回调函数移除,以恢复 Node.js 的默认退出行为,并重新发送一遍接受到的终止信号。

The Death of a Node.js Process

与『你的 Node.js 进程为什么还在运行?』对称的另一个话题是:『你的 Node.js 进程为什么会突然暴毙?』

以下这几种原因会导致 Node.js 的进程异常终止:

操作 例子
手动退出进程 process.exit(1)
未捕获的异常 throw new Error()
未处理的 Promise reject Promise.reject()
被忽略的 error 事件 EventEmitter#emit('error')
未处理的信号 $ kill <PROCESS_ID>

表格引用自 The Death of a Node.js Process。 此博客内容还包含一些 Node.js 如何错误处理的 Tips 可以扩展阅读。

未捕获的异常可以手动在可能有问题的位置 try catch,或者程序入口最顶层进行 try catch,或者你可以监听 uncaughtException 这个事件:

ts 复制代码
process.on('uncaughtException', error => {
  console.error(error)
})

未处理的 Promise reject 可以监听 unhandledRejection 这个事件:

ts 复制代码
process.on('unhandledRejection', error => {
  console.error(error)
})

因此,对 uncaughtExceptionunhandledRejection 这两个事件,SIGINT, SIGTERM, SIGQUIT 三个终止信号注册回调函数,可以帮助你更加 robust 的处理一个 Node.js 命令行程序『临终』时应该做些什么。

相关推荐
前端大卫1 小时前
Vue3 + Element-Plus 自定义虚拟表格滚动实现方案【附源码】
前端
却尘1 小时前
Next.js 请求最佳实践 - vercel 2026一月发布指南
前端·react.js·next.js
ccnocare1 小时前
浅浅看一下设计模式
前端
Lee川1 小时前
🎬 从标签到屏幕:揭秘现代网页构建与适配之道
前端·面试
Ticnix2 小时前
ECharts初始化、销毁、resize 适配组件封装(含完整封装代码)
前端·echarts
纯爱掌门人2 小时前
终焉轮回里,藏着 AI 与人类的答案
前端·人工智能·aigc
twl2 小时前
OpenClaw 深度技术解析
前端
崔庆才丨静觅2 小时前
比官方便宜一半以上!Grok API 申请及使用
前端
星光不问赶路人2 小时前
vue3使用jsx语法详解
前端·vue.js
天蓝色的鱼鱼2 小时前
shadcn/ui,给你一个真正可控的UI组件库
前端