node-loop-detective 1.1.0 → 1.3.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 +5 -0
- package/bin/cli.js +11 -0
- package/package.json +1 -1
- package/src/analyzer.js +13 -39
- package/src/detective.js +108 -106
- package/src/inspector.js +11 -6
package/README.md
CHANGED
|
@@ -74,6 +74,9 @@ loop-detective -p 12345 -d 30 -t 100
|
|
|
74
74
|
# Detect slow I/O with a 1-second threshold
|
|
75
75
|
loop-detective -p 12345 --io-threshold 1000
|
|
76
76
|
|
|
77
|
+
# Connect to a remote inspector (Docker, K8s, remote server)
|
|
78
|
+
loop-detective --host 192.168.1.100 --port 9229
|
|
79
|
+
|
|
77
80
|
# Continuous monitoring mode
|
|
78
81
|
loop-detective -p 12345 --watch
|
|
79
82
|
|
|
@@ -86,6 +89,7 @@ loop-detective -p 12345 --json
|
|
|
86
89
|
| Flag | Description | Default |
|
|
87
90
|
|------|-------------|---------|
|
|
88
91
|
| `-p, --pid <pid>` | Target Node.js process ID | — |
|
|
92
|
+
| `-H, --host <host>` | Inspector host (remote connections) | 127.0.0.1 |
|
|
89
93
|
| `-P, --port <port>` | Inspector port (skip SIGUSR1) | — |
|
|
90
94
|
| `-d, --duration <sec>` | Profiling duration in seconds | 10 |
|
|
91
95
|
| `-t, --threshold <ms>` | Event loop lag threshold | 50 |
|
|
@@ -124,6 +128,7 @@ const { Detective } = require('node-loop-detective');
|
|
|
124
128
|
|
|
125
129
|
const detective = new Detective({
|
|
126
130
|
pid: 12345,
|
|
131
|
+
inspectorHost: '127.0.0.1', // or remote host
|
|
127
132
|
duration: 10000,
|
|
128
133
|
threshold: 50,
|
|
129
134
|
interval: 100,
|
package/bin/cli.js
CHANGED
|
@@ -10,6 +10,7 @@ function parseCliArgs(argv) {
|
|
|
10
10
|
const args = argv.slice(2);
|
|
11
11
|
const values = {
|
|
12
12
|
pid: null,
|
|
13
|
+
host: null,
|
|
13
14
|
port: null,
|
|
14
15
|
duration: '10',
|
|
15
16
|
threshold: '50',
|
|
@@ -24,6 +25,7 @@ function parseCliArgs(argv) {
|
|
|
24
25
|
|
|
25
26
|
const flagMap = {
|
|
26
27
|
'-p': 'pid', '--pid': 'pid',
|
|
28
|
+
'-H': 'host', '--host': 'host',
|
|
27
29
|
'-P': 'port', '--port': 'port',
|
|
28
30
|
'-d': 'duration', '--duration': 'duration',
|
|
29
31
|
'-t': 'threshold', '--threshold': 'threshold',
|
|
@@ -85,9 +87,11 @@ function printUsage() {
|
|
|
85
87
|
loop-detective <pid>
|
|
86
88
|
loop-detective --pid <pid>
|
|
87
89
|
loop-detective --port <inspector-port>
|
|
90
|
+
loop-detective --host <remote-host> --port <inspector-port>
|
|
88
91
|
|
|
89
92
|
OPTIONS
|
|
90
93
|
-p, --pid <pid> Target Node.js process ID
|
|
94
|
+
-H, --host <host> Inspector host (default: 127.0.0.1)
|
|
91
95
|
-P, --port <port> Connect to an already-open inspector port
|
|
92
96
|
-d, --duration <sec> Profiling duration in seconds (default: 10)
|
|
93
97
|
-t, --threshold <ms> Event loop lag threshold in ms (default: 50)
|
|
@@ -102,6 +106,7 @@ function printUsage() {
|
|
|
102
106
|
loop-detective 12345
|
|
103
107
|
loop-detective --pid 12345 --duration 30 --threshold 100
|
|
104
108
|
loop-detective --port 9229 --watch
|
|
109
|
+
loop-detective --host 192.168.1.100 --port 9229
|
|
105
110
|
loop-detective -p 12345 -d 5 -j
|
|
106
111
|
|
|
107
112
|
HOW IT WORKS
|
|
@@ -116,6 +121,7 @@ function printUsage() {
|
|
|
116
121
|
async function main() {
|
|
117
122
|
const config = {
|
|
118
123
|
pid: pid ? parseInt(pid, 10) : null,
|
|
124
|
+
inspectorHost: values.host || '127.0.0.1',
|
|
119
125
|
inspectorPort,
|
|
120
126
|
duration: parseInt(values.duration, 10) * 1000,
|
|
121
127
|
threshold: parseInt(values.threshold, 10),
|
|
@@ -128,6 +134,11 @@ async function main() {
|
|
|
128
134
|
const reporter = new Reporter(config);
|
|
129
135
|
const detective = new Detective(config);
|
|
130
136
|
|
|
137
|
+
// Security warning for remote connections
|
|
138
|
+
if (config.inspectorHost !== '127.0.0.1' && config.inspectorHost !== 'localhost') {
|
|
139
|
+
reporter.onInfo(`⚠ Warning: Connecting to remote host ${config.inspectorHost}. The CDP protocol has no authentication — ensure the network is trusted.`);
|
|
140
|
+
}
|
|
141
|
+
|
|
131
142
|
detective.on('connected', () => reporter.onConnected());
|
|
132
143
|
detective.on('lag', (data) => reporter.onLag(data));
|
|
133
144
|
detective.on('slowIO', (data) => reporter.onSlowIO(data));
|
package/package.json
CHANGED
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;
|
|
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,
|
|
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
|
-
//
|
|
69
|
-
const callStacks = this._buildCallStacks(nodeMap,
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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;
|
|
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);
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
191
|
-
type: 'http',
|
|
192
|
-
|
|
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
|
-
|
|
208
|
-
type: 'http',
|
|
209
|
-
|
|
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
|
-
|
|
226
|
-
|
|
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
|
-
|
|
257
|
-
type: 'dns',
|
|
258
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
328
|
-
|
|
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
|
|
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,13 +401,12 @@ 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
|
-
|
|
415
|
-
this.inspector = new Inspector({ port });
|
|
409
|
+
this.inspector = new Inspector({ host: this.config.inspectorHost, port });
|
|
416
410
|
await this.inspector.connect();
|
|
417
411
|
this.emit('connected');
|
|
418
412
|
|
|
@@ -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
|
-
|
|
451
|
-
|
|
452
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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;
|