node-loop-detective 1.1.0 → 1.2.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-loop-detective",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Detect event loop blocking & lag in running Node.js apps without code changes or restarts",
5
5
  "main": "src/index.js",
6
6
  "bin": {
package/src/analyzer.js CHANGED
@@ -7,12 +7,6 @@ class Analyzer {
7
7
 
8
8
  /**
9
9
  * Analyze a V8 CPU profile to find blocking functions
10
- *
11
- * V8 CPU profile format:
12
- * - nodes: array of { id, callFrame: { functionName, url, lineNumber, columnNumber }, hitCount, children }
13
- * - startTime, endTime: microseconds
14
- * - samples: array of node IDs (sampled at each tick)
15
- * - timeDeltas: array of time deltas between samples (microseconds)
16
10
  */
17
11
  analyzeProfile(profile) {
18
12
  const { nodes, samples, timeDeltas, startTime, endTime } = profile;
@@ -22,7 +16,7 @@ class Analyzer {
22
16
  nodeMap.set(node.id, node);
23
17
  }
24
18
 
25
- // Calculate total time per node
19
+ // Calculate total time per node (keyed by node ID)
26
20
  const timings = new Map();
27
21
  for (let i = 0; i < samples.length; i++) {
28
22
  const nodeId = samples[i];
@@ -30,7 +24,6 @@ class Analyzer {
30
24
  timings.set(nodeId, (timings.get(nodeId) || 0) + delta);
31
25
  }
32
26
 
33
- // Build results: find heavy functions
34
27
  const totalDuration = endTime - startTime; // microseconds
35
28
  const heavyFunctions = [];
36
29
 
@@ -40,35 +33,30 @@ class Analyzer {
40
33
 
41
34
  const { functionName, url, lineNumber, columnNumber } = node.callFrame;
42
35
 
43
- // Skip internal/idle nodes
44
36
  if (!url && !functionName) continue;
45
37
  if (functionName === '(idle)' || functionName === '(program)') continue;
46
- if (functionName === '(garbage collector)') {
47
- // GC is interesting — keep it
48
- }
49
38
 
50
39
  const selfTimeMs = selfTime / 1000;
51
40
  const percentage = totalDuration > 0 ? (selfTime / totalDuration) * 100 : 0;
52
41
 
53
- if (selfTimeMs < 1) continue; // skip trivial entries
42
+ if (selfTimeMs < 1) continue;
54
43
 
55
44
  heavyFunctions.push({
45
+ nodeId, // Fix #5: carry node ID for accurate call stack building
56
46
  functionName: functionName || '(anonymous)',
57
47
  url: url || '(native)',
58
- lineNumber: lineNumber + 1, // V8 uses 0-based
48
+ lineNumber: lineNumber + 1,
59
49
  columnNumber: columnNumber + 1,
60
50
  selfTimeMs: Math.round(selfTimeMs * 100) / 100,
61
51
  percentage: Math.round(percentage * 100) / 100,
62
52
  });
63
53
  }
64
54
 
65
- // Sort by self time descending
66
55
  heavyFunctions.sort((a, b) => b.selfTimeMs - a.selfTimeMs);
67
56
 
68
- // Build call tree for the top blockers
69
- const callStacks = this._buildCallStacks(nodeMap, timings, heavyFunctions.slice(0, 5));
57
+ // Fix #5: Pass node IDs to _buildCallStacks instead of matching by name
58
+ const callStacks = this._buildCallStacks(nodeMap, heavyFunctions.slice(0, 5));
70
59
 
71
- // Detect event loop blocking patterns
72
60
  const blockingPatterns = this._detectPatterns(heavyFunctions, profile);
73
61
 
74
62
  return {
@@ -77,7 +65,8 @@ class Analyzer {
77
65
  samplesCount: samples.length,
78
66
  heavyFunctionCount: heavyFunctions.length,
79
67
  },
80
- heavyFunctions: heavyFunctions.slice(0, 20),
68
+ // Strip nodeId from public output
69
+ heavyFunctions: heavyFunctions.slice(0, 20).map(({ nodeId, ...rest }) => rest),
81
70
  callStacks,
82
71
  blockingPatterns,
83
72
  timestamp: Date.now(),
@@ -85,9 +74,11 @@ class Analyzer {
85
74
  }
86
75
 
87
76
  /**
88
- * Build call stacks for the heaviest functions
77
+ * Build call stacks for the heaviest functions.
78
+ * Fix for Issue #5: Uses node ID directly instead of matching by function name,
79
+ * which avoids incorrect matches for same-named functions or minified code.
89
80
  */
90
- _buildCallStacks(nodeMap, timings, topFunctions) {
81
+ _buildCallStacks(nodeMap, topFunctions) {
91
82
  const stacks = [];
92
83
 
93
84
  // Build parent map
@@ -101,18 +92,7 @@ class Analyzer {
101
92
  }
102
93
 
103
94
  for (const fn of topFunctions) {
104
- // Find the node matching this function
105
- let targetNode = null;
106
- for (const node of nodeMap.values()) {
107
- const cf = node.callFrame;
108
- if (cf.functionName === fn.functionName &&
109
- cf.url === (fn.url === '(native)' ? '' : fn.url) &&
110
- cf.lineNumber === fn.lineNumber - 1) {
111
- targetNode = node;
112
- break;
113
- }
114
- }
115
-
95
+ const targetNode = nodeMap.get(fn.nodeId);
116
96
  if (!targetNode) continue;
117
97
 
118
98
  // Walk up the call stack
@@ -149,7 +129,6 @@ class Analyzer {
149
129
  const patterns = [];
150
130
  const totalMs = (profile.endTime - profile.startTime) / 1000;
151
131
 
152
- // Pattern: Single function dominating CPU
153
132
  const topFn = heavyFunctions[0];
154
133
  if (topFn && topFn.percentage > 50) {
155
134
  patterns.push({
@@ -161,7 +140,6 @@ class Analyzer {
161
140
  });
162
141
  }
163
142
 
164
- // Pattern: JSON parsing / serialization
165
143
  const jsonFns = heavyFunctions.filter(
166
144
  (f) => f.functionName.includes('JSON') || f.functionName.includes('parse') || f.functionName.includes('stringify')
167
145
  );
@@ -175,7 +153,6 @@ class Analyzer {
175
153
  });
176
154
  }
177
155
 
178
- // Pattern: RegExp heavy
179
156
  const regexFns = heavyFunctions.filter(
180
157
  (f) => f.functionName.includes('RegExp') || f.functionName.includes('exec') || f.functionName.includes('match')
181
158
  );
@@ -189,7 +166,6 @@ class Analyzer {
189
166
  });
190
167
  }
191
168
 
192
- // Pattern: GC pressure
193
169
  const gcFns = heavyFunctions.filter((f) => f.functionName.includes('garbage collector'));
194
170
  const gcTime = gcFns.reduce((sum, f) => sum + f.selfTimeMs, 0);
195
171
  if (gcTime > totalMs * 0.05) {
@@ -201,7 +177,6 @@ class Analyzer {
201
177
  });
202
178
  }
203
179
 
204
- // Pattern: Synchronous file I/O
205
180
  const syncFns = heavyFunctions.filter(
206
181
  (f) => f.functionName.includes('Sync') || f.url.includes('fs.js') || f.url.includes('node:fs')
207
182
  );
@@ -215,7 +190,6 @@ class Analyzer {
215
190
  });
216
191
  }
217
192
 
218
- // Pattern: Crypto operations
219
193
  const cryptoFns = heavyFunctions.filter(
220
194
  (f) => f.url.includes('crypto') || f.functionName.includes('pbkdf') || f.functionName.includes('hash')
221
195
  );
package/src/detective.js CHANGED
@@ -11,6 +11,7 @@ class Detective extends EventEmitter {
11
11
  this.inspector = null;
12
12
  this.analyzer = new Analyzer(config);
13
13
  this._running = false;
14
+ this._stopping = false;
14
15
  this._lagTimer = null;
15
16
  this._ioTimer = null;
16
17
  }
@@ -19,13 +20,13 @@ class Detective extends EventEmitter {
19
20
  * Activate the inspector on the target process via SIGUSR1
20
21
  */
21
22
  _activateInspector() {
22
- if (this.config.inspectorPort) return; // already specified
23
+ if (this.config.inspectorPort) return;
23
24
 
24
25
  const pid = this.config.pid;
25
26
  if (!pid) throw new Error('No PID provided');
26
27
 
27
28
  try {
28
- process.kill(pid, 0); // check process exists
29
+ process.kill(pid, 0);
29
30
  } catch (err) {
30
31
  throw new Error(`Process ${pid} not found or not accessible: ${err.message}`);
31
32
  }
@@ -45,19 +46,21 @@ class Detective extends EventEmitter {
45
46
  */
46
47
  async _findInspectorPort() {
47
48
  if (this.config.inspectorPort) return this.config.inspectorPort;
48
-
49
- // After SIGUSR1, Node.js opens inspector on 9229 by default
50
- // Give it a moment to start
51
49
  await this._sleep(1000);
52
50
  return 9229;
53
51
  }
54
52
 
55
53
  /**
56
54
  * Start event loop lag detection via CDP Runtime.evaluate
57
- * We inject a tiny lag-measuring snippet into the target process
55
+ *
56
+ * Note on stack traces (Issue #6): The setInterval callback fires AFTER
57
+ * blocking code has finished, so captureStack() captures the timer's own
58
+ * stack, not the blocking code's stack. The lag event stacks are best-effort
59
+ * context. For accurate blocking code identification, use the CPU profile
60
+ * analysis (heavyFunctions + callStacks) which is based on V8 sampling and
61
+ * reliably identifies the actual blocking functions.
58
62
  */
59
63
  async _startLagDetection() {
60
- // Inject a lag detector that also captures stack traces
61
64
  const script = `
62
65
  (function() {
63
66
  if (globalThis.__loopDetective) {
@@ -67,13 +70,11 @@ class Detective extends EventEmitter {
67
70
  let lastTime = Date.now();
68
71
  const threshold = ${this.config.threshold};
69
72
 
70
- // Capture stack trace at the point of lag detection
71
73
  function captureStack() {
72
74
  const orig = Error.stackTraceLimit;
73
75
  Error.stackTraceLimit = 20;
74
76
  const err = new Error();
75
77
  Error.stackTraceLimit = orig;
76
- // Parse the stack into structured frames
77
78
  const frames = (err.stack || '').split('\\n').slice(2).map(line => {
78
79
  const m = line.match(/at\\s+(?:(.+?)\\s+\\()?(.+?):(\\d+):(\\d+)\\)?/);
79
80
  if (m) return { fn: m[1] || '(anonymous)', file: m[2], line: +m[3], col: +m[4] };
@@ -99,7 +100,6 @@ class Detective extends EventEmitter {
99
100
  lastTime = now;
100
101
  }, ${this.config.interval});
101
102
 
102
- // Make sure our timer doesn't keep the process alive
103
103
  if (timer.unref) timer.unref();
104
104
 
105
105
  globalThis.__loopDetective = {
@@ -127,7 +127,6 @@ class Detective extends EventEmitter {
127
127
  throw new Error(`Failed to inject lag detector: ${JSON.stringify(result.exceptionDetails)}`);
128
128
  }
129
129
 
130
- // Poll for lag events
131
130
  this._lagTimer = setInterval(async () => {
132
131
  if (!this._running) return;
133
132
  try {
@@ -148,6 +147,10 @@ class Detective extends EventEmitter {
148
147
  /**
149
148
  * Start slow async I/O detection via CDP Runtime.evaluate
150
149
  * Monkey-patches http, https, net, dns to track slow operations
150
+ *
151
+ * Fix for Issue #1: Original functions are stored and restored on cleanup.
152
+ * Fix for Issue #7: http.get is wrapped around the original http.get,
153
+ * not reimplemented via mod.request + req.end().
151
154
  */
152
155
  async _startAsyncIOTracking() {
153
156
  const ioThreshold = this.config.ioThreshold || 500;
@@ -160,6 +163,20 @@ class Detective extends EventEmitter {
160
163
 
161
164
  const slowOps = [];
162
165
  const threshold = ${ioThreshold};
166
+ const originals = {};
167
+
168
+ function captureCallerStack() {
169
+ const origLimit = Error.stackTraceLimit;
170
+ Error.stackTraceLimit = 10;
171
+ const stackErr = new Error();
172
+ Error.stackTraceLimit = origLimit;
173
+ return (stackErr.stack || '').split('\\n').slice(2, 6).map(l => l.trim());
174
+ }
175
+
176
+ function recordSlowOp(op) {
177
+ slowOps.push(op);
178
+ if (slowOps.length > 200) slowOps.shift();
179
+ }
163
180
 
164
181
  // --- Track outgoing HTTP/HTTPS requests ---
165
182
  function patchHttp(modName) {
@@ -167,65 +184,78 @@ class Detective extends EventEmitter {
167
184
  try { mod = require(modName); } catch { return; }
168
185
  const origRequest = mod.request;
169
186
  const origGet = mod.get;
187
+ originals[modName + '.request'] = { mod, key: 'request', fn: origRequest };
188
+ originals[modName + '.get'] = { mod, key: 'get', fn: origGet };
170
189
 
171
190
  mod.request = function patchedRequest(...args) {
172
191
  const startTime = Date.now();
173
192
  const opts = typeof args[0] === 'string' ? { href: args[0] } : (args[0] || {});
174
193
  const target = opts.href || opts.hostname || opts.host || 'unknown';
175
194
  const method = (opts.method || 'GET').toUpperCase();
176
- const label = modName + ' ' + method + ' ' + target;
177
-
178
- // Capture caller stack
179
- const origLimit = Error.stackTraceLimit;
180
- Error.stackTraceLimit = 10;
181
- const stackErr = new Error();
182
- Error.stackTraceLimit = origLimit;
183
- const callerStack = (stackErr.stack || '').split('\\n').slice(2, 6).map(l => l.trim());
195
+ const callerStack = captureCallerStack();
184
196
 
185
197
  const req = origRequest.apply(this, args);
186
198
 
187
199
  req.on('response', (res) => {
188
200
  const duration = Date.now() - startTime;
189
201
  if (duration >= threshold) {
190
- slowOps.push({
191
- type: 'http',
192
- protocol: modName,
193
- method,
194
- target,
195
- statusCode: res.statusCode,
196
- duration,
197
- timestamp: Date.now(),
198
- stack: callerStack,
202
+ recordSlowOp({
203
+ type: 'http', protocol: modName, method, target,
204
+ statusCode: res.statusCode, duration, timestamp: Date.now(), stack: callerStack,
199
205
  });
200
- if (slowOps.length > 200) slowOps.shift();
201
206
  }
202
207
  });
203
208
 
204
209
  req.on('error', (err) => {
205
210
  const duration = Date.now() - startTime;
206
211
  if (duration >= threshold) {
207
- slowOps.push({
208
- type: 'http',
209
- protocol: modName,
210
- method,
211
- target,
212
- error: err.message,
213
- duration,
214
- timestamp: Date.now(),
215
- stack: callerStack,
212
+ recordSlowOp({
213
+ type: 'http', protocol: modName, method, target,
214
+ error: err.message, duration, timestamp: Date.now(), stack: callerStack,
216
215
  });
217
- if (slowOps.length > 200) slowOps.shift();
218
216
  }
219
217
  });
220
218
 
221
219
  return req;
222
220
  };
223
221
 
222
+ // Fix #7: Wrap original http.get instead of reimplementing
224
223
  mod.get = function patchedGet(...args) {
225
- const req = mod.request(...args);
226
- req.end();
224
+ return origGet.apply(this, args);
225
+ };
226
+ // Add timing to get as well
227
+ const wrappedGet = mod.get;
228
+ mod.get = function patchedGetWithTiming(...args) {
229
+ const startTime = Date.now();
230
+ const opts = typeof args[0] === 'string' ? { href: args[0] } : (args[0] || {});
231
+ const target = opts.href || opts.hostname || opts.host || 'unknown';
232
+ const callerStack = captureCallerStack();
233
+
234
+ const req = origGet.apply(this, args);
235
+
236
+ req.on('response', (res) => {
237
+ const duration = Date.now() - startTime;
238
+ if (duration >= threshold) {
239
+ recordSlowOp({
240
+ type: 'http', protocol: modName, method: 'GET', target,
241
+ statusCode: res.statusCode, duration, timestamp: Date.now(), stack: callerStack,
242
+ });
243
+ }
244
+ });
245
+
246
+ req.on('error', (err) => {
247
+ const duration = Date.now() - startTime;
248
+ if (duration >= threshold) {
249
+ recordSlowOp({
250
+ type: 'http', protocol: modName, method: 'GET', target,
251
+ error: err.message, duration, timestamp: Date.now(), stack: callerStack,
252
+ });
253
+ }
254
+ });
255
+
227
256
  return req;
228
257
  };
258
+ originals[modName + '.get'].fn = origGet;
229
259
  }
230
260
 
231
261
  patchHttp('http');
@@ -236,6 +266,7 @@ class Detective extends EventEmitter {
236
266
  let dns;
237
267
  try { dns = require('dns'); } catch { return; }
238
268
  const origLookup = dns.lookup;
269
+ originals['dns.lookup'] = { mod: dns, key: 'lookup', fn: origLookup };
239
270
 
240
271
  dns.lookup = function patchedLookup(hostname, options, callback) {
241
272
  const startTime = Date.now();
@@ -243,25 +274,15 @@ class Detective extends EventEmitter {
243
274
  callback = options;
244
275
  options = {};
245
276
  }
246
-
247
- const origLimit = Error.stackTraceLimit;
248
- Error.stackTraceLimit = 10;
249
- const stackErr = new Error();
250
- Error.stackTraceLimit = origLimit;
251
- const callerStack = (stackErr.stack || '').split('\\n').slice(2, 6).map(l => l.trim());
277
+ const callerStack = captureCallerStack();
252
278
 
253
279
  return origLookup.call(dns, hostname, options, function(err, address, family) {
254
280
  const duration = Date.now() - startTime;
255
281
  if (duration >= threshold) {
256
- slowOps.push({
257
- type: 'dns',
258
- target: hostname,
259
- duration,
260
- error: err ? err.message : null,
261
- timestamp: Date.now(),
262
- stack: callerStack,
282
+ recordSlowOp({
283
+ type: 'dns', target: hostname, duration,
284
+ error: err ? err.message : null, timestamp: Date.now(), stack: callerStack,
263
285
  });
264
- if (slowOps.length > 200) slowOps.shift();
265
286
  }
266
287
  if (callback) callback(err, address, family);
267
288
  });
@@ -273,44 +294,25 @@ class Detective extends EventEmitter {
273
294
  let net;
274
295
  try { net = require('net'); } catch { return; }
275
296
  const origConnect = net.Socket.prototype.connect;
297
+ originals['net.Socket.connect'] = { mod: net.Socket.prototype, key: 'connect', fn: origConnect };
276
298
 
277
299
  net.Socket.prototype.connect = function patchedConnect(...args) {
278
300
  const startTime = Date.now();
279
301
  const opts = typeof args[0] === 'object' ? args[0] : { port: args[0], host: args[1] };
280
302
  const target = (opts.host || '127.0.0.1') + ':' + (opts.port || '?');
281
-
282
- const origLimit = Error.stackTraceLimit;
283
- Error.stackTraceLimit = 10;
284
- const stackErr = new Error();
285
- Error.stackTraceLimit = origLimit;
286
- const callerStack = (stackErr.stack || '').split('\\n').slice(2, 6).map(l => l.trim());
303
+ const callerStack = captureCallerStack();
287
304
 
288
305
  this.once('connect', () => {
289
306
  const duration = Date.now() - startTime;
290
307
  if (duration >= threshold) {
291
- slowOps.push({
292
- type: 'tcp',
293
- target,
294
- duration,
295
- timestamp: Date.now(),
296
- stack: callerStack,
297
- });
298
- if (slowOps.length > 200) slowOps.shift();
308
+ recordSlowOp({ type: 'tcp', target, duration, timestamp: Date.now(), stack: callerStack });
299
309
  }
300
310
  });
301
311
 
302
312
  this.once('error', (err) => {
303
313
  const duration = Date.now() - startTime;
304
314
  if (duration >= threshold) {
305
- slowOps.push({
306
- type: 'tcp',
307
- target,
308
- error: err.message,
309
- duration,
310
- timestamp: Date.now(),
311
- stack: callerStack,
312
- });
313
- if (slowOps.length > 200) slowOps.shift();
315
+ recordSlowOp({ type: 'tcp', target, error: err.message, duration, timestamp: Date.now(), stack: callerStack });
314
316
  }
315
317
  });
316
318
 
@@ -319,13 +321,12 @@ class Detective extends EventEmitter {
319
321
  })();
320
322
 
321
323
  globalThis.__loopDetectiveIO = {
322
- getSlowOps: () => {
323
- const result = slowOps.splice(0);
324
- return result;
325
- },
324
+ getSlowOps: () => slowOps.splice(0),
326
325
  cleanup: () => {
327
- // Note: we don't restore originals to avoid complexity.
328
- // The patches become no-ops once threshold filtering removes them.
326
+ // Fix #1: Restore all original functions
327
+ for (const entry of Object.values(originals)) {
328
+ entry.mod[entry.key] = entry.fn;
329
+ }
329
330
  delete globalThis.__loopDetectiveIO;
330
331
  }
331
332
  };
@@ -340,12 +341,10 @@ class Detective extends EventEmitter {
340
341
  });
341
342
 
342
343
  if (result.exceptionDetails) {
343
- // Non-fatal: IO tracking is optional
344
344
  this.emit('error', new Error(`Failed to inject I/O tracker (non-fatal): ${JSON.stringify(result.exceptionDetails)}`));
345
345
  return;
346
346
  }
347
347
 
348
- // Poll for slow I/O events
349
348
  this._ioTimer = setInterval(async () => {
350
349
  if (!this._running) return;
351
350
  try {
@@ -382,23 +381,19 @@ class Detective extends EventEmitter {
382
381
  /**
383
382
  * Clean up the injected lag detector and I/O tracker
384
383
  */
385
- async _cleanupLagDetector() {
384
+ async _cleanupInjectedCode() {
386
385
  try {
387
386
  await this.inspector.send('Runtime.evaluate', {
388
387
  expression: 'globalThis.__loopDetective && globalThis.__loopDetective.cleanup()',
389
388
  returnByValue: true,
390
389
  });
391
- } catch {
392
- // Best effort cleanup
393
- }
390
+ } catch { /* best effort */ }
394
391
  try {
395
392
  await this.inspector.send('Runtime.evaluate', {
396
393
  expression: 'globalThis.__loopDetectiveIO && globalThis.__loopDetectiveIO.cleanup()',
397
394
  returnByValue: true,
398
395
  });
399
- } catch {
400
- // Best effort cleanup
401
- }
396
+ } catch { /* best effort */ }
402
397
  }
403
398
 
404
399
  /**
@@ -406,12 +401,11 @@ class Detective extends EventEmitter {
406
401
  */
407
402
  async start() {
408
403
  this._running = true;
404
+ this._stopping = false;
409
405
 
410
- // Step 1: Activate inspector
411
406
  this._activateInspector();
412
407
  const port = await this._findInspectorPort();
413
408
 
414
- // Step 2: Connect
415
409
  this.inspector = new Inspector({ port });
416
410
  await this.inspector.connect();
417
411
  this.emit('connected');
@@ -425,14 +419,10 @@ class Detective extends EventEmitter {
425
419
 
426
420
  async _singleRun() {
427
421
  try {
428
- // Step 3: Start lag detection + async I/O tracking
429
422
  await this._startLagDetection();
430
423
  await this._startAsyncIOTracking();
431
424
 
432
- // Step 4: Capture CPU profile
433
425
  const profile = await this._captureProfile(this.config.duration);
434
-
435
- // Step 5: Analyze
436
426
  const analysis = this.analyzer.analyzeProfile(profile);
437
427
  this.emit('profile', analysis);
438
428
  } finally {
@@ -440,6 +430,10 @@ class Detective extends EventEmitter {
440
430
  }
441
431
  }
442
432
 
433
+ /**
434
+ * Fix for Issue #2: Wrap runCycle in try/catch, emit errors,
435
+ * and continue the watch loop.
436
+ */
443
437
  async _watchMode() {
444
438
  await this._startLagDetection();
445
439
  await this._startAsyncIOTracking();
@@ -447,22 +441,30 @@ class Detective extends EventEmitter {
447
441
  const runCycle = async () => {
448
442
  if (!this._running) return;
449
443
 
450
- const profile = await this._captureProfile(this.config.duration);
451
- const analysis = this.analyzer.analyzeProfile(profile);
452
- this.emit('profile', analysis);
444
+ try {
445
+ const profile = await this._captureProfile(this.config.duration);
446
+ const analysis = this.analyzer.analyzeProfile(profile);
447
+ this.emit('profile', analysis);
448
+ } catch (err) {
449
+ this.emit('error', err);
450
+ }
453
451
 
454
452
  if (this._running) {
455
453
  setTimeout(runCycle, 1000);
456
454
  }
457
455
  };
458
456
 
459
- runCycle();
457
+ // Await the first cycle and catch its errors
458
+ await runCycle();
460
459
  }
461
460
 
462
461
  /**
463
- * Stop the detective and clean up
462
+ * Stop the detective and clean up.
463
+ * Fix for Issue #3: Idempotent — safe to call multiple times.
464
464
  */
465
465
  async stop() {
466
+ if (this._stopping) return;
467
+ this._stopping = true;
466
468
  this._running = false;
467
469
 
468
470
  if (this._lagTimer) {
@@ -476,7 +478,7 @@ class Detective extends EventEmitter {
476
478
  }
477
479
 
478
480
  if (this.inspector) {
479
- await this._cleanupLagDetector();
481
+ await this._cleanupInjectedCode();
480
482
  await this.inspector.disconnect();
481
483
  this.inspector = null;
482
484
  }
package/src/inspector.js CHANGED
@@ -64,7 +64,8 @@ class Inspector extends EventEmitter {
64
64
  this.ws.on('message', (data) => {
65
65
  const msg = JSON.parse(data.toString());
66
66
  if (msg.id !== undefined && this._callbacks.has(msg.id)) {
67
- const { resolve, reject } = this._callbacks.get(msg.id);
67
+ const { resolve, reject, timer } = this._callbacks.get(msg.id);
68
+ clearTimeout(timer);
68
69
  this._callbacks.delete(msg.id);
69
70
  if (msg.error) {
70
71
  reject(new Error(msg.error.message));
@@ -98,16 +99,15 @@ class Inspector extends EventEmitter {
98
99
  const id = ++this._id;
99
100
 
100
101
  return new Promise((resolve, reject) => {
101
- this._callbacks.set(id, { resolve, reject });
102
- this.ws.send(JSON.stringify({ id, method, params }));
103
-
104
- // Timeout for individual commands
105
- setTimeout(() => {
102
+ const timer = setTimeout(() => {
106
103
  if (this._callbacks.has(id)) {
107
104
  this._callbacks.delete(id);
108
105
  reject(new Error(`CDP command timeout: ${method}`));
109
106
  }
110
107
  }, 30000);
108
+
109
+ this._callbacks.set(id, { resolve, reject, timer });
110
+ this.ws.send(JSON.stringify({ id, method, params }));
111
111
  });
112
112
  }
113
113
 
@@ -116,6 +116,11 @@ class Inspector extends EventEmitter {
116
116
  */
117
117
  async disconnect() {
118
118
  if (this.ws) {
119
+ // Clear all pending timeouts and reject pending callbacks
120
+ for (const { reject, timer } of this._callbacks.values()) {
121
+ clearTimeout(timer);
122
+ try { reject(new Error('Inspector disconnected')); } catch {}
123
+ }
119
124
  this._callbacks.clear();
120
125
  this.ws.close();
121
126
  this.ws = null;