UNPKG

@j-o-r/sh

Version:

Execute shell commands on Linux-based systems from javascript

279 lines (261 loc) 7.85 kB
// 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;