likes
comments
collection
share

一种Node.js进程因为异步资源卡死的简单排查方案

作者站长头像
站长
· 阅读数 28

背景

前端开发日常的工作离不开Node.js的工具生态,我们时时刻刻都在使用nodejs,不可避免地,我们偶尔会碰到一些nodejs相关的问题,比如自动化测试跑着跑着,突然进程卡死了怎么办?比如使用webpack打包时,包打出来了,但是进程没退出怎么办?进程不退出,查看控制台也没有任何报错,会让许多人不知道从哪个地方排查调试。本文就简单地讲一种异步资源一直在运行,阻止nodejs进程结束,nodejs进程卡死、不退出的排查思路。

前置知识

async_hooks

async_hooks是nodejs的一个内置模块,现在最新的v19文档里仍然描述它是一个实验性模块,即使它从8.x从开始内置了。官方虽然不建议使用,但是我们只是用来排查调试async_hooks,不在生产中落地,我们还是可以使用它的。

一种Node.js进程因为异步资源卡死的简单排查方案

所以,async_hooks是什么呢? async_hooks提供了一系列钩子、api,用来追踪nodejs中异步资源生命周期的不同阶段。异步资源可以理解为有回调的对象,包括但不限于 Promise、Timeout、TCPWrap。

import { createHook } from 'node:async_hooks';

const asyncHook = createHook({
  // 异步资源初始化时调用
  init(asyncId, type, triggerAsyncId, resource) { },
  // 回调执行之前
  before(asyncId) { },
  // 回调执行之后
  after(asyncId) { },
  // 异步资源被销毁
  destroy(asyncId) { },
  // 当resolve传递给Promise构造函数的函数被调用时调用
  promiseResolved(asyncId) { },
});

// 通过enable方法启用
asyncHook.enable()
// 通过disable方法关闭
asyncHook.disable()

方案实现

我们可以创建一个空的Map

当异步资源初始化时,我们手动构造一个Error,解析Error堆栈的信息,

将Error堆栈的信息和异步资源的信息收集起来,保存到该map中,

当异步资源被销毁时,从该map中移除对应异步资源的信息。

const asyncHooks = require('async_hooks')
const ErrorStackParser = require('error-stack-parser')

const activeMap = new Map()
const hook = asyncHooks.createHook({
  // asyncId为异步资源id、type为异步资源类型、
  // triggerAsyncId为异步资源创建上下文id、resource异步资源信息
  init (asyncId, type, triggerAsyncId, resource) {
    if (type === 'TIMERWRAP' || type === 'PROMISE') return
    if (type === 'PerformanceObserver' || type === 'RANDOMBYTESREQUEST') return
    // 手动构造一个Error,从错误信息中解析提取我们想要的调用栈信息
    const err = new Error('whatevs')
    const stacks = ErrorStackParser.parse(err)
    activeMap.set(asyncId, { type, stacks, resource })
  },
  destroy (asyncId) {
    // 异步资源被销毁时,我们也删掉该异步资源的信息
    activeMap.delete(asyncId)
  }
})

hook.enable()

接下来写一个方法,用来输出打印map中收集到的异步资源信息。

stacks为error-stack-parser解析后的stackFrames数组(具体的可以查看其ts类型信息),

win平台和unix平台分隔符不一致,我们可以通过path.sep获取到分隔符。对stacks进行过滤,过滤条件是只要有文件名、分隔符和internal的。

const path = require('path')
const sep = path.sep

const stacks = o.stacks.slice(1).filter(function (s) {
    const filename = s.getFileName()
    return filename && filename.indexOf(sep) > -1 && filename.indexOf('internal' + sep) !== 0
})

通过getFileName方法和getLineNumber方法可以获取文件路径和行号,我们还可以尝试读取该文件该目标行,将该行的代码打印出来。

const fs = require('fs')

stacks.forEach(function (s) {
    const prefix = s.getFileName() + ':' + s.getLineNumber()
    try {
        const src = fs.readFileSync(s.getFileName(), 'utf-8').split(/\n|\r\n/)
        const code = src[s.getLineNumber() - 1].trim()
        logger.error(prefix + padding.slice(prefix.length) + ' - ' + code)
    } catch (e) {
        logger.error(prefix + padding.slice(prefix.length))
    }
})

完整的代码

const asyncHooks = require('async_hooks')
const ErrorStackParser = require('error-stack-parser')
const path = require('path')
const fs = require('fs')
const sep = path.sep

const activeMap = new Map()
const hook = asyncHooks.createHook({
  init (asyncId, type, triggerAsyncId, resource) {
    if (type === 'TIMERWRAP' || type === 'PROMISE') return
    if (type === 'PerformanceObserver' || type === 'RANDOMBYTESREQUEST') return
    const err = new Error('whatevs')
    const stacks = ErrorStackParser.parse(err)

    activeMap.set(asyncId, { type, stacks, resource })
  },
  destroy (asyncId) {
    activeMap.delete(asyncId)
  }
})

hook.enable()

module.exports = whyIsNodeRunning

function whyIsNodeRunning (logger) {
  if (!logger) logger = console

  hook.disable()
  var activeResources = [...activeMap.values()].filter(function(r) {
    if (
      typeof r.resource.hasRef === 'function'
      && !r.resource.hasRef()
    ) return false
    return true
  })

  logger.error('There are %d handle(s) keeping the process running', activeResources.length)
  for (const o of activeResources) printStacks(o)

  function printStacks (o) {
    const stacks = o.stacks.slice(1).filter(function (s) {
      const filename = s.getFileName()
      return filename && filename.indexOf(sep) > -1 && filename.indexOf('internal' + sep) !== 0
    })

    logger.error('')
    logger.error('# %s', o.type)

    if (!stacks[0]) {
      logger.error('(unknown stack trace)')
    } else {
      // 对齐   
      let padding = ''
      stacks.forEach(function (s) {
        const pad = (s.getFileName() + ':' + s.getLineNumber()).replace(/./g, ' ')
        if (pad.length > padding.length) padding = pad
      })
      stacks.forEach(function (s) {
        
        const prefix = s.getFileName() + ':' + s.getLineNumber()
        try {
          const src = fs.readFileSync(s.getFileName(), 'utf-8').split(/\n|\r\n/)
          const code = src[s.getLineNumber() - 1].trim()
          logger.error(prefix + padding.slice(prefix.length) + ' - ' + code)
        } catch (e) {
          logger.error(prefix + padding.slice(prefix.length))
        }
      })
    }
  }
}

打印效果:

一种Node.js进程因为异步资源卡死的简单排查方案

参考