@memlab/mcp-server 2.1.1 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +73 -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 +46 -1
- package/dist/heap-state.d.ts.map +1 -1
- package/dist/heap-state.js +142 -10
- package/dist/heap-state.js.map +1 -1
- package/dist/index.js +36 -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 +999 -37
- 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 +113 -18
- 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-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 +289 -0
- package/dist/tools/intern-opportunities.js.map +1 -0
- package/dist/tools/load-snapshot.d.ts +10 -0
- package/dist/tools/load-snapshot.d.ts.map +1 -1
- package/dist/tools/load-snapshot.js +152 -15
- 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 +95 -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 +56 -19
- 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 +74 -22
- 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 +234 -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 +16 -11
- package/dist/tools/sliced-strings.js.map +1 -1
- 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 +81 -2
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +303 -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, 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 = [];
|
|
@@ -420,6 +615,533 @@ function formatTrace(trace, maxSteps) {
|
|
|
420
615
|
}
|
|
421
616
|
return parts.join('\n → ');
|
|
422
617
|
}
|
|
618
|
+
function computeTraceOverlap(a, b) {
|
|
619
|
+
let prefixLen = 0;
|
|
620
|
+
const minLen = Math.min(a.length, b.length);
|
|
621
|
+
for (let i = 0; i < minLen; i++) {
|
|
622
|
+
if (a[i].nodeId === b[i].nodeId) {
|
|
623
|
+
prefixLen++;
|
|
624
|
+
}
|
|
625
|
+
else {
|
|
626
|
+
break;
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
return { prefixLen, matchIndex: -1 };
|
|
630
|
+
}
|
|
631
|
+
function findBestTraceMatch(trace, previousTraces) {
|
|
632
|
+
let bestMatch = null;
|
|
633
|
+
for (let j = 0; j < previousTraces.length; j++) {
|
|
634
|
+
const { prefixLen } = computeTraceOverlap(trace, previousTraces[j]);
|
|
635
|
+
if (prefixLen > trace.length * 0.5) {
|
|
636
|
+
if (!bestMatch || prefixLen > bestMatch.prefixLen) {
|
|
637
|
+
bestMatch = { matchedIndex: j, prefixLen };
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
return bestMatch;
|
|
642
|
+
}
|
|
643
|
+
function detectAsyncContextLeaks(findings) {
|
|
644
|
+
const alerts = [];
|
|
645
|
+
const seen = new Set();
|
|
646
|
+
for (const f of findings) {
|
|
647
|
+
const traceNames = f.trace.map(s => s.name);
|
|
648
|
+
const traceEdges = f.trace.map(s => s.edgeName ?? '');
|
|
649
|
+
// Pattern 1: TCP/Socket → kResourceStore → afterContext (Next.js request context leak)
|
|
650
|
+
const hasTCP = traceNames.some(n => n === 'TCP' || n === 'Socket' || n.includes('TCP'));
|
|
651
|
+
const hasResourceStore = traceEdges.some(e => e === 'kResourceStore' || e === 'resource_symbol');
|
|
652
|
+
const hasAfterContext = traceEdges.some(e => e === 'afterContext');
|
|
653
|
+
const hasOnClose = traceEdges.some(e => e === 'onClose');
|
|
654
|
+
const hasContext = traceNames.some(n => n === 'system / Context' || n.startsWith('system /'));
|
|
655
|
+
if (hasTCP &&
|
|
656
|
+
hasResourceStore &&
|
|
657
|
+
(hasAfterContext || hasOnClose) &&
|
|
658
|
+
hasContext &&
|
|
659
|
+
!seen.has('tcp-als')) {
|
|
660
|
+
seen.add('tcp-als');
|
|
661
|
+
alerts.push(`🔴 **[CRITICAL] AsyncLocalStorage context retained by TCP connection**\n` +
|
|
662
|
+
`A TCP socket's async context chain retains ${formatBytes(f.node.retainedSize)} of request-scoped data. ` +
|
|
663
|
+
`The \`onClose\` closure captures the request's AsyncLocalStorage context, which chains to prior contexts via \`previous\` pointers.\n` +
|
|
664
|
+
`**Fix:** Ensure response bodies are fully consumed/canceled (check \`resp.body.cancel()\`), ` +
|
|
665
|
+
`or avoid capturing large data in request-scoped closures.`);
|
|
666
|
+
}
|
|
667
|
+
// Pattern 2: Generator.parameters_and_registers holding large arrays (Undici response body retention)
|
|
668
|
+
const hasGenerator = traceNames.some(n => n === 'Generator' || n.includes('Generator'));
|
|
669
|
+
const hasParamsAndRegisters = traceEdges.some(e => e === 'parameters_and_registers');
|
|
670
|
+
if (hasGenerator &&
|
|
671
|
+
hasParamsAndRegisters &&
|
|
672
|
+
f.node.retainedSize > 1024 * 1024 &&
|
|
673
|
+
!seen.has('generator-undici')) {
|
|
674
|
+
seen.add('generator-undici');
|
|
675
|
+
alerts.push(`🟠 **[HIGH] Generator retaining large data via \`parameters_and_registers\`**\n` +
|
|
676
|
+
`A Generator object holds ${formatBytes(f.node.retainedSize)} through its parameters/registers. ` +
|
|
677
|
+
`This is a common Undici pattern where an async generator (response body stream) retains the full response buffer.\n` +
|
|
678
|
+
`**Fix:** Ensure response bodies are fully read and the stream is closed/destroyed after use. ` +
|
|
679
|
+
`For fetch(), always call \`resp.text()\`, \`resp.json()\`, or \`resp.body.cancel()\`.`);
|
|
680
|
+
}
|
|
681
|
+
// Pattern 3: PromiseReaction chains with large retained sizes (unresolved/leaked promises)
|
|
682
|
+
const hasPromiseReaction = traceNames.some(n => n === 'PromiseReaction' || n === 'Promise');
|
|
683
|
+
const hasReactionEdge = traceEdges.some(e => e === 'reactions_or_result' ||
|
|
684
|
+
e === 'fulfill_handler' ||
|
|
685
|
+
e === 'reject_handler');
|
|
686
|
+
if (hasPromiseReaction &&
|
|
687
|
+
hasReactionEdge &&
|
|
688
|
+
f.node.retainedSize > 5 * 1024 * 1024 &&
|
|
689
|
+
!seen.has('promise-chain')) {
|
|
690
|
+
seen.add('promise-chain');
|
|
691
|
+
alerts.push(`🟠 **[HIGH] Unresolved Promise chain retaining ${formatBytes(f.node.retainedSize)}**\n` +
|
|
692
|
+
`A PromiseReaction chain is keeping large data alive. This often indicates a stuck async operation ` +
|
|
693
|
+
`(HTTP request, DB query, timer) whose promise was never resolved or rejected.\n` +
|
|
694
|
+
`**Fix:** Add timeouts to async operations. Check for \`await\` on promises that may never resolve. ` +
|
|
695
|
+
`Ensure \`.catch()\` handlers don't capture the entire response.`);
|
|
696
|
+
}
|
|
697
|
+
// Pattern 4: Undici Client/Pool response retention (Feedback #6)
|
|
698
|
+
const hasUndiciClient = traceNames.some(n => n === 'Client' || n === 'Pool' || n === 'Agent');
|
|
699
|
+
const hasRequestQueue = traceEdges.some(e => e === 'requests' || e === 'queue' || e === 'pending' || e === 'running');
|
|
700
|
+
if (hasUndiciClient &&
|
|
701
|
+
hasRequestQueue &&
|
|
702
|
+
f.node.retainedSize > 1024 * 1024 &&
|
|
703
|
+
!seen.has('undici-client')) {
|
|
704
|
+
seen.add('undici-client');
|
|
705
|
+
alerts.push(`🟠 **[HIGH] Undici HTTP Client/Pool retaining ${formatBytes(f.node.retainedSize)} via request queue**\n` +
|
|
706
|
+
`An Undici Client or Pool object is keeping response data alive through its request queue or closure chain. ` +
|
|
707
|
+
`This typically happens when HTTP response bodies are not fully consumed or canceled.\n` +
|
|
708
|
+
`**Fix:** Always consume or cancel response bodies: \`await resp.text()\`, \`resp.body.cancel()\`, ` +
|
|
709
|
+
`or \`resp.body.destroy()\`. For streaming responses, ensure the readable stream is fully drained.`);
|
|
710
|
+
}
|
|
711
|
+
// Pattern 5: mysql2 Connection retaining large data (Feedback #8)
|
|
712
|
+
const hasMysqlConn = traceNames.some(n => n === 'Connection' ||
|
|
713
|
+
n === 'PoolConnection' ||
|
|
714
|
+
n === 'PromiseConnection');
|
|
715
|
+
const hasMysqlEdge = traceEdges.some(e => e === '_protocol' || e === '_statements' || e === 'connectionConfig');
|
|
716
|
+
if (hasMysqlConn &&
|
|
717
|
+
hasMysqlEdge &&
|
|
718
|
+
f.node.retainedSize > 5 * 1024 * 1024 &&
|
|
719
|
+
!seen.has('mysql2-conn')) {
|
|
720
|
+
seen.add('mysql2-conn');
|
|
721
|
+
alerts.push(`🟠 **[HIGH] mysql2 Connection retaining ${formatBytes(f.node.retainedSize)}**\n` +
|
|
722
|
+
`A mysql2 Connection object is keeping query results or protocol buffers alive, often through ` +
|
|
723
|
+
`error event handler closure chains or unreleased prepared statements.\n` +
|
|
724
|
+
`**Fix:** Set pool \`idleTimeout\` (e.g., 60000ms) to free idle connections. ` +
|
|
725
|
+
`Ensure query result rows are not captured in long-lived closures. ` +
|
|
726
|
+
`Consider \`stream()\` instead of \`query()\` for large result sets.`);
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
return alerts;
|
|
730
|
+
}
|
|
731
|
+
// Framework class-name signatures. These are scanned heap-wide (not just along
|
|
732
|
+
// the top retainer traces) because framework internals frequently accumulate
|
|
733
|
+
// across many small objects that never surface as a single "top retained
|
|
734
|
+
// object" — yet are high-signal root causes (Feedback §2b).
|
|
735
|
+
const OTEL_METRIC_CLASSES = new Set([
|
|
736
|
+
'SyncMetricStorage',
|
|
737
|
+
'AsyncMetricStorage',
|
|
738
|
+
'TemporalMetricProcessor',
|
|
739
|
+
'DeltaMetricProcessor',
|
|
740
|
+
'AttributeHashMap',
|
|
741
|
+
'HashMap',
|
|
742
|
+
'MetricStorageRegistry',
|
|
743
|
+
]);
|
|
744
|
+
const RELAY_STORE_CLASSES = new Set([
|
|
745
|
+
'RelayModernStore',
|
|
746
|
+
'RelayRecordSource',
|
|
747
|
+
'RecordSource',
|
|
748
|
+
'RelayModernRecord',
|
|
749
|
+
'RelayReader',
|
|
750
|
+
'DataChecker',
|
|
751
|
+
]);
|
|
752
|
+
function mapEntryCount(node) {
|
|
753
|
+
for (const edge of node.references) {
|
|
754
|
+
const n = String(edge.name_or_index);
|
|
755
|
+
if ((n === 'table' || n === 'backing_store') &&
|
|
756
|
+
(edge.toNode.type === 'array' || edge.toNode.type === 'hidden')) {
|
|
757
|
+
return edge.toNode.edge_count;
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
return node.edge_count;
|
|
761
|
+
}
|
|
762
|
+
// In-process source-map caches (@jridgewell/trace-mapping). These decode huge
|
|
763
|
+
// nested mapping arrays for stack-trace symbolication and are a recurring
|
|
764
|
+
// top-retainer across the Nest SSR fleet (Feedback round 3 §2a).
|
|
765
|
+
const SOURCEMAP_CLASSES = new Set(['TraceMap', 'AnyMap', 'SourceMapConsumer']);
|
|
766
|
+
function detectFrameworkLeaks(snapshot, totalSize) {
|
|
767
|
+
const alerts = [];
|
|
768
|
+
const otel = new Map();
|
|
769
|
+
const relay = new Map();
|
|
770
|
+
const sourceMaps = {
|
|
771
|
+
count: 0,
|
|
772
|
+
totalRetained: 0,
|
|
773
|
+
decodedRetained: 0,
|
|
774
|
+
encodedRetained: 0,
|
|
775
|
+
sourcesContentRetained: 0,
|
|
776
|
+
exampleId: 0,
|
|
777
|
+
exampleRetained: 0,
|
|
778
|
+
};
|
|
779
|
+
let topGenerator = null;
|
|
780
|
+
let generatorCount = 0;
|
|
781
|
+
const accumulate = (map, node, entries) => {
|
|
782
|
+
const e = map.get(node.name);
|
|
783
|
+
if (e) {
|
|
784
|
+
e.count++;
|
|
785
|
+
e.totalRetained += node.retainedSize;
|
|
786
|
+
e.maxEntries = Math.max(e.maxEntries, entries);
|
|
787
|
+
if (node.retainedSize >
|
|
788
|
+
(snapshot.getNodeById(e.exampleId)?.retainedSize ?? 0)) {
|
|
789
|
+
e.exampleId = node.id;
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
else {
|
|
793
|
+
map.set(node.name, {
|
|
794
|
+
count: 1,
|
|
795
|
+
totalRetained: node.retainedSize,
|
|
796
|
+
exampleId: node.id,
|
|
797
|
+
maxEntries: entries,
|
|
798
|
+
});
|
|
799
|
+
}
|
|
800
|
+
};
|
|
801
|
+
snapshot.nodes.forEach(node => {
|
|
802
|
+
if (node.id <= 3)
|
|
803
|
+
return;
|
|
804
|
+
if (OTEL_METRIC_CLASSES.has(node.name) && node.type === 'object') {
|
|
805
|
+
// Find the largest child Map/HashMap (the attribute->aggregation table).
|
|
806
|
+
let entries = 0;
|
|
807
|
+
for (const edge of node.references) {
|
|
808
|
+
const t = edge.toNode;
|
|
809
|
+
if (t.name === 'Map' || t.name === 'HashMap' || t.name === 'Set') {
|
|
810
|
+
entries = Math.max(entries, mapEntryCount(t));
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
accumulate(otel, node, entries);
|
|
814
|
+
}
|
|
815
|
+
if (RELAY_STORE_CLASSES.has(node.name) && node.type === 'object') {
|
|
816
|
+
accumulate(relay, node, mapEntryCount(node));
|
|
817
|
+
}
|
|
818
|
+
if (SOURCEMAP_CLASSES.has(node.name) && node.type === 'object') {
|
|
819
|
+
sourceMaps.count++;
|
|
820
|
+
sourceMaps.totalRetained += node.retainedSize;
|
|
821
|
+
if (node.retainedSize > sourceMaps.exampleRetained) {
|
|
822
|
+
sourceMaps.exampleRetained = node.retainedSize;
|
|
823
|
+
sourceMaps.exampleId = node.id;
|
|
824
|
+
}
|
|
825
|
+
for (const edge of node.references) {
|
|
826
|
+
if (edge.type !== 'property')
|
|
827
|
+
continue;
|
|
828
|
+
const p = String(edge.name_or_index);
|
|
829
|
+
if (p === '_decoded') {
|
|
830
|
+
sourceMaps.decodedRetained += edge.toNode.retainedSize;
|
|
831
|
+
}
|
|
832
|
+
else if (p === '_encoded') {
|
|
833
|
+
sourceMaps.encodedRetained += edge.toNode.retainedSize;
|
|
834
|
+
}
|
|
835
|
+
else if (p === 'sourcesContent') {
|
|
836
|
+
sourceMaps.sourcesContentRetained += edge.toNode.retainedSize;
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
if (node.name === 'Generator' &&
|
|
841
|
+
node.type === 'object' &&
|
|
842
|
+
node.retainedSize > 1024 * 1024) {
|
|
843
|
+
generatorCount++;
|
|
844
|
+
if (!topGenerator || node.retainedSize > topGenerator.retained) {
|
|
845
|
+
topGenerator = { id: node.id, retained: node.retainedSize };
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
});
|
|
849
|
+
// OpenTelemetry metric cardinality explosion.
|
|
850
|
+
const otelTotal = [...otel.values()].reduce((s, e) => s + e.totalRetained, 0);
|
|
851
|
+
const otelMaxEntries = Math.max(0, ...[...otel.values()].map(e => e.maxEntries));
|
|
852
|
+
if (otel.size > 0 &&
|
|
853
|
+
(otelTotal > 5 * 1024 * 1024 ||
|
|
854
|
+
(totalSize > 0 && otelTotal > totalSize * 0.03) ||
|
|
855
|
+
otelMaxEntries >= 1000)) {
|
|
856
|
+
const top = [...otel.entries()]
|
|
857
|
+
.sort((a, b) => b[1].totalRetained - a[1].totalRetained)
|
|
858
|
+
.slice(0, 3)
|
|
859
|
+
.map(([name, e]) => `${name} (${formatNumber(e.count)}×, ${formatBytes(e.totalRetained)}${e.maxEntries > 0 ? `, up to ${formatNumber(e.maxEntries)} attribute keys` : ''}, e.g. @${e.exampleId})`)
|
|
860
|
+
.join('; ');
|
|
861
|
+
alerts.push(`🔴 **[CRITICAL] OpenTelemetry metric cardinality explosion**\n` +
|
|
862
|
+
`OTel metric-storage internals retain ${formatBytes(otelTotal)} via per-attribute aggregation maps: ${top}.\n` +
|
|
863
|
+
`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` +
|
|
864
|
+
`**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` +
|
|
865
|
+
`**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.`);
|
|
866
|
+
}
|
|
867
|
+
// Relay store growth.
|
|
868
|
+
const relayTotal = [...relay.values()].reduce((s, e) => s + e.totalRetained, 0);
|
|
869
|
+
if (relay.size > 0 &&
|
|
870
|
+
(relayTotal > 5 * 1024 * 1024 ||
|
|
871
|
+
(totalSize > 0 && relayTotal > totalSize * 0.05))) {
|
|
872
|
+
const top = [...relay.entries()]
|
|
873
|
+
.sort((a, b) => b[1].totalRetained - a[1].totalRetained)
|
|
874
|
+
.slice(0, 3)
|
|
875
|
+
.map(([name, e]) => `${name} (${formatNumber(e.count)}×, ${formatBytes(e.totalRetained)}, e.g. @${e.exampleId})`)
|
|
876
|
+
.join('; ');
|
|
877
|
+
alerts.push(`🟠 **[HIGH] Relay store retaining ${formatBytes(relayTotal)}**\n` +
|
|
878
|
+
`Relay record source/store internals are holding a large normalized cache: ${top}.\n` +
|
|
879
|
+
`**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.`);
|
|
880
|
+
}
|
|
881
|
+
// In-process source-map / symbolicator caches.
|
|
882
|
+
if (sourceMaps.count > 0 &&
|
|
883
|
+
(sourceMaps.totalRetained > 2 * 1024 * 1024 ||
|
|
884
|
+
(totalSize > 0 && sourceMaps.totalRetained > totalSize * 0.03))) {
|
|
885
|
+
const parts = [];
|
|
886
|
+
if (sourceMaps.decodedRetained > 0) {
|
|
887
|
+
parts.push(`_decoded ${formatBytes(sourceMaps.decodedRetained)}`);
|
|
888
|
+
}
|
|
889
|
+
if (sourceMaps.encodedRetained > 0) {
|
|
890
|
+
parts.push(`_encoded ${formatBytes(sourceMaps.encodedRetained)}`);
|
|
891
|
+
}
|
|
892
|
+
if (sourceMaps.sourcesContentRetained > 0) {
|
|
893
|
+
parts.push(`sourcesContent ${formatBytes(sourceMaps.sourcesContentRetained)}`);
|
|
894
|
+
}
|
|
895
|
+
const split = parts.length > 0 ? ` (${parts.join(', ')})` : '';
|
|
896
|
+
alerts.push(`🟠 **[HIGH] In-process source-map cache retaining ${formatBytes(sourceMaps.totalRetained)}**\n` +
|
|
897
|
+
`${formatNumber(sourceMaps.count)} \`TraceMap\`/source-map object(s) (\`@jridgewell/trace-mapping\`) are cached in-process for stack-trace symbolication${split}. Largest: @${sourceMaps.exampleId}. ` +
|
|
898
|
+
`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` +
|
|
899
|
+
`**Inspect:** \`memlab_dominator_subtree(${sourceMaps.exampleId})\` to see the decoded arrays; \`memlab_retainer_trace(${sourceMaps.exampleId})\` for who holds the cache.\n` +
|
|
900
|
+
`**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.`);
|
|
901
|
+
}
|
|
902
|
+
// Suspended async functions (generators) retaining large locals.
|
|
903
|
+
if (topGenerator) {
|
|
904
|
+
const g = topGenerator;
|
|
905
|
+
alerts.push(`🟠 **[HIGH] Suspended async function retaining ${formatBytes(g.retained)}**\n` +
|
|
906
|
+
`${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` +
|
|
907
|
+
`**Inspect:** \`memlab_closure_inspection(${g.id})\` resolves the frame's \`parameters_and_registers\` to source variable names.\n` +
|
|
908
|
+
`**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.`);
|
|
909
|
+
}
|
|
910
|
+
return alerts;
|
|
911
|
+
}
|
|
912
|
+
/**
|
|
913
|
+
* Classify the long-lived GC root a closure hangs off of, by scanning its
|
|
914
|
+
* retainer path for the high-signal structures that pin request-scoped data for
|
|
915
|
+
* a connection/process lifetime (Feedback round 3 §2b/§2c).
|
|
916
|
+
*/
|
|
917
|
+
function classifyLongLivedHolder(trace) {
|
|
918
|
+
for (const s of trace) {
|
|
919
|
+
const n = s.name;
|
|
920
|
+
const e = s.edgeName ?? '';
|
|
921
|
+
if (n === 'Timeout' ||
|
|
922
|
+
n === 'TimersList' ||
|
|
923
|
+
e === '_onTimeout' ||
|
|
924
|
+
e === '_idleNext' ||
|
|
925
|
+
e === '_repeat') {
|
|
926
|
+
return 'setInterval/setTimeout timer';
|
|
927
|
+
}
|
|
928
|
+
if (e === 'onClose')
|
|
929
|
+
return 'onClose handler';
|
|
930
|
+
if (n === 'TCP' || n === 'Socket' || n === 'ReusedHandle') {
|
|
931
|
+
return 'keep-alive socket';
|
|
932
|
+
}
|
|
933
|
+
if (e === 'kResourceStore' ||
|
|
934
|
+
e === 'resource_symbol' ||
|
|
935
|
+
/AsyncLocalStorage/.test(n)) {
|
|
936
|
+
return 'AsyncLocalStorage store';
|
|
937
|
+
}
|
|
938
|
+
if (e === '_events' || /EventEmitter/.test(n))
|
|
939
|
+
return 'EventEmitter listener';
|
|
940
|
+
if (e === 'signal' || n === 'AbortSignal')
|
|
941
|
+
return 'AbortSignal listener';
|
|
942
|
+
}
|
|
943
|
+
return null;
|
|
944
|
+
}
|
|
945
|
+
/**
|
|
946
|
+
* Find the single largest variable a closure/context captures — the dominant
|
|
947
|
+
* captured scope member, which is usually what makes an oversized handler
|
|
948
|
+
* closure expensive.
|
|
949
|
+
*/
|
|
950
|
+
function dominantCapturedVar(closure) {
|
|
951
|
+
let ctx = null;
|
|
952
|
+
for (const edge of closure.references) {
|
|
953
|
+
if ((edge.type === 'internal' || edge.type === 'context') &&
|
|
954
|
+
String(edge.name_or_index) === 'context') {
|
|
955
|
+
ctx = edge.toNode;
|
|
956
|
+
break;
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
let best = null;
|
|
960
|
+
const consider = (node) => {
|
|
961
|
+
for (const edge of node.references) {
|
|
962
|
+
if (edge.type !== 'context' && edge.type !== 'property')
|
|
963
|
+
continue;
|
|
964
|
+
const name = String(edge.name_or_index);
|
|
965
|
+
if (name === 'previous' || name === 'native_context')
|
|
966
|
+
continue;
|
|
967
|
+
const t = edge.toNode;
|
|
968
|
+
if (t.id <= 3)
|
|
969
|
+
continue;
|
|
970
|
+
if (!best || t.retainedSize > best.retained) {
|
|
971
|
+
best = { name, retained: t.retainedSize, targetName: t.name };
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
};
|
|
975
|
+
consider(closure);
|
|
976
|
+
if (ctx)
|
|
977
|
+
consider(ctx);
|
|
978
|
+
return best;
|
|
979
|
+
}
|
|
980
|
+
/**
|
|
981
|
+
* Proactively surface the most common Node server leak shape this round: a
|
|
982
|
+
* long-lived closure (setInterval/onClose/listener) that captures a large
|
|
983
|
+
* variable it barely uses, pinning it for the connection/process lifetime.
|
|
984
|
+
* `closure_inspection` already computes the per-variable breakdown — this just
|
|
985
|
+
* flags the handler closures so the model doesn't have to suspect them first
|
|
986
|
+
* (Feedback round 3 §2b).
|
|
987
|
+
*/
|
|
988
|
+
function scanOversizedClosures(snapshot, totalSize) {
|
|
989
|
+
const closureMin = Math.max(1024 * 1024, totalSize > 0 ? totalSize * 0.02 : 0);
|
|
990
|
+
const candidates = [];
|
|
991
|
+
snapshot.nodes.forEach(node => {
|
|
992
|
+
if (node.type !== 'closure' || node.id <= 3)
|
|
993
|
+
return;
|
|
994
|
+
if (node.retainedSize < closureMin)
|
|
995
|
+
return;
|
|
996
|
+
candidates.push(node);
|
|
997
|
+
});
|
|
998
|
+
candidates.sort((a, b) => b.retainedSize - a.retainedSize);
|
|
999
|
+
const alerts = [];
|
|
1000
|
+
const seen = new Set();
|
|
1001
|
+
for (const closure of candidates.slice(0, 30)) {
|
|
1002
|
+
const trace = getRetainerPath(closure);
|
|
1003
|
+
const holder = classifyLongLivedHolder(trace);
|
|
1004
|
+
if (!holder)
|
|
1005
|
+
continue;
|
|
1006
|
+
const dom = dominantCapturedVar(closure);
|
|
1007
|
+
const key = `${holder}|${dom?.name ?? ''}`;
|
|
1008
|
+
if (seen.has(key))
|
|
1009
|
+
continue;
|
|
1010
|
+
seen.add(key);
|
|
1011
|
+
const domStr = dom
|
|
1012
|
+
? ` Its largest captured variable is \`${dom.name}\` → ${dom.targetName} (${formatBytes(dom.retained)})` +
|
|
1013
|
+
(dom.retained > closure.retainedSize * 0.6
|
|
1014
|
+
? ' — the bulk of the retention, and quite possibly captured but barely used.'
|
|
1015
|
+
: '.')
|
|
1016
|
+
: '';
|
|
1017
|
+
alerts.push(`🟠 **[HIGH] Oversized closure on a long-lived ${holder} retaining ${formatBytes(closure.retainedSize)}**\n` +
|
|
1018
|
+
`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` +
|
|
1019
|
+
`**Inspect:** \`memlab_closure_inspection(${closure.id}, {min_retained_size: 102400})\` for the full per-variable capture breakdown.\n` +
|
|
1020
|
+
`**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.`);
|
|
1021
|
+
if (alerts.length >= 5)
|
|
1022
|
+
break;
|
|
1023
|
+
}
|
|
1024
|
+
return alerts;
|
|
1025
|
+
}
|
|
1026
|
+
// Keys that mark a record as a log/conversation/message entry. An append-only
|
|
1027
|
+
// accumulation of these is a runaway-appender signature (Feedback round 3 §2d).
|
|
1028
|
+
const MESSAGE_RECORD_KEYS = new Set([
|
|
1029
|
+
'role',
|
|
1030
|
+
'content',
|
|
1031
|
+
'message',
|
|
1032
|
+
'msg',
|
|
1033
|
+
'timestamp',
|
|
1034
|
+
'ts',
|
|
1035
|
+
'level',
|
|
1036
|
+
'type',
|
|
1037
|
+
'text',
|
|
1038
|
+
]);
|
|
1039
|
+
// Fields whose value usually discriminates the record kind, used to test
|
|
1040
|
+
// whether a few values repeat across most records.
|
|
1041
|
+
const DISCRIMINATOR_KEYS = ['role', 'level', 'type', 'message', 'msg'];
|
|
1042
|
+
/**
|
|
1043
|
+
* A property whose value is expected to be unique per record. If such a field
|
|
1044
|
+
* has far fewer distinct values than instances, the SAME records are retained
|
|
1045
|
+
* multiple times (Feedback round 3 §1b).
|
|
1046
|
+
*/
|
|
1047
|
+
function looksUniqueKey(name) {
|
|
1048
|
+
return (/^(id|_id|uuid|guid|key|hash|sku|pk|cursor|etag)$/i.test(name) ||
|
|
1049
|
+
/(_id|Id|Key|Uuid|Guid|Hash)$/.test(name));
|
|
1050
|
+
}
|
|
1051
|
+
/**
|
|
1052
|
+
* Detect append-only logs/conversations: a very high count of a uniform record
|
|
1053
|
+
* shape dominated by a few repeated field values (e.g. 696K `{role, content,
|
|
1054
|
+
* timestamp}` objects from a scheduler that re-appends the same system/error
|
|
1055
|
+
* message forever). `growth_signals` misses these because they aren't keyed the
|
|
1056
|
+
* way its heuristic expects (Feedback round 3 §2d).
|
|
1057
|
+
*/
|
|
1058
|
+
function scanAppendOnlyRecords(snapshot, totalSize) {
|
|
1059
|
+
const shapeMap = new Map();
|
|
1060
|
+
snapshot.nodes.forEach(node => {
|
|
1061
|
+
if (node.type !== 'object' || node.id <= 3)
|
|
1062
|
+
return;
|
|
1063
|
+
if (node.name !== 'Object')
|
|
1064
|
+
return;
|
|
1065
|
+
const names = [];
|
|
1066
|
+
for (const edge of node.references) {
|
|
1067
|
+
if (edge.type === 'property')
|
|
1068
|
+
names.push(String(edge.name_or_index));
|
|
1069
|
+
}
|
|
1070
|
+
if (names.length === 0 || names.length > 12)
|
|
1071
|
+
return;
|
|
1072
|
+
if (!names.some(n => MESSAGE_RECORD_KEYS.has(n.toLowerCase())))
|
|
1073
|
+
return;
|
|
1074
|
+
names.sort();
|
|
1075
|
+
const key = names.join(',');
|
|
1076
|
+
const ex = shapeMap.get(key);
|
|
1077
|
+
if (ex) {
|
|
1078
|
+
ex.count++;
|
|
1079
|
+
ex.totalSelfSize += node.self_size;
|
|
1080
|
+
if (ex.exampleIds.length < 100)
|
|
1081
|
+
ex.exampleIds.push(node.id);
|
|
1082
|
+
}
|
|
1083
|
+
else {
|
|
1084
|
+
shapeMap.set(key, {
|
|
1085
|
+
properties: names,
|
|
1086
|
+
count: 1,
|
|
1087
|
+
totalSelfSize: node.self_size,
|
|
1088
|
+
exampleNodeId: node.id,
|
|
1089
|
+
exampleIds: [node.id],
|
|
1090
|
+
});
|
|
1091
|
+
}
|
|
1092
|
+
});
|
|
1093
|
+
const hits = [...shapeMap.values()]
|
|
1094
|
+
.filter(s => s.count >= 50_000)
|
|
1095
|
+
.sort((a, b) => b.count - a.count)
|
|
1096
|
+
.slice(0, 3);
|
|
1097
|
+
const alerts = [];
|
|
1098
|
+
for (const s of hits) {
|
|
1099
|
+
// Test a discriminator field for value concentration over the sample.
|
|
1100
|
+
const field = DISCRIMINATOR_KEYS.find(k => s.properties.includes(k)) ?? null;
|
|
1101
|
+
let concentration = '';
|
|
1102
|
+
if (field) {
|
|
1103
|
+
const freq = new Map();
|
|
1104
|
+
let sampled = 0;
|
|
1105
|
+
for (const id of s.exampleIds) {
|
|
1106
|
+
const node = snapshot.getNodeById(id);
|
|
1107
|
+
if (!node)
|
|
1108
|
+
continue;
|
|
1109
|
+
for (const edge of node.references) {
|
|
1110
|
+
if (edge.type === 'property' &&
|
|
1111
|
+
String(edge.name_or_index) === field) {
|
|
1112
|
+
let val = edge.toNode.name;
|
|
1113
|
+
if (edge.toNode.isString) {
|
|
1114
|
+
const sn = edge.toNode.toStringNode();
|
|
1115
|
+
if (sn)
|
|
1116
|
+
val = sn.stringValue;
|
|
1117
|
+
}
|
|
1118
|
+
freq.set(val, (freq.get(val) ?? 0) + 1);
|
|
1119
|
+
sampled++;
|
|
1120
|
+
break;
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
if (sampled > 0) {
|
|
1125
|
+
const top = [...freq.entries()].sort((a, b) => b[1] - a[1])[0];
|
|
1126
|
+
if (top) {
|
|
1127
|
+
const pct = ((top[1] / sampled) * 100).toFixed(0);
|
|
1128
|
+
const valDisp = top[0].length > 40 ? top[0].slice(0, 40) + '…' : top[0];
|
|
1129
|
+
concentration = ` The \`${field}\` field is dominated by one value ("${valDisp}", ${pct}% of sampled records) — a strong append-only signature.`;
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
const pctHeap = totalSize > 0
|
|
1134
|
+
? ` (${((s.totalSelfSize / totalSize) * 100).toFixed(1)}% of heap self size)`
|
|
1135
|
+
: '';
|
|
1136
|
+
const propsDisplay = s.properties.length <= 6
|
|
1137
|
+
? `{${s.properties.join(', ')}}`
|
|
1138
|
+
: `{${s.properties.slice(0, 5).join(', ')}, … +${s.properties.length - 5}}`;
|
|
1139
|
+
alerts.push(`🟠 **[HIGH] Append-only record accumulation — ${formatNumber(s.count)}× ${propsDisplay}**\n` +
|
|
1140
|
+
`${formatNumber(s.count)} uniform message/log records (${formatBytes(s.totalSelfSize)} self${pctHeap}, example @${s.exampleNodeId}).${concentration}\n` +
|
|
1141
|
+
`**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.`);
|
|
1142
|
+
}
|
|
1143
|
+
return alerts;
|
|
1144
|
+
}
|
|
423
1145
|
export function registerAutoInvestigate(server) {
|
|
424
1146
|
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.', {
|
|
425
1147
|
top_n: z
|
|
@@ -482,8 +1204,13 @@ export function registerAutoInvestigate(server) {
|
|
|
482
1204
|
topPending: [],
|
|
483
1205
|
};
|
|
484
1206
|
const shapes = focus === 'all' ? scanTopShapes(snapshot, 5) : [];
|
|
485
|
-
const subscriptions = focus === 'all'
|
|
1207
|
+
const subscriptions = focus === 'all'
|
|
1208
|
+
? scanSubscriptionAccumulation(snapshot, 1000, totalSize)
|
|
1209
|
+
: [];
|
|
486
1210
|
const errorAccum = focus === 'all' ? scanErrorAccumulation(snapshot, 10) : [];
|
|
1211
|
+
const distributed = focus === 'all'
|
|
1212
|
+
? scanDistributedAccumulation(snapshot, totalSize)
|
|
1213
|
+
: [];
|
|
487
1214
|
const lines = [`# Auto-Investigation Report`, ''];
|
|
488
1215
|
const focusLabel = focus === 'all' ? '' : ` (focus: ${focus})`;
|
|
489
1216
|
lines.push(`## Top ${findings.length} Retained Objects${focusLabel}`);
|
|
@@ -504,13 +1231,17 @@ export function registerAutoInvestigate(server) {
|
|
|
504
1231
|
}
|
|
505
1232
|
}
|
|
506
1233
|
}
|
|
1234
|
+
const previousTraces = [];
|
|
507
1235
|
for (let i = 0; i < findings.length; i++) {
|
|
508
1236
|
const f = findings[i];
|
|
509
1237
|
const siblings = f.collapsed_siblings ?? [];
|
|
510
|
-
|
|
511
|
-
|
|
1238
|
+
// Dominator-aware combined size: members on the same chain overlap,
|
|
1239
|
+
// so we must not sum their retained sizes (that produced the old
|
|
1240
|
+
// ">100% of heap" bug). combinedRetainedSize() keeps only
|
|
1241
|
+
// dominator-maximal members.
|
|
1242
|
+
const totalRetained = combinedRetainedSize([f.node, ...siblings.map(s => s.node)], snapshot);
|
|
512
1243
|
const pct = totalSize > 0
|
|
513
|
-
? ` (${((totalRetained / totalSize) * 100).toFixed(1)}% of heap)`
|
|
1244
|
+
? ` (${Math.min(100, (totalRetained / totalSize) * 100).toFixed(1)}% of heap)`
|
|
514
1245
|
: '';
|
|
515
1246
|
const name = truncateNodeName(f.node.name, f.node.type, f.node.self_size, 60);
|
|
516
1247
|
const sevIcon = f.severity === 'CRITICAL'
|
|
@@ -520,21 +1251,48 @@ export function registerAutoInvestigate(server) {
|
|
|
520
1251
|
: f.severity === 'MEDIUM'
|
|
521
1252
|
? '🟡'
|
|
522
1253
|
: '🔵';
|
|
1254
|
+
// Check if this finding's trace shares a common prefix with a previous one
|
|
1255
|
+
const traceMatch = i > 0 ? findBestTraceMatch(f.trace, previousTraces) : null;
|
|
523
1256
|
if (siblings.length > 0) {
|
|
524
|
-
lines.push(`### ${i + 1}. ${sevIcon} [${f.severity}] \`${name}\`
|
|
1257
|
+
lines.push(`### ${i + 1}. ${sevIcon} [${f.severity}] \`${name}\` — ${formatBytes(totalRetained)} retained, same subtree reached via ${1 + siblings.length} retainer(s)${pct}`);
|
|
525
1258
|
lines.push('');
|
|
526
|
-
|
|
1259
|
+
if (traceMatch) {
|
|
1260
|
+
const divergeStep = f.trace[traceMatch.prefixLen];
|
|
1261
|
+
const divergeEdge = divergeStep?.edgeName ?? '…';
|
|
1262
|
+
const divergeName = divergeStep
|
|
1263
|
+
? truncateNodeName(divergeStep.name, divergeStep.type, divergeStep.selfSize, 40)
|
|
1264
|
+
: '…';
|
|
1265
|
+
lines.push(`**Retainer chain:** (same chain as #${traceMatch.matchedIndex + 1}) → [${divergeEdge}] ${divergeName} — ${formatBytes(f.node.retainedSize)}`);
|
|
1266
|
+
}
|
|
1267
|
+
else {
|
|
1268
|
+
lines.push(`**Retainer chain:** ${formatTrace(f.trace, 8)}`);
|
|
1269
|
+
}
|
|
527
1270
|
lines.push('');
|
|
528
|
-
lines.push(`**
|
|
1271
|
+
lines.push(`**Alternate retainers of the same subtree** (retained sizes overlap — not additive): @${f.node.id} (${formatBytes(f.node.retainedSize)})` +
|
|
529
1272
|
siblings
|
|
530
1273
|
.map(sib => `, @${sib.node.id} ${truncateNodeName(sib.node.name, sib.node.type, sib.node.self_size, 30)} (${formatBytes(sib.node.retainedSize)})`)
|
|
531
1274
|
.join(''));
|
|
532
1275
|
}
|
|
1276
|
+
else if (traceMatch) {
|
|
1277
|
+
const divergeStep = f.trace[traceMatch.prefixLen];
|
|
1278
|
+
const divergeEdge = divergeStep?.edgeName ?? '…';
|
|
1279
|
+
const divergeName = divergeStep
|
|
1280
|
+
? truncateNodeName(divergeStep.name, divergeStep.type, divergeStep.selfSize, 40)
|
|
1281
|
+
: '…';
|
|
1282
|
+
lines.push(`### ${i + 1}. ${sevIcon} [${f.severity}] @${f.node.id} \`${name}\` (${f.node.type}) — ${formatBytes(f.node.retainedSize)}${pct}`);
|
|
1283
|
+
lines.push('');
|
|
1284
|
+
lines.push(`**Retainer chain:** (same chain as #${traceMatch.matchedIndex + 1}) → [${divergeEdge}] ${divergeName} — ${formatBytes(f.node.retainedSize)}`);
|
|
1285
|
+
}
|
|
533
1286
|
else {
|
|
534
1287
|
lines.push(`### ${i + 1}. ${sevIcon} [${f.severity}] @${f.node.id} \`${name}\` (${f.node.type}) — ${formatBytes(f.node.retainedSize)}${pct}`);
|
|
535
1288
|
lines.push('');
|
|
536
1289
|
lines.push(`**Retainer chain:** ${formatTrace(f.trace, 8)}`);
|
|
537
1290
|
}
|
|
1291
|
+
previousTraces.push(f.trace);
|
|
1292
|
+
const instrNote = instrumentationRetainerNote(f.trace);
|
|
1293
|
+
if (instrNote) {
|
|
1294
|
+
lines.push(`> ⚠ ${instrNote}`);
|
|
1295
|
+
}
|
|
538
1296
|
lines.push('');
|
|
539
1297
|
if (f.pinchPoint) {
|
|
540
1298
|
const pp = f.pinchPoint;
|
|
@@ -558,8 +1316,20 @@ export function registerAutoInvestigate(server) {
|
|
|
558
1316
|
lines.push('## Shared Pinch Points');
|
|
559
1317
|
lines.push('');
|
|
560
1318
|
for (const { pp, findingIndices } of sharedPinchPoints) {
|
|
1319
|
+
const ppNode = snapshot.getNodeById(pp.nodeId);
|
|
561
1320
|
const ppName = truncateNodeName(pp.name, pp.type, pp.selfSize, 50);
|
|
562
1321
|
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)}.`);
|
|
1322
|
+
if (ppNode) {
|
|
1323
|
+
const topProps = [];
|
|
1324
|
+
for (const edge of ppNode.references) {
|
|
1325
|
+
if (edge.type === 'property' && topProps.length < 5) {
|
|
1326
|
+
topProps.push(String(edge.name_or_index));
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
if (topProps.length > 0) {
|
|
1330
|
+
lines.push(` Properties: {${topProps.join(', ')}${ppNode.edge_count > 5 ? ', …' : ''}}`);
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
563
1333
|
}
|
|
564
1334
|
lines.push('');
|
|
565
1335
|
}
|
|
@@ -607,6 +1377,154 @@ export function registerAutoInvestigate(server) {
|
|
|
607
1377
|
}
|
|
608
1378
|
lines.push('', '_Use `memlab_shape_histogram` for full shape analysis with retained sizes._');
|
|
609
1379
|
lines.push('');
|
|
1380
|
+
// Feedback #4: Low-cardinality column detection
|
|
1381
|
+
// Feedback #8: High-cost unique property detection
|
|
1382
|
+
// Feedback round 3 §1b: same-object duplication detection
|
|
1383
|
+
// For large shapes (>10K instances), sample property value cardinality
|
|
1384
|
+
const columnAlerts = [];
|
|
1385
|
+
const duplicationAlerts = [];
|
|
1386
|
+
const dupShapesSeen = new Set();
|
|
1387
|
+
// Pre-bucket: collect qualifying shape keys, then single-pass scan
|
|
1388
|
+
const qualifyingShapes = shapes.filter(s => {
|
|
1389
|
+
if (s.count < 10_000)
|
|
1390
|
+
return false;
|
|
1391
|
+
return !!snapshot.getNodeById(s.exampleNodeId);
|
|
1392
|
+
});
|
|
1393
|
+
const shapeBuckets = new Map();
|
|
1394
|
+
for (const s of qualifyingShapes) {
|
|
1395
|
+
shapeBuckets.set(s.properties.join(','), []);
|
|
1396
|
+
}
|
|
1397
|
+
if (shapeBuckets.size > 0) {
|
|
1398
|
+
const maxSamples = 200;
|
|
1399
|
+
snapshot.nodes.forEach(node => {
|
|
1400
|
+
if (node.type !== 'object' || node.id <= 3)
|
|
1401
|
+
return;
|
|
1402
|
+
if (node.name !== 'Object')
|
|
1403
|
+
return;
|
|
1404
|
+
const props = [];
|
|
1405
|
+
for (const edge of node.references) {
|
|
1406
|
+
if (edge.type === 'property') {
|
|
1407
|
+
props.push(String(edge.name_or_index));
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
props.sort();
|
|
1411
|
+
const key = props.join(',');
|
|
1412
|
+
const bucket = shapeBuckets.get(key);
|
|
1413
|
+
if (bucket && bucket.length < maxSamples) {
|
|
1414
|
+
bucket.push(node);
|
|
1415
|
+
}
|
|
1416
|
+
});
|
|
1417
|
+
}
|
|
1418
|
+
for (const s of qualifyingShapes) {
|
|
1419
|
+
const sampleNodes = shapeBuckets.get(s.properties.join(',')) ?? [];
|
|
1420
|
+
if (sampleNodes.length < 50)
|
|
1421
|
+
continue;
|
|
1422
|
+
// Analyze each property's cardinality and value characteristics
|
|
1423
|
+
for (const propName of s.properties) {
|
|
1424
|
+
const values = new Set();
|
|
1425
|
+
let totalValueSize = 0;
|
|
1426
|
+
let stringValueCount = 0;
|
|
1427
|
+
let allUnique = true;
|
|
1428
|
+
for (const node of sampleNodes) {
|
|
1429
|
+
for (const edge of node.references) {
|
|
1430
|
+
if (edge.type === 'property' &&
|
|
1431
|
+
String(edge.name_or_index) === propName) {
|
|
1432
|
+
const target = edge.toNode;
|
|
1433
|
+
let val = target.name;
|
|
1434
|
+
if (target.isString) {
|
|
1435
|
+
const strNode = target.toStringNode();
|
|
1436
|
+
if (strNode)
|
|
1437
|
+
val = strNode.stringValue;
|
|
1438
|
+
stringValueCount++;
|
|
1439
|
+
totalValueSize += target.self_size;
|
|
1440
|
+
}
|
|
1441
|
+
if (values.has(val))
|
|
1442
|
+
allUnique = false;
|
|
1443
|
+
values.add(val);
|
|
1444
|
+
break;
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
const cardinality = values.size;
|
|
1449
|
+
const sampleCount = sampleNodes.length;
|
|
1450
|
+
const uniqueKey = looksUniqueKey(propName);
|
|
1451
|
+
const shapeKey = s.properties.join(',');
|
|
1452
|
+
// Same-object duplication vs genuine low cardinality (§1b): a field
|
|
1453
|
+
// that *should* be unique per record but has far fewer distinct
|
|
1454
|
+
// values than sampled instances means the SAME records are retained
|
|
1455
|
+
// N times — a retention/concurrency fix, not interning.
|
|
1456
|
+
if (uniqueKey &&
|
|
1457
|
+
sampleCount >= 100 &&
|
|
1458
|
+
cardinality >= 1 &&
|
|
1459
|
+
cardinality < sampleCount * 0.6 &&
|
|
1460
|
+
!dupShapesSeen.has(shapeKey)) {
|
|
1461
|
+
const dupFactor = Math.round(sampleCount / Math.max(1, cardinality));
|
|
1462
|
+
if (dupFactor >= 2) {
|
|
1463
|
+
dupShapesSeen.add(shapeKey);
|
|
1464
|
+
duplicationAlerts.push(`- \`${propName}\` (a unique-looking key) has only **${formatNumber(cardinality)} distinct value(s)** across ${formatNumber(sampleCount)} sampled of ${formatNumber(s.count)} total \`{${s.properties.slice(0, 4).join(', ')}${s.properties.length > 4 ? ', …' : ''}}\` instances — looks like **~${dupFactor} retained copies of the same records**, not a low-cardinality column. Investigate **retention/concurrency** (e.g. many concurrent requests each holding a full copy of this dataset), NOT string interning.`);
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
else if (!uniqueKey &&
|
|
1468
|
+
cardinality <= 20 &&
|
|
1469
|
+
sampleCount >= 100) {
|
|
1470
|
+
// Genuine low-cardinality column.
|
|
1471
|
+
columnAlerts.push(`- Property \`${propName}\` has only **${cardinality} unique value(s)** across ${formatNumber(s.count)} instances — low-cardinality column suitable for pre-filtering at the data source or string interning`);
|
|
1472
|
+
}
|
|
1473
|
+
// High-cost unique: all values unique, string, large average size
|
|
1474
|
+
if (allUnique &&
|
|
1475
|
+
stringValueCount > sampleCount * 0.8 &&
|
|
1476
|
+
cardinality > sampleCount * 0.9) {
|
|
1477
|
+
const avgSize = totalValueSize / stringValueCount;
|
|
1478
|
+
if (avgSize > 50 && s.count > 10_000) {
|
|
1479
|
+
const estimatedWaste = avgSize * s.count;
|
|
1480
|
+
columnAlerts.push(`- Property \`${propName}\` has **all unique string values** (avg ${Math.round(avgSize)}B each × ${formatNumber(s.count)} instances = ~${formatBytes(estimatedWaste)}) — verify this field is needed by consumers`);
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
if (duplicationAlerts.length > 0) {
|
|
1486
|
+
lines.push('## Same-Object Duplication');
|
|
1487
|
+
lines.push('');
|
|
1488
|
+
lines.push('Unique-looking keys with far fewer distinct values than instances — the same records appear to be retained multiple times:');
|
|
1489
|
+
lines.push('');
|
|
1490
|
+
lines.push(...duplicationAlerts.slice(0, 8));
|
|
1491
|
+
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)._');
|
|
1492
|
+
lines.push('');
|
|
1493
|
+
}
|
|
1494
|
+
if (columnAlerts.length > 0) {
|
|
1495
|
+
// Cap to the most relevant lines — on wide shapes this section
|
|
1496
|
+
// used to emit ~100 near-identical lines (Feedback §4).
|
|
1497
|
+
const COLUMN_ALERT_CAP = 12;
|
|
1498
|
+
lines.push('## Data Column Analysis');
|
|
1499
|
+
lines.push('');
|
|
1500
|
+
lines.push('Property value analysis across large object shapes:');
|
|
1501
|
+
lines.push('');
|
|
1502
|
+
lines.push(...columnAlerts.slice(0, COLUMN_ALERT_CAP));
|
|
1503
|
+
if (columnAlerts.length > COLUMN_ALERT_CAP) {
|
|
1504
|
+
lines.push(`- … and ${columnAlerts.length - COLUMN_ALERT_CAP} more column(s). Use \`memlab_property_distribution\` to drill into a specific property.`);
|
|
1505
|
+
}
|
|
1506
|
+
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._');
|
|
1507
|
+
lines.push('');
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
1510
|
+
if (distributed.length > 0) {
|
|
1511
|
+
lines.push(`## Distributed Accumulation`);
|
|
1512
|
+
lines.push('');
|
|
1513
|
+
lines.push('Classes with many small instances that collectively consume significant memory. No single instance stands out, but the total is substantial:');
|
|
1514
|
+
lines.push('');
|
|
1515
|
+
for (const d of distributed) {
|
|
1516
|
+
const pct = totalSize > 0
|
|
1517
|
+
? ` (${((d.totalSelfSize / totalSize) * 100).toFixed(1)}% of heap)`
|
|
1518
|
+
: '';
|
|
1519
|
+
const sevIcon = d.totalSelfSize >= totalSize * 0.1
|
|
1520
|
+
? '🔴'
|
|
1521
|
+
: d.totalSelfSize >= totalSize * 0.05
|
|
1522
|
+
? '🟠'
|
|
1523
|
+
: '🟡';
|
|
1524
|
+
lines.push(`- ${sevIcon} \`${d.className}\` (${d.nodeType}) — **${formatNumber(d.count)}** instances × ${Math.round(d.avgSize)}B avg = **${formatBytes(d.totalSelfSize)}**${pct} (example: @${d.exampleNodeId})`);
|
|
1525
|
+
}
|
|
1526
|
+
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)._');
|
|
1527
|
+
lines.push('');
|
|
610
1528
|
}
|
|
611
1529
|
if (subscriptions.length > 0) {
|
|
612
1530
|
lines.push(`## Subscription/Listener Accumulation`);
|
|
@@ -615,17 +1533,39 @@ export function registerAutoInvestigate(server) {
|
|
|
615
1533
|
lines.push('');
|
|
616
1534
|
for (const s of subscriptions.slice(0, 5)) {
|
|
617
1535
|
const propsDisplay = `{${s.properties.join(', ')}}`;
|
|
618
|
-
const
|
|
619
|
-
const
|
|
1536
|
+
const baseSev = classifySeverity(s.totalSelfSize, totalSize);
|
|
1537
|
+
const effectiveSev = s.fanOut
|
|
1538
|
+
? s.fanOut.escalatedSeverity
|
|
1539
|
+
: baseSev;
|
|
1540
|
+
const subSevIcon = effectiveSev === 'CRITICAL'
|
|
620
1541
|
? '🔴'
|
|
621
|
-
:
|
|
1542
|
+
: effectiveSev === 'HIGH'
|
|
622
1543
|
? '🟠'
|
|
623
|
-
:
|
|
1544
|
+
: effectiveSev === 'MEDIUM'
|
|
624
1545
|
? '🟡'
|
|
625
1546
|
: '🔵';
|
|
626
|
-
lines.push(`- ${subSevIcon} [${
|
|
1547
|
+
lines.push(`- ${subSevIcon} [${effectiveSev}] ${propsDisplay} — **${formatNumber(s.count)}** instances, ${formatBytes(s.totalSelfSize)} self (example: @${s.exampleNodeId})`);
|
|
1548
|
+
if (s.fanOut) {
|
|
1549
|
+
const fo = s.fanOut;
|
|
1550
|
+
lines.push(` - **Listener fan-out detected:** ${formatNumber(fo.uniqueCallbacks)} unique callback(s), ${formatNumber(fo.uniqueContexts)} unique context(s)`);
|
|
1551
|
+
if (fo.orphanedContexts > 0) {
|
|
1552
|
+
lines.push(` - **${formatNumber(fo.orphanedContexts)} orphaned context(s)** — only retained by listener registrations`);
|
|
1553
|
+
}
|
|
1554
|
+
if (fo.contextShapes.length > 0) {
|
|
1555
|
+
const shapeStr = fo.contextShapes
|
|
1556
|
+
.map(cs => `${cs.count}× ${cs.name}`)
|
|
1557
|
+
.join(', ');
|
|
1558
|
+
lines.push(` - Context shapes: ${shapeStr}`);
|
|
1559
|
+
}
|
|
1560
|
+
if (effectiveSev !== baseSev) {
|
|
1561
|
+
lines.push(` - _Severity escalated from ${baseSev} → ${effectiveSev} due to listener fan-out pattern (few callbacks, many orphaned contexts)_`);
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
627
1564
|
}
|
|
628
|
-
lines.push(''
|
|
1565
|
+
lines.push('');
|
|
1566
|
+
lines.push('_These may be event listeners, signal subscriptions, or observer registrations that are not being cleaned up. ' +
|
|
1567
|
+
'Use `memlab_event_listener_leaks` for detailed listener analysis or ' +
|
|
1568
|
+
'`memlab_retainer_summary` with node_ids from the examples to trace the retention pattern._');
|
|
629
1569
|
lines.push('');
|
|
630
1570
|
}
|
|
631
1571
|
if (errorAccum.length > 0) {
|
|
@@ -642,6 +1582,26 @@ export function registerAutoInvestigate(server) {
|
|
|
642
1582
|
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
1583
|
lines.push('');
|
|
644
1584
|
}
|
|
1585
|
+
const asyncAlerts = detectAsyncContextLeaks(findings);
|
|
1586
|
+
const frameworkAlerts = focus === 'all' ? detectFrameworkLeaks(snapshot, totalSize) : [];
|
|
1587
|
+
const closureAlerts = focus === 'all' || focus === 'closures'
|
|
1588
|
+
? scanOversizedClosures(snapshot, totalSize)
|
|
1589
|
+
: [];
|
|
1590
|
+
const appendOnlyAlerts = focus === 'all' ? scanAppendOnlyRecords(snapshot, totalSize) : [];
|
|
1591
|
+
const allPatternAlerts = [
|
|
1592
|
+
...frameworkAlerts,
|
|
1593
|
+
...closureAlerts,
|
|
1594
|
+
...appendOnlyAlerts,
|
|
1595
|
+
...asyncAlerts,
|
|
1596
|
+
];
|
|
1597
|
+
if (allPatternAlerts.length > 0) {
|
|
1598
|
+
lines.push('## Known Leak Patterns');
|
|
1599
|
+
lines.push('');
|
|
1600
|
+
for (const alert of allPatternAlerts) {
|
|
1601
|
+
lines.push(alert);
|
|
1602
|
+
lines.push('');
|
|
1603
|
+
}
|
|
1604
|
+
}
|
|
645
1605
|
const sourceHints = extractSourceHints(findings.map(f => f.trace));
|
|
646
1606
|
if (sourceHints.length > 0) {
|
|
647
1607
|
lines.push('## Source Hints');
|
|
@@ -655,23 +1615,25 @@ export function registerAutoInvestigate(server) {
|
|
|
655
1615
|
}
|
|
656
1616
|
lines.push('');
|
|
657
1617
|
}
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
1618
|
+
if (!suggestionsSuppressed()) {
|
|
1619
|
+
lines.push('## Suggested Next Steps');
|
|
1620
|
+
lines.push('');
|
|
1621
|
+
let stepNum = 1;
|
|
1622
|
+
if (findings[0]?.pinchPoint) {
|
|
1623
|
+
const pp = findings[0].pinchPoint;
|
|
1624
|
+
lines.push(`${stepNum++}. Inspect pinch point: \`memlab_get_node(${pp.nodeId})\` then \`memlab_dominator_subtree(${pp.nodeId})\``);
|
|
1625
|
+
}
|
|
1626
|
+
lines.push(`${stepNum++}. Trace top retainer: \`memlab_trace_dominators(${findings[0].node.id})\` for full dominator chain in one call`);
|
|
1627
|
+
lines.push(`${stepNum++}. Check for patterns: \`memlab_retainer_summary\` with class name of top objects`);
|
|
1628
|
+
if (promises.pendingCount > 0 && promises.topPending.length > 0) {
|
|
1629
|
+
lines.push(`${stepNum++}. Investigate pending promises: \`memlab_trace_dominators(${promises.topPending[0].nodeId})\``);
|
|
1630
|
+
}
|
|
1631
|
+
if (caches.length > 0) {
|
|
1632
|
+
lines.push(`${stepNum++}. Analyze caches: \`memlab_cache_analysis\` for detailed cache inspection`);
|
|
1633
|
+
}
|
|
1634
|
+
if (shapes.length > 0) {
|
|
1635
|
+
lines.push(`${stepNum++}. Explore data shapes: \`memlab_shape_histogram\` for detailed shape clustering`);
|
|
1636
|
+
}
|
|
675
1637
|
}
|
|
676
1638
|
return toolResult(lines.join('\n'));
|
|
677
1639
|
}
|