@j-o-r/asynctracker
Version:
Keep track on unresolved async methods/promises
260 lines (252 loc) • 7.76 kB
JavaScript
// Copyright 2023 J.H. Duin
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { writeFileSync } from 'node:fs';
import { format } from 'node:util';
import async_hooks from 'async_hooks';
const DEBUG = false;
/**
* @typedef {Object} AsyncHookItem
* Represents an item in the async hooks tracking.
*
* @property {number} key - The unique identifier for the async operation.
* @property {string} type - The type of the async operation, e.g., 'PROMISE'.
* @property {number} triggerAsyncId - The async ID of the resource that triggered this operation.
* @property {string} stack - The call stack trace when the async operation was initialized.
* @property {SystemTypes} resource - The Promise object associated with this operation, including its state and async IDs.-
*/
/**
* @typedef {'PROMISE'|'TIMEOUT'|'PROCESSNEXTTICK'|'TICKOBJECT' | 'SCRIPT' |'QUERYWRAP' | 'FILEHANDLE' | 'HTTP2SESSION' | 'HTTP2STREAM' | 'ZLIB' | 'UDPSENDWRAP' | 'WRITEWRAP' | 'SHUTDOWNWRAP' | 'PROMISEEXECUTOR'|'TCPCONNECTWRAP'| 'GETADDRINFOREQWRAP' | 'GETNAMEINFOREQWRAP' |'IMMEDIATE'|'TCPWRAP'|'TCPSERVERWRAP'|'UDPWRAP'|'FSREQCALLBACK'|'HTTPPARSER'|'PIPEWRAP'|'PIPECONNECTWRAP'|'STREAMWRAP'|'TTYWRAP'|'PROCESS'|'SIGNALWRAP'|'TIMERWRAP'} SystemTypes
/**
* @param {any[]} args
*/
function debug(...args) {
if (!DEBUG) return;
// Use a function like this one when debugging inside an AsyncHook callback
writeFileSync('debug.out', `${format(...args)}\n`, { flag: 'a' });
}
const TYPES = {
TIMERWRAP: 'setTimeout(), setInterval()',
PROMISE: 'Promise',
IMMEDIATE: 'setImmediate()',
PROCESSNEXTTICK: 'process.nextTick()',
TICKOBJECT: 'Used internally by process.nextTick()',
SCRIPT: 'vm.Script.runInThisContext()',
PROMISEEXECUTOR: 'Used internally by Promise',
TIMEOUT: 'setTimeout()',
PIPECONNECTWRAP: 'Used by pipes',
PIPEWRAP: 'Used by pipes',
TCPCONNECTWRAP: 'Used by TCP sockets',
TCPWRAP: 'Used by TCP sockets',
GETADDRINFOREQWRAP: 'Used by DNS queries',
GETNAMEINFOREQWRAP: 'Used by DNS queries',
QUERYWRAP: 'Used by DNS queries',
FSREQCALLBACK: 'Used by FS operations',
FILEHANDLE: 'Used by file handles',
SIGNALWRAP: 'Used by signal handlers',
HTTPPARSER: 'Used by HTTP parsing',
HTTP2SESSION: 'Used by HTTP/2 sessions',
HTTP2STREAM: 'Used by HTTP/2 streams',
ZLIB: 'Used by Zlib',
TTYWRAP: 'Used by TTY handles',
UDPSENDWRAP: 'Used by UDP sockets',
UDPWRAP: 'Used by UDP sockets',
WRITEWRAP: 'Used by process.stdout and process.stderr',
SHUTDOWNWRAP: 'Used by socket.end()',
};
/*
--- resourceTypes ---
'Promise',
'Timeout',
'Immediate',
'TCPWrap',
'TCPSERVERWRAP',
'UDPWrap',
'FSReqCallback',
'HTTPParser',
'PipeWrap',
'PipeConnectWrap',
'StreamWrap',
'TtyWrap',
'Process',
'SignalWrap',
'TimerWrap'
*/
/**
* @param {SystemTypes} type
*/
const getTypeDescription = (type) => {
if (TYPES[type]) return TYPES[type];
const err = `Unknown type: ${type}`;
throw new Error(err);
}
class AsyncTracker {
#enabled = false;
#filter = '';
#storage = new Map();
/** @type {import('async_hooks').AsyncHook} */
#hook;
constructor() {
}
/**
* 'arm' the registration of async methods
*/
#arm() {
if (this.#hook) {
this.#hook.disable();
}
this.#storage.clear();
const storage = this.#storage;
const filter = this.#filter
// CreateHook triggers a Promise itself
this.#hook = async_hooks.createHook({
/**
* @param {number} asyncId
* @param {string} type
* @param {number} triggerAsyncId
* @param {object} resource
*/
init(asyncId, type, triggerAsyncId, resource) {
type = type.toUpperCase();
if (filter === '' || filter === type) {
const stackString = new Error().stack;
const stackLines = stackString.split("\n");
const stack = stackLines.slice(6, 20).join("\n").trim();
// Only add a type where we can trace back to a file
const hasFile = stack.includes('file:///');
if (hasFile) {
debug({ action: 'set', asyncId, type, hasFile });
// debug({ action: 'set', asyncId, type})
storage.set(asyncId, { type, triggerAsyncId, stack, resource });
}
}
},
/**
* @param {number} asyncId
*/
after(asyncId) {
// // after() is called just after the resource's callback has finished.
if (storage.has(asyncId)) {
debug({ type: 'after', asyncId })
storage.delete(asyncId);
}
},
/**
* @param {number} asyncId
*/
destroy(asyncId) {
// `destroy` is called by the garbage collector once resolved/runned
if (storage.has(asyncId)) {
debug({ type: 'destroy', asyncId })
storage.delete(asyncId);
}
},
/**
* @param {number} asyncId
*/
promiseResolve(asyncId) {
if (storage.has(asyncId)) {
debug({ type: 'resolve', asyncId })
storage.delete(asyncId);
}
}
});
}
/**
* Enables tracking of asynchronous methods. Optionally, only methods of a specified type can be tracked.
* @param {SystemTypes} [type] - optional only register async methods for a specific type
*/
enable(type) {
if (type && !TYPES[type.toUpperCase()]) {
const err = `Unknown type: ${type}`;
throw new Error(err);
}
this.#filter = type ? type.toUpperCase() : '';
this.#arm();
this.#enabled = true;
this.#hook.enable()
}
/**
* Disables the tracking of asynchronous methods.
*/
disable() {
if (this.#hook) this.#hook.disable();
this.#enabled = false;
}
/**
* Clears all tracked asynchronous methods.
*/
reset() {
this.#storage.clear();
}
/*
* Prints an overview of active asynchronous calls.
* @param {boolean} [verbose] - default false, print an overview of active async calls
* @returns {number} Number of active, unresolved async calls
*/
report(verbose = false) {
const size = this.#storage.size;
if (verbose) {
let i = 0;
if (size > 0) process.stdout.write('\n');
this.#storage.forEach((value, key) => {
i++;
process.stdout.write(`Async resource of type ${value.type} with ID ${key}:\n`);
process.stdout.write(`Stack: ${value.stack}\n`);
process.stdout.write(`Resource: ${value.resource}\n`);
});
}
return size;
}
/**
* Returns an array of unresolved asynchronous methods, optionally filtered by type
* @param {SystemTypes} [type] - The type of async methods to filter by
* @returns {AsyncHookItem[]}
*/
getUnresolved(type) {
if (this.#enabled) {
this.#hook.disable();
}
// @ts-ignore
if (type) type = type.toUpperCase();
let items = Array.from(this.#storage, ([key, value]) => ({ key, ...value }));
if (type) {
items = items.filter(item => item.type === type);
}
if (this.#enabled) {
this.#hook.enable();
}
return items;
}
/**
* Returns a description of the specified asynchronous method type
* @param {SystemTypes} type - The type of asynchronous method.
* @returns {string}
*/
getTypeDescription(type) {
// @ts-ignore
type = type.toUpperCase();
return getTypeDescription(type)
}
/**
* Adds or overwrites a custom type for asynchronous methods.
* @param {string} type - The type of asynchronous method.
* @param {string} description - the description
* @returns {void}
*/
addCustomType(type, description) {
type = type.toUpperCase();
TYPES[type] = description;
}
}
export default AsyncTracker;