@memlab/mcp-server 2.3.0 → 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 +18 -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 +96 -2
- package/dist/heap-state.d.ts.map +1 -1
- package/dist/heap-state.js +197 -2
- package/dist/heap-state.js.map +1 -1
- package/dist/index.js +41 -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/auto-investigate.d.ts.map +1 -1
- package/dist/tools/auto-investigate.js +91 -49
- package/dist/tools/auto-investigate.js.map +1 -1
- 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/diff-snapshots.d.ts.map +1 -1
- package/dist/tools/diff-snapshots.js +101 -44
- package/dist/tools/diff-snapshots.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 +787 -30
- 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-references.d.ts.map +1 -1
- package/dist/tools/get-references.js +35 -6
- package/dist/tools/get-references.js.map +1 -1
- package/dist/tools/get-referrers.d.ts.map +1 -1
- package/dist/tools/get-referrers.js +52 -6
- 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 +721 -75
- 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 +43 -16
- 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 +78 -0
- package/dist/tools/load-snapshot.d.ts.map +1 -1
- package/dist/tools/load-snapshot.js +519 -29
- 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 +92 -30
- 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/quick-diagnosis.d.ts.map +1 -1
- package/dist/tools/quick-diagnosis.js +12 -4
- package/dist/tools/quick-diagnosis.js.map +1 -1
- 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 +105 -25
- 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 +268 -78
- 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/sliced-strings.d.ts.map +1 -1
- package/dist/tools/sliced-strings.js +22 -4
- package/dist/tools/sliced-strings.js.map +1 -1
- package/dist/tools/snapshot-header.d.ts +28 -0
- package/dist/tools/snapshot-header.d.ts.map +1 -0
- package/dist/tools/snapshot-header.js +111 -0
- package/dist/tools/snapshot-header.js.map +1 -0
- package/dist/tools/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 +204 -1
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +478 -8
- package/dist/utils.js.map +1 -1
- package/package.json +5 -4
|
@@ -10,15 +10,204 @@
|
|
|
10
10
|
import fs from 'fs';
|
|
11
11
|
import os from 'os';
|
|
12
12
|
import path from 'path';
|
|
13
|
+
import v8 from 'v8';
|
|
13
14
|
import { execFileSync } from 'child_process';
|
|
14
15
|
import { z } from 'zod';
|
|
16
|
+
import memlabCore from '@memlab/core';
|
|
15
17
|
import memlabHeapAnalysis from '@memlab/heap-analysis';
|
|
16
18
|
const { getFullHeapFromFile } = memlabHeapAnalysis;
|
|
17
|
-
|
|
18
|
-
|
|
19
|
+
// `getFullHeapFromFile` = parse + `analysis.preparePathFinder`. Light mode calls
|
|
20
|
+
// only the parse half, so the two pieces are reached directly here. Measured on
|
|
21
|
+
// a 380 MB / 4.19M-node browser snapshot: parse 7.9s, preparePathFinder 9.9s.
|
|
22
|
+
const { utils: memlabUtils } = memlabCore;
|
|
23
|
+
import { setSnapshot, getSnapshotMetadata, setSessionConfig, listSnapshots, clearAllSnapshots, evictLeastRecentlyUsed, findResidentByPath, retainOnlySnapshot, setCurrentSnapshot, } from '../heap-state.js';
|
|
24
|
+
import { makeProgressReporter } from '../progress.js';
|
|
25
|
+
import { formatBytes, formatNumber, truncateNodeName, errorResult, textResult, suggestionsSuppressed, } from '../utils.js';
|
|
19
26
|
// The bucket the Nest auto-capture + the Manifold links in diffs use, so a
|
|
20
27
|
// bare snapshot filename resolves predictably (Feedback §7).
|
|
21
28
|
export const DEFAULT_MANIFOLD_BUCKET = 'nest_server_nodejs_heap_snapshots';
|
|
29
|
+
// Size caps for the OOM guard. Server heap snapshots (Nest auto-capture, the
|
|
30
|
+
// Manifold bucket above) routinely run 550–2100 MB, so every one exceeds the
|
|
31
|
+
// conservative local-file default and a sweep previously had to pass
|
|
32
|
+
// max_file_size_mb on EVERY load call. When a snapshot is fetched from Manifold
|
|
33
|
+
// (a server capture, not an arbitrary local file) raise the effective cap to the
|
|
34
|
+
// analyzer's documented safe ceiling — larger than this reliably OOMs the parser
|
|
35
|
+
// — unless the caller set an explicit limit. Local files keep the tighter guard.
|
|
36
|
+
// (Sweep feedback round 6 §2.)
|
|
37
|
+
export const LOCAL_FILE_SIZE_LIMIT_MB = 900;
|
|
38
|
+
export const MANIFOLD_FETCH_SIZE_LIMIT_MB = 2100;
|
|
39
|
+
// A node/edge-count ceiling complements the file-size cap. The load computes a
|
|
40
|
+
// dominator tree via the Chrome-DevTools iterative fix-point algorithm, whose
|
|
41
|
+
// cost is ~O(passes × edges) and blows up with graph DEPTH and cycle density,
|
|
42
|
+
// not raw disk size — so a structurally deep snapshot only modestly larger on
|
|
43
|
+
// disk can take effectively forever. Worse, the whole load is one synchronous
|
|
44
|
+
// block the cooperative wall-clock guardrail cannot preempt, so there is no
|
|
45
|
+
// timeout to fall back on (P2403258184). We therefore refuse BEFORE starting the
|
|
46
|
+
// pass when the (cheaply peeked) node/edge counts exceed the ceiling, and warn
|
|
47
|
+
// above the soft threshold. File size is a poor proxy for dominator-pass cost,
|
|
48
|
+
// so these are separate from the MB caps. Override with max_nodes/max_edges, or
|
|
49
|
+
// bypass entirely with force:true (expect a long, uninterruptible load).
|
|
50
|
+
//
|
|
51
|
+
// These fixed values are FLOORS: the effective ceiling is auto-scaled UP from the
|
|
52
|
+
// process's configured old-space limit (see computeDefaultCeilings) and only
|
|
53
|
+
// falls back to these when the limit can't be read. A memory-starved server is
|
|
54
|
+
// therefore never less safe than before, while an 8 GB server admits the tier
|
|
55
|
+
// that previously required a hand-passed override on every call.
|
|
56
|
+
export const DEFAULT_MAX_NODES = 12_000_000;
|
|
57
|
+
export const DEFAULT_MAX_EDGES = 24_000_000;
|
|
58
|
+
export const WARN_NODE_COUNT = 2_000_000;
|
|
59
|
+
// Auto-scaling rates (sweep feedback: "the load ceiling is mismatched to Nest
|
|
60
|
+
// server snapshots"). Nest server captures are edge-dense (~4 edges/node), so the
|
|
61
|
+
// EDGE ceiling — not the node ceiling — is what rejected essentially the whole
|
|
62
|
+
// meaningful part of the 550–2100 MB band. Empirically, on a server started with
|
|
63
|
+
// --max-old-space-size=8192 the ~11–14M-node / ~45–49M-edge / ~1.1 GB tier loads
|
|
64
|
+
// cleanly in seconds-to-tens-of-seconds with no OOM, while the ~2 GB / 24M-node /
|
|
65
|
+
// 89M-edge tier is genuinely risky. Rather than hard-code one ceiling tuned to a
|
|
66
|
+
// single heap size, scale it from the process's actual old-space limit so an 8 GB
|
|
67
|
+
// server admits the 1.1 GB tier by default and a smaller server stays
|
|
68
|
+
// conservative. Rates chosen so ~8192 MB → ~13.9M nodes / ~55.7M edges (admits the
|
|
69
|
+
// 45–49M-edge band, still rejects the 89M-edge / 2 GB band).
|
|
70
|
+
export const NODES_PER_HEAP_MB = 1700;
|
|
71
|
+
export const EDGES_PER_HEAP_MB = 6800;
|
|
72
|
+
// Per-node / per-edge working-set estimate for a full load, fitted to a
|
|
73
|
+
// measured browser capture: 4,185,734 nodes + 18,755,739 edges peaked at
|
|
74
|
+
// ~1,500 MB RSS (200 B/node + 40 B/edge predicts ~1,588 MB). Deliberately a
|
|
75
|
+
// slight OVER-estimate — the failure this guards against is an OOM that kills
|
|
76
|
+
// every resident snapshot, so erring toward "not enough headroom" is the cheap
|
|
77
|
+
// direction to be wrong in. File size is not used: it correlates poorly with
|
|
78
|
+
// graph cost, which is why the node/edge ceilings exist at all.
|
|
79
|
+
export const LOAD_BYTES_PER_NODE = 200;
|
|
80
|
+
export const LOAD_BYTES_PER_EDGE = 40;
|
|
81
|
+
// Fraction of remaining old-space a single load may be projected to consume
|
|
82
|
+
// before the guard fires. Well below 1.0 because the estimate is approximate,
|
|
83
|
+
// the parse allocates in bursts the GC cannot always keep up with, and analysis
|
|
84
|
+
// after the load needs room of its own.
|
|
85
|
+
export const HEADROOM_SAFETY_FRACTION = 0.8;
|
|
86
|
+
/**
|
|
87
|
+
* Projected additional heap (MB) a full load of this graph will occupy.
|
|
88
|
+
* Light loads skip the dominator/retained-size arrays; the parsed graph still
|
|
89
|
+
* dominates, so the discount is modest and intentionally conservative.
|
|
90
|
+
*/
|
|
91
|
+
export function estimateLoadHeapMB(counts, light) {
|
|
92
|
+
const bytes = counts.nodeCount * LOAD_BYTES_PER_NODE +
|
|
93
|
+
counts.edgeCount * LOAD_BYTES_PER_EDGE;
|
|
94
|
+
return Math.round((bytes / (1024 * 1024)) * (light ? 0.85 : 1));
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Old-space headroom (MB) left for a new load: the configured limit minus what
|
|
98
|
+
* is already committed. Returns null when the limit can't be read, in which case
|
|
99
|
+
* the caller skips the guard rather than guessing.
|
|
100
|
+
*/
|
|
101
|
+
export function getHeapHeadroomMB() {
|
|
102
|
+
try {
|
|
103
|
+
const s = v8.getHeapStatistics();
|
|
104
|
+
const limit = s.heap_size_limit / (1024 * 1024);
|
|
105
|
+
const used = s.used_heap_size / (1024 * 1024);
|
|
106
|
+
if (!Number.isFinite(limit) || limit <= 0)
|
|
107
|
+
return null;
|
|
108
|
+
return Math.max(0, Math.round(limit - used));
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Configured V8 old-space limit in MB (reflects --max-old-space-size). Returns 0
|
|
116
|
+
* when it can't be read, so callers fall back to the fixed floors.
|
|
117
|
+
*/
|
|
118
|
+
export function getOldSpaceLimitMB() {
|
|
119
|
+
try {
|
|
120
|
+
return Math.round(v8.getHeapStatistics().heap_size_limit / (1024 * 1024));
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return 0;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Default node/edge load ceilings, auto-scaled from the configured old-space
|
|
128
|
+
* limit and floored at the historical fixed defaults so the ceiling never
|
|
129
|
+
* regresses below what was previously safe. `heapLimitMB` is 0 when the limit
|
|
130
|
+
* couldn't be read (ceilings then equal the fixed floors).
|
|
131
|
+
*/
|
|
132
|
+
export function computeDefaultCeilings() {
|
|
133
|
+
const heapLimitMB = getOldSpaceLimitMB();
|
|
134
|
+
if (heapLimitMB <= 0) {
|
|
135
|
+
return {
|
|
136
|
+
maxNodes: DEFAULT_MAX_NODES,
|
|
137
|
+
maxEdges: DEFAULT_MAX_EDGES,
|
|
138
|
+
heapLimitMB: 0,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
maxNodes: Math.max(DEFAULT_MAX_NODES, Math.round(heapLimitMB * NODES_PER_HEAP_MB)),
|
|
143
|
+
maxEdges: Math.max(DEFAULT_MAX_EDGES, Math.round(heapLimitMB * EDGES_PER_HEAP_MB)),
|
|
144
|
+
heapLimitMB,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Cheaply read `node_count` / `edge_count` from a .heapsnapshot header without
|
|
149
|
+
* parsing the whole file. The V8 format begins with
|
|
150
|
+
* `{"snapshot":{"meta":{…},"node_count":N,"edge_count":M,…},…}`, so both counts
|
|
151
|
+
* sit within the first few KB. Reads a bounded prefix and regex-extracts them;
|
|
152
|
+
* returns null when they aren't found in the prefix (caller then skips the count
|
|
153
|
+
* guard rather than failing the load).
|
|
154
|
+
*/
|
|
155
|
+
export function peekSnapshotCounts(localPath) {
|
|
156
|
+
let fd = null;
|
|
157
|
+
try {
|
|
158
|
+
fd = fs.openSync(localPath, 'r');
|
|
159
|
+
const buf = Buffer.allocUnsafe(256 * 1024);
|
|
160
|
+
const bytes = fs.readSync(fd, buf, 0, buf.length, 0);
|
|
161
|
+
const head = buf.toString('utf8', 0, bytes);
|
|
162
|
+
const n = head.match(/"node_count"\s*:\s*(\d+)/);
|
|
163
|
+
const e = head.match(/"edge_count"\s*:\s*(\d+)/);
|
|
164
|
+
if (!n || !e)
|
|
165
|
+
return null;
|
|
166
|
+
return { nodeCount: Number(n[1]), edgeCount: Number(e[1]) };
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
finally {
|
|
172
|
+
if (fd != null)
|
|
173
|
+
fs.closeSync(fd);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Estimate the largest same-app capture (in MB) that would fit under the load
|
|
178
|
+
* ceiling, from THIS snapshot's own node/edge density. Server captures of one app
|
|
179
|
+
* have a roughly stable nodes-per-MB and edges-per-MB, so once a capture is
|
|
180
|
+
* refused for exceeding the ceiling, `ceiling ÷ density` tells the reader what
|
|
181
|
+
* file size to look for instead — turning the manual "hit ceiling → compute
|
|
182
|
+
* density by hand → re-query Scuba for a smaller file" loop into one number
|
|
183
|
+
* (sweep feedback round 7 §2: the guess-and-retry loop was the dominant friction).
|
|
184
|
+
* Edge-density usually binds first on edge-dense Nest heaps, so we take the min of
|
|
185
|
+
* the node-bound and edge-bound estimates. Returns null when it can't be computed.
|
|
186
|
+
*/
|
|
187
|
+
export function estimateMaxLoadableMB(counts, fileSizeMB, ceilings) {
|
|
188
|
+
if (fileSizeMB <= 0)
|
|
189
|
+
return null;
|
|
190
|
+
const nodesPerMB = counts.nodeCount / fileSizeMB;
|
|
191
|
+
const edgesPerMB = counts.edgeCount / fileSizeMB;
|
|
192
|
+
const byNodes = nodesPerMB > 0 ? ceilings.maxNodes / nodesPerMB : Infinity;
|
|
193
|
+
const byEdges = edgesPerMB > 0 ? ceilings.maxEdges / edgesPerMB : Infinity;
|
|
194
|
+
const mb = Math.min(byNodes, byEdges);
|
|
195
|
+
if (!Number.isFinite(mb) || mb <= 0)
|
|
196
|
+
return null;
|
|
197
|
+
return Math.floor(mb);
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Resolve the effective max-file-size limit (MB). An explicit caller value always
|
|
201
|
+
* wins; otherwise default by source — Manifold-fetched server snapshots get the
|
|
202
|
+
* higher ceiling, local files keep the tighter guard.
|
|
203
|
+
*/
|
|
204
|
+
export function resolveMaxFileSizeMB(explicit, fetchedFromManifold) {
|
|
205
|
+
if (explicit != null)
|
|
206
|
+
return explicit;
|
|
207
|
+
return fetchedFromManifold
|
|
208
|
+
? MANIFOLD_FETCH_SIZE_LIMIT_MB
|
|
209
|
+
: LOCAL_FILE_SIZE_LIMIT_MB;
|
|
210
|
+
}
|
|
22
211
|
/**
|
|
23
212
|
* Resolve a `file_path` that may be a local path, a `manifold://bucket/key`
|
|
24
213
|
* URL, or a bare snapshot filename to a local path, fetching from Manifold
|
|
@@ -47,12 +236,39 @@ export function resolveSnapshotPath(filePath) {
|
|
|
47
236
|
manifoldKey = `${DEFAULT_MANIFOLD_BUCKET}/${manifoldKey}`;
|
|
48
237
|
}
|
|
49
238
|
const dest = path.join(os.tmpdir(), `memlab-${path.basename(manifoldKey).replace(/[^A-Za-z0-9._-]/g, '_')}`);
|
|
239
|
+
// Reuse an already-downloaded copy. The temp name is derived from the unique
|
|
240
|
+
// Manifold key, so a non-empty file here IS this object. This makes a RETRY
|
|
241
|
+
// after a size-limit rejection succeed (the prior attempt fully downloaded
|
|
242
|
+
// before the size check ran) instead of failing with "Local object exists",
|
|
243
|
+
// and avoids re-downloading multi-GB snapshots (feedback §A.1).
|
|
244
|
+
if (fs.existsSync(dest) && fs.statSync(dest).size > 0) {
|
|
245
|
+
process.stderr.write(`Reusing already-downloaded ${dest}\n`);
|
|
246
|
+
return { localPath: dest, fetchedFrom: manifoldKey };
|
|
247
|
+
}
|
|
50
248
|
process.stderr.write(`Fetching ${manifoldKey} from Manifold → ${dest}…\n`);
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
249
|
+
try {
|
|
250
|
+
// --overwrite_local_path so a stale/empty/partial leftover never blocks the
|
|
251
|
+
// fetch (the cause of the "Local object exists" retry failure).
|
|
252
|
+
execFileSync('manifold', ['get', '--overwrite_local_path', manifoldKey, dest], {
|
|
253
|
+
stdio: ['ignore', 'ignore', 'pipe'],
|
|
254
|
+
timeout: 5 * 60 * 1000,
|
|
255
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
catch (err) {
|
|
259
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
260
|
+
// Friendlier message for a missing object (feedback §E.18) — the raw
|
|
261
|
+
// StorageException is opaque about WHY a path isn't found.
|
|
262
|
+
if (/not found|does not map|StorageException/i.test(msg)) {
|
|
263
|
+
throw new Error(`Snapshot not found in Manifold at "${manifoldKey}". ` +
|
|
264
|
+
`It may never have been uploaded (OnDemand-host captures often aren't), ` +
|
|
265
|
+
`or the name/path is wrong. Available mounts: flat/, nodes/, tree/. ` +
|
|
266
|
+
`List an app's snapshots with: ` +
|
|
267
|
+
`manifold ls ${DEFAULT_MANIFOLD_BUCKET}/flat | grep <app-name>\n\n` +
|
|
268
|
+
`Underlying error: ${msg}`);
|
|
269
|
+
}
|
|
270
|
+
throw err;
|
|
271
|
+
}
|
|
56
272
|
return { localPath: dest, fetchedFrom: manifoldKey };
|
|
57
273
|
}
|
|
58
274
|
function findLargestObject(snapshot) {
|
|
@@ -75,6 +291,26 @@ function findLargestObject(snapshot) {
|
|
|
75
291
|
});
|
|
76
292
|
return best;
|
|
77
293
|
}
|
|
294
|
+
// Nest auto-capture filenames embed the capture time as an epoch (ms or s).
|
|
295
|
+
// Surfacing it lets the agent check whether a candidate leak was likely already
|
|
296
|
+
// fixed since the snapshot was taken — the dominant outcome when sweeping a
|
|
297
|
+
// backlog of old snapshots (Feedback round 5 §12).
|
|
298
|
+
export function extractCaptureTime(fileName) {
|
|
299
|
+
const msMatch = fileName.match(/(?<!\d)(1\d{12})(?!\d)/);
|
|
300
|
+
const sMatch = fileName.match(/(?<!\d)(1\d{9})(?!\d)/);
|
|
301
|
+
let ms = null;
|
|
302
|
+
if (msMatch)
|
|
303
|
+
ms = Number(msMatch[1]);
|
|
304
|
+
else if (sMatch)
|
|
305
|
+
ms = Number(sMatch[1]) * 1000;
|
|
306
|
+
if (ms == null)
|
|
307
|
+
return null;
|
|
308
|
+
const d = new Date(ms);
|
|
309
|
+
const year = d.getUTCFullYear();
|
|
310
|
+
if (year < 2020 || year > 2035)
|
|
311
|
+
return null;
|
|
312
|
+
return d;
|
|
313
|
+
}
|
|
78
314
|
function detectEnv(snapshot) {
|
|
79
315
|
let hasWindow = false;
|
|
80
316
|
let hasModule = false;
|
|
@@ -97,7 +333,7 @@ function detectEnv(snapshot) {
|
|
|
97
333
|
return 'node';
|
|
98
334
|
return 'unknown';
|
|
99
335
|
}
|
|
100
|
-
function quickDiagnosis(snapshot, totalSelfSize) {
|
|
336
|
+
function quickDiagnosis(snapshot, totalSelfSize, light = false) {
|
|
101
337
|
const warnings = [];
|
|
102
338
|
const largeStrings = [];
|
|
103
339
|
const stringCounts = new Map();
|
|
@@ -134,7 +370,10 @@ function quickDiagnosis(snapshot, totalSelfSize) {
|
|
|
134
370
|
const display = val.length > 40 ? val.slice(0, 40) + '…' : val;
|
|
135
371
|
warnings.push(`⚠ "${display}" duplicated ${formatNumber(count)} times`);
|
|
136
372
|
}
|
|
137
|
-
|
|
373
|
+
// Skipped on a light load: `findLargestObject` ranks by retainedSize, which
|
|
374
|
+
// is uncomputed there — every candidate would read 0 and the "retains N% of
|
|
375
|
+
// heap" line would be fiction.
|
|
376
|
+
const largest = light ? null : findLargestObject(snapshot);
|
|
138
377
|
if (largest && totalSelfSize > 0) {
|
|
139
378
|
const pct = ((largest.size / totalSelfSize) * 100).toFixed(0);
|
|
140
379
|
if (largest.size >= totalSelfSize * 0.3) {
|
|
@@ -148,13 +387,34 @@ function quickDiagnosis(snapshot, totalSelfSize) {
|
|
|
148
387
|
for (const [name, count] of anomalous) {
|
|
149
388
|
warnings.push(`⚠ ${formatNumber(count)}× \`${name}\` instances`);
|
|
150
389
|
}
|
|
151
|
-
if (warnings.length > 0) {
|
|
152
|
-
warnings.push('', 'Run `memlab_quick_diagnosis` for actionable triage or `memlab_auto_investigate` for deep analysis.');
|
|
153
|
-
}
|
|
154
390
|
return warnings;
|
|
155
391
|
}
|
|
392
|
+
/**
|
|
393
|
+
* What to call next, indexed by the QUESTION rather than by tool.
|
|
394
|
+
*
|
|
395
|
+
* The old trailer always pointed at `quick_diagnosis` / `auto_investigate`
|
|
396
|
+
* regardless of why the snapshot was loaded, which is right for "I don't know
|
|
397
|
+
* what I'm looking at" and wrong for every other case. Measured effect of the
|
|
398
|
+
* generic version: over one long investigation 28 of the server's tools were
|
|
399
|
+
* used and the rest never were, including several that answer a stated question
|
|
400
|
+
* in a single call. Naming the question is what makes the right tool findable
|
|
401
|
+
* at the only moment the caller is guaranteed to be reading.
|
|
402
|
+
*/
|
|
403
|
+
function nextStepMenu() {
|
|
404
|
+
return [
|
|
405
|
+
'**What do you want to know?**',
|
|
406
|
+
'- _What is this heap made of?_ → `memlab_app_heap` (application data vs bundle source vs V8 machinery — run it before trusting any percentage)',
|
|
407
|
+
'- _Anything obviously wrong?_ → `memlab_quick_diagnosis`, then `memlab_auto_investigate` for the deep dive',
|
|
408
|
+
'- _Is X a leak?_ → `memlab_hypothesis`',
|
|
409
|
+
'- _Who owns the growth?_ → `memlab_explain_delta` (needs a baseline loaded with `keep_previous:true`)',
|
|
410
|
+
'- _What grew across the ladder?_ → `memlab_leak_report` / `memlab_sequence_analysis`',
|
|
411
|
+
'- _What does this object pin?_ → `memlab_dominator_chain`, then `memlab_dominator_attribution`',
|
|
412
|
+
'- _Is it real or an artifact?_ → `memlab_dev_artifacts` (and `memlab_explain_delta` for JIT warmup, which it does not count)',
|
|
413
|
+
'- _Something else_ → `memlab_tools` lists every tool grouped by the question it answers',
|
|
414
|
+
];
|
|
415
|
+
}
|
|
156
416
|
export function registerLoadSnapshot(server) {
|
|
157
|
-
server.tool('memlab_load_snapshot', 'Load and parse a .heapsnapshot file. This builds indexes, computes the dominator tree, and calculates retained sizes. Returns a quick diagnosis highlighting potential issues. Accepts a local absolute path, a manifold:// URL, or a bare snapshot filename (resolved against the nest_server_nodejs_heap_snapshots bucket and fetched automatically). Multiple snapshots can be kept resident — pass keep_previous:true to load several for diffing/comparison; switch between them with memlab_snapshots.', {
|
|
417
|
+
server.tool('memlab_load_snapshot', 'Load and parse a .heapsnapshot file. This builds indexes, computes the dominator tree, and calculates retained sizes. Returns a quick diagnosis highlighting potential issues. Accepts a local absolute path, a manifold:// URL, or a bare snapshot filename (resolved against the nest_server_nodejs_heap_snapshots bucket and fetched automatically). Multiple snapshots can be kept resident — pass keep_previous:true to load several for diffing/comparison; switch between them with memlab_snapshots. Cost note: the load working set is several× the file size and the dominator pass runs uninterruptibly, so large/deep snapshots are memory- and time-heavy — run the server with NODE_OPTIONS="--max-old-space-size=8192", load one snapshot at a time (omit keep_previous) on large heaps, and prefer memlab_sequence_analysis (transient per-snapshot loads) when you only need trend/growth across a ladder. A node/edge-count ceiling (max_nodes/max_edges) refuses pathologically large loads before they wedge the server; the ceiling auto-scales from the server\'s configured --max-old-space-size (an 8 GB server admits the ~1.1 GB / ~14M-node / ~55M-edge Nest tier by default), and force:true self-sizes from the header counts so a one-off oversized load needs no manual max_nodes/max_edges.', {
|
|
158
418
|
file_path: z
|
|
159
419
|
.string()
|
|
160
420
|
.describe('A local absolute path to a .heapsnapshot file, a manifold:// URL (manifold://bucket/key), or a bare snapshot filename to fetch from the nest_server_nodejs_heap_snapshots bucket.'),
|
|
@@ -178,9 +438,36 @@ export function registerLoadSnapshot(server) {
|
|
|
178
438
|
max_file_size_mb: z
|
|
179
439
|
.number()
|
|
180
440
|
.optional()
|
|
181
|
-
.default(
|
|
182
|
-
|
|
183
|
-
|
|
441
|
+
.describe(`Maximum file size in MB to attempt loading. Defaults to ${LOCAL_FILE_SIZE_LIMIT_MB} for local files; for snapshots fetched from Manifold (a bare Nest snapshot filename or a manifold:// URL) it defaults to ${MANIFOLD_FETCH_SIZE_LIMIT_MB} — the analyzer's safe ceiling — since server captures routinely exceed ${LOCAL_FILE_SIZE_LIMIT_MB} MB. Pass an explicit value to override either default (e.g. raise it if your Node.js process has extra memory via --max-old-space-size). Snapshots larger than the effective limit return an error instead of risking an OOM crash.`),
|
|
442
|
+
max_nodes: z
|
|
443
|
+
.number()
|
|
444
|
+
.optional()
|
|
445
|
+
.describe(`Maximum node count to attempt loading. Defaults to an auto-scaled ceiling derived from the server's configured --max-old-space-size (≈${formatNumber(NODES_PER_HEAP_MB)}× the old-space MB, floored at ${formatNumber(DEFAULT_MAX_NODES)}); an 8 GB server defaults to ~${formatNumber(8192 * NODES_PER_HEAP_MB)}. The dominator-tree computation done at load is super-linear in graph size AND depth and runs uninterruptibly, so a very large/deep snapshot can wedge the server with no timeout; the count is peeked cheaply from the header and the load is refused before it starts if it exceeds this. File size is a poor proxy for this cost, so this is separate from max_file_size_mb.`),
|
|
446
|
+
max_edges: z
|
|
447
|
+
.number()
|
|
448
|
+
.optional()
|
|
449
|
+
.describe(`Maximum edge count to attempt loading; see max_nodes. Defaults to an auto-scaled ceiling (≈${formatNumber(EDGES_PER_HEAP_MB)}× the old-space MB, floored at ${formatNumber(DEFAULT_MAX_EDGES)}); an 8 GB server defaults to ~${formatNumber(8192 * EDGES_PER_HEAP_MB)}. Nest server snapshots are edge-dense (~4 edges/node), so this — not max_nodes — is usually the binding limit. Peeked from the header; the load is refused before it starts if it exceeds this.`),
|
|
450
|
+
force: z
|
|
451
|
+
.boolean()
|
|
452
|
+
.optional()
|
|
453
|
+
.describe('Bypass the node/edge-count ceiling AND the memory-headroom guard, and attempt the load anyway (default false). force:true is self-sizing — it uses the header-peeked counts, so you do NOT also need to pass max_nodes/max_edges. The load cannot be interrupted once started, so only use this when you accept a potentially very long load or possible OOM.'),
|
|
454
|
+
force_reload: z
|
|
455
|
+
.boolean()
|
|
456
|
+
.optional()
|
|
457
|
+
.default(false)
|
|
458
|
+
.describe('Re-parse even if this exact file is already resident in this server process (default false: an already-loaded file is switched to instead, reported as `cache: hit`). Only needed if the file changed on disk since it was loaded.'),
|
|
459
|
+
light: z
|
|
460
|
+
.boolean()
|
|
461
|
+
.optional()
|
|
462
|
+
.default(false)
|
|
463
|
+
.describe('Count-only load: parse the graph but SKIP the dominator / retained-size / shortest-path pass (default false). Roughly halves load time (measured on a 380 MB / 4.19M-node capture: 18s → 8s) for questions that only need counts, names, types, self sizes or string values. The saving is time, not memory — the parsed graph is the bulk of the working set. A light snapshot REFUSES any tool that needs retained sizes, dominators or retainer paths (memlab_retainer_trace, memlab_detached_dom, memlab_largest_objects, memlab_dominator_*, …) instead of silently reporting zeros; reload without light to run those. Light-safe: memlab_class_histogram (include_retained_size:false), memlab_search_nodes, memlab_search_strings, memlab_string_patterns, memlab_snapshot_summary, memlab_snapshot_header, memlab_snapshots.'),
|
|
464
|
+
}, async ({ file_path, alias, keep_previous, quiet, suppress_suggestions, max_file_size_mb, max_nodes, max_edges, force, force_reload, light, }, extra) => {
|
|
465
|
+
// 6 phases: resolve → guards → parse → (dominators | skipped) → index →
|
|
466
|
+
// diagnose. Reported at boundaries only; see progress.ts for why there is
|
|
467
|
+
// nothing finer-grained available.
|
|
468
|
+
const progress = makeProgressReporter(extra, 'load_snapshot');
|
|
469
|
+
const PHASES = 6;
|
|
470
|
+
const startedAt = Date.now();
|
|
184
471
|
try {
|
|
185
472
|
if (quiet != null || suppress_suggestions != null) {
|
|
186
473
|
setSessionConfig({
|
|
@@ -191,6 +478,7 @@ export function registerLoadSnapshot(server) {
|
|
|
191
478
|
});
|
|
192
479
|
}
|
|
193
480
|
const previousMeta = getSnapshotMetadata();
|
|
481
|
+
progress.phase(1, PHASES, `resolving ${file_path} (a manifold:// or bare filename is fetched here)`);
|
|
194
482
|
let resolved;
|
|
195
483
|
let fetchedFrom = null;
|
|
196
484
|
try {
|
|
@@ -204,6 +492,43 @@ export function registerLoadSnapshot(server) {
|
|
|
204
492
|
`Ensure the 'manifold' CLI is installed and you have access to the bucket, ` +
|
|
205
493
|
`or pass a local absolute path instead.`));
|
|
206
494
|
}
|
|
495
|
+
// In-process snapshot cache. Re-parsing a file this process already
|
|
496
|
+
// holds is the single most expensive avoidable thing the server does —
|
|
497
|
+
// a measured 18.8s for a 380 MB capture — and a long investigation
|
|
498
|
+
// re-loads the same file repeatedly. A hit switches to the resident
|
|
499
|
+
// graph instead. It must be indistinguishable from a real load apart
|
|
500
|
+
// from speed, so replace-mode (`keep_previous:false`) still unloads the
|
|
501
|
+
// others, exactly as a re-parse would have.
|
|
502
|
+
//
|
|
503
|
+
// A resident LIGHT snapshot does NOT satisfy a full request: it has no
|
|
504
|
+
// dominator tree, so serving it would silently answer a retention
|
|
505
|
+
// question with zeros. The reverse is fine — a full graph satisfies a
|
|
506
|
+
// light request.
|
|
507
|
+
if (!force_reload) {
|
|
508
|
+
const resident = findResidentByPath(resolved);
|
|
509
|
+
if (resident != null && (!resident.light || light)) {
|
|
510
|
+
if (keep_previous) {
|
|
511
|
+
setCurrentSnapshot(resident.handle);
|
|
512
|
+
}
|
|
513
|
+
else {
|
|
514
|
+
retainOnlySnapshot(resident.handle);
|
|
515
|
+
}
|
|
516
|
+
const headroomHitMB = getHeapHeadroomMB();
|
|
517
|
+
return textResult([
|
|
518
|
+
`cache: **hit** — ${resident.fileName} was already parsed in this server process; switched to it in ${Date.now() - startedAt} ms instead of re-parsing [handle: ${resident.handle}]`,
|
|
519
|
+
`${formatNumber(resident.nodeCount)} nodes, ${formatNumber(resident.edgeCount)} edges, ${formatBytes(resident.totalSize)} heap size` +
|
|
520
|
+
(resident.light
|
|
521
|
+
? ' (LIGHT — no dominator tree / retained sizes)'
|
|
522
|
+
: ''),
|
|
523
|
+
headroomHitMB != null
|
|
524
|
+
? `Memory: ~${formatNumber(headroomHitMB)} MB of the ~${formatNumber(getOldSpaceLimitMB())} MB old-space limit free (unchanged — nothing was parsed).`
|
|
525
|
+
: '',
|
|
526
|
+
'_Pass `force_reload: true` if the file changed on disk since it was loaded._',
|
|
527
|
+
]
|
|
528
|
+
.filter(Boolean)
|
|
529
|
+
.join('\n'));
|
|
530
|
+
}
|
|
531
|
+
}
|
|
207
532
|
if (!fs.existsSync(resolved)) {
|
|
208
533
|
return errorResult(new Error(`File not found: ${resolved}` +
|
|
209
534
|
(file_path.includes('/')
|
|
@@ -212,21 +537,151 @@ export function registerLoadSnapshot(server) {
|
|
|
212
537
|
}
|
|
213
538
|
const fileStat = fs.statSync(resolved);
|
|
214
539
|
const fileSizeMB = fileStat.size / (1024 * 1024);
|
|
215
|
-
|
|
216
|
-
|
|
540
|
+
// Manifold-fetched server snapshots get the higher ceiling by default so
|
|
541
|
+
// the common 550–2100 MB Nest captures load without an explicit override
|
|
542
|
+
// (sweep feedback round 6 §2); an explicit caller value still wins.
|
|
543
|
+
const effectiveMaxFileSizeMB = resolveMaxFileSizeMB(max_file_size_mb, fetchedFrom != null);
|
|
544
|
+
if (fileSizeMB > effectiveMaxFileSizeMB) {
|
|
545
|
+
const heapMB = getOldSpaceLimitMB();
|
|
546
|
+
const options = [
|
|
547
|
+
`1. Use a smaller snapshot from the same app if available`,
|
|
548
|
+
`2. Increase the limit: memlab_load_snapshot({max_file_size_mb: ${Math.ceil(fileSizeMB + 100)}})`,
|
|
549
|
+
];
|
|
550
|
+
// Only recommend more memory when the server isn't already at 8 GB —
|
|
551
|
+
// the shipped default (feedback: the "restart with more memory" advice
|
|
552
|
+
// was a red herring when the server was already at 8 GB). An unknown
|
|
553
|
+
// limit (heapMB === 0) also satisfies this and keeps the advice, since
|
|
554
|
+
// we can't confirm the server is already provisioned.
|
|
555
|
+
if (heapMB < 8192) {
|
|
556
|
+
options.push(`3. Restart the MCP server with more memory: NODE_OPTIONS="--max-old-space-size=8192"`);
|
|
557
|
+
}
|
|
558
|
+
return errorResult(new Error(`Snapshot file is ${formatBytes(fileStat.size)} — exceeds the ${effectiveMaxFileSizeMB} MB safety limit. ` +
|
|
217
559
|
`Loading snapshots this large often causes the MCP server to crash with an out-of-memory error, ` +
|
|
218
|
-
`losing all analysis state
|
|
560
|
+
`losing all analysis state.` +
|
|
561
|
+
(heapMB > 0
|
|
562
|
+
? ` (This server's Node.js heap is ~${formatNumber(heapMB)} MB.)`
|
|
563
|
+
: '') +
|
|
564
|
+
`\n\nOptions:\n` +
|
|
565
|
+
options.join('\n')));
|
|
566
|
+
}
|
|
567
|
+
// Node/edge-count ceiling (P2403258184). The load's dominator pass is
|
|
568
|
+
// super-linear in graph size AND depth and runs as one uninterruptible
|
|
569
|
+
// synchronous block the wall-clock guardrail cannot preempt, so a very
|
|
570
|
+
// large/deep snapshot can wedge the server with no timeout. Peek the
|
|
571
|
+
// counts cheaply from the header and refuse BEFORE starting the pass.
|
|
572
|
+
const counts = peekSnapshotCounts(resolved);
|
|
573
|
+
const autoCeilings = computeDefaultCeilings();
|
|
574
|
+
const effectiveMaxNodes = max_nodes ?? autoCeilings.maxNodes;
|
|
575
|
+
const effectiveMaxEdges = max_edges ?? autoCeilings.maxEdges;
|
|
576
|
+
if (counts &&
|
|
577
|
+
!force &&
|
|
578
|
+
(counts.nodeCount > effectiveMaxNodes ||
|
|
579
|
+
counts.edgeCount > effectiveMaxEdges)) {
|
|
580
|
+
const heapMB = autoCeilings.heapLimitMB;
|
|
581
|
+
// Tailor the advice to the server's ACTUAL old-space size. The generic
|
|
582
|
+
// "restart with more memory" line is a red herring when the server is
|
|
583
|
+
// already at 8 GB (the shipped default) — in that case force:true is all
|
|
584
|
+
// that's needed, and it self-sizes from these header counts.
|
|
585
|
+
const heapNote = heapMB > 0
|
|
586
|
+
? `This server's Node.js heap is ~${formatNumber(heapMB)} MB (--max-old-space-size), so the auto-scaled ceiling is ${formatNumber(effectiveMaxNodes)} nodes / ${formatNumber(effectiveMaxEdges)} edges. `
|
|
587
|
+
: '';
|
|
588
|
+
// Density-derived hint: from this capture's own nodes/edges per MB,
|
|
589
|
+
// estimate the largest same-app capture that WOULD fit, so the reader
|
|
590
|
+
// can pick a smaller snapshot in one query instead of hand-computing
|
|
591
|
+
// density and guessing (sweep feedback round 7 §2).
|
|
592
|
+
const estMB = estimateMaxLoadableMB(counts, fileSizeMB, {
|
|
593
|
+
maxNodes: effectiveMaxNodes,
|
|
594
|
+
maxEdges: effectiveMaxEdges,
|
|
595
|
+
});
|
|
596
|
+
const sizeHint = estMB != null
|
|
597
|
+
? `This capture is ${formatBytes(fileStat.size)} at ${formatNumber(Math.round(counts.nodeCount / fileSizeMB))} nodes/MB and ${formatNumber(Math.round(counts.edgeCount / fileSizeMB))} edges/MB, so for this app the ceiling corresponds to roughly a ≤${formatNumber(estMB)} MB capture — pick a smaller snapshot below that size.`
|
|
598
|
+
: '';
|
|
599
|
+
const options = [
|
|
600
|
+
`1. Get trend/growth WITHOUT a full load: memlab_sequence_analysis loads snapshots transiently (one at a time) and is the right tool for a ladder.`,
|
|
601
|
+
`2. Use a smaller/earlier snapshot from the same ladder.${estMB != null ? ` ${sizeHint}` : ''} Check a candidate's counts first WITHOUT a load via memlab_snapshot_header.`,
|
|
602
|
+
`3. Load it anyway with force:true — it self-sizes from these header counts, so you do NOT also need max_nodes/max_edges: memlab_load_snapshot({file_path: "${file_path}", force: true}). Expect a long, uninterruptible dominator pass.`,
|
|
603
|
+
];
|
|
604
|
+
// Only suggest more memory when the server is actually under-provisioned.
|
|
605
|
+
if (heapMB > 0 && heapMB < 8192) {
|
|
606
|
+
options.push(`4. This server has only ~${formatNumber(heapMB)} MB — restart it with more memory to raise the ceiling before forcing: NODE_OPTIONS="--max-old-space-size=8192".`);
|
|
607
|
+
}
|
|
608
|
+
return errorResult(new Error(`Snapshot has ${formatNumber(counts.nodeCount)} nodes / ${formatNumber(counts.edgeCount)} edges — exceeds the load ceiling ` +
|
|
609
|
+
`(${formatNumber(effectiveMaxNodes)} nodes / ${formatNumber(effectiveMaxEdges)} edges). ${heapNote}\n\n` +
|
|
610
|
+
`The load computes a dominator tree whose cost grows super-linearly with graph size and depth, and it runs as one synchronous block that the wall-clock guardrail cannot interrupt — so a snapshot this large/deep can wedge the server with no timeout. Refusing before the pass starts.\n\n` +
|
|
219
611
|
`Options:\n` +
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
612
|
+
options.join('\n')));
|
|
613
|
+
}
|
|
614
|
+
const bigBySize = fileSizeMB > 200;
|
|
615
|
+
const bigByCount = counts != null && counts.nodeCount > WARN_NODE_COUNT;
|
|
616
|
+
if (bigBySize || bigByCount) {
|
|
617
|
+
const countStr = counts
|
|
618
|
+
? `${formatNumber(counts.nodeCount)} nodes / ${formatNumber(counts.edgeCount)} edges, `
|
|
619
|
+
: '';
|
|
620
|
+
process.stderr.write(`Loading ${formatBytes(fileStat.size)} snapshot (${countStr}parsing${light ? '' : ' → dominators → retained sizes'}). ` +
|
|
621
|
+
`This runs uninterruptibly and can take minutes on a large/deep heap; if it appears to hang it is the ${light ? 'parse' : 'dominator pass'}, not a crash.\n`);
|
|
223
622
|
}
|
|
224
|
-
|
|
225
|
-
|
|
623
|
+
// Free any resident snapshot BEFORE parsing the next so peak memory is
|
|
624
|
+
// one heap, not two — the double-residency that tips a large load into
|
|
625
|
+
// GC thrash (P2403258184 §3). Only in replace mode; keep_previous
|
|
626
|
+
// callers explicitly opt into holding several resident.
|
|
627
|
+
if (!keep_previous) {
|
|
628
|
+
clearAllSnapshots();
|
|
226
629
|
}
|
|
630
|
+
// Memory-headroom guard. The count ceiling above bounds the graph in the
|
|
631
|
+
// ABSTRACT; this bounds it against what this process actually has left,
|
|
632
|
+
// which is a different question once other snapshots are resident. When
|
|
633
|
+
// the projection does not fit, evict cold snapshots (never the current
|
|
634
|
+
// one) before refusing — an OOM here kills every resident snapshot and
|
|
635
|
+
// the whole session's state, so shedding the coldest is strictly better.
|
|
636
|
+
const evicted = [];
|
|
637
|
+
let headroomMB = getHeapHeadroomMB();
|
|
638
|
+
let estimateMB = null;
|
|
639
|
+
if (counts != null && headroomMB != null) {
|
|
640
|
+
estimateMB = estimateLoadHeapMB(counts, light);
|
|
641
|
+
while (estimateMB > (headroomMB ?? 0) * HEADROOM_SAFETY_FRACTION &&
|
|
642
|
+
!force) {
|
|
643
|
+
const victim = evictLeastRecentlyUsed();
|
|
644
|
+
if (victim == null)
|
|
645
|
+
break;
|
|
646
|
+
evicted.push(victim.handle);
|
|
647
|
+
headroomMB = getHeapHeadroomMB();
|
|
648
|
+
}
|
|
649
|
+
if (!force &&
|
|
650
|
+
estimateMB > (headroomMB ?? 0) * HEADROOM_SAFETY_FRACTION) {
|
|
651
|
+
const heapMB = getOldSpaceLimitMB();
|
|
652
|
+
return errorResult(new Error(`Not enough memory headroom to load this snapshot: it projects to ~${formatNumber(estimateMB)} MB ` +
|
|
653
|
+
`(${formatNumber(counts.nodeCount)} nodes × ${LOAD_BYTES_PER_NODE} B + ${formatNumber(counts.edgeCount)} edges × ${LOAD_BYTES_PER_EDGE} B), ` +
|
|
654
|
+
`over the ~${formatNumber(Math.round((headroomMB ?? 0) * HEADROOM_SAFETY_FRACTION))} MB budget for one load ` +
|
|
655
|
+
`(${Math.round(HEADROOM_SAFETY_FRACTION * 100)}% of the ~${formatNumber(headroomMB ?? 0)} MB free of this server's ~${formatNumber(heapMB)} MB old-space limit; the rest is reserved because the estimate is approximate and the parse allocates in bursts)` +
|
|
656
|
+
(evicted.length > 0
|
|
657
|
+
? ` (after unloading ${evicted.length} cold snapshot(s): ${evicted.join(', ')})`
|
|
658
|
+
: '') +
|
|
659
|
+
`.\n\nAn out-of-memory crash here would take every resident snapshot and all session state with it, so the load is refused instead.\n\nOptions:\n` +
|
|
660
|
+
`1. Unload snapshots you no longer need: memlab_snapshots({action: "unload", handle: "…"}).\n` +
|
|
661
|
+
`2. Load light — skips the dominator/retained-size pass: memlab_load_snapshot({file_path: "${file_path}", light: true}).\n` +
|
|
662
|
+
`3. Use a smaller/earlier snapshot, or memlab_sequence_analysis for trend questions (it holds one graph at a time).\n` +
|
|
663
|
+
`4. Restart the server with more memory: NODE_OPTIONS="--max-old-space-size=8192".\n` +
|
|
664
|
+
`5. Override this projection with force:true (accepts the OOM risk).`));
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
progress.phase(2, PHASES, `guards passed${estimateMB != null ? ` (projected ~${formatNumber(estimateMB)} MB, ~${formatNumber(headroomMB ?? 0)} MB free)` : ''}${evicted.length > 0 ? `, evicted ${evicted.join(', ')}` : ''}`);
|
|
668
|
+
progress.phase(3, PHASES, `parsing ${formatBytes(fileStat.size)} — this phase is synchronous and emits no further updates until it completes`);
|
|
227
669
|
let snapshot;
|
|
228
670
|
try {
|
|
229
|
-
|
|
671
|
+
if (light) {
|
|
672
|
+
// Parse only. `preparePathFinder` (shortest paths + dominators +
|
|
673
|
+
// retained sizes + Fiber marking) is the half being skipped, and it
|
|
674
|
+
// is the expensive half.
|
|
675
|
+
snapshot = await memlabUtils.getSnapshotFromFile(resolved, {
|
|
676
|
+
buildNodeIdIndex: true,
|
|
677
|
+
verbose: false,
|
|
678
|
+
});
|
|
679
|
+
progress.phase(4, PHASES, 'parsed; dominator/retained-size pass SKIPPED (light mode)');
|
|
680
|
+
}
|
|
681
|
+
else {
|
|
682
|
+
snapshot = await getFullHeapFromFile(resolved);
|
|
683
|
+
progress.phase(4, PHASES, 'parsed; dominator tree and retained sizes computed');
|
|
684
|
+
}
|
|
230
685
|
}
|
|
231
686
|
catch (loadErr) {
|
|
232
687
|
const msg = loadErr instanceof Error ? loadErr.message : String(loadErr);
|
|
@@ -234,14 +689,21 @@ export function registerLoadSnapshot(server) {
|
|
|
234
689
|
msg.includes('allocation failed') ||
|
|
235
690
|
msg.includes('JavaScript heap') ||
|
|
236
691
|
msg.includes('ENOMEM')) {
|
|
692
|
+
const heapMB = getOldSpaceLimitMB();
|
|
693
|
+
const already8g = heapMB >= 8192;
|
|
237
694
|
return errorResult(new Error(`Out of memory while loading ${formatBytes(fileStat.size)} snapshot. ` +
|
|
238
|
-
`The snapshot requires more memory than is available to the MCP server process
|
|
695
|
+
`The snapshot requires more memory than is available to the MCP server process` +
|
|
696
|
+
(heapMB > 0 ? ` (~${formatNumber(heapMB)} MB heap)` : '') +
|
|
697
|
+
`.\n\n` +
|
|
239
698
|
`Try:\n` +
|
|
240
|
-
`1. A smaller snapshot from the same app\n` +
|
|
241
|
-
|
|
699
|
+
`1. A smaller/earlier snapshot from the same app or ladder\n` +
|
|
700
|
+
(already8g
|
|
701
|
+
? `2. This server is already at ~${formatNumber(heapMB)} MB — this snapshot is simply too large to load in full; use memlab_sequence_analysis for trend/growth without a full load.`
|
|
702
|
+
: `2. Restart with more memory: NODE_OPTIONS="--max-old-space-size=8192"`)));
|
|
242
703
|
}
|
|
243
704
|
throw loadErr;
|
|
244
705
|
}
|
|
706
|
+
progress.phase(5, PHASES, 'indexing nodes and edges');
|
|
245
707
|
let nodeCount = 0;
|
|
246
708
|
let edgeCount = 0;
|
|
247
709
|
let totalSize = 0;
|
|
@@ -260,6 +722,7 @@ export function registerLoadSnapshot(server) {
|
|
|
260
722
|
edgeCount,
|
|
261
723
|
totalSize,
|
|
262
724
|
env,
|
|
725
|
+
light,
|
|
263
726
|
}, { alias, replace: !keep_previous });
|
|
264
727
|
const envLabel = env === 'browser'
|
|
265
728
|
? 'Browser'
|
|
@@ -273,7 +736,30 @@ export function registerLoadSnapshot(server) {
|
|
|
273
736
|
if (fetchedFrom) {
|
|
274
737
|
lines.push(`Fetched from Manifold: ${fetchedFrom}`);
|
|
275
738
|
}
|
|
739
|
+
if (evicted.length > 0) {
|
|
740
|
+
lines.push(`⚠ Unloaded ${evicted.length} cold snapshot(s) to make room: ${evicted.join(', ')}`);
|
|
741
|
+
}
|
|
276
742
|
lines.push(`Loaded ${fileName} (${formatBytes(fileStat.size)} on disk): ${formatNumber(nodeCount)} nodes, ${formatNumber(edgeCount)} edges, ${formatBytes(totalSize)} heap size (${envLabel} snapshot) [handle: ${meta.handle}]`);
|
|
743
|
+
if (light) {
|
|
744
|
+
lines.push('⚡ **LIGHT mode** — dominator tree, retained sizes and retainer paths were NOT computed. Counts, names, types, self sizes and string values are available; tools needing retention data will refuse rather than report zeros. Reload without `light` for those.');
|
|
745
|
+
}
|
|
746
|
+
// Report headroom on every load, not just when the guard fires: the
|
|
747
|
+
// number a caller needs in order to decide whether to keep_previous the
|
|
748
|
+
// NEXT one is what is left after this load, and nothing else surfaces it.
|
|
749
|
+
const headroomAfterMB = getHeapHeadroomMB();
|
|
750
|
+
if (headroomAfterMB != null) {
|
|
751
|
+
const limitMB = getOldSpaceLimitMB();
|
|
752
|
+
lines.push(`Memory: ~${formatNumber(headroomAfterMB)} MB of the ~${formatNumber(limitMB)} MB old-space limit still free after this load.`);
|
|
753
|
+
}
|
|
754
|
+
const captured = extractCaptureTime(fileName);
|
|
755
|
+
if (captured) {
|
|
756
|
+
const iso = captured.toISOString().slice(0, 10);
|
|
757
|
+
// Clamp to 0: a filename whose embedded epoch is slightly ahead of
|
|
758
|
+
// the host clock (clock skew, or a future-dated name within the
|
|
759
|
+
// allowed window) must not surface as "(-N day(s) ago)".
|
|
760
|
+
const ageDays = Math.max(0, Math.floor((Date.now() - captured.getTime()) / 86_400_000));
|
|
761
|
+
lines.push(`Captured ${iso} (${ageDays} day(s) ago) — if you find a candidate, verify the fix wasn't already deployed since then before writing one.`);
|
|
762
|
+
}
|
|
277
763
|
if (keep_previous) {
|
|
278
764
|
const all = listSnapshots();
|
|
279
765
|
if (all.length > 1) {
|
|
@@ -282,10 +768,14 @@ export function registerLoadSnapshot(server) {
|
|
|
282
768
|
.join(', ')}. Switch with memlab_snapshots.`);
|
|
283
769
|
}
|
|
284
770
|
}
|
|
285
|
-
|
|
771
|
+
progress.phase(6, PHASES, 'running quick diagnosis');
|
|
772
|
+
const warnings = quickDiagnosis(snapshot, totalSize, light);
|
|
286
773
|
if (warnings.length > 0) {
|
|
287
774
|
lines.push('', ...warnings);
|
|
288
775
|
}
|
|
776
|
+
if (!suggestionsSuppressed()) {
|
|
777
|
+
lines.push('', ...nextStepMenu());
|
|
778
|
+
}
|
|
289
779
|
return textResult(lines.join('\n'));
|
|
290
780
|
}
|
|
291
781
|
catch (err) {
|