argusqa-os 9.4.2 → 9.4.3
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 +4 -6
- package/src/adapters/browser.js +86 -86
- package/src/mcp-server.js +312 -312
- package/src/utils/github-reporter.js +1 -1
- package/src/utils/mcp-client.js +263 -263
- package/src/utils/memory-analyzer.js +270 -270
|
@@ -1,270 +1,270 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ARGUS Memory Analyzer (v3 Phase B1)
|
|
3
|
-
*
|
|
4
|
-
* Two detection surfaces:
|
|
5
|
-
* 1. Detached DOM nodes — via take_heapsnapshot (saves snapshot to disk, parsed here)
|
|
6
|
-
* Nodes removed from the live DOM but still referenced in JS are retained
|
|
7
|
-
* in the V8 heap as "Detached HTMLXxx" objects, causing memory pressure.
|
|
8
|
-
* 2. Heap size growth — via performance.memory across navigate-away + navigate-back
|
|
9
|
-
* Significant heap growth after a round-trip indicates a per-load leak.
|
|
10
|
-
*
|
|
11
|
-
* Called as a standalone function after crawlRoute, like analyzeResponsive.
|
|
12
|
-
* The function always leaves the browser navigated to the target URL.
|
|
13
|
-
*
|
|
14
|
-
* Note: take_heapsnapshot requires a filePath argument — it writes the V8
|
|
15
|
-
* heap snapshot JSON to disk. We read and parse it, then delete the temp file.
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
|
-
import fs from 'fs';
|
|
19
|
-
import os from 'os';
|
|
20
|
-
import path from 'path';
|
|
21
|
-
import { unwrapEval } from './mcp-client.js';
|
|
22
|
-
import { registerExpensive } from '../registry.js';
|
|
23
|
-
import { thresholds } from '../config/targets.js';
|
|
24
|
-
import { childLogger } from './logger.js';
|
|
25
|
-
|
|
26
|
-
const logger = childLogger('memory-analyzer');
|
|
27
|
-
|
|
28
|
-
// ── Scripts ────────────────────────────────────────────────────────────────────
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Reads performance.memory from the page context.
|
|
32
|
-
* Chrome-only non-standard API, always present in Chrome/headless Chrome.
|
|
33
|
-
*/
|
|
34
|
-
const HEAP_SIZE_SCRIPT = `() => {
|
|
35
|
-
var m = window.performance && window.performance.memory;
|
|
36
|
-
if (!m) return JSON.stringify({ usedJSHeapSize: null });
|
|
37
|
-
return JSON.stringify({
|
|
38
|
-
usedJSHeapSize: m.usedJSHeapSize,
|
|
39
|
-
totalJSHeapSize: m.totalJSHeapSize,
|
|
40
|
-
jsHeapSizeLimit: m.jsHeapSizeLimit,
|
|
41
|
-
});
|
|
42
|
-
}`;
|
|
43
|
-
|
|
44
|
-
// ── Snapshot Parsing ───────────────────────────────────────────────────────────
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Walk a flat V8 heap snapshot nodes array and count detached DOM nodes.
|
|
48
|
-
*
|
|
49
|
-
* The nodes array is a flat int array: each record has nodeFields.length entries.
|
|
50
|
-
* The "name" field indexes into the strings table; Chrome serializes detached
|
|
51
|
-
* DOM elements with "Detached " prepended to their class name (e.g.
|
|
52
|
-
* "Detached HTMLDivElement"). If the `detachedness` field is present (Chrome 90+),
|
|
53
|
-
* value 2 = detached — checked as a secondary signal.
|
|
54
|
-
*
|
|
55
|
-
* @param {object} snapshot - Parsed V8 heap snapshot ({ snapshot, nodes, strings })
|
|
56
|
-
* @returns {{ detachedNodeCount: number, totalNodeCount: number|null }}
|
|
57
|
-
*/
|
|
58
|
-
function parseV8Snapshot(snapshot) {
|
|
59
|
-
const strings = Array.isArray(snapshot.strings) ? snapshot.strings : [];
|
|
60
|
-
const meta = snapshot.snapshot?.meta ?? {};
|
|
61
|
-
const nodeFields = Array.isArray(meta.node_fields) ? meta.node_fields : [];
|
|
62
|
-
|
|
63
|
-
let detachedCount = 0;
|
|
64
|
-
const nameIdx = nodeFields.indexOf('name');
|
|
65
|
-
const detachednessIdx = nodeFields.indexOf('detachedness');
|
|
66
|
-
|
|
67
|
-
if (Array.isArray(snapshot.nodes) && snapshot.nodes.length > 0 &&
|
|
68
|
-
(nameIdx !== -1 || detachednessIdx !== -1)) {
|
|
69
|
-
const stride = nodeFields.length;
|
|
70
|
-
const nodes = snapshot.nodes;
|
|
71
|
-
|
|
72
|
-
for (let i = 0; i < nodes.length; i += stride) {
|
|
73
|
-
// Primary: "Detached " prefix in node name string (all Chrome versions)
|
|
74
|
-
if (nameIdx !== -1) {
|
|
75
|
-
const strIdx = nodes[i + nameIdx];
|
|
76
|
-
if (strIdx < strings.length && /^Detached /.test(strings[strIdx])) {
|
|
77
|
-
detachedCount++;
|
|
78
|
-
continue;
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
// Secondary: detachedness field value 2 = detached (Chrome 90+)
|
|
82
|
-
if (detachednessIdx !== -1 && nodes[i + detachednessIdx] === 2) {
|
|
83
|
-
detachedCount++;
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
} else if (strings.length > 0) {
|
|
87
|
-
// Structural fields not found — use string presence as a lower-bound indicator
|
|
88
|
-
detachedCount = strings.filter(s => /^Detached (HTML|SVG|Text|Document)/.test(s)).length;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
return {
|
|
92
|
-
detachedNodeCount: detachedCount,
|
|
93
|
-
totalNodeCount: snapshot.snapshot?.node_count ?? null,
|
|
94
|
-
};
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
// ── Heap Size Helper ───────────────────────────────────────────────────────────
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* Read usedJSHeapSize from the currently-loaded page via evaluate_script.
|
|
101
|
-
* Returns null if performance.memory is unavailable.
|
|
102
|
-
*/
|
|
103
|
-
async function getHeapSize(browser) {
|
|
104
|
-
try {
|
|
105
|
-
const raw = await browser.evaluate(HEAP_SIZE_SCRIPT);
|
|
106
|
-
const val = unwrapEval(raw);
|
|
107
|
-
const parsed = typeof val === 'string' ? JSON.parse(val) : val;
|
|
108
|
-
return typeof parsed?.usedJSHeapSize === 'number' ? parsed.usedJSHeapSize : null;
|
|
109
|
-
} catch {
|
|
110
|
-
return null;
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
// ── Snapshot Capture ───────────────────────────────────────────────────────────
|
|
115
|
-
|
|
116
|
-
/**
|
|
117
|
-
* Take a V8 heap snapshot, save it to a temp file, parse it, and delete the file.
|
|
118
|
-
* take_heapsnapshot writes the snapshot JSON to disk (filePath is required).
|
|
119
|
-
*
|
|
120
|
-
* @param {object} browser - CdpBrowserAdapter
|
|
121
|
-
* @returns {Promise<{ detachedNodeCount: number, totalNodeCount: number|null } | null>}
|
|
122
|
-
*/
|
|
123
|
-
async function captureAndParseSnapshot(browser) {
|
|
124
|
-
// Include PID + random suffix — Date.now() alone can collide when two parallel
|
|
125
|
-
// shard workers both enter captureAndParseSnapshot within the same millisecond.
|
|
126
|
-
const filePath = path.join(os.tmpdir(), `argus-heap-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.heapsnapshot`);
|
|
127
|
-
try {
|
|
128
|
-
await browser.heapSnapshot({ filePath });
|
|
129
|
-
|
|
130
|
-
let raw;
|
|
131
|
-
try {
|
|
132
|
-
raw = await fs.promises.readFile(filePath, 'utf8');
|
|
133
|
-
} catch (readErr) {
|
|
134
|
-
if (readErr.code === 'ENOENT') {
|
|
135
|
-
logger.warn(`[ARGUS] Snapshot file not written at ${filePath}`);
|
|
136
|
-
return null;
|
|
137
|
-
}
|
|
138
|
-
throw readErr;
|
|
139
|
-
}
|
|
140
|
-
const snapshot = JSON.parse(raw);
|
|
141
|
-
return parseV8Snapshot(snapshot);
|
|
142
|
-
} catch (err) {
|
|
143
|
-
logger.warn(`[ARGUS] Snapshot capture/parse error: ${err.message}`);
|
|
144
|
-
return null;
|
|
145
|
-
} finally {
|
|
146
|
-
try { fs.unlinkSync(filePath); } catch { /* best-effort cleanup */ }
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
// ── Main Analysis ──────────────────────────────────────────────────────────────
|
|
151
|
-
|
|
152
|
-
/**
|
|
153
|
-
* Analyse a URL for memory leaks.
|
|
154
|
-
*
|
|
155
|
-
* Detection 1 — Detached DOM nodes (hard, deterministic):
|
|
156
|
-
* Navigate to url → wait → take_heapsnapshot to disk → parse for "Detached Xxx" nodes.
|
|
157
|
-
* Detached nodes are DOM elements removed from the live tree but still referenced
|
|
158
|
-
* in JS (e.g. stashed in a closure, array, or event handler), preventing GC.
|
|
159
|
-
*
|
|
160
|
-
* Detection 2 — Heap growth across navigate-away + back (soft, GC-dependent):
|
|
161
|
-
* Record baseline heap size → navigate to awayUrl → wait → navigate back → compare.
|
|
162
|
-
* A growing heap across identical page loads indicates a per-load leak.
|
|
163
|
-
* Tagged with soft:true so the harness can treat them as non-blocking.
|
|
164
|
-
*
|
|
165
|
-
* The function always ends with the browser on `url`.
|
|
166
|
-
*
|
|
167
|
-
* @param {object} browser - CdpBrowserAdapter
|
|
168
|
-
* @param {string} url - URL to analyse
|
|
169
|
-
* @param {string} [awayUrl='about:blank'] - Neutral URL for the navigate-away step
|
|
170
|
-
* @returns {Promise<object[]>} Memory findings (same shape as crawlRoute errors)
|
|
171
|
-
*/
|
|
172
|
-
export async function analyzeMemory(browser, url, awayUrl = 'about:blank') {
|
|
173
|
-
const findings = [];
|
|
174
|
-
|
|
175
|
-
// ── 1. Navigate to the target page ──────────────────────────────────────────
|
|
176
|
-
try {
|
|
177
|
-
await browser.navigate(url);
|
|
178
|
-
await new Promise(r => setTimeout(r, 1500)); // let JS run, detached nodes accumulate
|
|
179
|
-
} catch (err) {
|
|
180
|
-
logger.warn(`[ARGUS] Memory analysis navigation failed for ${url}: ${err.message}`);
|
|
181
|
-
return findings;
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
// ── 2. Detached DOM node detection via heap snapshot ─────────────────────────
|
|
185
|
-
try {
|
|
186
|
-
const parsed = await captureAndParseSnapshot(browser);
|
|
187
|
-
|
|
188
|
-
if (parsed !== null) {
|
|
189
|
-
const { detachedNodeCount: count, totalNodeCount } = parsed;
|
|
190
|
-
|
|
191
|
-
if (count > thresholds.memory.detachedCritical) {
|
|
192
|
-
findings.push({
|
|
193
|
-
type: 'memory_detached_dom_nodes',
|
|
194
|
-
count,
|
|
195
|
-
totalNodes: totalNodeCount,
|
|
196
|
-
message: `${count} detached DOM node(s) in heap — severe leak (threshold: >${thresholds.memory.detachedCritical})`,
|
|
197
|
-
severity: 'critical',
|
|
198
|
-
url,
|
|
199
|
-
});
|
|
200
|
-
} else if (count > thresholds.memory.detachedWarning) {
|
|
201
|
-
findings.push({
|
|
202
|
-
type: 'memory_detached_dom_nodes',
|
|
203
|
-
count,
|
|
204
|
-
totalNodes: totalNodeCount,
|
|
205
|
-
message: `${count} detached DOM node(s) in heap — probable leak (threshold: >${thresholds.memory.detachedWarning})`,
|
|
206
|
-
severity: 'warning',
|
|
207
|
-
url,
|
|
208
|
-
});
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
} catch (err) {
|
|
212
|
-
logger.warn(`[ARGUS] Detached node detection skipped for ${url}: ${err.message}`);
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
// ── 3. Heap growth — navigate-away + navigate-back ────────────────────────────
|
|
216
|
-
// GC timing makes this non-deterministic; findings are tagged soft:true.
|
|
217
|
-
try {
|
|
218
|
-
const baseline = await getHeapSize(browser);
|
|
219
|
-
|
|
220
|
-
if (baseline !== null) {
|
|
221
|
-
await browser.navigate(awayUrl);
|
|
222
|
-
await new Promise(r => setTimeout(r, 2000)); // allow GC pass
|
|
223
|
-
|
|
224
|
-
await browser.navigate(url);
|
|
225
|
-
await new Promise(r => setTimeout(r, 1500));
|
|
226
|
-
|
|
227
|
-
const post = await getHeapSize(browser);
|
|
228
|
-
|
|
229
|
-
if (post !== null) {
|
|
230
|
-
const growth = post - baseline;
|
|
231
|
-
|
|
232
|
-
if (growth > thresholds.memory.heapGrowthCritical) {
|
|
233
|
-
findings.push({
|
|
234
|
-
type: 'memory_heap_growth',
|
|
235
|
-
baselineBytes: baseline,
|
|
236
|
-
postBytes: post,
|
|
237
|
-
growthBytes: growth,
|
|
238
|
-
message: `Heap grew ${Math.round(growth / 1024)} KB after navigate-away + back — probable leak (baseline ${Math.round(baseline / 1024)} KB → post ${Math.round(post / 1024)} KB)`,
|
|
239
|
-
severity: 'critical',
|
|
240
|
-
soft: true,
|
|
241
|
-
url,
|
|
242
|
-
});
|
|
243
|
-
} else if (growth > thresholds.memory.heapGrowthWarning) {
|
|
244
|
-
findings.push({
|
|
245
|
-
type: 'memory_heap_growth',
|
|
246
|
-
baselineBytes: baseline,
|
|
247
|
-
postBytes: post,
|
|
248
|
-
growthBytes: growth,
|
|
249
|
-
message: `Heap grew ${Math.round(growth / 1024)} KB after navigate-away + back (baseline ${Math.round(baseline / 1024)} KB → post ${Math.round(post / 1024)} KB)`,
|
|
250
|
-
severity: 'warning',
|
|
251
|
-
soft: true,
|
|
252
|
-
url,
|
|
253
|
-
});
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
} catch (err) {
|
|
258
|
-
logger.warn(`[ARGUS] Heap growth check skipped for ${url}: ${err.message}`);
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
return findings;
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
// ── Self-registration ─────────────────────────────────────────────────────────
|
|
265
|
-
registerExpensive({
|
|
266
|
-
name: 'memory',
|
|
267
|
-
async analyze(browser, url) {
|
|
268
|
-
return analyzeMemory(browser, url);
|
|
269
|
-
},
|
|
270
|
-
});
|
|
1
|
+
/**
|
|
2
|
+
* ARGUS Memory Analyzer (v3 Phase B1)
|
|
3
|
+
*
|
|
4
|
+
* Two detection surfaces:
|
|
5
|
+
* 1. Detached DOM nodes — via take_heapsnapshot (saves snapshot to disk, parsed here)
|
|
6
|
+
* Nodes removed from the live DOM but still referenced in JS are retained
|
|
7
|
+
* in the V8 heap as "Detached HTMLXxx" objects, causing memory pressure.
|
|
8
|
+
* 2. Heap size growth — via performance.memory across navigate-away + navigate-back
|
|
9
|
+
* Significant heap growth after a round-trip indicates a per-load leak.
|
|
10
|
+
*
|
|
11
|
+
* Called as a standalone function after crawlRoute, like analyzeResponsive.
|
|
12
|
+
* The function always leaves the browser navigated to the target URL.
|
|
13
|
+
*
|
|
14
|
+
* Note: take_heapsnapshot requires a filePath argument — it writes the V8
|
|
15
|
+
* heap snapshot JSON to disk. We read and parse it, then delete the temp file.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import fs from 'fs';
|
|
19
|
+
import os from 'os';
|
|
20
|
+
import path from 'path';
|
|
21
|
+
import { unwrapEval } from './mcp-client.js';
|
|
22
|
+
import { registerExpensive } from '../registry.js';
|
|
23
|
+
import { thresholds } from '../config/targets.js';
|
|
24
|
+
import { childLogger } from './logger.js';
|
|
25
|
+
|
|
26
|
+
const logger = childLogger('memory-analyzer');
|
|
27
|
+
|
|
28
|
+
// ── Scripts ────────────────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Reads performance.memory from the page context.
|
|
32
|
+
* Chrome-only non-standard API, always present in Chrome/headless Chrome.
|
|
33
|
+
*/
|
|
34
|
+
const HEAP_SIZE_SCRIPT = `() => {
|
|
35
|
+
var m = window.performance && window.performance.memory;
|
|
36
|
+
if (!m) return JSON.stringify({ usedJSHeapSize: null });
|
|
37
|
+
return JSON.stringify({
|
|
38
|
+
usedJSHeapSize: m.usedJSHeapSize,
|
|
39
|
+
totalJSHeapSize: m.totalJSHeapSize,
|
|
40
|
+
jsHeapSizeLimit: m.jsHeapSizeLimit,
|
|
41
|
+
});
|
|
42
|
+
}`;
|
|
43
|
+
|
|
44
|
+
// ── Snapshot Parsing ───────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Walk a flat V8 heap snapshot nodes array and count detached DOM nodes.
|
|
48
|
+
*
|
|
49
|
+
* The nodes array is a flat int array: each record has nodeFields.length entries.
|
|
50
|
+
* The "name" field indexes into the strings table; Chrome serializes detached
|
|
51
|
+
* DOM elements with "Detached " prepended to their class name (e.g.
|
|
52
|
+
* "Detached HTMLDivElement"). If the `detachedness` field is present (Chrome 90+),
|
|
53
|
+
* value 2 = detached — checked as a secondary signal.
|
|
54
|
+
*
|
|
55
|
+
* @param {object} snapshot - Parsed V8 heap snapshot ({ snapshot, nodes, strings })
|
|
56
|
+
* @returns {{ detachedNodeCount: number, totalNodeCount: number|null }}
|
|
57
|
+
*/
|
|
58
|
+
function parseV8Snapshot(snapshot) {
|
|
59
|
+
const strings = Array.isArray(snapshot.strings) ? snapshot.strings : [];
|
|
60
|
+
const meta = snapshot.snapshot?.meta ?? {};
|
|
61
|
+
const nodeFields = Array.isArray(meta.node_fields) ? meta.node_fields : [];
|
|
62
|
+
|
|
63
|
+
let detachedCount = 0;
|
|
64
|
+
const nameIdx = nodeFields.indexOf('name');
|
|
65
|
+
const detachednessIdx = nodeFields.indexOf('detachedness');
|
|
66
|
+
|
|
67
|
+
if (Array.isArray(snapshot.nodes) && snapshot.nodes.length > 0 &&
|
|
68
|
+
(nameIdx !== -1 || detachednessIdx !== -1)) {
|
|
69
|
+
const stride = nodeFields.length;
|
|
70
|
+
const nodes = snapshot.nodes;
|
|
71
|
+
|
|
72
|
+
for (let i = 0; i < nodes.length; i += stride) {
|
|
73
|
+
// Primary: "Detached " prefix in node name string (all Chrome versions)
|
|
74
|
+
if (nameIdx !== -1) {
|
|
75
|
+
const strIdx = nodes[i + nameIdx];
|
|
76
|
+
if (strIdx < strings.length && /^Detached /.test(strings[strIdx])) {
|
|
77
|
+
detachedCount++;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
// Secondary: detachedness field value 2 = detached (Chrome 90+)
|
|
82
|
+
if (detachednessIdx !== -1 && nodes[i + detachednessIdx] === 2) {
|
|
83
|
+
detachedCount++;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
} else if (strings.length > 0) {
|
|
87
|
+
// Structural fields not found — use string presence as a lower-bound indicator
|
|
88
|
+
detachedCount = strings.filter(s => /^Detached (HTML|SVG|Text|Document)/.test(s)).length;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
detachedNodeCount: detachedCount,
|
|
93
|
+
totalNodeCount: snapshot.snapshot?.node_count ?? null,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ── Heap Size Helper ───────────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Read usedJSHeapSize from the currently-loaded page via evaluate_script.
|
|
101
|
+
* Returns null if performance.memory is unavailable.
|
|
102
|
+
*/
|
|
103
|
+
async function getHeapSize(browser) {
|
|
104
|
+
try {
|
|
105
|
+
const raw = await browser.evaluate(HEAP_SIZE_SCRIPT);
|
|
106
|
+
const val = unwrapEval(raw);
|
|
107
|
+
const parsed = typeof val === 'string' ? JSON.parse(val) : val;
|
|
108
|
+
return typeof parsed?.usedJSHeapSize === 'number' ? parsed.usedJSHeapSize : null;
|
|
109
|
+
} catch {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ── Snapshot Capture ───────────────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Take a V8 heap snapshot, save it to a temp file, parse it, and delete the file.
|
|
118
|
+
* take_heapsnapshot writes the snapshot JSON to disk (filePath is required).
|
|
119
|
+
*
|
|
120
|
+
* @param {object} browser - CdpBrowserAdapter
|
|
121
|
+
* @returns {Promise<{ detachedNodeCount: number, totalNodeCount: number|null } | null>}
|
|
122
|
+
*/
|
|
123
|
+
async function captureAndParseSnapshot(browser) {
|
|
124
|
+
// Include PID + random suffix — Date.now() alone can collide when two parallel
|
|
125
|
+
// shard workers both enter captureAndParseSnapshot within the same millisecond.
|
|
126
|
+
const filePath = path.join(os.tmpdir(), `argus-heap-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.heapsnapshot`);
|
|
127
|
+
try {
|
|
128
|
+
await browser.heapSnapshot({ filePath });
|
|
129
|
+
|
|
130
|
+
let raw;
|
|
131
|
+
try {
|
|
132
|
+
raw = await fs.promises.readFile(filePath, 'utf8');
|
|
133
|
+
} catch (readErr) {
|
|
134
|
+
if (readErr.code === 'ENOENT') {
|
|
135
|
+
logger.warn(`[ARGUS] Snapshot file not written at ${filePath}`);
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
throw readErr;
|
|
139
|
+
}
|
|
140
|
+
const snapshot = JSON.parse(raw);
|
|
141
|
+
return parseV8Snapshot(snapshot);
|
|
142
|
+
} catch (err) {
|
|
143
|
+
logger.warn(`[ARGUS] Snapshot capture/parse error: ${err.message}`);
|
|
144
|
+
return null;
|
|
145
|
+
} finally {
|
|
146
|
+
try { fs.unlinkSync(filePath); } catch { /* best-effort cleanup */ }
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ── Main Analysis ──────────────────────────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Analyse a URL for memory leaks.
|
|
154
|
+
*
|
|
155
|
+
* Detection 1 — Detached DOM nodes (hard, deterministic):
|
|
156
|
+
* Navigate to url → wait → take_heapsnapshot to disk → parse for "Detached Xxx" nodes.
|
|
157
|
+
* Detached nodes are DOM elements removed from the live tree but still referenced
|
|
158
|
+
* in JS (e.g. stashed in a closure, array, or event handler), preventing GC.
|
|
159
|
+
*
|
|
160
|
+
* Detection 2 — Heap growth across navigate-away + back (soft, GC-dependent):
|
|
161
|
+
* Record baseline heap size → navigate to awayUrl → wait → navigate back → compare.
|
|
162
|
+
* A growing heap across identical page loads indicates a per-load leak.
|
|
163
|
+
* Tagged with soft:true so the harness can treat them as non-blocking.
|
|
164
|
+
*
|
|
165
|
+
* The function always ends with the browser on `url`.
|
|
166
|
+
*
|
|
167
|
+
* @param {object} browser - CdpBrowserAdapter
|
|
168
|
+
* @param {string} url - URL to analyse
|
|
169
|
+
* @param {string} [awayUrl='about:blank'] - Neutral URL for the navigate-away step
|
|
170
|
+
* @returns {Promise<object[]>} Memory findings (same shape as crawlRoute errors)
|
|
171
|
+
*/
|
|
172
|
+
export async function analyzeMemory(browser, url, awayUrl = 'about:blank') {
|
|
173
|
+
const findings = [];
|
|
174
|
+
|
|
175
|
+
// ── 1. Navigate to the target page ──────────────────────────────────────────
|
|
176
|
+
try {
|
|
177
|
+
await browser.navigate(url);
|
|
178
|
+
await new Promise(r => setTimeout(r, 1500)); // let JS run, detached nodes accumulate
|
|
179
|
+
} catch (err) {
|
|
180
|
+
logger.warn(`[ARGUS] Memory analysis navigation failed for ${url}: ${err.message}`);
|
|
181
|
+
return findings;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ── 2. Detached DOM node detection via heap snapshot ─────────────────────────
|
|
185
|
+
try {
|
|
186
|
+
const parsed = await captureAndParseSnapshot(browser);
|
|
187
|
+
|
|
188
|
+
if (parsed !== null) {
|
|
189
|
+
const { detachedNodeCount: count, totalNodeCount } = parsed;
|
|
190
|
+
|
|
191
|
+
if (count > thresholds.memory.detachedCritical) {
|
|
192
|
+
findings.push({
|
|
193
|
+
type: 'memory_detached_dom_nodes',
|
|
194
|
+
count,
|
|
195
|
+
totalNodes: totalNodeCount,
|
|
196
|
+
message: `${count} detached DOM node(s) in heap — severe leak (threshold: >${thresholds.memory.detachedCritical})`,
|
|
197
|
+
severity: 'critical',
|
|
198
|
+
url,
|
|
199
|
+
});
|
|
200
|
+
} else if (count > thresholds.memory.detachedWarning) {
|
|
201
|
+
findings.push({
|
|
202
|
+
type: 'memory_detached_dom_nodes',
|
|
203
|
+
count,
|
|
204
|
+
totalNodes: totalNodeCount,
|
|
205
|
+
message: `${count} detached DOM node(s) in heap — probable leak (threshold: >${thresholds.memory.detachedWarning})`,
|
|
206
|
+
severity: 'warning',
|
|
207
|
+
url,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
} catch (err) {
|
|
212
|
+
logger.warn(`[ARGUS] Detached node detection skipped for ${url}: ${err.message}`);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ── 3. Heap growth — navigate-away + navigate-back ────────────────────────────
|
|
216
|
+
// GC timing makes this non-deterministic; findings are tagged soft:true.
|
|
217
|
+
try {
|
|
218
|
+
const baseline = await getHeapSize(browser);
|
|
219
|
+
|
|
220
|
+
if (baseline !== null) {
|
|
221
|
+
await browser.navigate(awayUrl);
|
|
222
|
+
await new Promise(r => setTimeout(r, 2000)); // allow GC pass
|
|
223
|
+
|
|
224
|
+
await browser.navigate(url);
|
|
225
|
+
await new Promise(r => setTimeout(r, 1500));
|
|
226
|
+
|
|
227
|
+
const post = await getHeapSize(browser);
|
|
228
|
+
|
|
229
|
+
if (post !== null) {
|
|
230
|
+
const growth = post - baseline;
|
|
231
|
+
|
|
232
|
+
if (growth > thresholds.memory.heapGrowthCritical) {
|
|
233
|
+
findings.push({
|
|
234
|
+
type: 'memory_heap_growth',
|
|
235
|
+
baselineBytes: baseline,
|
|
236
|
+
postBytes: post,
|
|
237
|
+
growthBytes: growth,
|
|
238
|
+
message: `Heap grew ${Math.round(growth / 1024)} KB after navigate-away + back — probable leak (baseline ${Math.round(baseline / 1024)} KB → post ${Math.round(post / 1024)} KB)`,
|
|
239
|
+
severity: 'critical',
|
|
240
|
+
soft: true,
|
|
241
|
+
url,
|
|
242
|
+
});
|
|
243
|
+
} else if (growth > thresholds.memory.heapGrowthWarning) {
|
|
244
|
+
findings.push({
|
|
245
|
+
type: 'memory_heap_growth',
|
|
246
|
+
baselineBytes: baseline,
|
|
247
|
+
postBytes: post,
|
|
248
|
+
growthBytes: growth,
|
|
249
|
+
message: `Heap grew ${Math.round(growth / 1024)} KB after navigate-away + back (baseline ${Math.round(baseline / 1024)} KB → post ${Math.round(post / 1024)} KB)`,
|
|
250
|
+
severity: 'warning',
|
|
251
|
+
soft: true,
|
|
252
|
+
url,
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
} catch (err) {
|
|
258
|
+
logger.warn(`[ARGUS] Heap growth check skipped for ${url}: ${err.message}`);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return findings;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ── Self-registration ─────────────────────────────────────────────────────────
|
|
265
|
+
registerExpensive({
|
|
266
|
+
name: 'memory',
|
|
267
|
+
async analyze(browser, url) {
|
|
268
|
+
return analyzeMemory(browser, url);
|
|
269
|
+
},
|
|
270
|
+
});
|