node-loop-detective 1.0.2 → 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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # node-loop-detective 🔍
2
2
 
3
- Detect event loop blocking & lag in **running** Node.js apps — without code changes or restarts.
3
+ Detect event loop blocking, lag, and slow async I/O in **running** Node.js apps — without code changes or restarts.
4
4
 
5
5
  ```
6
6
  $ loop-detective 12345
@@ -10,6 +10,8 @@ $ loop-detective 12345
10
10
 
11
11
  ⚠ Event loop lag: 312ms at 2025-01-15T10:23:45.123Z
12
12
  ⚠ Event loop lag: 156ms at 2025-01-15T10:23:48.456Z
13
+ 🌐 Slow HTTP: 2340ms GET api.example.com/users → 200
14
+ 🔌 Slow TCP: 1520ms db-server:3306
13
15
 
14
16
  ────────────────────────────────────────────────────────────
15
17
  Event Loop Detective Report
@@ -28,6 +30,17 @@ $ loop-detective 12345
28
30
  1. heavyComputation
29
31
  ██████████████░░░░░░ 6245ms (62.3%)
30
32
  /app/server.js:42:1
33
+
34
+ ⚠ Slow Async I/O Summary
35
+ Total slow ops: 3
36
+
37
+ 🌐 HTTP — 2 slow ops, avg 1800ms, max 2340ms
38
+ GET api.example.com/users
39
+ 2 calls, total 3600ms, avg 1800ms, max 2340ms
40
+
41
+ 🔌 TCP — 1 slow ops, avg 1520ms, max 1520ms
42
+ db-server:3306
43
+ 1 calls, total 1520ms, max 1520ms
31
44
  ```
32
45
 
33
46
  ## How It Works
@@ -35,9 +48,10 @@ $ loop-detective 12345
35
48
  1. Sends `SIGUSR1` to activate the Node.js built-in inspector (or connects to `--port`)
36
49
  2. Connects via Chrome DevTools Protocol (CDP)
37
50
  3. Injects a lightweight event loop lag monitor
38
- 4. Captures a CPU profile to identify blocking code
39
- 5. Analyzes the profile for common blocking patterns
40
- 6. Disconnects cleanly minimal impact on your running app
51
+ 4. Tracks slow async I/O (HTTP, DNS, TCP) via monkey-patching
52
+ 5. Captures a CPU profile to identify blocking code
53
+ 6. Analyzes the profile for common blocking patterns
54
+ 7. Disconnects cleanly — minimal impact on your running app
41
55
 
42
56
  ## Install
43
57
 
@@ -57,6 +71,9 @@ loop-detective --port 9229
57
71
  # Profile for 30 seconds with 100ms lag threshold
58
72
  loop-detective -p 12345 -d 30 -t 100
59
73
 
74
+ # Detect slow I/O with a 1-second threshold
75
+ loop-detective -p 12345 --io-threshold 1000
76
+
60
77
  # Continuous monitoring mode
61
78
  loop-detective -p 12345 --watch
62
79
 
@@ -73,11 +90,14 @@ loop-detective -p 12345 --json
73
90
  | `-d, --duration <sec>` | Profiling duration in seconds | 10 |
74
91
  | `-t, --threshold <ms>` | Event loop lag threshold | 50 |
75
92
  | `-i, --interval <ms>` | Lag sampling interval | 100 |
93
+ | `--io-threshold <ms>` | Slow I/O threshold | 500 |
76
94
  | `-j, --json` | Output as JSON | false |
77
95
  | `-w, --watch` | Continuous monitoring | false |
78
96
 
79
97
  ## What It Detects
80
98
 
99
+ ### CPU / Event Loop Blocking
100
+
81
101
  | Pattern | Description |
82
102
  |---------|-------------|
83
103
  | `cpu-hog` | Single function consuming >50% CPU |
@@ -87,6 +107,16 @@ loop-detective -p 12345 --json
87
107
  | `sync-io` | Synchronous file I/O calls |
