@memlab/mcp-server 2.1.1 → 2.18.1
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 +83 -3
- package/dist/analysis-budget.d.ts +71 -0
- package/dist/analysis-budget.d.ts.map +1 -0
- package/dist/analysis-budget.js +118 -0
- package/dist/analysis-budget.js.map +1 -0
- package/dist/guardrail.d.ts +24 -0
- package/dist/guardrail.d.ts.map +1 -0
- package/dist/guardrail.js +65 -0
- package/dist/guardrail.js.map +1 -0
- package/dist/heap-state.d.ts +53 -1
- package/dist/heap-state.d.ts.map +1 -1
- package/dist/heap-state.js +153 -10
- package/dist/heap-state.js.map +1 -1
- package/dist/index.js +38 -1
- package/dist/index.js.map +1 -1
- package/dist/tools/array-group-by.d.ts +12 -0
- package/dist/tools/array-group-by.d.ts.map +1 -0
- package/dist/tools/array-group-by.js +191 -0
- package/dist/tools/array-group-by.js.map +1 -0
- package/dist/tools/auto-investigate.d.ts.map +1 -1
- package/dist/tools/auto-investigate.js +1056 -52
- package/dist/tools/auto-investigate.js.map +1 -1
- package/dist/tools/cache-analysis.d.ts.map +1 -1
- package/dist/tools/cache-analysis.js +438 -21
- package/dist/tools/cache-analysis.js.map +1 -1
- package/dist/tools/check-health.d.ts.map +1 -1
- package/dist/tools/check-health.js +261 -1
- package/dist/tools/check-health.js.map +1 -1
- package/dist/tools/class-histogram.d.ts.map +1 -1
- package/dist/tools/class-histogram.js +142 -33
- package/dist/tools/class-histogram.js.map +1 -1
- package/dist/tools/closure-inspection.d.ts.map +1 -1
- package/dist/tools/closure-inspection.js +238 -25
- package/dist/tools/closure-inspection.js.map +1 -1
- package/dist/tools/detached-dom.d.ts.map +1 -1
- package/dist/tools/detached-dom.js +25 -3
- package/dist/tools/detached-dom.js.map +1 -1
- package/dist/tools/dev-artifacts.d.ts +33 -0
- package/dist/tools/dev-artifacts.d.ts.map +1 -0
- package/dist/tools/dev-artifacts.js +165 -0
- package/dist/tools/dev-artifacts.js.map +1 -0
- package/dist/tools/diff-snapshots.d.ts.map +1 -1
- package/dist/tools/diff-snapshots.js +200 -48
- package/dist/tools/diff-snapshots.js.map +1 -1
- package/dist/tools/duplicated-strings.d.ts.map +1 -1
- package/dist/tools/duplicated-strings.js +74 -12
- package/dist/tools/duplicated-strings.js.map +1 -1
- package/dist/tools/eval.d.ts.map +1 -1
- package/dist/tools/eval.js +189 -10
- package/dist/tools/eval.js.map +1 -1
- package/dist/tools/event-listener-leaks.d.ts.map +1 -1
- package/dist/tools/event-listener-leaks.js +235 -13
- package/dist/tools/event-listener-leaks.js.map +1 -1
- package/dist/tools/event-registry.d.ts +12 -0
- package/dist/tools/event-registry.d.ts.map +1 -0
- package/dist/tools/event-registry.js +183 -0
- package/dist/tools/event-registry.js.map +1 -0
- package/dist/tools/find-by-property.d.ts.map +1 -1
- package/dist/tools/find-by-property.js +113 -31
- package/dist/tools/find-by-property.js.map +1 -1
- package/dist/tools/find-by-shape.d.ts +12 -0
- package/dist/tools/find-by-shape.d.ts.map +1 -0
- package/dist/tools/find-by-shape.js +178 -0
- package/dist/tools/find-by-shape.js.map +1 -0
- package/dist/tools/find-nodes-by-class.d.ts.map +1 -1
- package/dist/tools/find-nodes-by-class.js +50 -6
- package/dist/tools/find-nodes-by-class.js.map +1 -1
- package/dist/tools/for-each.d.ts.map +1 -1
- package/dist/tools/for-each.js +1 -1
- package/dist/tools/for-each.js.map +1 -1
- package/dist/tools/get-node.d.ts.map +1 -1
- package/dist/tools/get-node.js +6 -0
- package/dist/tools/get-node.js.map +1 -1
- package/dist/tools/get-references.d.ts.map +1 -1
- package/dist/tools/get-references.js +35 -6
- package/dist/tools/get-references.js.map +1 -1
- package/dist/tools/get-referrers.d.ts.map +1 -1
- package/dist/tools/get-referrers.js +35 -6
- package/dist/tools/get-referrers.js.map +1 -1
- package/dist/tools/get-string.d.ts +12 -0
- package/dist/tools/get-string.d.ts.map +1 -0
- package/dist/tools/get-string.js +223 -0
- package/dist/tools/get-string.js.map +1 -0
- package/dist/tools/get-value.d.ts +12 -0
- package/dist/tools/get-value.d.ts.map +1 -0
- package/dist/tools/get-value.js +152 -0
- package/dist/tools/get-value.js.map +1 -0
- package/dist/tools/global-variables.d.ts.map +1 -1
- package/dist/tools/global-variables.js +54 -34
- package/dist/tools/global-variables.js.map +1 -1
- package/dist/tools/growth-signals.d.ts +12 -0
- package/dist/tools/growth-signals.d.ts.map +1 -0
- package/dist/tools/growth-signals.js +211 -0
- package/dist/tools/growth-signals.js.map +1 -0
- package/dist/tools/intern-opportunities.d.ts +12 -0
- package/dist/tools/intern-opportunities.d.ts.map +1 -0
- package/dist/tools/intern-opportunities.js +870 -0
- package/dist/tools/intern-opportunities.js.map +1 -0
- package/dist/tools/largest-objects.d.ts.map +1 -1
- package/dist/tools/largest-objects.js +37 -14
- package/dist/tools/largest-objects.js.map +1 -1
- package/dist/tools/load-snapshot.d.ts +70 -0
- package/dist/tools/load-snapshot.d.ts.map +1 -1
- package/dist/tools/load-snapshot.js +446 -17
- package/dist/tools/load-snapshot.js.map +1 -1
- package/dist/tools/object-cost-breakdown.d.ts +12 -0
- package/dist/tools/object-cost-breakdown.d.ts.map +1 -0
- package/dist/tools/object-cost-breakdown.js +439 -0
- package/dist/tools/object-cost-breakdown.js.map +1 -0
- package/dist/tools/object-shape.d.ts.map +1 -1
- package/dist/tools/object-shape.js +109 -2
- package/dist/tools/object-shape.js.map +1 -1
- package/dist/tools/pinch-points.d.ts.map +1 -1
- package/dist/tools/pinch-points.js +9 -9
- package/dist/tools/pinch-points.js.map +1 -1
- package/dist/tools/property-distribution.d.ts +12 -0
- package/dist/tools/property-distribution.d.ts.map +1 -0
- package/dist/tools/property-distribution.js +168 -0
- package/dist/tools/property-distribution.js.map +1 -0
- package/dist/tools/quick-diagnosis.d.ts.map +1 -1
- package/dist/tools/quick-diagnosis.js +65 -20
- package/dist/tools/quick-diagnosis.js.map +1 -1
- package/dist/tools/retainer-summary.d.ts.map +1 -1
- package/dist/tools/retainer-summary.js +90 -5
- package/dist/tools/retainer-summary.js.map +1 -1
- package/dist/tools/retainer-trace.d.ts.map +1 -1
- package/dist/tools/retainer-trace.js +173 -43
- package/dist/tools/retainer-trace.js.map +1 -1
- package/dist/tools/search-nodes.d.ts.map +1 -1
- package/dist/tools/search-nodes.js +10 -3
- package/dist/tools/search-nodes.js.map +1 -1
- package/dist/tools/search-strings.d.ts +12 -0
- package/dist/tools/search-strings.d.ts.map +1 -0
- package/dist/tools/search-strings.js +182 -0
- package/dist/tools/search-strings.js.map +1 -0
- package/dist/tools/sequence-analysis.d.ts +12 -0
- package/dist/tools/sequence-analysis.d.ts.map +1 -0
- package/dist/tools/sequence-analysis.js +244 -0
- package/dist/tools/sequence-analysis.js.map +1 -0
- package/dist/tools/server-status.d.ts +12 -0
- package/dist/tools/server-status.d.ts.map +1 -0
- package/dist/tools/server-status.js +41 -0
- package/dist/tools/server-status.js.map +1 -0
- package/dist/tools/shape-histogram.d.ts.map +1 -1
- package/dist/tools/shape-histogram.js +61 -16
- package/dist/tools/shape-histogram.js.map +1 -1
- package/dist/tools/sliced-strings.d.ts.map +1 -1
- package/dist/tools/sliced-strings.js +38 -15
- package/dist/tools/sliced-strings.js.map +1 -1
- package/dist/tools/snapshot-header.d.ts +28 -0
- package/dist/tools/snapshot-header.d.ts.map +1 -0
- package/dist/tools/snapshot-header.js +111 -0
- package/dist/tools/snapshot-header.js.map +1 -0
- package/dist/tools/snapshot-summary.d.ts.map +1 -1
- package/dist/tools/snapshot-summary.js +55 -13
- package/dist/tools/snapshot-summary.js.map +1 -1
- package/dist/tools/snapshots.d.ts +12 -0
- package/dist/tools/snapshots.d.ts.map +1 -0
- package/dist/tools/snapshots.js +93 -0
- package/dist/tools/snapshots.js.map +1 -0
- package/dist/tools/stale-collections.d.ts.map +1 -1
- package/dist/tools/stale-collections.js +248 -7
- package/dist/tools/stale-collections.js.map +1 -1
- package/dist/tools/trace-dominators.d.ts.map +1 -1
- package/dist/tools/trace-dominators.js +28 -1
- package/dist/tools/trace-dominators.js.map +1 -1
- package/dist/tools/weakmap-entries.d.ts +12 -0
- package/dist/tools/weakmap-entries.d.ts.map +1 -0
- package/dist/tools/weakmap-entries.js +156 -0
- package/dist/tools/weakmap-entries.js.map +1 -0
- package/dist/utils.d.ts +139 -2
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +448 -35
- package/dist/utils.js.map +1 -1
- package/package.json +3 -3
|
@@ -7,9 +7,11 @@
|
|
|
7
7
|
* @format
|
|
8
8
|
* @oncall memory_lab
|
|
9
9
|
*/
|
|
10
|
+
import memlabCore from '@memlab/core';
|
|
11
|
+
const { utils, NumericSet } = memlabCore;
|
|
10
12
|
import { z } from 'zod';
|
|
11
13
|
import { getSnapshot, getSnapshotMetadata } from '../heap-state.js';
|
|
12
|
-
import { filterLargestObjects, isNodeWorthInspecting, formatBytes, formatNumber, truncateNodeName, errorResult, toolResult, } from '../utils.js';
|
|
14
|
+
import { filterLargestObjects, isNodeWorthInspecting, formatBytes, formatNumber, formatRetainerTree, truncateNodeName, instrumentationRetainerNote, errorResult, toolResult, suggestionsSuppressed, } from '../utils.js';
|
|
13
15
|
function classifySeverity(retainedSize, totalSize) {
|
|
14
16
|
if (totalSize === 0)
|
|
15
17
|
return 'LOW';
|
|
@@ -244,6 +246,47 @@ function scanTopShapes(snapshot, limit) {
|
|
|
244
246
|
.sort((a, b) => b.totalSelfSize - a.totalSelfSize)
|
|
245
247
|
.slice(0, limit);
|
|
246
248
|
}
|
|
249
|
+
function scanDistributedAccumulation(snapshot, totalSize) {
|
|
250
|
+
const classMap = new Map();
|
|
251
|
+
snapshot.nodes.forEach(node => {
|
|
252
|
+
if (node.id <= 3)
|
|
253
|
+
return;
|
|
254
|
+
if (node.type === 'hidden' || node.type === 'array')
|
|
255
|
+
return;
|
|
256
|
+
const key = `${node.type}::${node.name}`;
|
|
257
|
+
const existing = classMap.get(key);
|
|
258
|
+
if (existing) {
|
|
259
|
+
existing.count++;
|
|
260
|
+
existing.totalSelfSize += node.self_size;
|
|
261
|
+
}
|
|
262
|
+
else {
|
|
263
|
+
classMap.set(key, {
|
|
264
|
+
className: node.name,
|
|
265
|
+
nodeType: node.type,
|
|
266
|
+
count: 1,
|
|
267
|
+
totalSelfSize: node.self_size,
|
|
268
|
+
exampleNodeId: node.id,
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
return [...classMap.values()]
|
|
273
|
+
.filter(c => {
|
|
274
|
+
if (c.count < 50_000)
|
|
275
|
+
return false;
|
|
276
|
+
const avgSize = c.totalSelfSize / c.count;
|
|
277
|
+
if (avgSize > 10_000)
|
|
278
|
+
return false;
|
|
279
|
+
if (totalSize > 0 && c.totalSelfSize < totalSize * 0.03)
|
|
280
|
+
return false;
|
|
281
|
+
return true;
|
|
282
|
+
})
|
|
283
|
+
.map(c => ({
|
|
284
|
+
...c,
|
|
285
|
+
avgSize: c.totalSelfSize / c.count,
|
|
286
|
+
}))
|
|
287
|
+
.sort((a, b) => b.totalSelfSize - a.totalSelfSize)
|
|
288
|
+
.slice(0, 5);
|
|
289
|
+
}
|
|
247
290
|
const LISTENER_SHAPE_KEYWORDS = new Set([
|
|
248
291
|
'callback',
|
|
249
292
|
'handler',
|
|
@@ -264,7 +307,15 @@ const LISTENER_CONTEXT_KEYWORDS = new Set([
|
|
|
264
307
|
'scope',
|
|
265
308
|
'self',
|
|
266
309
|
]);
|
|
267
|
-
function scanSubscriptionAccumulation(snapshot, threshold) {
|
|
310
|
+
function scanSubscriptionAccumulation(snapshot, threshold, totalSize) {
|
|
311
|
+
const CALLBACK_PROPS = new Set(['callback', 'fn', 'handler', 'listener']);
|
|
312
|
+
const CONTEXT_PROPS_SET = new Set([
|
|
313
|
+
'context',
|
|
314
|
+
'ctx',
|
|
315
|
+
'this',
|
|
316
|
+
'target',
|
|
317
|
+
'scope',
|
|
318
|
+
]);
|
|
268
319
|
const shapeMap = new Map();
|
|
269
320
|
snapshot.nodes.forEach(node => {
|
|
270
321
|
if (node.type !== 'object' || node.id <= 3)
|
|
@@ -272,9 +323,20 @@ function scanSubscriptionAccumulation(snapshot, threshold) {
|
|
|
272
323
|
if (node.name !== 'Object')
|
|
273
324
|
return;
|
|
274
325
|
const names = [];
|
|
326
|
+
let callbackId = 0;
|
|
327
|
+
let contextId = 0;
|
|
328
|
+
let contextName = '';
|
|
275
329
|
for (const edge of node.references) {
|
|
276
330
|
if (edge.type === 'property') {
|
|
277
|
-
|
|
331
|
+
const pName = String(edge.name_or_index);
|
|
332
|
+
names.push(pName);
|
|
333
|
+
if (CALLBACK_PROPS.has(pName)) {
|
|
334
|
+
callbackId = edge.toNode.id;
|
|
335
|
+
}
|
|
336
|
+
if (CONTEXT_PROPS_SET.has(pName)) {
|
|
337
|
+
contextId = edge.toNode.id;
|
|
338
|
+
contextName = edge.toNode.name;
|
|
339
|
+
}
|
|
278
340
|
}
|
|
279
341
|
}
|
|
280
342
|
if (names.length === 0 || names.length > 10)
|
|
@@ -285,10 +347,29 @@ function scanSubscriptionAccumulation(snapshot, threshold) {
|
|
|
285
347
|
if (existing) {
|
|
286
348
|
existing.count++;
|
|
287
349
|
existing.totalSelfSize += node.self_size;
|
|
350
|
+
if (callbackId > 0)
|
|
351
|
+
existing.callbackIds.add(callbackId);
|
|
352
|
+
if (contextId > 0) {
|
|
353
|
+
existing.contextIds.add(contextId);
|
|
354
|
+
existing.contextShapeMap.set(contextName, (existing.contextShapeMap.get(contextName) ?? 0) + 1);
|
|
355
|
+
const ctxNode = snapshot.getNodeById(contextId);
|
|
356
|
+
if (ctxNode && ctxNode.numOfReferrers <= 2) {
|
|
357
|
+
existing.orphanedContexts++;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
288
360
|
}
|
|
289
361
|
else {
|
|
290
362
|
const hasListener = names.some(n => LISTENER_SHAPE_KEYWORDS.has(n) || n.startsWith('on'));
|
|
291
363
|
const hasContext = names.some(n => LISTENER_CONTEXT_KEYWORDS.has(n));
|
|
364
|
+
const contextShapeMap = new Map();
|
|
365
|
+
let orphanedContexts = 0;
|
|
366
|
+
if (contextId > 0) {
|
|
367
|
+
contextShapeMap.set(contextName, 1);
|
|
368
|
+
const ctxNode = snapshot.getNodeById(contextId);
|
|
369
|
+
if (ctxNode && ctxNode.numOfReferrers <= 2) {
|
|
370
|
+
orphanedContexts = 1;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
292
373
|
shapeMap.set(key, {
|
|
293
374
|
properties: names,
|
|
294
375
|
count: 1,
|
|
@@ -296,12 +377,47 @@ function scanSubscriptionAccumulation(snapshot, threshold) {
|
|
|
296
377
|
exampleNodeId: node.id,
|
|
297
378
|
hasListenerProp: hasListener,
|
|
298
379
|
hasContextProp: hasContext,
|
|
380
|
+
callbackIds: new Set(callbackId > 0 ? [callbackId] : []),
|
|
381
|
+
contextIds: new Set(contextId > 0 ? [contextId] : []),
|
|
382
|
+
contextShapeMap,
|
|
383
|
+
orphanedContexts,
|
|
299
384
|
});
|
|
300
385
|
}
|
|
301
386
|
});
|
|
302
|
-
|
|
387
|
+
const results = [...shapeMap.values()]
|
|
303
388
|
.filter(s => s.count >= threshold && s.hasListenerProp && s.hasContextProp)
|
|
304
389
|
.sort((a, b) => b.totalSelfSize - a.totalSelfSize);
|
|
390
|
+
return results.map(s => {
|
|
391
|
+
const hasHighFanOut = s.callbackIds.size <= 5 && s.contextIds.size > 10;
|
|
392
|
+
const hasOrphanedContexts = s.orphanedContexts > s.contextIds.size * 0.5;
|
|
393
|
+
let fanOut;
|
|
394
|
+
if (hasHighFanOut || hasOrphanedContexts) {
|
|
395
|
+
let escalatedSeverity = classifySeverity(s.totalSelfSize, totalSize);
|
|
396
|
+
if (hasHighFanOut &&
|
|
397
|
+
hasOrphanedContexts &&
|
|
398
|
+
(escalatedSeverity === 'LOW' || escalatedSeverity === 'MEDIUM')) {
|
|
399
|
+
escalatedSeverity = s.orphanedContexts > 100 ? 'CRITICAL' : 'HIGH';
|
|
400
|
+
}
|
|
401
|
+
const contextShapes = [...s.contextShapeMap.entries()]
|
|
402
|
+
.map(([name, count]) => ({ name, count }))
|
|
403
|
+
.sort((a, b) => b.count - a.count)
|
|
404
|
+
.slice(0, 5);
|
|
405
|
+
fanOut = {
|
|
406
|
+
uniqueCallbacks: s.callbackIds.size,
|
|
407
|
+
uniqueContexts: s.contextIds.size,
|
|
408
|
+
orphanedContexts: s.orphanedContexts,
|
|
409
|
+
contextShapes,
|
|
410
|
+
escalatedSeverity,
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
return {
|
|
414
|
+
properties: s.properties,
|
|
415
|
+
count: s.count,
|
|
416
|
+
totalSelfSize: s.totalSelfSize,
|
|
417
|
+
exampleNodeId: s.exampleNodeId,
|
|
418
|
+
fanOut,
|
|
419
|
+
};
|
|
420
|
+
});
|
|
305
421
|
}
|
|
306
422
|
const ERROR_CLASS_NAMES = new Set([
|
|
307
423
|
'Error',
|
|
@@ -354,9 +470,46 @@ function scanErrorAccumulation(snapshot, minCount) {
|
|
|
354
470
|
function tracePrefix(trace) {
|
|
355
471
|
return trace.map(s => `${s.name}(${s.type})`).join('->');
|
|
356
472
|
}
|
|
473
|
+
/**
|
|
474
|
+
* True if `ancestor` dominates `descendant` in the dominator tree (i.e. all
|
|
475
|
+
* paths from the GC root to `descendant` pass through `ancestor`, so
|
|
476
|
+
* `ancestor.retainedSize` already includes `descendant.retainedSize`). Walks
|
|
477
|
+
* the unique dominator chain upward, bounded to avoid pathological depth.
|
|
478
|
+
*/
|
|
479
|
+
function isDominatorAncestor(ancestor, descendant, maxWalk = 500) {
|
|
480
|
+
if (ancestor.id === descendant.id)
|
|
481
|
+
return false;
|
|
482
|
+
let cur = descendant.dominatorNode ?? null;
|
|
483
|
+
let steps = 0;
|
|
484
|
+
while (cur && steps < maxWalk) {
|
|
485
|
+
if (cur.id === ancestor.id)
|
|
486
|
+
return true;
|
|
487
|
+
if (cur.dominatorNode?.id === cur.id)
|
|
488
|
+
break; // reached root self-loop
|
|
489
|
+
cur = cur.dominatorNode ?? null;
|
|
490
|
+
steps++;
|
|
491
|
+
}
|
|
492
|
+
return false;
|
|
493
|
+
}
|
|
494
|
+
/**
|
|
495
|
+
* Correct combined retained size for a set of nodes that may overlap on the
|
|
496
|
+
* dominator tree. In a dominator tree two nodes' retained subtrees are either
|
|
497
|
+
* nested or disjoint, so only dominator-maximal members are counted (any node
|
|
498
|
+
* dominated by another member has its bytes already included). This never
|
|
499
|
+
* exceeds the heap total — fixing the ">100% of heap" accounting.
|
|
500
|
+
*
|
|
501
|
+
* Delegates to memlab's `aggregateDominatorMetrics`, which dedups by walking
|
|
502
|
+
* each node's dominator chain once against a set — O(N·D) (N = nodes, D =
|
|
503
|
+
* dominator depth), versus the previous O(N²·D) pairwise `isDominatorAncestor`
|
|
504
|
+
* scan.
|
|
505
|
+
*/
|
|
506
|
+
function combinedRetainedSize(nodes, snapshot) {
|
|
507
|
+
return utils.aggregateDominatorMetrics(new NumericSet(nodes.map(n => n.id)), snapshot, () => true, (node) => node.retainedSize);
|
|
508
|
+
}
|
|
357
509
|
function deduplicateFindings(findings) {
|
|
358
510
|
if (findings.length <= 1)
|
|
359
511
|
return findings;
|
|
512
|
+
// Phase 1: Collapse findings that share the same retainer trace prefix
|
|
360
513
|
const prefixMap = new Map();
|
|
361
514
|
for (const f of findings) {
|
|
362
515
|
const collapsed = f.trace.filter(s => s.type !== 'hidden' &&
|
|
@@ -384,12 +537,54 @@ function deduplicateFindings(findings) {
|
|
|
384
537
|
prefixMap.set(prefix, { representative: f, others: [] });
|
|
385
538
|
}
|
|
386
539
|
}
|
|
387
|
-
const
|
|
540
|
+
const prefixResult = [];
|
|
388
541
|
for (const { representative, others } of prefixMap.values()) {
|
|
389
542
|
representative.collapsed_siblings = others;
|
|
390
|
-
|
|
543
|
+
prefixResult.push(representative);
|
|
391
544
|
}
|
|
392
|
-
|
|
545
|
+
// Phase 2: Merge findings that retain the *same dominated subtree* — whether
|
|
546
|
+
// because one node dominates the other in the dominator tree (so their
|
|
547
|
+
// retained sizes overlap) or because the same big subtree is reachable two
|
|
548
|
+
// ways and shows up under two different top retainers (Feedback §2a, §1a).
|
|
549
|
+
// The larger-retained node becomes the representative; the other is recorded
|
|
550
|
+
// as an alternate path so we report ONE finding instead of two ~equal ones.
|
|
551
|
+
if (prefixResult.length <= 1)
|
|
552
|
+
return prefixResult;
|
|
553
|
+
const traceNodeSets = prefixResult.map(f => {
|
|
554
|
+
const ids = new Set();
|
|
555
|
+
for (const step of f.trace) {
|
|
556
|
+
ids.add(step.nodeId);
|
|
557
|
+
}
|
|
558
|
+
return ids;
|
|
559
|
+
});
|
|
560
|
+
const absorbed = new Set();
|
|
561
|
+
for (let i = 0; i < prefixResult.length; i++) {
|
|
562
|
+
if (absorbed.has(i))
|
|
563
|
+
continue;
|
|
564
|
+
for (let j = i + 1; j < prefixResult.length; j++) {
|
|
565
|
+
if (absorbed.has(j))
|
|
566
|
+
continue;
|
|
567
|
+
const a = prefixResult[i].node;
|
|
568
|
+
const b = prefixResult[j].node;
|
|
569
|
+
const related = isDominatorAncestor(a, b) ||
|
|
570
|
+
isDominatorAncestor(b, a) ||
|
|
571
|
+
traceNodeSets[i].has(b.id) ||
|
|
572
|
+
traceNodeSets[j].has(a.id);
|
|
573
|
+
if (!related)
|
|
574
|
+
continue;
|
|
575
|
+
// Keep the larger-retained finding as representative.
|
|
576
|
+
const keepIdx = a.retainedSize >= b.retainedSize ? i : j;
|
|
577
|
+
const dropIdx = keepIdx === i ? j : i;
|
|
578
|
+
const siblings = prefixResult[keepIdx].collapsed_siblings ?? [];
|
|
579
|
+
siblings.push(prefixResult[dropIdx]);
|
|
580
|
+
siblings.push(...(prefixResult[dropIdx].collapsed_siblings ?? []));
|
|
581
|
+
prefixResult[keepIdx].collapsed_siblings = siblings;
|
|
582
|
+
absorbed.add(dropIdx);
|
|
583
|
+
if (dropIdx === i)
|
|
584
|
+
break; // i itself was absorbed; advance outer loop
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
return prefixResult.filter((_, idx) => !absorbed.has(idx));
|
|
393
588
|
}
|
|
394
589
|
function formatTrace(trace, maxSteps) {
|
|
395
590
|
const collapsed = [];
|
|
@@ -403,22 +598,558 @@ function formatTrace(trace, maxSteps) {
|
|
|
403
598
|
continue;
|
|
404
599
|
collapsed.push(step);
|
|
405
600
|
}
|
|
406
|
-
const
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
601
|
+
const overLimit = collapsed.length > maxSteps;
|
|
602
|
+
const shown = overLimit
|
|
603
|
+
? [...collapsed.slice(0, 3), ...collapsed.slice(-2)]
|
|
604
|
+
: collapsed;
|
|
605
|
+
// Render as a top-down indented tree so the retention direction is
|
|
606
|
+
// unambiguous (GC root on top, each node retained by the one above it).
|
|
607
|
+
// `s.edgeName` is the OUTGOING edge (s -> next); the edge pointing INTO a node
|
|
608
|
+
// is the previous node's edgeName, which is what the renderer surfaces.
|
|
609
|
+
const treeSteps = shown.map((s, i) => ({
|
|
610
|
+
id: s.nodeId,
|
|
611
|
+
name: truncateNodeName(s.name, s.type, s.selfSize, 50),
|
|
612
|
+
type: s.type,
|
|
613
|
+
retainedSize: s.retainedSize,
|
|
614
|
+
// `shown[i - 1].edgeName` is the previous node's OUTGOING edge = the edge
|
|
615
|
+
// INTO this node, but only when the two are actually adjacent. At the
|
|
616
|
+
// truncation boundary (i === 3) `shown[2]`'s outgoing edge points into the
|
|
617
|
+
// elided `collapsed[3]`, not into this tail node, so leave it undefined
|
|
618
|
+
// rather than misattribute an unrelated edge — the gap is already marked by
|
|
619
|
+
// `collapsedBefore`.
|
|
620
|
+
edgeName: i > 0 && !(overLimit && i === 3) ? shown[i - 1].edgeName : undefined,
|
|
621
|
+
// The two tail nodes were sliced off the end, so the hop into the first of
|
|
622
|
+
// them crosses the elided middle — mark the gap.
|
|
623
|
+
collapsedBefore: overLimit && i === 3 ? collapsed.length - 5 : undefined,
|
|
624
|
+
}));
|
|
625
|
+
return formatRetainerTree(treeSteps, { showSizes: true });
|
|
626
|
+
}
|
|
627
|
+
function computeTraceOverlap(a, b) {
|
|
628
|
+
let prefixLen = 0;
|
|
629
|
+
const minLen = Math.min(a.length, b.length);
|
|
630
|
+
for (let i = 0; i < minLen; i++) {
|
|
631
|
+
if (a[i].nodeId === b[i].nodeId) {
|
|
632
|
+
prefixLen++;
|
|
633
|
+
}
|
|
634
|
+
else {
|
|
635
|
+
break;
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
return { prefixLen, matchIndex: -1 };
|
|
639
|
+
}
|
|
640
|
+
function findBestTraceMatch(trace, previousTraces) {
|
|
641
|
+
let bestMatch = null;
|
|
642
|
+
for (let j = 0; j < previousTraces.length; j++) {
|
|
643
|
+
const { prefixLen } = computeTraceOverlap(trace, previousTraces[j]);
|
|
644
|
+
if (prefixLen > trace.length * 0.5) {
|
|
645
|
+
if (!bestMatch || prefixLen > bestMatch.prefixLen) {
|
|
646
|
+
bestMatch = { matchedIndex: j, prefixLen };
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
return bestMatch;
|
|
651
|
+
}
|
|
652
|
+
function detectAsyncContextLeaks(findings) {
|
|
653
|
+
const alerts = [];
|
|
654
|
+
const seen = new Set();
|
|
655
|
+
for (const f of findings) {
|
|
656
|
+
const traceNames = f.trace.map(s => s.name);
|
|
657
|
+
const traceEdges = f.trace.map(s => s.edgeName ?? '');
|
|
658
|
+
// Pattern 1: TCP/Socket → kResourceStore → afterContext (Next.js request context leak)
|
|
659
|
+
const hasTCP = traceNames.some(n => n === 'TCP' || n === 'Socket' || n.includes('TCP'));
|
|
660
|
+
const hasResourceStore = traceEdges.some(e => e === 'kResourceStore' || e === 'resource_symbol');
|
|
661
|
+
const hasAfterContext = traceEdges.some(e => e === 'afterContext');
|
|
662
|
+
const hasOnClose = traceEdges.some(e => e === 'onClose');
|
|
663
|
+
const hasContext = traceNames.some(n => n === 'system / Context' || n.startsWith('system /'));
|
|
664
|
+
if (hasTCP &&
|
|
665
|
+
hasResourceStore &&
|
|
666
|
+
(hasAfterContext || hasOnClose) &&
|
|
667
|
+
hasContext &&
|
|
668
|
+
!seen.has('tcp-als')) {
|
|
669
|
+
seen.add('tcp-als');
|
|
670
|
+
alerts.push(`🔴 **[CRITICAL] AsyncLocalStorage context retained by TCP connection**\n` +
|
|
671
|
+
`A TCP socket's async context chain retains ${formatBytes(f.node.retainedSize)} of request-scoped data. ` +
|
|
672
|
+
`The \`onClose\` closure captures the request's AsyncLocalStorage context, which chains to prior contexts via \`previous\` pointers.\n` +
|
|
673
|
+
`**Fix:** Ensure response bodies are fully consumed/canceled (check \`resp.body.cancel()\`), ` +
|
|
674
|
+
`or avoid capturing large data in request-scoped closures.`);
|
|
675
|
+
}
|
|
676
|
+
// Pattern 2: Generator.parameters_and_registers holding large arrays (Undici response body retention)
|
|
677
|
+
const hasGenerator = traceNames.some(n => n === 'Generator' || n.includes('Generator'));
|
|
678
|
+
const hasParamsAndRegisters = traceEdges.some(e => e === 'parameters_and_registers');
|
|
679
|
+
if (hasGenerator &&
|
|
680
|
+
hasParamsAndRegisters &&
|
|
681
|
+
f.node.retainedSize > 1024 * 1024 &&
|
|
682
|
+
!seen.has('generator-undici')) {
|
|
683
|
+
seen.add('generator-undici');
|
|
684
|
+
alerts.push(`🟠 **[HIGH] Generator retaining large data via \`parameters_and_registers\`**\n` +
|
|
685
|
+
`A Generator object holds ${formatBytes(f.node.retainedSize)} through its parameters/registers. ` +
|
|
686
|
+
`This is a common Undici pattern where an async generator (response body stream) retains the full response buffer.\n` +
|
|
687
|
+
`**Fix:** Ensure response bodies are fully read and the stream is closed/destroyed after use. ` +
|
|
688
|
+
`For fetch(), always call \`resp.text()\`, \`resp.json()\`, or \`resp.body.cancel()\`.`);
|
|
689
|
+
}
|
|
690
|
+
// Pattern 3: PromiseReaction chains with large retained sizes (unresolved/leaked promises)
|
|
691
|
+
const hasPromiseReaction = traceNames.some(n => n === 'PromiseReaction' || n === 'Promise');
|
|
692
|
+
const hasReactionEdge = traceEdges.some(e => e === 'reactions_or_result' ||
|
|
693
|
+
e === 'fulfill_handler' ||
|
|
694
|
+
e === 'reject_handler');
|
|
695
|
+
if (hasPromiseReaction &&
|
|
696
|
+
hasReactionEdge &&
|
|
697
|
+
f.node.retainedSize > 5 * 1024 * 1024 &&
|
|
698
|
+
!seen.has('promise-chain')) {
|
|
699
|
+
seen.add('promise-chain');
|
|
700
|
+
alerts.push(`🟠 **[HIGH] Unresolved Promise chain retaining ${formatBytes(f.node.retainedSize)}**\n` +
|
|
701
|
+
`A PromiseReaction chain is keeping large data alive. This often indicates a stuck async operation ` +
|
|
702
|
+
`(HTTP request, DB query, timer) whose promise was never resolved or rejected.\n` +
|
|
703
|
+
`**Fix:** Add timeouts to async operations. Check for \`await\` on promises that may never resolve. ` +
|
|
704
|
+
`Ensure \`.catch()\` handlers don't capture the entire response.`);
|
|
705
|
+
}
|
|
706
|
+
// Pattern 4: Undici Client/Pool response retention (Feedback #6)
|
|
707
|
+
const hasUndiciClient = traceNames.some(n => n === 'Client' || n === 'Pool' || n === 'Agent');
|
|
708
|
+
const hasRequestQueue = traceEdges.some(e => e === 'requests' || e === 'queue' || e === 'pending' || e === 'running');
|
|
709
|
+
if (hasUndiciClient &&
|
|
710
|
+
hasRequestQueue &&
|
|
711
|
+
f.node.retainedSize > 1024 * 1024 &&
|
|
712
|
+
!seen.has('undici-client')) {
|
|
713
|
+
seen.add('undici-client');
|
|
714
|
+
alerts.push(`🟠 **[HIGH] Undici HTTP Client/Pool retaining ${formatBytes(f.node.retainedSize)} via request queue**\n` +
|
|
715
|
+
`An Undici Client or Pool object is keeping response data alive through its request queue or closure chain. ` +
|
|
716
|
+
`This typically happens when HTTP response bodies are not fully consumed or canceled.\n` +
|
|
717
|
+
`**Fix:** Always consume or cancel response bodies: \`await resp.text()\`, \`resp.body.cancel()\`, ` +
|
|
718
|
+
`or \`resp.body.destroy()\`. For streaming responses, ensure the readable stream is fully drained.`);
|
|
719
|
+
}
|
|
720
|
+
// Pattern 5: mysql2 Connection retaining large data (Feedback #8)
|
|
721
|
+
const hasMysqlConn = traceNames.some(n => n === 'Connection' ||
|
|
722
|
+
n === 'PoolConnection' ||
|
|
723
|
+
n === 'PromiseConnection');
|
|
724
|
+
const hasMysqlEdge = traceEdges.some(e => e === '_protocol' || e === '_statements' || e === 'connectionConfig');
|
|
725
|
+
if (hasMysqlConn &&
|
|
726
|
+
hasMysqlEdge &&
|
|
727
|
+
f.node.retainedSize > 5 * 1024 * 1024 &&
|
|
728
|
+
!seen.has('mysql2-conn')) {
|
|
729
|
+
seen.add('mysql2-conn');
|
|
730
|
+
alerts.push(`🟠 **[HIGH] mysql2 Connection retaining ${formatBytes(f.node.retainedSize)}**\n` +
|
|
731
|
+
`A mysql2 Connection object is keeping query results or protocol buffers alive, often through ` +
|
|
732
|
+
`error event handler closure chains or unreleased prepared statements.\n` +
|
|
733
|
+
`**Fix:** Set pool \`idleTimeout\` (e.g., 60000ms) to free idle connections. ` +
|
|
734
|
+
`Ensure query result rows are not captured in long-lived closures. ` +
|
|
735
|
+
`Consider \`stream()\` instead of \`query()\` for large result sets.`);
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
return alerts;
|
|
739
|
+
}
|
|
740
|
+
// Framework class-name signatures. These are scanned heap-wide (not just along
|
|
741
|
+
// the top retainer traces) because framework internals frequently accumulate
|
|
742
|
+
// across many small objects that never surface as a single "top retained
|
|
743
|
+
// object" — yet are high-signal root causes (Feedback §2b).
|
|
744
|
+
const OTEL_METRIC_CLASSES = new Set([
|
|
745
|
+
'SyncMetricStorage',
|
|
746
|
+
'AsyncMetricStorage',
|
|
747
|
+
'TemporalMetricProcessor',
|
|
748
|
+
'DeltaMetricProcessor',
|
|
749
|
+
'AttributeHashMap',
|
|
750
|
+
'HashMap',
|
|
751
|
+
'MetricStorageRegistry',
|
|
752
|
+
]);
|
|
753
|
+
const RELAY_STORE_CLASSES = new Set([
|
|
754
|
+
'RelayModernStore',
|
|
755
|
+
'RelayRecordSource',
|
|
756
|
+
'RecordSource',
|
|
757
|
+
'RelayModernRecord',
|
|
758
|
+
'RelayReader',
|
|
759
|
+
'DataChecker',
|
|
760
|
+
]);
|
|
761
|
+
function mapEntryCount(node) {
|
|
762
|
+
for (const edge of node.references) {
|
|
763
|
+
const n = String(edge.name_or_index);
|
|
764
|
+
if ((n === 'table' || n === 'backing_store') &&
|
|
765
|
+
(edge.toNode.type === 'array' || edge.toNode.type === 'hidden')) {
|
|
766
|
+
return edge.toNode.edge_count;
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
return node.edge_count;
|
|
770
|
+
}
|
|
771
|
+
// In-process source-map caches (@jridgewell/trace-mapping). These decode huge
|
|
772
|
+
// nested mapping arrays for stack-trace symbolication and are a recurring
|
|
773
|
+
// top-retainer across the Nest SSR fleet (Feedback round 3 §2a).
|
|
774
|
+
const SOURCEMAP_CLASSES = new Set(['TraceMap', 'AnyMap', 'SourceMapConsumer']);
|
|
775
|
+
function detectFrameworkLeaks(snapshot, totalSize) {
|
|
776
|
+
const alerts = [];
|
|
777
|
+
const otel = new Map();
|
|
778
|
+
const relay = new Map();
|
|
779
|
+
const sourceMaps = {
|
|
780
|
+
count: 0,
|
|
781
|
+
totalRetained: 0,
|
|
782
|
+
decodedRetained: 0,
|
|
783
|
+
encodedRetained: 0,
|
|
784
|
+
sourcesContentRetained: 0,
|
|
785
|
+
exampleId: 0,
|
|
786
|
+
exampleRetained: 0,
|
|
787
|
+
};
|
|
788
|
+
let topGenerator = null;
|
|
789
|
+
let generatorCount = 0;
|
|
790
|
+
const accumulate = (map, node, entries) => {
|
|
791
|
+
const e = map.get(node.name);
|
|
792
|
+
if (e) {
|
|
793
|
+
e.count++;
|
|
794
|
+
e.totalRetained += node.retainedSize;
|
|
795
|
+
e.maxEntries = Math.max(e.maxEntries, entries);
|
|
796
|
+
if (node.retainedSize >
|
|
797
|
+
(snapshot.getNodeById(e.exampleId)?.retainedSize ?? 0)) {
|
|
798
|
+
e.exampleId = node.id;
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
else {
|
|
802
|
+
map.set(node.name, {
|
|
803
|
+
count: 1,
|
|
804
|
+
totalRetained: node.retainedSize,
|
|
805
|
+
exampleId: node.id,
|
|
806
|
+
maxEntries: entries,
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
};
|
|
810
|
+
snapshot.nodes.forEach(node => {
|
|
811
|
+
if (node.id <= 3)
|
|
812
|
+
return;
|
|
813
|
+
if (OTEL_METRIC_CLASSES.has(node.name) && node.type === 'object') {
|
|
814
|
+
// Find the largest child Map/HashMap (the attribute->aggregation table).
|
|
815
|
+
let entries = 0;
|
|
816
|
+
for (const edge of node.references) {
|
|
817
|
+
const t = edge.toNode;
|
|
818
|
+
if (t.name === 'Map' || t.name === 'HashMap' || t.name === 'Set') {
|
|
819
|
+
entries = Math.max(entries, mapEntryCount(t));
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
accumulate(otel, node, entries);
|
|
823
|
+
}
|
|
824
|
+
if (RELAY_STORE_CLASSES.has(node.name) && node.type === 'object') {
|
|
825
|
+
accumulate(relay, node, mapEntryCount(node));
|
|
826
|
+
}
|
|
827
|
+
if (SOURCEMAP_CLASSES.has(node.name) && node.type === 'object') {
|
|
828
|
+
sourceMaps.count++;
|
|
829
|
+
sourceMaps.totalRetained += node.retainedSize;
|
|
830
|
+
if (node.retainedSize > sourceMaps.exampleRetained) {
|
|
831
|
+
sourceMaps.exampleRetained = node.retainedSize;
|
|
832
|
+
sourceMaps.exampleId = node.id;
|
|
833
|
+
}
|
|
834
|
+
for (const edge of node.references) {
|
|
835
|
+
if (edge.type !== 'property')
|
|
836
|
+
continue;
|
|
837
|
+
const p = String(edge.name_or_index);
|
|
838
|
+
if (p === '_decoded') {
|
|
839
|
+
sourceMaps.decodedRetained += edge.toNode.retainedSize;
|
|
840
|
+
}
|
|
841
|
+
else if (p === '_encoded') {
|
|
842
|
+
sourceMaps.encodedRetained += edge.toNode.retainedSize;
|
|
843
|
+
}
|
|
844
|
+
else if (p === 'sourcesContent') {
|
|
845
|
+
sourceMaps.sourcesContentRetained += edge.toNode.retainedSize;
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
if (node.name === 'Generator' &&
|
|
850
|
+
node.type === 'object' &&
|
|
851
|
+
node.retainedSize > 1024 * 1024) {
|
|
852
|
+
generatorCount++;
|
|
853
|
+
if (!topGenerator || node.retainedSize > topGenerator.retained) {
|
|
854
|
+
topGenerator = { id: node.id, retained: node.retainedSize };
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
});
|
|
858
|
+
// OpenTelemetry metric cardinality explosion.
|
|
859
|
+
const otelTotal = [...otel.values()].reduce((s, e) => s + e.totalRetained, 0);
|
|
860
|
+
const otelMaxEntries = Math.max(0, ...[...otel.values()].map(e => e.maxEntries));
|
|
861
|
+
if (otel.size > 0 &&
|
|
862
|
+
(otelTotal > 5 * 1024 * 1024 ||
|
|
863
|
+
(totalSize > 0 && otelTotal > totalSize * 0.03) ||
|
|
864
|
+
otelMaxEntries >= 1000)) {
|
|
865
|
+
const top = [...otel.entries()]
|
|
866
|
+
.sort((a, b) => b[1].totalRetained - a[1].totalRetained)
|
|
867
|
+
.slice(0, 3)
|
|
868
|
+
.map(([name, e]) => `${name} (${formatNumber(e.count)}×, ${formatBytes(e.totalRetained)}${e.maxEntries > 0 ? `, up to ${formatNumber(e.maxEntries)} attribute keys` : ''}, e.g. @${e.exampleId})`)
|
|
869
|
+
.join('; ');
|
|
870
|
+
alerts.push(`🔴 **[CRITICAL] OpenTelemetry metric cardinality explosion**\n` +
|
|
871
|
+
`OTel metric-storage internals retain ${formatBytes(otelTotal)} via per-attribute aggregation maps: ${top}.\n` +
|
|
872
|
+
`This is the classic Node OTel footgun: a high-cardinality attribute (request id, full URL with ids, user id) is used as a metric dimension, so each distinct value allocates a new aggregation in the \`AttributeHashMap\` / \`_valueMap\`.\n` +
|
|
873
|
+
`**Confirm:** \`memlab_property_distribution\` on the metric attribute shape, or \`memlab_search_strings\` for the serialized attribute keys (e.g. \`[["http.route","/x/<uuid>"]]\`).\n` +
|
|
874
|
+
`**Fix:** remove or bucket high-cardinality attributes (template the route: \`/artifact/:id\`), or add an allowed-attributes view so unbounded dimensions never enter the metric.`);
|
|
875
|
+
}
|
|
876
|
+
// Relay store growth.
|
|
877
|
+
const relayTotal = [...relay.values()].reduce((s, e) => s + e.totalRetained, 0);
|
|
878
|
+
if (relay.size > 0 &&
|
|
879
|
+
(relayTotal > 5 * 1024 * 1024 ||
|
|
880
|
+
(totalSize > 0 && relayTotal > totalSize * 0.05))) {
|
|
881
|
+
const top = [...relay.entries()]
|
|
882
|
+
.sort((a, b) => b[1].totalRetained - a[1].totalRetained)
|
|
883
|
+
.slice(0, 3)
|
|
884
|
+
.map(([name, e]) => `${name} (${formatNumber(e.count)}×, ${formatBytes(e.totalRetained)}, e.g. @${e.exampleId})`)
|
|
885
|
+
.join('; ');
|
|
886
|
+
alerts.push(`🟠 **[HIGH] Relay store retaining ${formatBytes(relayTotal)}**\n` +
|
|
887
|
+
`Relay record source/store internals are holding a large normalized cache: ${top}.\n` +
|
|
888
|
+
`**Fix:** ensure queries are disposed/GC'd (check \`retain()\` without matching \`dispose()\`), enable store garbage collection (\`gcReleaseBufferSize\`), or scope long-lived environments per request instead of a single global store.`);
|
|
889
|
+
}
|
|
890
|
+
// In-process source-map / symbolicator caches.
|
|
891
|
+
if (sourceMaps.count > 0 &&
|
|
892
|
+
(sourceMaps.totalRetained > 2 * 1024 * 1024 ||
|
|
893
|
+
(totalSize > 0 && sourceMaps.totalRetained > totalSize * 0.03))) {
|
|
894
|
+
const parts = [];
|
|
895
|
+
if (sourceMaps.decodedRetained > 0) {
|
|
896
|
+
parts.push(`_decoded ${formatBytes(sourceMaps.decodedRetained)}`);
|
|
897
|
+
}
|
|
898
|
+
if (sourceMaps.encodedRetained > 0) {
|
|
899
|
+
parts.push(`_encoded ${formatBytes(sourceMaps.encodedRetained)}`);
|
|
900
|
+
}
|
|
901
|
+
if (sourceMaps.sourcesContentRetained > 0) {
|
|
902
|
+
parts.push(`sourcesContent ${formatBytes(sourceMaps.sourcesContentRetained)}`);
|
|
903
|
+
}
|
|
904
|
+
const split = parts.length > 0 ? ` (${parts.join(', ')})` : '';
|
|
905
|
+
alerts.push(`🟠 **[HIGH] In-process source-map cache retaining ${formatBytes(sourceMaps.totalRetained)}**\n` +
|
|
906
|
+
`${formatNumber(sourceMaps.count)} \`TraceMap\`/source-map object(s) (\`@jridgewell/trace-mapping\`) are cached in-process for stack-trace symbolication${split}. Largest: @${sourceMaps.exampleId}. ` +
|
|
907
|
+
`The decoded mappings (\`_decoded\`) are huge nested arrays; on the Nest SSR fleet these are typically held by the OTel structured logger, the patched console, or a \`beforeExit\` closure.\n` +
|
|
908
|
+
`**Inspect:** \`memlab_dominator_subtree(${sourceMaps.exampleId})\` to see the decoded arrays; \`memlab_retainer_trace(${sourceMaps.exampleId})\` for who holds the cache.\n` +
|
|
909
|
+
`**Fix:** strip \`sourcesContent\` from the maps, cap/evict the symbolication cache, or move symbolication out-of-process. Every Nest server that symbolicates errors in-process hits this.`);
|
|
910
|
+
}
|
|
911
|
+
// Suspended async functions (generators) retaining large locals.
|
|
912
|
+
if (topGenerator) {
|
|
913
|
+
const g = topGenerator;
|
|
914
|
+
alerts.push(`🟠 **[HIGH] Suspended async function retaining ${formatBytes(g.retained)}**\n` +
|
|
915
|
+
`${formatNumber(generatorCount)} generator/async frame(s) >1 MB are parked mid-\`await\`, keeping their local variables (response buffers, accumulated rows) alive while the awaited resource never settles. Largest: @${g.id}.\n` +
|
|
916
|
+
`**Inspect:** \`memlab_closure_inspection(${g.id})\` resolves the frame's \`parameters_and_registers\` to source variable names.\n` +
|
|
917
|
+
`**Fix:** add timeouts/cancellation to the awaited operation; fully consume or cancel streamed response bodies (\`await resp.text()\` / \`resp.body.cancel()\`); avoid capturing whole datasets in the async frame.`);
|
|
918
|
+
}
|
|
919
|
+
return alerts;
|
|
920
|
+
}
|
|
921
|
+
/**
|
|
922
|
+
* Classify the long-lived GC root a closure hangs off of, by scanning its
|
|
923
|
+
* retainer path for the high-signal structures that pin request-scoped data for
|
|
924
|
+
* a connection/process lifetime (Feedback round 3 §2b/§2c).
|
|
925
|
+
*/
|
|
926
|
+
function classifyLongLivedHolder(trace) {
|
|
927
|
+
for (const s of trace) {
|
|
928
|
+
const n = s.name;
|
|
929
|
+
const e = s.edgeName ?? '';
|
|
930
|
+
if (n === 'Timeout' ||
|
|
931
|
+
n === 'TimersList' ||
|
|
932
|
+
e === '_onTimeout' ||
|
|
933
|
+
e === '_idleNext' ||
|
|
934
|
+
e === '_repeat') {
|
|
935
|
+
return 'setInterval/setTimeout timer';
|
|
936
|
+
}
|
|
937
|
+
if (e === 'onClose')
|
|
938
|
+
return 'onClose handler';
|
|
939
|
+
if (n === 'TCP' || n === 'Socket' || n === 'ReusedHandle') {
|
|
940
|
+
return 'keep-alive socket';
|
|
941
|
+
}
|
|
942
|
+
if (e === 'kResourceStore' ||
|
|
943
|
+
e === 'resource_symbol' ||
|
|
944
|
+
/AsyncLocalStorage/.test(n)) {
|
|
945
|
+
return 'AsyncLocalStorage store';
|
|
946
|
+
}
|
|
947
|
+
if (e === '_events' || /EventEmitter/.test(n))
|
|
948
|
+
return 'EventEmitter listener';
|
|
949
|
+
if (e === 'signal' || n === 'AbortSignal')
|
|
950
|
+
return 'AbortSignal listener';
|
|
951
|
+
}
|
|
952
|
+
return null;
|
|
953
|
+
}
|
|
954
|
+
/**
|
|
955
|
+
* Find the single largest variable a closure/context captures — the dominant
|
|
956
|
+
* captured scope member, which is usually what makes an oversized handler
|
|
957
|
+
* closure expensive.
|
|
958
|
+
*/
|
|
959
|
+
function dominantCapturedVar(closure) {
|
|
960
|
+
let ctx = null;
|
|
961
|
+
for (const edge of closure.references) {
|
|
962
|
+
if ((edge.type === 'internal' || edge.type === 'context') &&
|
|
963
|
+
String(edge.name_or_index) === 'context') {
|
|
964
|
+
ctx = edge.toNode;
|
|
965
|
+
break;
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
let best = null;
|
|
969
|
+
const consider = (node) => {
|
|
970
|
+
for (const edge of node.references) {
|
|
971
|
+
if (edge.type !== 'context' && edge.type !== 'property')
|
|
972
|
+
continue;
|
|
973
|
+
const name = String(edge.name_or_index);
|
|
974
|
+
if (name === 'previous' || name === 'native_context')
|
|
975
|
+
continue;
|
|
976
|
+
const t = edge.toNode;
|
|
977
|
+
if (t.id <= 3)
|
|
978
|
+
continue;
|
|
979
|
+
if (!best || t.retainedSize > best.retained) {
|
|
980
|
+
best = { name, retained: t.retainedSize, targetName: t.name };
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
};
|
|
984
|
+
consider(closure);
|
|
985
|
+
if (ctx)
|
|
986
|
+
consider(ctx);
|
|
987
|
+
return best;
|
|
988
|
+
}
|
|
989
|
+
/**
|
|
990
|
+
* Proactively surface the most common Node server leak shape this round: a
|
|
991
|
+
* long-lived closure (setInterval/onClose/listener) that captures a large
|
|
992
|
+
* variable it barely uses, pinning it for the connection/process lifetime.
|
|
993
|
+
* `closure_inspection` already computes the per-variable breakdown — this just
|
|
994
|
+
* flags the handler closures so the model doesn't have to suspect them first
|
|
995
|
+
* (Feedback round 3 §2b).
|
|
996
|
+
*/
|
|
997
|
+
function scanOversizedClosures(snapshot, totalSize) {
|
|
998
|
+
const closureMin = Math.max(1024 * 1024, totalSize > 0 ? totalSize * 0.02 : 0);
|
|
999
|
+
const candidates = [];
|
|
1000
|
+
snapshot.nodes.forEach(node => {
|
|
1001
|
+
if (node.type !== 'closure' || node.id <= 3)
|
|
1002
|
+
return;
|
|
1003
|
+
if (node.retainedSize < closureMin)
|
|
1004
|
+
return;
|
|
1005
|
+
candidates.push(node);
|
|
1006
|
+
});
|
|
1007
|
+
candidates.sort((a, b) => b.retainedSize - a.retainedSize);
|
|
1008
|
+
const alerts = [];
|
|
1009
|
+
const seen = new Set();
|
|
1010
|
+
for (const closure of candidates.slice(0, 30)) {
|
|
1011
|
+
const trace = getRetainerPath(closure);
|
|
1012
|
+
const holder = classifyLongLivedHolder(trace);
|
|
1013
|
+
if (!holder)
|
|
1014
|
+
continue;
|
|
1015
|
+
const dom = dominantCapturedVar(closure);
|
|
1016
|
+
const key = `${holder}|${dom?.name ?? ''}`;
|
|
1017
|
+
if (seen.has(key))
|
|
1018
|
+
continue;
|
|
1019
|
+
seen.add(key);
|
|
1020
|
+
const domStr = dom
|
|
1021
|
+
? ` Its largest captured variable is \`${dom.name}\` → ${dom.targetName} (${formatBytes(dom.retained)})` +
|
|
1022
|
+
(dom.retained > closure.retainedSize * 0.6
|
|
1023
|
+
? ' — the bulk of the retention, and quite possibly captured but barely used.'
|
|
1024
|
+
: '.')
|
|
1025
|
+
: '';
|
|
1026
|
+
alerts.push(`🟠 **[HIGH] Oversized closure on a long-lived ${holder} retaining ${formatBytes(closure.retainedSize)}**\n` +
|
|
1027
|
+
`Closure @${closure.id}${closure.name ? ` \`${truncateNodeName(closure.name, closure.type, closure.self_size, 40)}\`` : ''} is reachable from a ${holder} and keeps its captured scope alive for that root's lifetime.${domStr}\n` +
|
|
1028
|
+
`**Inspect:** \`memlab_closure_inspection(${closure.id}, {min_retained_size: 102400})\` for the full per-variable capture breakdown.\n` +
|
|
1029
|
+
`**Fix:** extract only the fields the callback needs before registering it (don't close over the whole request scope / serialized blob), or clear the timer / unregister the listener when the request or connection ends.`);
|
|
1030
|
+
if (alerts.length >= 5)
|
|
1031
|
+
break;
|
|
1032
|
+
}
|
|
1033
|
+
return alerts;
|
|
1034
|
+
}
|
|
1035
|
+
// Keys that mark a record as a log/conversation/message entry. An append-only
|
|
1036
|
+
// accumulation of these is a runaway-appender signature (Feedback round 3 §2d).
|
|
1037
|
+
const MESSAGE_RECORD_KEYS = new Set([
|
|
1038
|
+
'role',
|
|
1039
|
+
'content',
|
|
1040
|
+
'message',
|
|
1041
|
+
'msg',
|
|
1042
|
+
'timestamp',
|
|
1043
|
+
'ts',
|
|
1044
|
+
'level',
|
|
1045
|
+
'type',
|
|
1046
|
+
'text',
|
|
1047
|
+
]);
|
|
1048
|
+
// Fields whose value usually discriminates the record kind, used to test
|
|
1049
|
+
// whether a few values repeat across most records.
|
|
1050
|
+
const DISCRIMINATOR_KEYS = ['role', 'level', 'type', 'message', 'msg'];
|
|
1051
|
+
/**
|
|
1052
|
+
* A property whose value is expected to be unique per record. If such a field
|
|
1053
|
+
* has far fewer distinct values than instances, the SAME records are retained
|
|
1054
|
+
* multiple times (Feedback round 3 §1b).
|
|
1055
|
+
*/
|
|
1056
|
+
function looksUniqueKey(name) {
|
|
1057
|
+
return (/^(id|_id|uuid|guid|key|hash|sku|pk|cursor|etag)$/i.test(name) ||
|
|
1058
|
+
/(_id|Id|Key|Uuid|Guid|Hash)$/.test(name));
|
|
1059
|
+
}
|
|
1060
|
+
/**
|
|
1061
|
+
* Detect append-only logs/conversations: a very high count of a uniform record
|
|
1062
|
+
* shape dominated by a few repeated field values (e.g. 696K `{role, content,
|
|
1063
|
+
* timestamp}` objects from a scheduler that re-appends the same system/error
|
|
1064
|
+
* message forever). `growth_signals` misses these because they aren't keyed the
|
|
1065
|
+
* way its heuristic expects (Feedback round 3 §2d).
|
|
1066
|
+
*/
|
|
1067
|
+
function scanAppendOnlyRecords(snapshot, totalSize) {
|
|
1068
|
+
const shapeMap = new Map();
|
|
1069
|
+
snapshot.nodes.forEach(node => {
|
|
1070
|
+
if (node.type !== 'object' || node.id <= 3)
|
|
1071
|
+
return;
|
|
1072
|
+
if (node.name !== 'Object')
|
|
1073
|
+
return;
|
|
1074
|
+
const names = [];
|
|
1075
|
+
for (const edge of node.references) {
|
|
1076
|
+
if (edge.type === 'property')
|
|
1077
|
+
names.push(String(edge.name_or_index));
|
|
1078
|
+
}
|
|
1079
|
+
if (names.length === 0 || names.length > 12)
|
|
1080
|
+
return;
|
|
1081
|
+
if (!names.some(n => MESSAGE_RECORD_KEYS.has(n.toLowerCase())))
|
|
1082
|
+
return;
|
|
1083
|
+
names.sort();
|
|
1084
|
+
const key = names.join(',');
|
|
1085
|
+
const ex = shapeMap.get(key);
|
|
1086
|
+
if (ex) {
|
|
1087
|
+
ex.count++;
|
|
1088
|
+
ex.totalSelfSize += node.self_size;
|
|
1089
|
+
if (ex.exampleIds.length < 100)
|
|
1090
|
+
ex.exampleIds.push(node.id);
|
|
419
1091
|
}
|
|
1092
|
+
else {
|
|
1093
|
+
shapeMap.set(key, {
|
|
1094
|
+
properties: names,
|
|
1095
|
+
count: 1,
|
|
1096
|
+
totalSelfSize: node.self_size,
|
|
1097
|
+
exampleNodeId: node.id,
|
|
1098
|
+
exampleIds: [node.id],
|
|
1099
|
+
});
|
|
1100
|
+
}
|
|
1101
|
+
});
|
|
1102
|
+
const hits = [...shapeMap.values()]
|
|
1103
|
+
.filter(s => s.count >= 50_000)
|
|
1104
|
+
.sort((a, b) => b.count - a.count)
|
|
1105
|
+
.slice(0, 3);
|
|
1106
|
+
const alerts = [];
|
|
1107
|
+
for (const s of hits) {
|
|
1108
|
+
// Test a discriminator field for value concentration over the sample.
|
|
1109
|
+
const field = DISCRIMINATOR_KEYS.find(k => s.properties.includes(k)) ?? null;
|
|
1110
|
+
let concentration = '';
|
|
1111
|
+
if (field) {
|
|
1112
|
+
const freq = new Map();
|
|
1113
|
+
let sampled = 0;
|
|
1114
|
+
for (const id of s.exampleIds) {
|
|
1115
|
+
const node = snapshot.getNodeById(id);
|
|
1116
|
+
if (!node)
|
|
1117
|
+
continue;
|
|
1118
|
+
for (const edge of node.references) {
|
|
1119
|
+
if (edge.type === 'property' &&
|
|
1120
|
+
String(edge.name_or_index) === field) {
|
|
1121
|
+
let val = edge.toNode.name;
|
|
1122
|
+
if (edge.toNode.isString) {
|
|
1123
|
+
const sn = edge.toNode.toStringNode();
|
|
1124
|
+
if (sn)
|
|
1125
|
+
val = sn.stringValue;
|
|
1126
|
+
}
|
|
1127
|
+
freq.set(val, (freq.get(val) ?? 0) + 1);
|
|
1128
|
+
sampled++;
|
|
1129
|
+
break;
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
if (sampled > 0) {
|
|
1134
|
+
const top = [...freq.entries()].sort((a, b) => b[1] - a[1])[0];
|
|
1135
|
+
if (top) {
|
|
1136
|
+
const pct = ((top[1] / sampled) * 100).toFixed(0);
|
|
1137
|
+
const valDisp = top[0].length > 40 ? top[0].slice(0, 40) + '…' : top[0];
|
|
1138
|
+
concentration = ` The \`${field}\` field is dominated by one value ("${valDisp}", ${pct}% of sampled records) — a strong append-only signature.`;
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
const pctHeap = totalSize > 0
|
|
1143
|
+
? ` (${((s.totalSelfSize / totalSize) * 100).toFixed(1)}% of heap self size)`
|
|
1144
|
+
: '';
|
|
1145
|
+
const propsDisplay = s.properties.length <= 6
|
|
1146
|
+
? `{${s.properties.join(', ')}}`
|
|
1147
|
+
: `{${s.properties.slice(0, 5).join(', ')}, … +${s.properties.length - 5}}`;
|
|
1148
|
+
alerts.push(`🟠 **[HIGH] Append-only record accumulation — ${formatNumber(s.count)}× ${propsDisplay}**\n` +
|
|
1149
|
+
`${formatNumber(s.count)} uniform message/log records (${formatBytes(s.totalSelfSize)} self${pctHeap}, example @${s.exampleNodeId}).${concentration}\n` +
|
|
1150
|
+
`**Fix:** bound the conversation/log history (ring buffer, max length, or periodic flush). Check for a scheduler/retry loop that re-appends the same system or error message without ever trimming.`);
|
|
420
1151
|
}
|
|
421
|
-
return
|
|
1152
|
+
return alerts;
|
|
422
1153
|
}
|
|
423
1154
|
export function registerAutoInvestigate(server) {
|
|
424
1155
|
server.tool('memlab_auto_investigate', 'One-shot deep analysis: finds the top retained objects, traces each retainer chain to the GC root, identifies pinch points (small objects retaining large subtrees), and detects unbounded caches. Returns a structured report with root causes and suggested fixes. Use this as the first tool after loading a snapshot to get immediate actionable findings.', {
|
|
@@ -482,8 +1213,13 @@ export function registerAutoInvestigate(server) {
|
|
|
482
1213
|
topPending: [],
|
|
483
1214
|
};
|
|
484
1215
|
const shapes = focus === 'all' ? scanTopShapes(snapshot, 5) : [];
|
|
485
|
-
const subscriptions = focus === 'all'
|
|
1216
|
+
const subscriptions = focus === 'all'
|
|
1217
|
+
? scanSubscriptionAccumulation(snapshot, 1000, totalSize)
|
|
1218
|
+
: [];
|
|
486
1219
|
const errorAccum = focus === 'all' ? scanErrorAccumulation(snapshot, 10) : [];
|
|
1220
|
+
const distributed = focus === 'all'
|
|
1221
|
+
? scanDistributedAccumulation(snapshot, totalSize)
|
|
1222
|
+
: [];
|
|
487
1223
|
const lines = [`# Auto-Investigation Report`, ''];
|
|
488
1224
|
const focusLabel = focus === 'all' ? '' : ` (focus: ${focus})`;
|
|
489
1225
|
lines.push(`## Top ${findings.length} Retained Objects${focusLabel}`);
|
|
@@ -504,13 +1240,17 @@ export function registerAutoInvestigate(server) {
|
|
|
504
1240
|
}
|
|
505
1241
|
}
|
|
506
1242
|
}
|
|
1243
|
+
const previousTraces = [];
|
|
507
1244
|
for (let i = 0; i < findings.length; i++) {
|
|
508
1245
|
const f = findings[i];
|
|
509
1246
|
const siblings = f.collapsed_siblings ?? [];
|
|
510
|
-
|
|
511
|
-
|
|
1247
|
+
// Dominator-aware combined size: members on the same chain overlap,
|
|
1248
|
+
// so we must not sum their retained sizes (that produced the old
|
|
1249
|
+
// ">100% of heap" bug). combinedRetainedSize() keeps only
|
|
1250
|
+
// dominator-maximal members.
|
|
1251
|
+
const totalRetained = combinedRetainedSize([f.node, ...siblings.map(s => s.node)], snapshot);
|
|
512
1252
|
const pct = totalSize > 0
|
|
513
|
-
? ` (${((totalRetained / totalSize) * 100).toFixed(1)}% of heap)`
|
|
1253
|
+
? ` (${Math.min(100, (totalRetained / totalSize) * 100).toFixed(1)}% of heap)`
|
|
514
1254
|
: '';
|
|
515
1255
|
const name = truncateNodeName(f.node.name, f.node.type, f.node.self_size, 60);
|
|
516
1256
|
const sevIcon = f.severity === 'CRITICAL'
|
|
@@ -520,20 +1260,47 @@ export function registerAutoInvestigate(server) {
|
|
|
520
1260
|
: f.severity === 'MEDIUM'
|
|
521
1261
|
? '🟡'
|
|
522
1262
|
: '🔵';
|
|
1263
|
+
// Check if this finding's trace shares a common prefix with a previous one
|
|
1264
|
+
const traceMatch = i > 0 ? findBestTraceMatch(f.trace, previousTraces) : null;
|
|
523
1265
|
if (siblings.length > 0) {
|
|
524
|
-
lines.push(`### ${i + 1}. ${sevIcon} [${f.severity}] \`${name}\`
|
|
1266
|
+
lines.push(`### ${i + 1}. ${sevIcon} [${f.severity}] \`${name}\` — ${formatBytes(totalRetained)} retained, same subtree reached via ${1 + siblings.length} retainer(s)${pct}`);
|
|
525
1267
|
lines.push('');
|
|
526
|
-
|
|
1268
|
+
if (traceMatch) {
|
|
1269
|
+
const divergeStep = f.trace[traceMatch.prefixLen];
|
|
1270
|
+
const divergeEdge = divergeStep?.edgeName ?? '…';
|
|
1271
|
+
const divergeName = divergeStep
|
|
1272
|
+
? truncateNodeName(divergeStep.name, divergeStep.type, divergeStep.selfSize, 40)
|
|
1273
|
+
: '…';
|
|
1274
|
+
lines.push(`**Retainer chain:** (same chain as #${traceMatch.matchedIndex + 1}) → [${divergeEdge}] ${divergeName} — ${formatBytes(f.node.retainedSize)}`);
|
|
1275
|
+
}
|
|
1276
|
+
else {
|
|
1277
|
+
lines.push(`**Retainer chain:**\n${formatTrace(f.trace, 8)}`);
|
|
1278
|
+
}
|
|
527
1279
|
lines.push('');
|
|
528
|
-
lines.push(`**
|
|
1280
|
+
lines.push(`**Alternate retainers of the same subtree** (retained sizes overlap — not additive): @${f.node.id} (${formatBytes(f.node.retainedSize)})` +
|
|
529
1281
|
siblings
|
|
530
1282
|
.map(sib => `, @${sib.node.id} ${truncateNodeName(sib.node.name, sib.node.type, sib.node.self_size, 30)} (${formatBytes(sib.node.retainedSize)})`)
|
|
531
1283
|
.join(''));
|
|
532
1284
|
}
|
|
1285
|
+
else if (traceMatch) {
|
|
1286
|
+
const divergeStep = f.trace[traceMatch.prefixLen];
|
|
1287
|
+
const divergeEdge = divergeStep?.edgeName ?? '…';
|
|
1288
|
+
const divergeName = divergeStep
|
|
1289
|
+
? truncateNodeName(divergeStep.name, divergeStep.type, divergeStep.selfSize, 40)
|
|
1290
|
+
: '…';
|
|
1291
|
+
lines.push(`### ${i + 1}. ${sevIcon} [${f.severity}] @${f.node.id} \`${name}\` (${f.node.type}) — ${formatBytes(f.node.retainedSize)}${pct}`);
|
|
1292
|
+
lines.push('');
|
|
1293
|
+
lines.push(`**Retainer chain:** (same chain as #${traceMatch.matchedIndex + 1}) → [${divergeEdge}] ${divergeName} — ${formatBytes(f.node.retainedSize)}`);
|
|
1294
|
+
}
|
|
533
1295
|
else {
|
|
534
1296
|
lines.push(`### ${i + 1}. ${sevIcon} [${f.severity}] @${f.node.id} \`${name}\` (${f.node.type}) — ${formatBytes(f.node.retainedSize)}${pct}`);
|
|
535
1297
|
lines.push('');
|
|
536
|
-
lines.push(`**Retainer chain
|
|
1298
|
+
lines.push(`**Retainer chain:**\n${formatTrace(f.trace, 8)}`);
|
|
1299
|
+
}
|
|
1300
|
+
previousTraces.push(f.trace);
|
|
1301
|
+
const instrNote = instrumentationRetainerNote(f.trace);
|
|
1302
|
+
if (instrNote) {
|
|
1303
|
+
lines.push(`> ⚠ ${instrNote}`);
|
|
537
1304
|
}
|
|
538
1305
|
lines.push('');
|
|
539
1306
|
if (f.pinchPoint) {
|
|
@@ -558,8 +1325,20 @@ export function registerAutoInvestigate(server) {
|
|
|
558
1325
|
lines.push('## Shared Pinch Points');
|
|
559
1326
|
lines.push('');
|
|
560
1327
|
for (const { pp, findingIndices } of sharedPinchPoints) {
|
|
1328
|
+
const ppNode = snapshot.getNodeById(pp.nodeId);
|
|
561
1329
|
const ppName = truncateNodeName(pp.name, pp.type, pp.selfSize, 50);
|
|
562
1330
|
lines.push(`- @${pp.nodeId} \`${ppName}\` — self: ${formatBytes(pp.selfSize)}, retains: ${formatBytes(pp.retainedSize)} (${formatNumber(Math.round(pp.ratio))}:1 ratio). Shared by findings ${findingIndices.map(j => `#${j + 1}`).join(', ')}. Freeing this single object would reclaim ${formatBytes(pp.retainedSize)}.`);
|
|
1331
|
+
if (ppNode) {
|
|
1332
|
+
const topProps = [];
|
|
1333
|
+
for (const edge of ppNode.references) {
|
|
1334
|
+
if (edge.type === 'property' && topProps.length < 5) {
|
|
1335
|
+
topProps.push(String(edge.name_or_index));
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
if (topProps.length > 0) {
|
|
1339
|
+
lines.push(` Properties: {${topProps.join(', ')}${ppNode.edge_count > 5 ? ', …' : ''}}`);
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
563
1342
|
}
|
|
564
1343
|
lines.push('');
|
|
565
1344
|
}
|
|
@@ -607,6 +1386,187 @@ export function registerAutoInvestigate(server) {
|
|
|
607
1386
|
}
|
|
608
1387
|
lines.push('', '_Use `memlab_shape_histogram` for full shape analysis with retained sizes._');
|
|
609
1388
|
lines.push('');
|
|
1389
|
+
// Feedback #4: Low-cardinality column detection
|
|
1390
|
+
// Feedback #8: High-cost unique property detection
|
|
1391
|
+
// Feedback round 3 §1b: same-object duplication detection
|
|
1392
|
+
// For large shapes (>10K instances), sample property value cardinality
|
|
1393
|
+
const columnAlerts = [];
|
|
1394
|
+
const duplicationAlerts = [];
|
|
1395
|
+
const dupShapesSeen = new Set();
|
|
1396
|
+
// Pre-bucket: collect qualifying shape keys, then single-pass scan
|
|
1397
|
+
const qualifyingShapes = shapes.filter(s => {
|
|
1398
|
+
if (s.count < 10_000)
|
|
1399
|
+
return false;
|
|
1400
|
+
return !!snapshot.getNodeById(s.exampleNodeId);
|
|
1401
|
+
});
|
|
1402
|
+
const shapeBuckets = new Map();
|
|
1403
|
+
for (const s of qualifyingShapes) {
|
|
1404
|
+
shapeBuckets.set(s.properties.join(','), []);
|
|
1405
|
+
}
|
|
1406
|
+
if (shapeBuckets.size > 0) {
|
|
1407
|
+
const maxSamples = 200;
|
|
1408
|
+
snapshot.nodes.forEach(node => {
|
|
1409
|
+
if (node.type !== 'object' || node.id <= 3)
|
|
1410
|
+
return;
|
|
1411
|
+
if (node.name !== 'Object')
|
|
1412
|
+
return;
|
|
1413
|
+
const props = [];
|
|
1414
|
+
for (const edge of node.references) {
|
|
1415
|
+
if (edge.type === 'property') {
|
|
1416
|
+
props.push(String(edge.name_or_index));
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
props.sort();
|
|
1420
|
+
const key = props.join(',');
|
|
1421
|
+
const bucket = shapeBuckets.get(key);
|
|
1422
|
+
if (bucket && bucket.length < maxSamples) {
|
|
1423
|
+
bucket.push(node);
|
|
1424
|
+
}
|
|
1425
|
+
});
|
|
1426
|
+
}
|
|
1427
|
+
for (const s of qualifyingShapes) {
|
|
1428
|
+
const sampleNodes = shapeBuckets.get(s.properties.join(',')) ?? [];
|
|
1429
|
+
if (sampleNodes.length < 50)
|
|
1430
|
+
continue;
|
|
1431
|
+
const sampleCount = sampleNodes.length;
|
|
1432
|
+
const shapeKey = s.properties.join(',');
|
|
1433
|
+
const propStats = [];
|
|
1434
|
+
for (const propName of s.properties) {
|
|
1435
|
+
// Skip prototype / internal pseudo-properties — they are not data
|
|
1436
|
+
// columns, so "pre-filter / intern this column" advice is noise
|
|
1437
|
+
// (Feedback round 4 §2).
|
|
1438
|
+
if (propName === '__proto__' || propName.startsWith('<')) {
|
|
1439
|
+
continue;
|
|
1440
|
+
}
|
|
1441
|
+
const values = new Set();
|
|
1442
|
+
let totalValueSize = 0;
|
|
1443
|
+
let stringValueCount = 0;
|
|
1444
|
+
let allUnique = true;
|
|
1445
|
+
for (const node of sampleNodes) {
|
|
1446
|
+
for (const edge of node.references) {
|
|
1447
|
+
if (edge.type === 'property' &&
|
|
1448
|
+
String(edge.name_or_index) === propName) {
|
|
1449
|
+
const target = edge.toNode;
|
|
1450
|
+
let val = target.name;
|
|
1451
|
+
if (target.isString) {
|
|
1452
|
+
const strNode = target.toStringNode();
|
|
1453
|
+
if (strNode)
|
|
1454
|
+
val = strNode.stringValue;
|
|
1455
|
+
stringValueCount++;
|
|
1456
|
+
totalValueSize += target.self_size;
|
|
1457
|
+
}
|
|
1458
|
+
if (values.has(val))
|
|
1459
|
+
allUnique = false;
|
|
1460
|
+
values.add(val);
|
|
1461
|
+
break;
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
propStats.push({
|
|
1466
|
+
name: propName,
|
|
1467
|
+
cardinality: values.size,
|
|
1468
|
+
stringValueCount,
|
|
1469
|
+
totalValueSize,
|
|
1470
|
+
allUnique,
|
|
1471
|
+
});
|
|
1472
|
+
}
|
|
1473
|
+
// Same-object duplication (§1b), copy-factor corrected (round 4 §1a):
|
|
1474
|
+
// derive the factor from the MOST-DISTINCT string-valued field — the
|
|
1475
|
+
// record's true discriminator — NOT from an arbitrary unique-looking
|
|
1476
|
+
// field. The old code did `sampleCount / cardinality` on any
|
|
1477
|
+
// `*_id`-shaped key, so a CONSTANT field (e.g. `gala_project_id`
|
|
1478
|
+
// with 1 value) reported "~200 copies" when the real duplication was
|
|
1479
|
+
// 2×. If even the most-distinct field repeats, the whole record is
|
|
1480
|
+
// duplicated; if the most-distinct field is ~unique, there is no
|
|
1481
|
+
// duplication regardless of how many constant columns exist.
|
|
1482
|
+
const stringStats = propStats.filter(p => p.stringValueCount >= sampleCount * 0.5);
|
|
1483
|
+
let maxCard = 0;
|
|
1484
|
+
let discrim = '';
|
|
1485
|
+
for (const p of stringStats) {
|
|
1486
|
+
if (p.cardinality > maxCard) {
|
|
1487
|
+
maxCard = p.cardinality;
|
|
1488
|
+
discrim = p.name;
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
if (stringStats.length > 0 &&
|
|
1492
|
+
maxCard >= 1 &&
|
|
1493
|
+
maxCard < sampleCount * 0.6 &&
|
|
1494
|
+
sampleCount >= 100 &&
|
|
1495
|
+
!dupShapesSeen.has(shapeKey)) {
|
|
1496
|
+
const copyFactor = Math.round(sampleCount / maxCard);
|
|
1497
|
+
if (copyFactor >= 2) {
|
|
1498
|
+
dupShapesSeen.add(shapeKey);
|
|
1499
|
+
duplicationAlerts.push(`- \`{${s.properties.slice(0, 4).join(', ')}${s.properties.length > 4 ? ', …' : ''}}\` (${formatNumber(s.count)} total instances): even the most-distinct field \`${discrim}\` has only **${formatNumber(maxCard)} distinct value(s)** across ${formatNumber(sampleCount)} sampled — the same records appear to be retained **≈${copyFactor}×** (sample-based estimate; confirm the exact factor with \`memlab_property_distribution\` on \`${discrim}\`, or a content-hash count). Investigate **retention/concurrency** (e.g. many concurrent requests each holding a full copy of this dataset, or the same listing materialized twice), NOT string interning.`);
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
// Pass 2: per-property low-cardinality + high-cost-unique notes.
|
|
1503
|
+
for (const p of propStats) {
|
|
1504
|
+
const uniqueKey = looksUniqueKey(p.name);
|
|
1505
|
+
if (!uniqueKey && p.cardinality <= 20 && sampleCount >= 100) {
|
|
1506
|
+
// Genuine low-cardinality column. Only suggest string interning
|
|
1507
|
+
// when the column actually holds strings — interning a numeric
|
|
1508
|
+
// (SMI/heap-number) or boolean column is meaningless
|
|
1509
|
+
// (Feedback round 4 §2).
|
|
1510
|
+
const isStringColumn = p.stringValueCount >= sampleCount * 0.5;
|
|
1511
|
+
columnAlerts.push(isStringColumn
|
|
1512
|
+
? `- Property \`${p.name}\` has only **${p.cardinality} unique value(s)** across ${formatNumber(s.count)} instances — low-cardinality column suitable for pre-filtering at the data source or string interning`
|
|
1513
|
+
: `- Property \`${p.name}\` has only **${p.cardinality} unique value(s)** across ${formatNumber(s.count)} instances — low-cardinality column suitable for pre-filtering at the data source`);
|
|
1514
|
+
}
|
|
1515
|
+
// High-cost unique: all values unique, string, large average size.
|
|
1516
|
+
if (p.allUnique &&
|
|
1517
|
+
p.stringValueCount > sampleCount * 0.8 &&
|
|
1518
|
+
p.cardinality > sampleCount * 0.9) {
|
|
1519
|
+
const avgSize = p.totalValueSize / p.stringValueCount;
|
|
1520
|
+
if (avgSize > 50 && s.count > 10_000) {
|
|
1521
|
+
const estimatedWaste = avgSize * s.count;
|
|
1522
|
+
columnAlerts.push(`- Property \`${p.name}\` has **all unique string values** (avg ${Math.round(avgSize)}B each × ${formatNumber(s.count)} instances = ~${formatBytes(estimatedWaste)}) — verify this field is needed by consumers`);
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
if (duplicationAlerts.length > 0) {
|
|
1528
|
+
lines.push('## Same-Object Duplication');
|
|
1529
|
+
lines.push('');
|
|
1530
|
+
lines.push('Unique-looking keys with far fewer distinct values than instances — the same records appear to be retained multiple times:');
|
|
1531
|
+
lines.push('');
|
|
1532
|
+
lines.push(...duplicationAlerts.slice(0, 8));
|
|
1533
|
+
lines.push('', '_This is a retention/concurrency issue, **not** an interning opportunity: interning a per-record copy cannot reclaim duplicated whole records. Trace one example with `memlab_retainer_summary` / `memlab_trace_dominators` to find why N copies are kept alive (e.g. unbounded request concurrency, a cache keyed per-request, or fan-out without back-pressure)._');
|
|
1534
|
+
lines.push('');
|
|
1535
|
+
}
|
|
1536
|
+
if (columnAlerts.length > 0) {
|
|
1537
|
+
// Cap to the most relevant lines — on wide shapes this section
|
|
1538
|
+
// used to emit ~100 near-identical lines (Feedback §4).
|
|
1539
|
+
const COLUMN_ALERT_CAP = 12;
|
|
1540
|
+
lines.push('## Data Column Analysis');
|
|
1541
|
+
lines.push('');
|
|
1542
|
+
lines.push('Property value analysis across large object shapes:');
|
|
1543
|
+
lines.push('');
|
|
1544
|
+
lines.push(...columnAlerts.slice(0, COLUMN_ALERT_CAP));
|
|
1545
|
+
if (columnAlerts.length > COLUMN_ALERT_CAP) {
|
|
1546
|
+
lines.push(`- … and ${columnAlerts.length - COLUMN_ALERT_CAP} more column(s). Use \`memlab_property_distribution\` to drill into a specific property.`);
|
|
1547
|
+
}
|
|
1548
|
+
lines.push('', '_Low-cardinality columns can often be filtered at the data source (SQL WHERE clause). High-cost unique string properties should be verified as needed by consumers._');
|
|
1549
|
+
lines.push('');
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
if (distributed.length > 0) {
|
|
1553
|
+
lines.push(`## Distributed Accumulation`);
|
|
1554
|
+
lines.push('');
|
|
1555
|
+
lines.push('Classes with many small instances that collectively consume significant memory. No single instance stands out, but the total is substantial:');
|
|
1556
|
+
lines.push('');
|
|
1557
|
+
for (const d of distributed) {
|
|
1558
|
+
const pct = totalSize > 0
|
|
1559
|
+
? ` (${((d.totalSelfSize / totalSize) * 100).toFixed(1)}% of heap)`
|
|
1560
|
+
: '';
|
|
1561
|
+
const sevIcon = d.totalSelfSize >= totalSize * 0.1
|
|
1562
|
+
? '🔴'
|
|
1563
|
+
: d.totalSelfSize >= totalSize * 0.05
|
|
1564
|
+
? '🟠'
|
|
1565
|
+
: '🟡';
|
|
1566
|
+
lines.push(`- ${sevIcon} \`${d.className}\` (${d.nodeType}) — **${formatNumber(d.count)}** instances × ${Math.round(d.avgSize)}B avg = **${formatBytes(d.totalSelfSize)}**${pct} (example: @${d.exampleNodeId})`);
|
|
1567
|
+
}
|
|
1568
|
+
lines.push('', '_These won\'t appear in "top retained objects" because no single instance retains enough. Reduce instance count at the source (batch, deduplicate, or filter before storing)._');
|
|
1569
|
+
lines.push('');
|
|
610
1570
|
}
|
|
611
1571
|
if (subscriptions.length > 0) {
|
|
612
1572
|
lines.push(`## Subscription/Listener Accumulation`);
|
|
@@ -615,17 +1575,39 @@ export function registerAutoInvestigate(server) {
|
|
|
615
1575
|
lines.push('');
|
|
616
1576
|
for (const s of subscriptions.slice(0, 5)) {
|
|
617
1577
|
const propsDisplay = `{${s.properties.join(', ')}}`;
|
|
618
|
-
const
|
|
619
|
-
const
|
|
1578
|
+
const baseSev = classifySeverity(s.totalSelfSize, totalSize);
|
|
1579
|
+
const effectiveSev = s.fanOut
|
|
1580
|
+
? s.fanOut.escalatedSeverity
|
|
1581
|
+
: baseSev;
|
|
1582
|
+
const subSevIcon = effectiveSev === 'CRITICAL'
|
|
620
1583
|
? '🔴'
|
|
621
|
-
:
|
|
1584
|
+
: effectiveSev === 'HIGH'
|
|
622
1585
|
? '🟠'
|
|
623
|
-
:
|
|
1586
|
+
: effectiveSev === 'MEDIUM'
|
|
624
1587
|
? '🟡'
|
|
625
1588
|
: '🔵';
|
|
626
|
-
lines.push(`- ${subSevIcon} [${
|
|
1589
|
+
lines.push(`- ${subSevIcon} [${effectiveSev}] ${propsDisplay} — **${formatNumber(s.count)}** instances, ${formatBytes(s.totalSelfSize)} self (example: @${s.exampleNodeId})`);
|
|
1590
|
+
if (s.fanOut) {
|
|
1591
|
+
const fo = s.fanOut;
|
|
1592
|
+
lines.push(` - **Listener fan-out detected:** ${formatNumber(fo.uniqueCallbacks)} unique callback(s), ${formatNumber(fo.uniqueContexts)} unique context(s)`);
|
|
1593
|
+
if (fo.orphanedContexts > 0) {
|
|
1594
|
+
lines.push(` - **${formatNumber(fo.orphanedContexts)} orphaned context(s)** — only retained by listener registrations`);
|
|
1595
|
+
}
|
|
1596
|
+
if (fo.contextShapes.length > 0) {
|
|
1597
|
+
const shapeStr = fo.contextShapes
|
|
1598
|
+
.map(cs => `${cs.count}× ${cs.name}`)
|
|
1599
|
+
.join(', ');
|
|
1600
|
+
lines.push(` - Context shapes: ${shapeStr}`);
|
|
1601
|
+
}
|
|
1602
|
+
if (effectiveSev !== baseSev) {
|
|
1603
|
+
lines.push(` - _Severity escalated from ${baseSev} → ${effectiveSev} due to listener fan-out pattern (few callbacks, many orphaned contexts)_`);
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
627
1606
|
}
|
|
628
|
-
lines.push(''
|
|
1607
|
+
lines.push('');
|
|
1608
|
+
lines.push('_These may be event listeners, signal subscriptions, or observer registrations that are not being cleaned up. ' +
|
|
1609
|
+
'Use `memlab_event_listener_leaks` for detailed listener analysis or ' +
|
|
1610
|
+
'`memlab_retainer_summary` with node_ids from the examples to trace the retention pattern._');
|
|
629
1611
|
lines.push('');
|
|
630
1612
|
}
|
|
631
1613
|
if (errorAccum.length > 0) {
|
|
@@ -642,6 +1624,26 @@ export function registerAutoInvestigate(server) {
|
|
|
642
1624
|
lines.push('', '_Repeated identical errors are almost always a bug — a module failing to load, a misconfigured import, or a retry loop that never succeeds._');
|
|
643
1625
|
lines.push('');
|
|
644
1626
|
}
|
|
1627
|
+
const asyncAlerts = detectAsyncContextLeaks(findings);
|
|
1628
|
+
const frameworkAlerts = focus === 'all' ? detectFrameworkLeaks(snapshot, totalSize) : [];
|
|
1629
|
+
const closureAlerts = focus === 'all' || focus === 'closures'
|
|
1630
|
+
? scanOversizedClosures(snapshot, totalSize)
|
|
1631
|
+
: [];
|
|
1632
|
+
const appendOnlyAlerts = focus === 'all' ? scanAppendOnlyRecords(snapshot, totalSize) : [];
|
|
1633
|
+
const allPatternAlerts = [
|
|
1634
|
+
...frameworkAlerts,
|
|
1635
|
+
...closureAlerts,
|
|
1636
|
+
...appendOnlyAlerts,
|
|
1637
|
+
...asyncAlerts,
|
|
1638
|
+
];
|
|
1639
|
+
if (allPatternAlerts.length > 0) {
|
|
1640
|
+
lines.push('## Known Leak Patterns');
|
|
1641
|
+
lines.push('');
|
|
1642
|
+
for (const alert of allPatternAlerts) {
|
|
1643
|
+
lines.push(alert);
|
|
1644
|
+
lines.push('');
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
645
1647
|
const sourceHints = extractSourceHints(findings.map(f => f.trace));
|
|
646
1648
|
if (sourceHints.length > 0) {
|
|
647
1649
|
lines.push('## Source Hints');
|
|
@@ -655,23 +1657,25 @@ export function registerAutoInvestigate(server) {
|
|
|
655
1657
|
}
|
|
656
1658
|
lines.push('');
|
|
657
1659
|
}
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
1660
|
+
if (!suggestionsSuppressed()) {
|
|
1661
|
+
lines.push('## Suggested Next Steps');
|
|
1662
|
+
lines.push('');
|
|
1663
|
+
let stepNum = 1;
|
|
1664
|
+
if (findings[0]?.pinchPoint) {
|
|
1665
|
+
const pp = findings[0].pinchPoint;
|
|
1666
|
+
lines.push(`${stepNum++}. Inspect pinch point: \`memlab_get_node(${pp.nodeId})\` then \`memlab_dominator_subtree(${pp.nodeId})\``);
|
|
1667
|
+
}
|
|
1668
|
+
lines.push(`${stepNum++}. Trace top retainer: \`memlab_trace_dominators(${findings[0].node.id})\` for full dominator chain in one call`);
|
|
1669
|
+
lines.push(`${stepNum++}. Check for patterns: \`memlab_retainer_summary\` with class name of top objects`);
|
|
1670
|
+
if (promises.pendingCount > 0 && promises.topPending.length > 0) {
|
|
1671
|
+
lines.push(`${stepNum++}. Investigate pending promises: \`memlab_trace_dominators(${promises.topPending[0].nodeId})\``);
|
|
1672
|
+
}
|
|
1673
|
+
if (caches.length > 0) {
|
|
1674
|
+
lines.push(`${stepNum++}. Analyze caches: \`memlab_cache_analysis\` for detailed cache inspection`);
|
|
1675
|
+
}
|
|
1676
|
+
if (shapes.length > 0) {
|
|
1677
|
+
lines.push(`${stepNum++}. Explore data shapes: \`memlab_shape_histogram\` for detailed shape clustering`);
|
|
1678
|
+
}
|
|
675
1679
|
}
|
|
676
1680
|
return toolResult(lines.join('\n'));
|
|
677
1681
|
}
|