一种Node.js进程因为异步资源卡死的简单排查方案
背景
前端开发日常的工作离不开Node.js
的工具生态,我们时时刻刻都在使用nodejs,不可避免地,我们偶尔会碰到一些nodejs相关的问题,比如自动化测试跑着跑着,突然进程卡死了怎么办?比如使用webpack打包时,包打出来了,但是进程没退出怎么办?进程不退出,查看控制台也没有任何报错,会让许多人不知道从哪个地方排查调试。本文就简单地讲一种异步资源一直在运行,阻止nodejs进程结束,nodejs进程卡死、不退出的排查思路。
前置知识
async_hooks
async_hooks是nodejs的一个内置模块,现在最新的v19文档里仍然描述它是一个实验性模块,即使它从8.x从开始内置了。官方虽然不建议使用,但是我们只是用来排查调试async_hooks,不在生产中落地,我们还是可以使用它的。
所以,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))
}
})
}
}
}
打印效果:
参考
转载自:https://juejin.cn/post/7183318267073658940