88
108
  | `crypto-heavy` | CPU-intensive crypto operations |
89
109
 
110
+ ### Slow Async I/O
111
+
112
+ | Type | What It Tracks |
113
+ |------|---------------|
114
+ | 🌐 HTTP/HTTPS | Outgoing HTTP requests — method, target, status code, duration |
115
+ | 🔍 DNS | DNS lookups — hostname, resolution time |
116
+ | 🔌 TCP | TCP connections — target host:port, connect time (covers databases, Redis, etc.) |
117
+
118
+ Each slow I/O event includes the caller stack trace, so you know exactly which code initiated the slow operation.
119
+
90
120
  ## Programmatic API
91
121
 
92
122
  ```js
@@ -97,9 +127,11 @@ const detective = new Detective({
97
127
  duration: 10000,
98
128
  threshold: 50,
99
129
  interval: 100,
130
+ ioThreshold: 500,
100
131
  });
101
132
 
102
133
  detective.on('lag', (data) => console.log('Lag:', data.lag, 'ms'));
134
+ detective.on('slowIO', (data) => console.log('Slow I/O:', data.type, data.target, data.duration, 'ms'));
103
135
  detective.on('profile', (analysis) => {
104
136
  console.log('Heavy functions:', analysis.heavyFunctions);
105
137
  console.log('Patterns:', analysis.blockingPatterns);
@@ -117,7 +149,7 @@ await detective.start();
117
149
 
118
150
  ## How is this different from clinic.js / 0x?
119
151
 
120
- Those are great tools, but they require you to **start** your app through them. `loop-detective` attaches to an **already running** process — perfect for production debugging.
152
+ Those are great tools, but they require you to **start** your app through them. `loop-detective` attaches to an **already running** process — perfect for production debugging. It also tracks slow async I/O (HTTP, DNS, TCP) which those tools don't focus on.
121
153
 
122
154
  ## License
123
155
 
package/bin/cli.js CHANGED
@@ -14,6 +14,7 @@ function parseCliArgs(argv) {
14
14
  duration: '10',
15
15
  threshold: '50',
16
16
  interval: '100',
17
+ 'io-threshold': '500',
17
18
  json: false,
18
19
  watch: false,
19
20
  help: false,
@@ -27,6 +28,7 @@ function parseCliArgs(argv) {
27
28
  '-d': 'duration', '--duration': 'duration',
28
29
  '-t': 'threshold', '--threshold': 'threshold',
29
30
  '-i': 'interval', '--interval': 'interval',
31
+ '--io-threshold': 'io-threshold',
30
32
  };
31
33
  const boolMap = {
32
34
  '-j': 'json', '--json': 'json',
@@ -90,6 +92,7 @@ function printUsage() {
90
92
  -d, --duration <sec> Profiling duration in seconds (default: 10)
91
93
  -t, --threshold <ms> Event loop lag threshold in ms (default: 50)
92
94
  -i, --interval <ms> Sampling interval in ms (default: 100)
95
+ --io-threshold <ms> Slow I/O threshold in ms (default: 500)
93
96
  -j, --json Output results as JSON
94
97
  -w, --watch Continuous monitoring mode
95
98
  -h, --help Show this help
@@ -117,6 +120,7 @@ async function main() {
117
120
  duration: parseInt(values.duration, 10) * 1000,
118
121
  threshold: parseInt(values.threshold, 10),
119
122
  interval: parseInt(values.interval, 10),
123
+ ioThreshold: parseInt(values['io-threshold'], 10),
120
124
  watch: values.watch,
121
125
  json: values.json,
122
126
  };
@@ -126,6 +130,7 @@ async function main() {
126
130
 
127
131
  detective.on('connected', () => reporter.onConnected());
128
132
  detective.on('lag', (data) => reporter.onLag(data));
133
+ detective.on('slowIO', (data) => reporter.onSlowIO(data));
129
134
  detective.on('profile', (data) => reporter.onProfile(data));
130
135
  detective.on('error', (err) => reporter.onError(err));
131
136
  detective.on('disconnected', () => reporter.onDisconnected());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-loop-detective",
3
- "version": "1.0.2",
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,20 +11,22 @@ 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;
16
+ this._ioTimer = null;
15
17
  }
16
18
 
17
19
  /**
18
20
  * Activate the inspector on the target process via SIGUSR1
19
21
  */
20
22
  _activateInspector() {
21
- if (this.config.inspectorPort) return; // already specified
23
+ if (this.config.inspectorPort) return;
22
24
 
23
25
  const pid = this.config.pid;
24
26
  if (!pid) throw new Error('No PID provided');
25
27
 
26
28
  try {
27
- process.kill(pid, 0); // check process exists
29
+ process.kill(pid, 0);
28
30
  } catch (err) {
29
31
  throw new Error(`Process ${pid} not found or not accessible: ${err.message}`);
30
32
  }
@@ -44,19 +46,21 @@ class Detective extends EventEmitter {
44
46
  */
45
47
  async _findInspectorPort() {
46
48
  if (this.config.inspectorPort) return this.config.inspectorPort;
47
-
48
- // After SIGUSR1, Node.js opens inspector on 9229 by default
49
- // Give it a moment to start
50
49
  await this._sleep(1000);
51
50
  return 9229;
52
51
  }
53
52
 
54
53
  /**
55
54
  * Start event loop lag detection via CDP Runtime.evaluate
56
- * 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.
57
62
  */
58
63
  async _startLagDetection() {
59
- // Inject a lag detector that also captures stack traces
60
64
  const script = `
