@j-o-r/sh
Version:
Execute shell commands on Linux-based systems from javascript
279 lines (261 loc) • 7.85 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 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
* @property {number} key - Unique async ID.
* @property {string} type - Async type (e.g., 'PROMISE').
* @property {number} triggerAsyncId - Triggering async ID.
* @property {string} stack - Call stack at init.
* @property {any} resource - Associated resource (e.g., Promise).
*/
/**
* @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
* @description Node.js async resource types supported by async_hooks.
*/
/**
* Debug logger (file output if DEBUG=true).
*
* @param {...any[]} args - Log args.
*/
function debug(...args) {
if (!DEBUG) return;
writeFileSync('debug.out', `${format(...args)}\n`, { flag: 'a' });
}
/**
* Human-readable descriptions for {@link SystemTypes}.
*
* @type {Record<SystemTypes, string>}
*/
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()',
};
/**
* Gets description for a {@link SystemTypes} value.
*
* @param {SystemTypes} type - Async type.
* @returns {string} Description.
* @throws {Error} Unknown type.
*/
const getTypeDescription = (type) => {
const upper = type.toUpperCase();
if (TYPES[upper]) return TYPES[upper];
throw new Error(`Unknown type: ${type}`);
};
/**
* Tracks unresolved async operations using Node's async_hooks.
*
* Detects leaks like hanging Promises, Timers. Used by {@link Test}.
*
* @example
* const tracker = new AsyncTracker();
* tracker.enable('PROMISE');
* // Run code...
* console.log(tracker.report(true)); // Unresolved count + details
*/
class AsyncTracker {
#enabled = false;
#filter = '';
#storage = new Map();
/** @type {import('async_hooks').AsyncHook | null} */
#hook = null;
/**
* Arms the async hook (clears storage, sets filter).
*
* @private
*/
#arm() {
if (this.#hook) {
this.#hook.disable();
}
this.#storage.clear();
const storage = this.#storage;
const filter = this.#filter;
this.#hook = async_hooks.createHook({
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();
const hasFile = stack.includes('file:///');
if (hasFile) {
debug({ action: 'set', asyncId, type, hasFile });
storage.set(asyncId, { type, triggerAsyncId, stack, resource });
}
}
},
after(asyncId) {
if (storage.has(asyncId)) {
debug({ type: 'after', asyncId });
storage.delete(asyncId);
}
},
destroy(asyncId) {
if (storage.has(asyncId)) {
debug({ type: 'destroy', asyncId });
storage.delete(asyncId);
}
},
promiseResolve(asyncId) {
if (storage.has(asyncId)) {
debug({ type: 'resolve', asyncId });
storage.delete(asyncId);
}
}
});
}
/**
* Enables tracking (optionally filter by type).
*
* @param {SystemTypes} [type] - Filter to specific type (e.g., 'PROMISE').
* @throws {Error} Unknown type.
* @example
* tracker.enable('PROMISE');
*/
enable(type) {
if (type && !TYPES[type.toUpperCase()]) {
throw new Error(`Unknown type: ${type}`);
}
this.#filter = type ? type.toUpperCase() : '';
this.#arm();
this.#enabled = true;
this.#hook.enable();
}
/**
* Disables tracking.
*
* @example
* tracker.disable();
*/
disable() {
if (this.#hook) this.#hook.disable();
this.#enabled = false;
}
/**
* Clears tracked items.
*
* @example
* tracker.reset();
*/
reset() {
this.#storage.clear();
}
/**
* Reports unresolved count (+ verbose details).
*
* @param {boolean} [verbose=false] - Print stack/resource per item.
* @returns {number} Unresolved count.
* @example
* if (tracker.report(true) > 0) console.error('Leaks!');
*/
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;
}
/**
* Gets unresolved items (filtered).
*
* Temporarily disables hook during query.
*
* @param {SystemTypes} [type] - Filter type.
* @returns {AsyncHookItem[]} Array of items.
* @example
* const leaks = tracker.getUnresolved('PROMISE');
*/
getUnresolved(type) {
if (this.#enabled) {
this.#hook.disable();
}
const upperType = type ? type.toUpperCase() : undefined;
let items = Array.from(this.#storage, ([key, value]) => ({ key, ...value }));
if (upperType) {
items = items.filter(item => item.type === upperType);
}
if (this.#enabled) {
this.#hook.enable();
}
return items;
}
/**
* Gets description for type.
*
* @param {SystemTypes} type - Type.
* @returns {string} Description.
* @throws {Error} Unknown.
* @example
* tracker.getTypeDescription('PROMISE'); // 'Promise'
*/
getTypeDescription(type) {
return getTypeDescription(type);
}
/**
* Registers custom type description.
*
* @param {string} type - Type key (uppercased).
* @param {string} description - Description.
* @example
* tracker.addCustomType('MYTYPE', 'My async op');
*/
addCustomType(type, description) {
TYPES[type.toUpperCase()] = description;
}
}
export default AsyncTracker;