@j-o-r/sh 1.1.28 → 1.1.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +119 -154
- package/TODO.md +18 -0
- package/lib/AsyncTracker.js +126 -107
- package/lib/SH.js +158 -154
- package/lib/SHDispatch.js +105 -91
- package/lib/SHExecute.js +83 -41
- package/lib/Test.js +153 -123
- package/package.json +1 -1
- package/types/AsyncTracker.d.ts +65 -31
- package/types/SH.d.ts +157 -164
- package/types/SHDispatch.d.ts +55 -78
- package/types/SHExecute.d.ts +44 -23
- package/types/Test.d.ts +66 -30
package/lib/AsyncTracker.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
|
|
2
1
|
// Copyright 2023 J.H. Duin
|
|
3
2
|
//
|
|
4
3
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
@@ -8,35 +7,53 @@
|
|
|
8
7
|
// https://www.apache.org/licenses/LICENSE-2.0
|
|
9
8
|
//
|
|
10
9
|
// Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
// distributed under the Apache License, Version 2.0 (the "License");
|
|
11
|
+
// you may not use this file except in compliance with the License.
|
|
12
|
+
// You may obtain a copy of the License at
|
|
13
|
+
//
|
|
14
|
+
// https://www.apache.org/licenses/LICENSE-2.0
|
|
15
|
+
//
|
|
16
|
+
// Unless required by applicable law or agreed to in writing, software
|
|
11
17
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
18
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
19
|
// See the License for the specific language governing permissions and
|
|
14
20
|
// limitations under the License.
|
|
21
|
+
|
|
15
22
|
import { writeFileSync } from 'node:fs';
|
|
16
23
|
import { format } from 'node:util';
|
|
17
24
|
import async_hooks from 'async_hooks';
|
|
18
25
|
|
|
19
26
|
const DEBUG = false;
|
|
27
|
+
|
|
20
28
|
/**
|
|
21
29
|
* @typedef {Object} AsyncHookItem
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
* @property {number}
|
|
25
|
-
* @property {string}
|
|
26
|
-
* @property {
|
|
27
|
-
* @property {string} stack - The call stack trace when the async operation was initialized.
|
|
28
|
-
* @property {SystemTypes} resource - The Promise object associated with this operation, including its state and async IDs.-
|
|
30
|
+
* @property {number} key - Unique async ID.
|
|
31
|
+
* @property {string} type - Async type (e.g., 'PROMISE').
|
|
32
|
+
* @property {number} triggerAsyncId - Triggering async ID.
|
|
33
|
+
* @property {string} stack - Call stack at init.
|
|
34
|
+
* @property {any} resource - Associated resource (e.g., Promise).
|
|
29
35
|
*/
|
|
36
|
+
|
|
30
37
|
/**
|
|
31
|
-
* @typedef
|
|
38
|
+
* @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
|
|
39
|
+
* @description Node.js async resource types supported by async_hooks.
|
|
40
|
+
*/
|
|
41
|
+
|
|
32
42
|
/**
|
|
33
|
-
*
|
|
34
|
-
|
|
43
|
+
* Debug logger (file output if DEBUG=true).
|
|
44
|
+
*
|
|
45
|
+
* @param {...any[]} args - Log args.
|
|
46
|
+
*/
|
|
35
47
|
function debug(...args) {
|
|
36
48
|
if (!DEBUG) return;
|
|
37
|
-
// Use a function like this one when debugging inside an AsyncHook callback
|
|
38
49
|
writeFileSync('debug.out', `${format(...args)}\n`, { flag: 'a' });
|
|
39
50
|
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Human-readable descriptions for {@link SystemTypes}.
|
|
54
|
+
*
|
|
55
|
+
* @type {Record<SystemTypes, string>}
|
|
56
|
+
*/
|
|
40
57
|
const TYPES = {
|
|
41
58
|
TIMERWRAP: 'setTimeout(), setInterval()',
|
|
42
59
|
PROMISE: 'Promise',
|
|
@@ -66,139 +83,132 @@ const TYPES = {
|
|
|
66
83
|
WRITEWRAP: 'Used by process.stdout and process.stderr',
|
|
67
84
|
SHUTDOWNWRAP: 'Used by socket.end()',
|
|
68
85
|
};
|
|
69
|
-
|
|
70
|
-
--- resourceTypes ---
|
|
71
|
-
'Promise',
|
|
72
|
-
'Timeout',
|
|
73
|
-
'Immediate',
|
|
74
|
-
'TCPWrap',
|
|
75
|
-
'TCPSERVERWRAP',
|
|
76
|
-
'UDPWrap',
|
|
77
|
-
'FSReqCallback',
|
|
78
|
-
'HTTPParser',
|
|
79
|
-
'PipeWrap',
|
|
80
|
-
'PipeConnectWrap',
|
|
81
|
-
'StreamWrap',
|
|
82
|
-
'TtyWrap',
|
|
83
|
-
'Process',
|
|
84
|
-
'SignalWrap',
|
|
85
|
-
'TimerWrap'
|
|
86
|
-
*/
|
|
86
|
+
|
|
87
87
|
/**
|
|
88
|
-
* @
|
|
89
|
-
|
|
88
|
+
* Gets description for a {@link SystemTypes} value.
|
|
89
|
+
*
|
|
90
|
+
* @param {SystemTypes} type - Async type.
|
|
91
|
+
* @returns {string} Description.
|
|
92
|
+
* @throws {Error} Unknown type.
|
|
93
|
+
*/
|
|
90
94
|
const getTypeDescription = (type) => {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
throw new Error(
|
|
94
|
-
}
|
|
95
|
+
const upper = type.toUpperCase();
|
|
96
|
+
if (TYPES[upper]) return TYPES[upper];
|
|
97
|
+
throw new Error(`Unknown type: ${type}`);
|
|
98
|
+
};
|
|
95
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Tracks unresolved async operations using Node's async_hooks.
|
|
102
|
+
*
|
|
103
|
+
* Detects leaks like hanging Promises, Timers. Used by {@link Test}.
|
|
104
|
+
*
|
|
105
|
+
* @example
|
|
106
|
+
* const tracker = new AsyncTracker();
|
|
107
|
+
* tracker.enable('PROMISE');
|
|
108
|
+
* // Run code...
|
|
109
|
+
* console.log(tracker.report(true)); // Unresolved count + details
|
|
110
|
+
*/
|
|
96
111
|
class AsyncTracker {
|
|
97
112
|
#enabled = false;
|
|
98
113
|
#filter = '';
|
|
99
114
|
#storage = new Map();
|
|
100
|
-
/** @type {import('async_hooks').AsyncHook} */
|
|
101
|
-
#hook;
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
115
|
+
/** @type {import('async_hooks').AsyncHook | null} */
|
|
116
|
+
#hook = null;
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Arms the async hook (clears storage, sets filter).
|
|
120
|
+
*
|
|
121
|
+
* @private
|
|
122
|
+
*/
|
|
107
123
|
#arm() {
|
|
108
124
|
if (this.#hook) {
|
|
109
125
|
this.#hook.disable();
|
|
110
126
|
}
|
|
111
127
|
this.#storage.clear();
|
|
112
128
|
const storage = this.#storage;
|
|
113
|
-
const filter = this.#filter
|
|
114
|
-
// CreateHook triggers a Promise itself
|
|
129
|
+
const filter = this.#filter;
|
|
115
130
|
this.#hook = async_hooks.createHook({
|
|
116
|
-
/**
|
|
117
|
-
* @param {number} asyncId
|
|
118
|
-
* @param {string} type
|
|
119
|
-
* @param {number} triggerAsyncId
|
|
120
|
-
* @param {object} resource
|
|
121
|
-
*/
|
|
122
131
|
init(asyncId, type, triggerAsyncId, resource) {
|
|
123
132
|
type = type.toUpperCase();
|
|
124
133
|
if (filter === '' || filter === type) {
|
|
125
134
|
const stackString = new Error().stack;
|
|
126
135
|
const stackLines = stackString.split("\n");
|
|
127
136
|
const stack = stackLines.slice(6, 20).join("\n").trim();
|
|
128
|
-
// Only add a type where we can trace back to a file
|
|
129
137
|
const hasFile = stack.includes('file:///');
|
|
130
138
|
if (hasFile) {
|
|
131
139
|
debug({ action: 'set', asyncId, type, hasFile });
|
|
132
|
-
// debug({ action: 'set', asyncId, type})
|
|
133
140
|
storage.set(asyncId, { type, triggerAsyncId, stack, resource });
|
|
134
141
|
}
|
|
135
142
|
}
|
|
136
143
|
},
|
|
137
|
-
/**
|
|
138
|
-
* @param {number} asyncId
|
|
139
|
-
*/
|
|
140
144
|
after(asyncId) {
|
|
141
|
-
// // after() is called just after the resource's callback has finished.
|
|
142
145
|
if (storage.has(asyncId)) {
|
|
143
|
-
debug({ type: 'after', asyncId })
|
|
146
|
+
debug({ type: 'after', asyncId });
|
|
144
147
|
storage.delete(asyncId);
|
|
145
148
|
}
|
|
146
149
|
},
|
|
147
|
-
/**
|
|
148
|
-
* @param {number} asyncId
|
|
149
|
-
*/
|
|
150
150
|
destroy(asyncId) {
|
|
151
|
-
// `destroy` is called by the garbage collector once resolved/runned
|
|
152
151
|
if (storage.has(asyncId)) {
|
|
153
|
-
debug({ type: 'destroy', asyncId })
|
|
152
|
+
debug({ type: 'destroy', asyncId });
|
|
154
153
|
storage.delete(asyncId);
|
|
155
154
|
}
|
|
156
155
|
},
|
|
157
|
-
|
|
158
|
-
/**
|
|
159
|
-
* @param {number} asyncId
|
|
160
|
-
*/
|
|
161
156
|
promiseResolve(asyncId) {
|
|
162
157
|
if (storage.has(asyncId)) {
|
|
163
|
-
debug({ type: 'resolve', asyncId })
|
|
158
|
+
debug({ type: 'resolve', asyncId });
|
|
164
159
|
storage.delete(asyncId);
|
|
165
160
|
}
|
|
166
161
|
}
|
|
167
162
|
});
|
|
168
163
|
}
|
|
169
164
|
|
|
170
|
-
/**
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
165
|
+
/**
|
|
166
|
+
* Enables tracking (optionally filter by type).
|
|
167
|
+
*
|
|
168
|
+
* @param {SystemTypes} [type] - Filter to specific type (e.g., 'PROMISE').
|
|
169
|
+
* @throws {Error} Unknown type.
|
|
170
|
+
* @example
|
|
171
|
+
* tracker.enable('PROMISE');
|
|
172
|
+
*/
|
|
174
173
|
enable(type) {
|
|
175
174
|
if (type && !TYPES[type.toUpperCase()]) {
|
|
176
|
-
|
|
177
|
-
throw new Error(err);
|
|
175
|
+
throw new Error(`Unknown type: ${type}`);
|
|
178
176
|
}
|
|
179
177
|
this.#filter = type ? type.toUpperCase() : '';
|
|
180
178
|
this.#arm();
|
|
181
179
|
this.#enabled = true;
|
|
182
|
-
this.#hook.enable()
|
|
180
|
+
this.#hook.enable();
|
|
183
181
|
}
|
|
182
|
+
|
|
184
183
|
/**
|
|
185
|
-
|
|
186
|
-
|
|
184
|
+
* Disables tracking.
|
|
185
|
+
*
|
|
186
|
+
* @example
|
|
187
|
+
* tracker.disable();
|
|
188
|
+
*/
|
|
187
189
|
disable() {
|
|
188
190
|
if (this.#hook) this.#hook.disable();
|
|
189
191
|
this.#enabled = false;
|
|
190
192
|
}
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Clears tracked items.
|
|
196
|
+
*
|
|
197
|
+
* @example
|
|
198
|
+
* tracker.reset();
|
|
199
|
+
*/
|
|
194
200
|
reset() {
|
|
195
201
|
this.#storage.clear();
|
|
196
202
|
}
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Reports unresolved count (+ verbose details).
|
|
206
|
+
*
|
|
207
|
+
* @param {boolean} [verbose=false] - Print stack/resource per item.
|
|
208
|
+
* @returns {number} Unresolved count.
|
|
209
|
+
* @example
|
|
210
|
+
* if (tracker.report(true) > 0) console.error('Leaks!');
|
|
211
|
+
*/
|
|
202
212
|
report(verbose = false) {
|
|
203
213
|
const size = this.#storage.size;
|
|
204
214
|
if (verbose) {
|
|
@@ -213,46 +223,55 @@ class AsyncTracker {
|
|
|
213
223
|
}
|
|
214
224
|
return size;
|
|
215
225
|
}
|
|
226
|
+
|
|
216
227
|
/**
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
228
|
+
* Gets unresolved items (filtered).
|
|
229
|
+
*
|
|
230
|
+
* Temporarily disables hook during query.
|
|
231
|
+
*
|
|
232
|
+
* @param {SystemTypes} [type] - Filter type.
|
|
233
|
+
* @returns {AsyncHookItem[]} Array of items.
|
|
234
|
+
* @example
|
|
235
|
+
* const leaks = tracker.getUnresolved('PROMISE');
|
|
236
|
+
*/
|
|
221
237
|
getUnresolved(type) {
|
|
222
238
|
if (this.#enabled) {
|
|
223
239
|
this.#hook.disable();
|
|
224
240
|
}
|
|
225
|
-
|
|
226
|
-
if (type) type = type.toUpperCase();
|
|
241
|
+
const upperType = type ? type.toUpperCase() : undefined;
|
|
227
242
|
let items = Array.from(this.#storage, ([key, value]) => ({ key, ...value }));
|
|
228
|
-
if (
|
|
229
|
-
items = items.filter(item => item.type ===
|
|
243
|
+
if (upperType) {
|
|
244
|
+
items = items.filter(item => item.type === upperType);
|
|
230
245
|
}
|
|
231
246
|
if (this.#enabled) {
|
|
232
247
|
this.#hook.enable();
|
|
233
248
|
}
|
|
234
249
|
return items;
|
|
235
250
|
}
|
|
251
|
+
|
|
236
252
|
/**
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
253
|
+
* Gets description for type.
|
|
254
|
+
*
|
|
255
|
+
* @param {SystemTypes} type - Type.
|
|
256
|
+
* @returns {string} Description.
|
|
257
|
+
* @throws {Error} Unknown.
|
|
258
|
+
* @example
|
|
259
|
+
* tracker.getTypeDescription('PROMISE'); // 'Promise'
|
|
260
|
+
*/
|
|
241
261
|
getTypeDescription(type) {
|
|
242
|
-
|
|
243
|
-
type = type.toUpperCase();
|
|
244
|
-
return getTypeDescription(type)
|
|
262
|
+
return getTypeDescription(type);
|
|
245
263
|
}
|
|
246
264
|
|
|
247
265
|
/**
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
266
|
+
* Registers custom type description.
|
|
267
|
+
*
|
|
268
|
+
* @param {string} type - Type key (uppercased).
|
|
269
|
+
* @param {string} description - Description.
|
|
270
|
+
* @example
|
|
271
|
+
* tracker.addCustomType('MYTYPE', 'My async op');
|
|
272
|
+
*/
|
|
253
273
|
addCustomType(type, description) {
|
|
254
|
-
type
|
|
255
|
-
TYPES[type] = description;
|
|
274
|
+
TYPES[type.toUpperCase()] = description;
|
|
256
275
|
}
|
|
257
276
|
}
|
|
258
277
|
|