61
65
  (function() {
62
66
  if (globalThis.__loopDetective) {
@@ -66,13 +70,11 @@ class Detective extends EventEmitter {
66
70
  let lastTime = Date.now();
67
71
  const threshold = ${this.config.threshold};
68
72
 
69
- // Capture stack trace at the point of lag detection
70
73
  function captureStack() {
71
74
  const orig = Error.stackTraceLimit;
72
75
  Error.stackTraceLimit = 20;
73
76
  const err = new Error();
74
77
  Error.stackTraceLimit = orig;
75
- // Parse the stack into structured frames
76
78
  const frames = (err.stack || '').split('\\n').slice(2).map(line => {
77
79
  const m = line.match(/at\\s+(?:(.+?)\\s+\\()?(.+?):(\\d+):(\\d+)\\)?/);
78
80
  if (m) return { fn: m[1] || '(anonymous)', file: m[2], line: +m[3], col: +m[4] };
@@ -98,7 +100,6 @@ class Detective extends EventEmitter {
98
100
  lastTime = now;
99
101
  }, ${this.config.interval});
100
102
 
101
- // Make sure our timer doesn't keep the process alive
102
103
  if (timer.unref) timer.unref();
103
104
 
104
105
  globalThis.__loopDetective = {
@@ -126,7 +127,6 @@ class Detective extends EventEmitter {
126
127
  throw new Error(`Failed to inject lag detector: ${JSON.stringify(result.exceptionDetails)}`);
127
128
  }
128
129
 
129
- // Poll for lag events
130
130
  this._lagTimer = setInterval(async () => {
131
131
  if (!this._running) return;
132
132
  try {
@@ -144,6 +144,224 @@ class Detective extends EventEmitter {
144
144
  }, 1000);
145
145
  }
146
146
 
147
+ /**
148
+ * Start slow async I/O detection via CDP Runtime.evaluate
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().
154
+ */
155
+ async _startAsyncIOTracking() {
156
+ const ioThreshold = this.config.ioThreshold || 500;
157
+
158
+ const script = `
159
+ (function() {
160
+ if (globalThis.__loopDetectiveIO) {
161
+ return { alreadyRunning: true };
162
+ }
163
+
164
+ const slowOps = [];
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
+ }
180
+
181
+ // --- Track outgoing HTTP/HTTPS requests ---
182
+ function patchHttp(modName) {
183
+ let mod;
184
+ try { mod = require(modName); } catch { return; }
185
+ const origRequest = mod.request;
186
+ const origGet = mod.get;
187
+ originals[modName + '.request'] = { mod, key: 'request', fn: origRequest };
188
+ originals[modName + '.get'] = { mod, key: 'get', fn: origGet };
189
+
190
+ mod.request = function patchedRequest(...args) {
191
+ const startTime = Date.now();
192
+ const opts = typeof args[0] === 'string' ? { href: args[0] } : (args[0] || {});
193
+ const target = opts.href || opts.hostname || opts.host || 'unknown';
194
+ const method = (opts.method || 'GET').toUpperCase();
195
+ const callerStack = captureCallerStack();
196
+
197
+ const req = origRequest.apply(this, args);
198
+
199
+ req.on('response', (res) => {
200
+ const duration = Date.now() - startTime;
201
+ if (duration >= threshold) {
202
+ recordSlowOp({
203
+ type: 'http', protocol: modName, method, target,
204
+ statusCode: res.statusCode, duration, timestamp: Date.now(), stack: callerStack,
205
+ });
206
+ }
207
+ });
208
+
209
+ req.on('error', (err) => {
210
+ const duration = Date.now() - startTime;
211
+ if (duration >= threshold) {
212
+ recordSlowOp({
213
+ type: 'http', protocol: modName, method, target,
214
+ error: err.message, duration, timestamp: Date.now(), stack: callerStack,
215
+ });
216
+ }
217
+ });
218
+
219
+ return req;
220
+ };
221
+
222
+ // Fix #7: Wrap original http.get instead of reimplementing
223
+ mod.get = function patchedGet(...args) {
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
+
256
+ return req;
257
+ };
258
+ originals[modName + '.get'].fn = origGet;
259
+ }
260
+
261
+ patchHttp('http');
262
+ patchHttp('https');
263
+
264
+ // --- Track DNS lookups ---
265
+ (function patchDns() {
266
+ let dns;
267
+ try { dns = require('dns'); } catch { return; }
268
+ const origLookup = dns.lookup;
269
+ originals['dns.lookup'] = { mod: dns, key: 'lookup', fn: origLookup };
270
+
271
+ dns.lookup = function patchedLookup(hostname, options, callback) {
272
+ const startTime = Date.now();
273
+ if (typeof options === 'function') {
274
+ callback = options;
275
+ options = {};
276
+ }
277
+ const callerStack = captureCallerStack();
278
+
279
+ return origLookup.call(dns, hostname, options, function(err, address, family) {
280
+ const duration = Date.now() - startTime;
281
+ if (duration >= threshold) {
282
+ recordSlowOp({
283
+ type: 'dns', target: hostname, duration,
284
+ error: err ? err.message : null, timestamp: Date.now(), stack: callerStack,
285
+ });
286
+ }
287
+ if (callback) callback(err, address, family);
288
+ });
289
+ };
290
+ })();
291
+
292
+ // --- Track TCP socket connections ---
293
+ (function patchNet() {
294
+ let net;
295
+ try { net = require('net'); } catch { return; }
296
+ const origConnect = net.Socket.prototype.connect;
297
+ originals['net.Socket.connect'] = { mod: net.Socket.prototype, key: 'connect', fn: origConnect };
298
+
299
+ net.Socket.prototype.connect = function patchedConnect(...args) {
300
+ const startTime = Date.now();
301
+ const opts = typeof args[0] === 'object' ? args[0] : { port: args[0], host: args[1] };
302
+ const target = (opts.host || '127.0.0.1') + ':' + (opts.port || '?');
303
+ const callerStack = captureCallerStack();
304
+
305
+ this.once('connect', () => {
306
+ const duration = Date.now() - startTime;
307
+ if (duration >= threshold) {
308
+ recordSlowOp({ type: 'tcp', target, duration, timestamp: Date.now(), stack: callerStack });
309
+ }
310
+ });
311
+
312
+ this.once('error', (err) => {
313
+ const duration = Date.now() - startTime;
314
+ if (duration >= threshold) {
315
+ recordSlowOp({ type: 'tcp', target, error: err.message, duration, timestamp: Date.now(), stack: callerStack });
316
+ }
317
+ });
318
+
319
+ return origConnect.apply(this, args);
320
+ };
321
+ })();
322
+
323
+ globalThis.__loopDetectiveIO = {
324
+ getSlowOps: () => slowOps.splice(0),
325
+ cleanup: () => {
326
+ // Fix #1: Restore all original functions
327
+ for (const entry of Object.values(originals)) {
328
+ entry.mod[entry.key] = entry.fn;
329
+ }
330
+ delete globalThis.__loopDetectiveIO;
331
+ }
332
+ };
333
+
334
+ return { started: true };
335
+ })()
336
+ `;
337
+
338
+ const result = await this.inspector.send('Runtime.evaluate', {
339
+ expression: script,
340
+ returnByValue: true,
341
+ });
342
+
343
+ if (result.exceptionDetails) {
344
+ this.emit('error', new Error(`Failed to inject I/O tracker (non-fatal): ${JSON.stringify(result.exceptionDetails)}`));
345
+ return;
346
+ }
347
+
348
+ this._ioTimer = setInterval(async () => {
349
+ if (!this._running) return;
350
+ try {
351
+ const pollResult = await this.inspector.send('Runtime.evaluate', {
352
+ expression: 'globalThis.__loopDetectiveIO ? globalThis.__loopDetectiveIO.getSlowOps() : []',
353
+ returnByValue: true,
354
+ });
355
+ const ops = pollResult.result?.value || [];
356
+ for (const op of ops) {
357
+ this.emit('slowIO', op);
358
+ }
359
+ } catch {
360
+ // Inspector may have disconnected
361
+ }
362
+ }, 1000);
363
+ }
364
+
147
365
  /**
148
366
  * Take a CPU profile to identify blocking code
149
367
  */
@@ -161,17 +379,21 @@ class Detective extends EventEmitter {
161
379
  }
162
380
 
163
381
  /**
164
- * Clean up the injected lag detector
382
+ * Clean up the injected lag detector and I/O tracker
165
383
  */
166
- async _cleanupLagDetector() {
384
+ async _cleanupInjectedCode() {
167
385
  try {
168
386
  await this.inspector.send('Runtime.evaluate', {
169
387
  expression: 'globalThis.__loopDetective && globalThis.__loopDetective.cleanup()',
170
388
  returnByValue: true,
171
389
  });
172
- } catch {
173
- // Best effort cleanup
174
- }
390
+ } catch { /* best effort */ }
391
+ try {
392
+ await this.inspector.send('Runtime.evaluate', {
393
+ expression: 'globalThis.__loopDetectiveIO && globalThis.__loopDetectiveIO.cleanup()',
394
+ returnByValue: true,
395
+ });
396
+ } catch { /* best effort */ }
175
397
  }
176
398
 
177
399
  /**
@@ -179,12 +401,11 @@ class Detective extends EventEmitter {
179
401
  */
180
402
  async start() {
181
403
  this._running = true;
404
+ this._stopping = false;
182
405
 
183
- // Step 1: Activate inspector
184
406
  this._activateInspector();
185
407
  const port = await this._findInspectorPort();
186
408
 
187
- // Step 2: Connect
188
409
  this.inspector = new Inspector({ port });
189
410
  await this.inspector.connect();
190
411
  this.emit('connected');
@@ -198,13 +419,10 @@ class Detective extends EventEmitter {
198
419
 
199
420
  async _singleRun() {
200
421
  try {
201
- // Step 3: Start lag detection
202
422
  await this._startLagDetection();
423
+ await this._startAsyncIOTracking();
203
424
 
204
- // Step 4: Capture CPU profile
205
425
  const profile = await this._captureProfile(this.config.duration);
206
-
207
- // Step 5: Analyze
208
426
  const analysis = this.analyzer.analyzeProfile(profile);
209
427
  this.emit('profile', analysis);
210
428
  } finally {
@@ -212,28 +430,41 @@ class Detective extends EventEmitter {
212
430
  }
213
431
  }
214
432
 
433
+ /**
434
+ * Fix for Issue #2: Wrap runCycle in try/catch, emit errors,
435
+ * and continue the watch loop.
436
+ */
215
437
  async _watchMode() {
216
438
  await this._startLagDetection();
439
+ await this._startAsyncIOTracking();
217
440
 
218
441
  const runCycle = async () => {
219
442
  if (!this._running) return;
220
443
 
221
- const profile = await this._captureProfile(this.config.duration);
222
- const analysis = this.analyzer.analyzeProfile(profile);
223
- 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
+ }
224
451
 
225
452
  if (this._running) {
226
453
  setTimeout(runCycle, 1000);
227
454
  }
228
455
  };
229
456
 
230
- runCycle();
457
+ // Await the first cycle and catch its errors
458
+ await runCycle();
231
459
  }
232
460
 
233
461
  /**
234
- * Stop the detective and clean up
462
+ * Stop the detective and clean up.
463
+ * Fix for Issue #3: Idempotent — safe to call multiple times.
235
464
  */
236
465
  async stop() {
466
+ if (this._stopping) return;
467
+ this._stopping = true;
237
468
  this._running = false;
238
469
 
239
470
  if (this._lagTimer) {
@@ -241,8 +472,13 @@ class Detective extends EventEmitter {
241
472
  this._lagTimer = null;
242
473
  }
243
474
 
475
+ if (this._ioTimer) {
476
+ clearInterval(this._ioTimer);
477
+ this._ioTimer = null;
478
+ }
479
+
244
480
  if (this.inspector) {
245
- await this._cleanupLagDetector();
481
+ await this._cleanupInjectedCode();
246
482
  await this.inspector.disconnect();
247
483
  this.inspector = null;
248
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;
package/src/reporter.js CHANGED
@@ -20,6 +20,7 @@ class Reporter {
20
20
  constructor(config) {
21
21
  this.config = config;
22
22
  this.lagEvents = [];
23
+ this.slowIOEvents = [];
23
24
  }
24
25
 
25
26
  onConnected() {
@@ -40,10 +41,29 @@ class Reporter {
40
41
  }
41
42
  }
42
43
 
44
+ onSlowIO(data) {
45
+ this.slowIOEvents.push(data);
46
+ if (this.config.json) return;
47
+ const severity = data.duration > 5000 ? COLORS.red : data.duration > 2000 ? COLORS.yellow : COLORS.magenta;
48
+ const icon = data.type === 'http' ? '🌐' : data.type === 'dns' ? '🔍' : '🔌';
49
+ const detail = data.type === 'http'
50
+ ? `${data.method} ${data.target} → ${data.statusCode || data.error || '?'}`
51
+ : data.type === 'dns'
52
+ ? `lookup ${data.target}${data.error ? ' (' + data.error + ')' : ''}`
53
+ : `connect ${data.target}${data.error ? ' (' + data.error + ')' : ''}`;
54
+ this._print(`${severity}${icon} Slow ${data.type.toUpperCase()}: ${data.duration}ms${COLORS.reset} ${detail}`);
55
+ if (data.stack && data.stack.length > 0) {
56
+ for (const line of data.stack.slice(0, 3)) {
57
+ this._print(` ${COLORS.dim} ${line}${COLORS.reset}`);
58
+ }
59
+ }
60
+ }
61
+
43
62
  onProfile(analysis) {
44
63
  if (this.config.json) {
45
- this._print(JSON.stringify({ ...analysis, lagEvents: this.lagEvents }, null, 2));
64
+ this._print(JSON.stringify({ ...analysis, lagEvents: this.lagEvents, slowIOEvents: this.slowIOEvents }, null, 2));
46
65
  this.lagEvents = [];
66
+ this.slowIOEvents = [];
47
67
  return;
48
68
  }
49
69
 
@@ -51,9 +71,11 @@ class Reporter {
51
71
  this._printPatterns(analysis.blockingPatterns);
52
72
  this._printHeavyFunctions(analysis.heavyFunctions);
53
73
  this._printCallStacks(analysis.callStacks);
74
+ this._printSlowIOSummary();
54
75
  this._printLagSummary();
55
76
 
56
77
  this.lagEvents = [];
78
+ this.slowIOEvents = [];
57
79
  }
58
80
 
59
81
  onError(err) {
@@ -138,6 +160,53 @@ class Reporter {
138
160
  }
139
161
  }
140
162
 
163
+ _printSlowIOSummary() {
164
+ if (this.slowIOEvents.length === 0) {
165
+ this._print(`\n ${COLORS.green}✔ No slow I/O operations detected${COLORS.reset}`);
166
+ return;
167
+ }
168
+
169
+ this._print(`\n ${COLORS.magenta}⚠ Slow Async I/O Summary${COLORS.reset}`);
170
+ this._print(` Total slow ops: ${this.slowIOEvents.length}`);
171
+
172
+ // Group by type
173
+ const byType = {};
174
+ for (const op of this.slowIOEvents) {
175
+ if (!byType[op.type]) byType[op.type] = [];
176
+ byType[op.type].push(op);
177
+ }
178
+
179
+ for (const [type, ops] of Object.entries(byType)) {
180
+ const icon = type === 'http' ? '🌐' : type === 'dns' ? '🔍' : '🔌';
181
+ const maxDur = Math.max(...ops.map(o => o.duration));
182
+ const avgDur = Math.round(ops.reduce((s, o) => s + o.duration, 0) / ops.length);
183
+ this._print(`\n ${icon} ${COLORS.bold}${type.toUpperCase()}${COLORS.reset} — ${ops.length} slow ops, avg ${avgDur}ms, max ${maxDur}ms`);
184
+
185
+ // Group by target
186
+ const byTarget = {};
187
+ for (const op of ops) {
188
+ const key = op.type === 'http' ? `${op.method} ${op.target}` : op.target;
189
+ if (!byTarget[key]) byTarget[key] = { count: 0, totalDuration: 0, maxDuration: 0, errors: 0, stack: op.stack };
190
+ byTarget[key].count++;
191
+ byTarget[key].totalDuration += op.duration;
192
+ byTarget[key].maxDuration = Math.max(byTarget[key].maxDuration, op.duration);
193
+ if (op.error) byTarget[key].errors++;
194
+ }
195
+
196
+ const sorted = Object.entries(byTarget).sort((a, b) => b[1].totalDuration - a[1].totalDuration);
197
+ for (const [target, stats] of sorted.slice(0, 5)) {
198
+ const errStr = stats.errors > 0 ? ` ${COLORS.red}(${stats.errors} errors)${COLORS.reset}` : '';
199
+ this._print(` ${COLORS.yellow}${target}${COLORS.reset}${errStr}`);
200
+ this._print(` ${stats.count} calls, total ${stats.totalDuration}ms, avg ${Math.round(stats.totalDuration / stats.count)}ms, max ${stats.maxDuration}ms`);
201
+ if (stats.stack && stats.stack.length > 0) {
202
+ for (const line of stats.stack.slice(0, 2)) {
203
+ this._print(` ${COLORS.dim}${line}${COLORS.reset}`);
204
+ }
205
+ }
206
+ }
207
+ }
208
+ }
209
+
141
210
  _printLagSummary() {
142
211
  if (this.lagEvents.length === 0) {
143
212
  this._print(`\n ${COLORS.green}✔ No event loop lag detected above threshold${COLORS.reset}`);