@memlab/mcp-server 2.18.1 → 2.52.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 +8 -14
- package/bin/.eslintrc.js +26 -0
- package/bin/memlab-cli.js +313 -0
- package/dist/artifact-classes.d.ts +86 -0
- package/dist/artifact-classes.d.ts.map +1 -0
- package/dist/artifact-classes.js +261 -0
- package/dist/artifact-classes.js.map +1 -0
- package/dist/capture-mode.d.ts +60 -0
- package/dist/capture-mode.d.ts.map +1 -0
- package/dist/capture-mode.js +39 -0
- package/dist/capture-mode.js.map +1 -0
- package/dist/guardrail.d.ts.map +1 -1
- package/dist/guardrail.js +13 -0
- package/dist/guardrail.js.map +1 -1
- package/dist/heap-state.d.ts +89 -2
- package/dist/heap-state.d.ts.map +1 -1
- package/dist/heap-state.js +186 -2
- package/dist/heap-state.js.map +1 -1
- package/dist/index.js +39 -1
- package/dist/index.js.map +1 -1
- package/dist/progress.d.ts +25 -0
- package/dist/progress.d.ts.map +1 -0
- package/dist/progress.js +46 -0
- package/dist/progress.js.map +1 -0
- package/dist/result-handles.d.ts +11 -0
- package/dist/result-handles.d.ts.map +1 -0
- package/dist/result-handles.js +77 -0
- package/dist/result-handles.js.map +1 -0
- package/dist/string-index.d.ts +55 -0
- package/dist/string-index.d.ts.map +1 -0
- package/dist/string-index.js +91 -0
- package/dist/string-index.js.map +1 -0
- package/dist/tool-registry.d.ts +42 -0
- package/dist/tool-registry.d.ts.map +1 -0
- package/dist/tool-registry.js +20 -0
- package/dist/tool-registry.js.map +1 -0
- package/dist/tools/analyze-run.d.ts +12 -0
- package/dist/tools/analyze-run.d.ts.map +1 -0
- package/dist/tools/analyze-run.js +343 -0
- package/dist/tools/analyze-run.js.map +1 -0
- package/dist/tools/app-heap.d.ts +12 -0
- package/dist/tools/app-heap.d.ts.map +1 -0
- package/dist/tools/app-heap.js +140 -0
- package/dist/tools/app-heap.js.map +1 -0
- package/dist/tools/batch.d.ts +12 -0
- package/dist/tools/batch.d.ts.map +1 -0
- package/dist/tools/batch.js +168 -0
- package/dist/tools/batch.js.map +1 -0
- package/dist/tools/cache-analysis.d.ts.map +1 -1
- package/dist/tools/cache-analysis.js +156 -5
- package/dist/tools/cache-analysis.js.map +1 -1
- package/dist/tools/class-histogram.d.ts.map +1 -1
- package/dist/tools/class-histogram.js +14 -3
- package/dist/tools/class-histogram.js.map +1 -1
- package/dist/tools/collection-trend.d.ts +55 -0
- package/dist/tools/collection-trend.d.ts.map +1 -0
- package/dist/tools/collection-trend.js +315 -0
- package/dist/tools/collection-trend.js.map +1 -0
- package/dist/tools/detached-dom.d.ts +2 -0
- package/dist/tools/detached-dom.d.ts.map +1 -1
- package/dist/tools/detached-dom.js +291 -20
- package/dist/tools/detached-dom.js.map +1 -1
- package/dist/tools/dev-artifacts.d.ts +62 -6
- package/dist/tools/dev-artifacts.d.ts.map +1 -1
- package/dist/tools/dev-artifacts.js +466 -23
- package/dist/tools/dev-artifacts.js.map +1 -1
- package/dist/tools/dominator-attribution.d.ts +12 -0
- package/dist/tools/dominator-attribution.d.ts.map +1 -0
- package/dist/tools/dominator-attribution.js +189 -0
- package/dist/tools/dominator-attribution.js.map +1 -0
- package/dist/tools/dominator-chain.d.ts +12 -0
- package/dist/tools/dominator-chain.d.ts.map +1 -0
- package/dist/tools/dominator-chain.js +92 -0
- package/dist/tools/dominator-chain.js.map +1 -0
- package/dist/tools/duplicate-objects.d.ts +12 -0
- package/dist/tools/duplicate-objects.d.ts.map +1 -0
- package/dist/tools/duplicate-objects.js +172 -0
- package/dist/tools/duplicate-objects.js.map +1 -0
- package/dist/tools/duplicated-strings.d.ts.map +1 -1
- package/dist/tools/duplicated-strings.js +46 -38
- package/dist/tools/duplicated-strings.js.map +1 -1
- package/dist/tools/eval.d.ts +1 -0
- package/dist/tools/eval.d.ts.map +1 -1
- package/dist/tools/eval.js +785 -28
- package/dist/tools/eval.js.map +1 -1
- package/dist/tools/explain-delta.d.ts +12 -0
- package/dist/tools/explain-delta.d.ts.map +1 -0
- package/dist/tools/explain-delta.js +170 -0
- package/dist/tools/explain-delta.js.map +1 -0
- package/dist/tools/find-nodes-by-class.d.ts.map +1 -1
- package/dist/tools/find-nodes-by-class.js +112 -13
- package/dist/tools/find-nodes-by-class.js.map +1 -1
- package/dist/tools/finding-index.d.ts +22 -0
- package/dist/tools/finding-index.d.ts.map +1 -0
- package/dist/tools/finding-index.js +247 -0
- package/dist/tools/finding-index.js.map +1 -0
- package/dist/tools/for-each.d.ts.map +1 -1
- package/dist/tools/for-each.js +15 -3
- package/dist/tools/for-each.js.map +1 -1
- package/dist/tools/get-referrers.d.ts.map +1 -1
- package/dist/tools/get-referrers.js +17 -0
- package/dist/tools/get-referrers.js.map +1 -1
- package/dist/tools/hunt-report.d.ts +12 -0
- package/dist/tools/hunt-report.d.ts.map +1 -0
- package/dist/tools/hunt-report.js +186 -0
- package/dist/tools/hunt-report.js.map +1 -0
- package/dist/tools/hypothesis.d.ts +12 -0
- package/dist/tools/hypothesis.d.ts.map +1 -0
- package/dist/tools/hypothesis.js +221 -0
- package/dist/tools/hypothesis.js.map +1 -0
- package/dist/tools/intern-opportunities.d.ts.map +1 -1
- package/dist/tools/intern-opportunities.js +112 -47
- package/dist/tools/intern-opportunities.js.map +1 -1
- package/dist/tools/ladder.d.ts +33 -0
- package/dist/tools/ladder.d.ts.map +1 -0
- package/dist/tools/ladder.js +266 -0
- package/dist/tools/ladder.js.map +1 -0
- package/dist/tools/largest-objects.d.ts.map +1 -1
- package/dist/tools/largest-objects.js +6 -2
- package/dist/tools/largest-objects.js.map +1 -1
- package/dist/tools/leak-report.d.ts +12 -0
- package/dist/tools/leak-report.d.ts.map +1 -0
- package/dist/tools/leak-report.js +256 -0
- package/dist/tools/leak-report.js.map +1 -0
- package/dist/tools/load-snapshot.d.ts +18 -0
- package/dist/tools/load-snapshot.d.ts.map +1 -1
- package/dist/tools/load-snapshot.js +211 -13
- package/dist/tools/load-snapshot.js.map +1 -1
- package/dist/tools/map-entries.d.ts +12 -0
- package/dist/tools/map-entries.d.ts.map +1 -0
- package/dist/tools/map-entries.js +139 -0
- package/dist/tools/map-entries.js.map +1 -0
- package/dist/tools/match-object.d.ts +12 -0
- package/dist/tools/match-object.d.ts.map +1 -0
- package/dist/tools/match-object.js +165 -0
- package/dist/tools/match-object.js.map +1 -0
- package/dist/tools/object-shape.d.ts.map +1 -1
- package/dist/tools/object-shape.js +53 -5
- package/dist/tools/object-shape.js.map +1 -1
- package/dist/tools/population-diff.d.ts +57 -0
- package/dist/tools/population-diff.d.ts.map +1 -0
- package/dist/tools/population-diff.js +338 -0
- package/dist/tools/population-diff.js.map +1 -0
- package/dist/tools/retainer-layers.d.ts +23 -0
- package/dist/tools/retainer-layers.d.ts.map +1 -0
- package/dist/tools/retainer-layers.js +179 -0
- package/dist/tools/retainer-layers.js.map +1 -0
- package/dist/tools/retainer-summary.d.ts +1 -0
- package/dist/tools/retainer-summary.d.ts.map +1 -1
- package/dist/tools/retainer-summary.js +128 -32
- 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 +4 -2
- 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 +5 -1
- package/dist/tools/search-nodes.js.map +1 -1
- package/dist/tools/search-strings.d.ts.map +1 -1
- package/dist/tools/search-strings.js +53 -7
- package/dist/tools/search-strings.js.map +1 -1
- package/dist/tools/sequence-analysis.d.ts +49 -0
- package/dist/tools/sequence-analysis.d.ts.map +1 -1
- package/dist/tools/sequence-analysis.js +266 -86
- package/dist/tools/sequence-analysis.js.map +1 -1
- package/dist/tools/shape-histogram.d.ts.map +1 -1
- package/dist/tools/shape-histogram.js +9 -2
- package/dist/tools/shape-histogram.js.map +1 -1
- package/dist/tools/string-patterns.d.ts.map +1 -1
- package/dist/tools/string-patterns.js +5 -4
- package/dist/tools/string-patterns.js.map +1 -1
- package/dist/tools/tools-index.d.ts +12 -0
- package/dist/tools/tools-index.d.ts.map +1 -0
- package/dist/tools/tools-index.js +342 -0
- package/dist/tools/tools-index.js.map +1 -0
- package/dist/tools/verify-fix.d.ts +23 -0
- package/dist/tools/verify-fix.d.ts.map +1 -0
- package/dist/tools/verify-fix.js +211 -0
- package/dist/tools/verify-fix.js.map +1 -0
- package/dist/tools/weakmap-entries.d.ts.map +1 -1
- package/dist/tools/weakmap-entries.js +6 -1
- package/dist/tools/weakmap-entries.js.map +1 -1
- package/dist/utils.d.ts +146 -1
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +335 -10
- package/dist/utils.js.map +1 -1
- package/package.json +5 -4
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
3
|
+
*
|
|
4
|
+
* This source code is licensed under the MIT license found in the
|
|
5
|
+
* LICENSE file in the root directory of this source tree.
|
|
6
|
+
*
|
|
7
|
+
* @format
|
|
8
|
+
* @oncall memory_lab
|
|
9
|
+
*/
|
|
10
|
+
import { z } from 'zod';
|
|
11
|
+
import { getSnapshot, getSnapshotMetadata } from '../heap-state.js';
|
|
12
|
+
import { formatBytes, formatNumber, markdownTable, errorResult, toolResult, } from '../utils.js';
|
|
13
|
+
import { classifyArtifact } from '../artifact-classes.js';
|
|
14
|
+
import { collectDevRoots, computeReachableWithoutDevRoots, } from './dev-artifacts.js';
|
|
15
|
+
/**
|
|
16
|
+
* How much of this heap is actually the APPLICATION?
|
|
17
|
+
*
|
|
18
|
+
* A dev-build heap is mostly not the app. Measured on one WhatsApp Web capture:
|
|
19
|
+
* `system / ExternalStringData` — the bundle's own source text — was 56% of a
|
|
20
|
+
* 311 MB heap, single strings in it retained 84.9 / 22.5 / 17.8 / 10.2 MB, and
|
|
21
|
+
* code-related memory came to roughly two thirds of the total. So "drive to
|
|
22
|
+
* 600 MB" is mostly an instruction to load more bundle, the number is not
|
|
23
|
+
* comparable to production, and a threshold expressed against it is measuring
|
|
24
|
+
* the wrong thing.
|
|
25
|
+
*
|
|
26
|
+
* The split below is deliberately coarse and named, not a single magic number:
|
|
27
|
+
* a reader has to be able to see WHICH bucket dominates, because that is what
|
|
28
|
+
* decides whether the capture is usable at all.
|
|
29
|
+
*/
|
|
30
|
+
const CODE_TYPES = new Set(['code']);
|
|
31
|
+
// Bundle source text and the V8 structures that hold it. These exist because
|
|
32
|
+
// the app was *loaded*, not because it did anything.
|
|
33
|
+
const BUNDLE_NAME_RE = /ExternalStringData|InstructionStream|BytecodeArray|SharedFunctionInfo|ScopeInfo|UncompiledData|FeedbackVector|FeedbackMetadata|FeedbackCell|ClosureFeedbackCellArray|\(constant pool\)|ConstantPool/;
|
|
34
|
+
export function registerAppHeap(server) {
|
|
35
|
+
server.tool('memlab_app_heap', 'Report APP-ATTRIBUTABLE heap: total self size minus bundle/code memory, minus dev-build artifacts, minus measurement-harness content — the part of the heap that is actually the application. ' +
|
|
36
|
+
'On a dev build the raw number is mostly not the app: a measured WhatsApp Web capture had bundle source text (`system / ExternalStringData`) at 56% of a 311 MB heap and code-related memory at roughly two thirds of the total. A leak-hunt threshold expressed against the raw total therefore mostly measures how much bundle has loaded, and is not comparable to production at all. ' +
|
|
37
|
+
'Use this as the number to drive a hunt against and to quote in a report; use memlab_leak_report for whether anything is actually growing.', {
|
|
38
|
+
show_breakdown: z
|
|
39
|
+
.boolean()
|
|
40
|
+
.optional()
|
|
41
|
+
.default(true)
|
|
42
|
+
.describe('Show the per-bucket table (default true). The breakdown is the point — which bucket dominates decides whether the capture is usable.'),
|
|
43
|
+
}, async ({ show_breakdown }) => {
|
|
44
|
+
try {
|
|
45
|
+
const snapshot = getSnapshot();
|
|
46
|
+
const meta = getSnapshotMetadata();
|
|
47
|
+
const devRoots = collectDevRoots(snapshot);
|
|
48
|
+
const reached = devRoots.byId.size > 0
|
|
49
|
+
? computeReachableWithoutDevRoots(snapshot, devRoots)
|
|
50
|
+
: null;
|
|
51
|
+
let total = 0;
|
|
52
|
+
let code = 0;
|
|
53
|
+
let bundle = 0;
|
|
54
|
+
let devOnly = 0;
|
|
55
|
+
let artifact = 0;
|
|
56
|
+
let app = 0;
|
|
57
|
+
let appNodes = 0;
|
|
58
|
+
const isDevOnly = (node) => reached != null && reached[node.nodeIndex] === 0;
|
|
59
|
+
snapshot.nodes.forEach(node => {
|
|
60
|
+
if (node.id <= 3)
|
|
61
|
+
return;
|
|
62
|
+
const size = node.self_size;
|
|
63
|
+
total += size;
|
|
64
|
+
// Precedence matters: a node is counted once, in the most specific
|
|
65
|
+
// bucket that explains why it is NOT app memory.
|
|
66
|
+
if (isDevOnly(node)) {
|
|
67
|
+
devOnly += size;
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (CODE_TYPES.has(node.type)) {
|
|
71
|
+
code += size;
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (BUNDLE_NAME_RE.test(node.name)) {
|
|
75
|
+
bundle += size;
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (classifyArtifact(node.name) != null) {
|
|
79
|
+
artifact += size;
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
app += size;
|
|
83
|
+
appNodes++;
|
|
84
|
+
});
|
|
85
|
+
const pct = (n) => total > 0 ? `${((n / total) * 100).toFixed(1)}%` : '—';
|
|
86
|
+
const lines = [
|
|
87
|
+
'## App-attributable heap',
|
|
88
|
+
'',
|
|
89
|
+
`**${formatBytes(app)} of ${formatBytes(total)} (${pct(app)}) is application memory** across ${formatNumber(appNodes)} nodes` +
|
|
90
|
+
(meta ? ` in \`${meta.fileName}\`` : '') +
|
|
91
|
+
'.',
|
|
92
|
+
'',
|
|
93
|
+
];
|
|
94
|
+
if (show_breakdown) {
|
|
95
|
+
lines.push(markdownTable(['Bucket', 'Self size', '% of heap', 'What it is'], [
|
|
96
|
+
[
|
|
97
|
+
'App',
|
|
98
|
+
formatBytes(app),
|
|
99
|
+
pct(app),
|
|
100
|
+
'The number to drive and report',
|
|
101
|
+
],
|
|
102
|
+
[
|
|
103
|
+
'Bundle / source text',
|
|
104
|
+
formatBytes(bundle),
|
|
105
|
+
pct(bundle),
|
|
106
|
+
'Loaded, not allocated by use; absent at this scale in prod',
|
|
107
|
+
],
|
|
108
|
+
[
|
|
109
|
+
'Code / bytecode',
|
|
110
|
+
formatBytes(code),
|
|
111
|
+
pct(code),
|
|
112
|
+
'JIT + compiled code; grows by exercising new paths',
|
|
113
|
+
],
|
|
114
|
+
[
|
|
115
|
+
'Dev/automation-retained',
|
|
116
|
+
formatBytes(devOnly),
|
|
117
|
+
pct(devOnly),
|
|
118
|
+
'Held only via inspector/dev roots; GC-eligible in prod',
|
|
119
|
+
],
|
|
120
|
+
[
|
|
121
|
+
'Known artifacts',
|
|
122
|
+
formatBytes(artifact),
|
|
123
|
+
pct(artifact),
|
|
124
|
+
'CDP/a11y/warmup families',
|
|
125
|
+
],
|
|
126
|
+
], new Set([1, 2])), '');
|
|
127
|
+
}
|
|
128
|
+
const nonApp = total - app;
|
|
129
|
+
if (total > 0 && nonApp / total > 0.5) {
|
|
130
|
+
lines.push(`> ⚠️ **${pct(nonApp)} of this heap is not the application.** A threshold expressed against the raw total is mostly measuring how much bundle and code has loaded. Drive against the app-attributable figure above, and do not compare the raw total to production.`);
|
|
131
|
+
}
|
|
132
|
+
lines.push('', '_Buckets are mutually exclusive and sum to the total; each node lands in the most specific bucket that explains why it is not app memory. Self size, not retained: retained sizes overlap and would not sum._');
|
|
133
|
+
return toolResult(lines.join('\n'));
|
|
134
|
+
}
|
|
135
|
+
catch (err) {
|
|
136
|
+
return errorResult(err);
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
//# sourceMappingURL=app-heap.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"app-heap.js","sourceRoot":"","sources":["../../src/tools/app-heap.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
3
|
+
*
|
|
4
|
+
* This source code is licensed under the MIT license found in the
|
|
5
|
+
* LICENSE file in the root directory of this source tree.
|
|
6
|
+
*
|
|
7
|
+
* @format
|
|
8
|
+
* @oncall memory_lab
|
|
9
|
+
*/
|
|
10
|
+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
11
|
+
export declare function registerBatch(server: McpServer): void;
|
|
12
|
+
//# sourceMappingURL=batch.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"batch.d.ts","sourceRoot":"","sources":["../../src/tools/batch.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,yCAAyC,CAAC;AA2CvE,wBAAgB,aAAa,CAAC,MAAM,EAAE,SAAS,GAAG,IAAI,CA2JrD"}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
3
|
+
*
|
|
4
|
+
* This source code is licensed under the MIT license found in the
|
|
5
|
+
* LICENSE file in the root directory of this source tree.
|
|
6
|
+
*
|
|
7
|
+
* @format
|
|
8
|
+
* @oncall memory_lab
|
|
9
|
+
*/
|
|
10
|
+
import { z } from 'zod';
|
|
11
|
+
import { getRegisteredTool, listToolNames } from '../tool-registry.js';
|
|
12
|
+
import { errorResult, toolResult } from '../utils.js';
|
|
13
|
+
/**
|
|
14
|
+
* A failed step usually does NOT throw. Tool handlers catch their own errors and
|
|
15
|
+
* return `errorResult`, which is a normal MCP envelope carrying `isError: true`,
|
|
16
|
+
* so treating only exceptions as failures silently mislabels those steps as
|
|
17
|
+
* successful — and, worse, makes `stop_on_error` a no-op for the most common
|
|
18
|
+
* failure there is.
|
|
19
|
+
*/
|
|
20
|
+
function isErrorResult(res) {
|
|
21
|
+
return (res != null &&
|
|
22
|
+
typeof res === 'object' &&
|
|
23
|
+
res.isError === true);
|
|
24
|
+
}
|
|
25
|
+
function renderToolText(res) {
|
|
26
|
+
// Tool handlers return the MCP content envelope produced by `toolResult`.
|
|
27
|
+
if (res && typeof res === 'object' && 'content' in res) {
|
|
28
|
+
const content = res.content;
|
|
29
|
+
if (Array.isArray(content)) {
|
|
30
|
+
return content
|
|
31
|
+
.map(c => c && typeof c === 'object' && 'text' in c
|
|
32
|
+
? String(c.text ?? '')
|
|
33
|
+
: JSON.stringify(c))
|
|
34
|
+
.join('\n');
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return typeof res === 'string' ? res : JSON.stringify(res);
|
|
38
|
+
}
|
|
39
|
+
export function registerBatch(server) {
|
|
40
|
+
server.tool('memlab_batch', 'Run several memlab tools against ONE snapshot load, in order, and return all their outputs together. ' +
|
|
41
|
+
'The natural unit of heap work is "load snapshot X, then run these N tools", but loading is by far the dominant cost — a 380 MB / 4M-node snapshot takes minutes to parse and build a dominator tree, and an MCP session that drops (or a client that cannot attach) makes every call pay it again. ' +
|
|
42
|
+
'Pass `load` to load a snapshot first (same arguments as memlab_load_snapshot); omit it to run against the already-resident snapshot. ' +
|
|
43
|
+
"Steps run sequentially and see each other's side effects, so a step may depend on an earlier one (e.g. load -> check_health -> retainer_summary). " +
|
|
44
|
+
"Every step's tool name AND arguments are validated before step 0 runs, so a step missing a required argument fails the batch immediately instead of after the snapshot load has been paid for. " +
|
|
45
|
+
'NOTE ON TIMEOUTS: the whole batch runs under a single wall-clock guardrail, not one per step — size it with `timeout_ms` (e.g. 600000 for a load plus several whole-heap scans).', {
|
|
46
|
+
load: z
|
|
47
|
+
.object({
|
|
48
|
+
file_path: z.string(),
|
|
49
|
+
alias: z.string().optional(),
|
|
50
|
+
keep_previous: z.boolean().optional(),
|
|
51
|
+
quiet: z.boolean().optional(),
|
|
52
|
+
max_file_size_mb: z.number().optional(),
|
|
53
|
+
})
|
|
54
|
+
.optional()
|
|
55
|
+
.describe('Optional memlab_load_snapshot arguments to run as step 0. Omit to use the resident snapshot.'),
|
|
56
|
+
steps: z
|
|
57
|
+
.array(z.object({
|
|
58
|
+
tool: z.string().describe('Tool name, e.g. "memlab_check_health".'),
|
|
59
|
+
args: z
|
|
60
|
+
.record(z.unknown())
|
|
61
|
+
.optional()
|
|
62
|
+
.describe('Arguments object for that tool (default {}).'),
|
|
63
|
+
}))
|
|
64
|
+
.min(1)
|
|
65
|
+
.describe('Ordered list of tools to run after the optional load.'),
|
|
66
|
+
stop_on_error: z
|
|
67
|
+
.boolean()
|
|
68
|
+
.optional()
|
|
69
|
+
.default(false)
|
|
70
|
+
.describe('Stop at the first failing step (default false: record the error and continue).'),
|
|
71
|
+
}, async ({ load, steps, stop_on_error }) => {
|
|
72
|
+
try {
|
|
73
|
+
const plan = [];
|
|
74
|
+
if (load != null) {
|
|
75
|
+
plan.push({
|
|
76
|
+
tool: 'memlab_load_snapshot',
|
|
77
|
+
args: load,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
for (const s of steps) {
|
|
81
|
+
plan.push({
|
|
82
|
+
tool: s.tool,
|
|
83
|
+
args: (s.args ?? {}),
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
const unknown = plan
|
|
87
|
+
.map(p => p.tool)
|
|
88
|
+
.filter(name => getRegisteredTool(name) == null);
|
|
89
|
+
if (unknown.length > 0) {
|
|
90
|
+
return errorResult(new Error(`Unknown tool(s): ${[...new Set(unknown)].join(', ')}. Available: ${listToolNames().join(', ')}`));
|
|
91
|
+
}
|
|
92
|
+
// Validate EVERY step's arguments before running step 0.
|
|
93
|
+
//
|
|
94
|
+
// Parsing inside the execution loop means a step that is missing a
|
|
95
|
+
// required argument fails only after the load has been paid for — and
|
|
96
|
+
// the load is the expensive part: on a 243 MB snapshot this cost a full
|
|
97
|
+
// parse twice in one session (`weakmap_entries` and `retainer_layers`,
|
|
98
|
+
// both missing `node_id`) before the batch reported anything. The
|
|
99
|
+
// information needed to refuse was available before any work started.
|
|
100
|
+
const planned = [];
|
|
101
|
+
const argErrors = [];
|
|
102
|
+
plan.forEach(({ tool, args }, i) => {
|
|
103
|
+
const entry = getRegisteredTool(tool);
|
|
104
|
+
if (entry == null)
|
|
105
|
+
return;
|
|
106
|
+
try {
|
|
107
|
+
// Apply the tool's own zod shape so `.default()` values are
|
|
108
|
+
// materialized exactly as they are for a direct MCP call. Without
|
|
109
|
+
// this a step that omits an optional gets `undefined` and a scan
|
|
110
|
+
// gated on e.g. `min_count` silently returns nothing.
|
|
111
|
+
planned.push({
|
|
112
|
+
tool,
|
|
113
|
+
parsed: entry.shape != null
|
|
114
|
+
? z.object(entry.shape).parse(args)
|
|
115
|
+
: args,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
const detail = err instanceof z.ZodError
|
|
120
|
+
? err.issues
|
|
121
|
+
.map(iss => `${iss.path.join('.') || '(root)'}: ${iss.message}`)
|
|
122
|
+
.join('; ')
|
|
123
|
+
: err instanceof Error
|
|
124
|
+
? err.message
|
|
125
|
+
: String(err);
|
|
126
|
+
argErrors.push(`step ${i + 1} \`${tool}\` — ${detail}`);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
if (argErrors.length > 0) {
|
|
130
|
+
return errorResult(new Error(`${argErrors.length} step(s) have invalid arguments; nothing was run (the snapshot load is the expensive part of a batch, so the whole plan is checked first):\n` +
|
|
131
|
+
argErrors.map(e => `- ${e}`).join('\n')));
|
|
132
|
+
}
|
|
133
|
+
const results = [];
|
|
134
|
+
for (const { tool, parsed } of planned) {
|
|
135
|
+
const entry = getRegisteredTool(tool);
|
|
136
|
+
if (entry == null)
|
|
137
|
+
continue;
|
|
138
|
+
try {
|
|
139
|
+
const res = await entry.handler(parsed, {});
|
|
140
|
+
const ok = !isErrorResult(res);
|
|
141
|
+
results.push({ tool, ok, text: renderToolText(res) });
|
|
142
|
+
if (!ok && stop_on_error)
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
catch (err) {
|
|
146
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
147
|
+
results.push({ tool, ok: false, text: `ERROR: ${msg}` });
|
|
148
|
+
if (stop_on_error)
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
const failed = results.filter(r => !r.ok).length;
|
|
153
|
+
const header = `# Batch: ${results.length} step(s) run` +
|
|
154
|
+
(failed > 0 ? `, ${failed} failed` : '') +
|
|
155
|
+
(results.length < plan.length
|
|
156
|
+
? ` (stopped early; ${plan.length - results.length} not run)`
|
|
157
|
+
: '');
|
|
158
|
+
const body = results
|
|
159
|
+
.map((r, i) => `\n---\n\n## Step ${i + 1}/${plan.length}: \`${r.tool}\`${r.ok ? '' : ' — FAILED'}\n\n${r.text}`)
|
|
160
|
+
.join('\n');
|
|
161
|
+
return toolResult(`${header}\n${body}`);
|
|
162
|
+
}
|
|
163
|
+
catch (err) {
|
|
164
|
+
return errorResult(err);
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
//# sourceMappingURL=batch.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"batch.js","sourceRoot":"","sources":["../../src/tools/batch.ts"],"names":[],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cache-analysis.d.ts","sourceRoot":"","sources":["../../src/tools/cache-analysis.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,yCAAyC,CAAC;
|
|
1
|
+
{"version":3,"file":"cache-analysis.d.ts","sourceRoot":"","sources":["../../src/tools/cache-analysis.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,yCAAyC,CAAC;AA8WvE,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,SAAS,GAAG,IAAI,CAosB7D"}
|
|
@@ -11,13 +11,81 @@ import { z } from 'zod';
|
|
|
11
11
|
import { getSnapshot, getSnapshotMetadata } from '../heap-state.js';
|
|
12
12
|
import { formatBytes, formatNumber, markdownTable, truncateNodeName, errorResult, toolResult, suggestionsSuppressed, } from '../utils.js';
|
|
13
13
|
const CACHE_EDGE_RE = /cache|memo|store|registry|lru|dedup|pool|index|lookup|byId|byKey/i;
|
|
14
|
+
/**
|
|
15
|
+
* Sibling property names specific enough to mark a ring buffer on their own: a
|
|
16
|
+
* plain cache has no reason to track where the next slot to overwrite is.
|
|
17
|
+
*/
|
|
18
|
+
const RING_WRITE_CURSOR_PROPS = new Set([
|
|
19
|
+
'localCursor',
|
|
20
|
+
'writeFrom',
|
|
21
|
+
'writeIndex',
|
|
22
|
+
'writeCursor',
|
|
23
|
+
'writePos',
|
|
24
|
+
'nextSlot',
|
|
25
|
+
]);
|
|
26
|
+
/**
|
|
27
|
+
* Cursor-ish names that are also carried by linked lists, queues, iterators,
|
|
28
|
+
* tree-traversal state and cursor-paginated result sets. Alone they are not
|
|
29
|
+
* evidence of a ring — a genuine unbounded cache whose owner happens to expose
|
|
30
|
+
* `head` would be dropped as bounded — so they only count when paired with a
|
|
31
|
+
* capacity control.
|
|
32
|
+
*/
|
|
33
|
+
const RING_AMBIGUOUS_CURSOR_PROPS = new Set([
|
|
34
|
+
'cursor',
|
|
35
|
+
'head',
|
|
36
|
+
'tail',
|
|
37
|
+
'nextIndex',
|
|
38
|
+
'readIndex',
|
|
39
|
+
'readCursor',
|
|
40
|
+
]);
|
|
41
|
+
/**
|
|
42
|
+
* Anchored on purpose: an unanchored pattern matches any name merely
|
|
43
|
+
* *containing* the token, so predicates and unrelated config (`hasCapacity`,
|
|
44
|
+
* `overCapacity`, `defaultMaxSize`) would read as capacity controls.
|
|
45
|
+
*/
|
|
46
|
+
const RING_CAPACITY_PROP_RE = /^_?(?:capacity|log_?capacity|max_?size|buffer_?size|ring_?size)$/i;
|
|
47
|
+
/**
|
|
48
|
+
* A fixed-capacity ring buffer looks exactly like an unbounded cache to a
|
|
49
|
+
* size-based heuristic: a large backing array full of entries, strongly held,
|
|
50
|
+
* with no eviction call in sight. It is the opposite — it can never grow.
|
|
51
|
+
*
|
|
52
|
+
* Observed false positive: a logger's 150,000-slot ring was reported as
|
|
53
|
+
* "cache-like, missing eviction is a likely leak" because the tool counted the
|
|
54
|
+
* ~11k non-null slots. Detect the shape from the owner's sibling properties (a
|
|
55
|
+
* write cursor and/or a capacity control) so it is labelled bounded instead.
|
|
56
|
+
*
|
|
57
|
+
* Biased towards under-detection: a missed ring is a false leak candidate the
|
|
58
|
+
* caller can dismiss, while a wrongly-detected one drops a real leak silently.
|
|
59
|
+
*/
|
|
60
|
+
function looksLikeRingBuffer(owner) {
|
|
61
|
+
if (!owner)
|
|
62
|
+
return false;
|
|
63
|
+
let hasAmbiguousCursor = false;
|
|
64
|
+
let hasCapacity = false;
|
|
65
|
+
for (const edge of owner.references) {
|
|
66
|
+
const name = String(edge.name_or_index);
|
|
67
|
+
if (RING_WRITE_CURSOR_PROPS.has(name))
|
|
68
|
+
return true;
|
|
69
|
+
if (RING_AMBIGUOUS_CURSOR_PROPS.has(name))
|
|
70
|
+
hasAmbiguousCursor = true;
|
|
71
|
+
if (RING_CAPACITY_PROP_RE.test(name))
|
|
72
|
+
hasCapacity = true;
|
|
73
|
+
if (hasAmbiguousCursor && hasCapacity)
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
14
78
|
/**
|
|
15
79
|
* Distinguish a genuine cache (named like one, owned by a cache class, or
|
|
16
80
|
* carrying cache config) from a plain large collection / per-request working
|
|
17
81
|
* set. Avoids over-labeling every big Map/Array as an "unbounded cache"
|
|
18
82
|
* (Feedback §2c).
|
|
19
83
|
*/
|
|
20
|
-
function classifyCollection(collectionType, ownerEdge, framework) {
|
|
84
|
+
function classifyCollection(collectionType, ownerEdge, framework, owner) {
|
|
85
|
+
// Checked first: a bounded structure is not a leak candidate however it is
|
|
86
|
+
// named, and ring buffers are frequently named like caches/logs.
|
|
87
|
+
if (looksLikeRingBuffer(owner ?? null))
|
|
88
|
+
return 'ring-buffer';
|
|
21
89
|
if (framework)
|
|
22
90
|
return 'cache-like';
|
|
23
91
|
if (/cache/i.test(collectionType))
|
|
@@ -77,6 +145,7 @@ function getOwnerInfo(node) {
|
|
|
77
145
|
return {
|
|
78
146
|
name: truncateNodeName(from.name, from.type, from.self_size, 40),
|
|
79
147
|
edge: edgeName,
|
|
148
|
+
node: from,
|
|
80
149
|
};
|
|
81
150
|
}
|
|
82
151
|
if (from.hasPathEdge) {
|
|
@@ -87,11 +156,40 @@ function getOwnerInfo(node) {
|
|
|
87
156
|
return {
|
|
88
157
|
name: truncateNodeName(outer.name, outer.type, outer.self_size, 40),
|
|
89
158
|
edge: `${String(outerEdge.name_or_index)}.${edgeName}`,
|
|
159
|
+
node: outer,
|
|
90
160
|
};
|
|
91
161
|
}
|
|
92
162
|
}
|
|
93
163
|
}
|
|
94
|
-
return { name: from.name, edge: edgeName };
|
|
164
|
+
return { name: from.name, edge: edgeName, node: from };
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* O(1)-ish upper bound on a collection's entry count, used to decide whether a
|
|
168
|
+
* collection is worth counting properly.
|
|
169
|
+
*
|
|
170
|
+
* Why it exists: `cache_analysis` gated every collection on `min_retained_size`
|
|
171
|
+
* first, so a collection that is LONG but cheap — many entries of small or
|
|
172
|
+
* shared values — was invisible. A positive-control Map planted for a leak hunt
|
|
173
|
+
* sat under the 512 KB default and never appeared, which is the one case the
|
|
174
|
+
* tool most needs to catch. Length and bytes are different questions.
|
|
175
|
+
*
|
|
176
|
+
* The full `countEntries` walk is O(entries), so running it on every Map/Set/
|
|
177
|
+
* Array in a multi-million-node heap is not affordable. Reading the backing
|
|
178
|
+
* table's `edge_count` is a single lookup and bounds the expensive walk to
|
|
179
|
+
* collections that are plausibly long.
|
|
180
|
+
*/
|
|
181
|
+
function approxEntryCount(node) {
|
|
182
|
+
if (node.name === 'Map' || node.name === 'Set') {
|
|
183
|
+
for (const edge of node.references) {
|
|
184
|
+
const eName = String(edge.name_or_index);
|
|
185
|
+
if ((eName === 'table' || eName === 'backing_store') &&
|
|
186
|
+
(edge.toNode.type === 'array' || edge.toNode.type === 'hidden')) {
|
|
187
|
+
const slots = edge.toNode.edge_count;
|
|
188
|
+
return node.name === 'Map' ? Math.floor(slots / 2) : slots;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return node.edge_count;
|
|
95
193
|
}
|
|
96
194
|
function countEntries(node) {
|
|
97
195
|
if (node.name === 'Map' || node.name === 'Set') {
|
|
@@ -283,6 +381,8 @@ export function registerCacheAnalysis(server) {
|
|
|
283
381
|
const totalSize = meta?.totalSize ?? 0;
|
|
284
382
|
const typeSet = new Set(collection_types);
|
|
285
383
|
const caches = [];
|
|
384
|
+
// Collections that clear `min_entries` but not `min_retained_size`.
|
|
385
|
+
const longButSmall = [];
|
|
286
386
|
snapshot.nodes.forEach(node => {
|
|
287
387
|
if (node.id <= 3)
|
|
288
388
|
return;
|
|
@@ -290,11 +390,26 @@ export function registerCacheAnalysis(server) {
|
|
|
290
390
|
return;
|
|
291
391
|
if (node.type !== 'object')
|
|
292
392
|
return;
|
|
293
|
-
|
|
393
|
+
// Size-independent path: a collection that is long enough to matter is
|
|
394
|
+
// examined even when it is small in bytes (see approxEntryCount).
|
|
395
|
+
const bigEnough = node.retainedSize >= min_retained_size;
|
|
396
|
+
if (!bigEnough && approxEntryCount(node) < min_entries)
|
|
294
397
|
return;
|
|
295
398
|
const { entries: entryCount, tableSlots } = countEntries(node);
|
|
296
399
|
if (entryCount < min_entries)
|
|
297
400
|
return;
|
|
401
|
+
if (!bigEnough) {
|
|
402
|
+
// Long but cheap. Reported in its own section rather than mixed into
|
|
403
|
+
// the ranked table, whose ordering and thresholds callers rely on.
|
|
404
|
+
longButSmall.push({
|
|
405
|
+
nodeId: node.id,
|
|
406
|
+
collectionType: node.name,
|
|
407
|
+
entryCount,
|
|
408
|
+
retainedSize: node.retainedSize,
|
|
409
|
+
ownerName: getOwnerInfo(node).name,
|
|
410
|
+
});
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
298
413
|
const owner = getOwnerInfo(node);
|
|
299
414
|
const framework = identifyCacheFramework(node, owner.edge);
|
|
300
415
|
const entry = {
|
|
@@ -308,7 +423,7 @@ export function registerCacheAnalysis(server) {
|
|
|
308
423
|
ownerEdge: owner.edge,
|
|
309
424
|
hasWeakRefs: hasWeakRefEntries(node),
|
|
310
425
|
framework,
|
|
311
|
-
classification: classifyCollection(node.name, owner.edge, framework),
|
|
426
|
+
classification: classifyCollection(node.name, owner.edge, framework, owner.node),
|
|
312
427
|
};
|
|
313
428
|
let inserted = false;
|
|
314
429
|
for (let i = 0; i < caches.length; i++) {
|
|
@@ -485,8 +600,42 @@ export function registerCacheAnalysis(server) {
|
|
|
485
600
|
}
|
|
486
601
|
});
|
|
487
602
|
}
|
|
603
|
+
// Long-but-cheap collections. Rendered as its own section so the size
|
|
604
|
+
// threshold can no longer hide a growing collection outright — the
|
|
605
|
+
// failure that made a planted positive-control Map invisible.
|
|
606
|
+
const longButSmallSection = () => {
|
|
607
|
+
if (longButSmall.length === 0)
|
|
608
|
+
return [];
|
|
609
|
+
longButSmall.sort((a, b) => b.entryCount - a.entryCount);
|
|
610
|
+
const top = longButSmall.slice(0, 10);
|
|
611
|
+
return [
|
|
612
|
+
'',
|
|
613
|
+
`### Long but small — ${formatNumber(longButSmall.length)} collection(s) with >= ${formatNumber(min_entries)} entries but under ${formatBytes(min_retained_size)} retained`,
|
|
614
|
+
'',
|
|
615
|
+
'These are excluded from the ranked table above by `min_retained_size`, but entry COUNT is the signal for unbounded growth — a collection that grows without bound is a leak whether or not its entries are large. Track these across a ladder.',
|
|
616
|
+
'',
|
|
617
|
+
markdownTable(['Collection', 'Entries', 'Retained', 'Owner', 'ID'], top.map(c => [
|
|
618
|
+
c.collectionType,
|
|
619
|
+
formatNumber(c.entryCount),
|
|
620
|
+
formatBytes(c.retainedSize),
|
|
621
|
+
c.ownerName.length > 40
|
|
622
|
+
? c.ownerName.slice(0, 37) + '…'
|
|
623
|
+
: c.ownerName,
|
|
624
|
+
`@${c.nodeId}`,
|
|
625
|
+
]), new Set([1, 2])),
|
|
626
|
+
...(longButSmall.length > top.length
|
|
627
|
+
? [
|
|
628
|
+
'',
|
|
629
|
+
`_… +${formatNumber(longButSmall.length - top.length)} more. Lower \`min_retained_size\` to rank them with the rest._`,
|
|
630
|
+
]
|
|
631
|
+
: []),
|
|
632
|
+
];
|
|
633
|
+
};
|
|
488
634
|
if (caches.length === 0) {
|
|
489
|
-
return toolResult(
|
|
635
|
+
return toolResult([
|
|
636
|
+
`No unbounded caches found with >= ${min_entries} entries and >= ${formatBytes(min_retained_size)} retained. Try lowering the thresholds.`,
|
|
637
|
+
...longButSmallSection(),
|
|
638
|
+
].join('\n'));
|
|
490
639
|
}
|
|
491
640
|
const hasAnyFramework = caches.some(c => c.framework !== '');
|
|
492
641
|
const hasSlotDifference = caches.some(c => c.tableSlots !== c.entryCount);
|
|
@@ -534,6 +683,7 @@ export function registerCacheAnalysis(server) {
|
|
|
534
683
|
'**Kind:**',
|
|
535
684
|
'- **cache-like** — named like a cache, owned by a cache class, or carrying TTL/maxSize config. Missing eviction here is a likely leak.',
|
|
536
685
|
'- **collection** — a plain large Map/Set/Array or per-request working set. Large is not the same as leaking; confirm it is actually retained across requests before treating it as a leak.',
|
|
686
|
+
'- **ring-buffer** — fixed-capacity buffer (the owner carries a write cursor and/or a capacity control). It looks like an unbounded cache to a size heuristic but cannot grow: **not a leak candidate**. Check the declared capacity before spending time on it.',
|
|
537
687
|
'',
|
|
538
688
|
'**Risk indicators:**',
|
|
539
689
|
'- **Weak? = No** means entries are strongly held and will never be evicted by GC',
|
|
@@ -748,6 +898,7 @@ export function registerCacheAnalysis(server) {
|
|
|
748
898
|
lines.push('', '_Entries have matching array lengths, child counts, and retained sizes. The cache key may not affect the cached data — consider deduplicating._');
|
|
749
899
|
}
|
|
750
900
|
}
|
|
901
|
+
lines.push(...longButSmallSection());
|
|
751
902
|
return toolResult(lines.join('\n'));
|
|
752
903
|
}
|
|
753
904
|
catch (err) {
|