@j-o-r/sh 1.1.28 → 1.1.31

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.
@@ -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
- * Represents an item in the async hooks tracking.
23
- *
24
- * @property {number} key - The unique identifier for the async operation.
25
- * @property {string} type - The type of the async operation, e.g., 'PROMISE'.
26
- * @property {number} triggerAsyncId - The async ID of the resource that triggered this operation.
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 {'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
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
- * @param {any[]} args
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
- * @param {SystemTypes} type
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
- if (TYPES[type]) return TYPES[type];
92
- const err = `Unknown type: ${type}`;
93
- throw new Error(err);
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
- constructor() {
103
- }
104
- /**
105
- * 'arm' the registration of async methods
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
- * Enables tracking of asynchronous methods. Optionally, only methods of a specified type can be tracked.
172
- * @param {SystemTypes} [type] - optional only register async methods for a specific type
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
- const err = `Unknown type: ${type}`;
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
- * Disables the tracking of asynchronous methods.
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
- * Clears all tracked asynchronous methods.
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
- * Prints an overview of active asynchronous calls.
199
- * @param {boolean} [verbose] - default false, print an overview of active async calls
200
- * @returns {number} Number of active, unresolved async calls
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
- * Returns an array of unresolved asynchronous methods, optionally filtered by type
218
- * @param {SystemTypes} [type] - The type of async methods to filter by
219
- * @returns {AsyncHookItem[]}
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
- // @ts-ignore
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 (type) {
229
- items = items.filter(item => item.type === 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
- * Returns a description of the specified asynchronous method type
238
- * @param {SystemTypes} type - The type of asynchronous method.
239
- * @returns {string}
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
- // @ts-ignore
243
- type = type.toUpperCase();
244
- return getTypeDescription(type)
262
+ return getTypeDescription(type);
245
263
  }
246
264
 
247
265
  /**
248
- * Adds or overwrites a custom type for asynchronous methods.
249
- * @param {string} type - The type of asynchronous method.
250
- * @param {string} description - the description
251
- * @returns {void}
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 = type.toUpperCase();
255
- TYPES[type] = description;
274
+ TYPES[type.toUpperCase()] = description;
256
275
  }
257
276
  }
258
277