@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
package/dist/tools/eval.js
CHANGED
|
@@ -10,24 +10,69 @@
|
|
|
10
10
|
import { z } from 'zod';
|
|
11
11
|
import vm from 'node:vm';
|
|
12
12
|
import memlabCore from '@memlab/core';
|
|
13
|
-
const { utils } = memlabCore;
|
|
14
|
-
import { getSnapshot } from '../heap-state.js';
|
|
15
|
-
import { errorResult, toolResult, serializeNodeSummary, serializeNodeDetail, formatBytes, formatNumber, markdownTable, isNodeWorthInspecting, filterLargestObjects, queryNodes, } from '../utils.js';
|
|
13
|
+
const { utils, NumericSet } = memlabCore;
|
|
14
|
+
import { getCurrentHandle, getSavedResult, getSnapshot, isLightSnapshot, listSavedResults, setSavedResult, getEvalScratch, getSnapshotMetadata, } from '../heap-state.js';
|
|
15
|
+
import { errorResult, toolResult, serializeNodeSummary, serializeNodeDetail, formatBytes, formatNumber, markdownTable, isNodeWorthInspecting, makeNamePatternTest, filterLargestObjects, queryNodes, enumerateMapEntries, enumerateSetElements, objectContentSignature, boundedDominatorRetainedSize, } from '../utils.js';
|
|
16
16
|
const MAX_OUTPUT_SIZE = 50 * 1024; // 50KB
|
|
17
|
+
// Prefix for user-named result sets inside the per-snapshot eval scratch, so
|
|
18
|
+
// they cannot collide with the internal `__classTypeIndex` / `__withProp:` keys.
|
|
17
19
|
function truncate(str, max) {
|
|
18
20
|
if (str.length <= max)
|
|
19
21
|
return str;
|
|
20
22
|
return str.slice(0, max) + '\n... [truncated, output exceeded 50KB]';
|
|
21
23
|
}
|
|
24
|
+
class BudgetExceeded extends Error {
|
|
25
|
+
constructor(max) {
|
|
26
|
+
super(`max_nodes budget of ${max} exhausted`);
|
|
27
|
+
this.name = 'BudgetExceeded';
|
|
28
|
+
}
|
|
29
|
+
}
|
|
22
30
|
const NODE_PROPERTY_ALIASES = {
|
|
23
|
-
retained_size: 'retainedSize',
|
|
24
31
|
referrer_count: 'numOfReferrers',
|
|
25
32
|
};
|
|
33
|
+
/**
|
|
34
|
+
* Lets helpers recover the real node behind a sandbox proxy. Sandbox code holds
|
|
35
|
+
* proxies; the helpers it passes them to need the underlying object, both to
|
|
36
|
+
* avoid proxy overhead per property read and because the proxy deliberately
|
|
37
|
+
* refuses `retainedSize` (below) while the helpers legitimately read it.
|
|
38
|
+
*/
|
|
39
|
+
const RAW_NODE = Symbol('memlabRawNode');
|
|
40
|
+
export function unwrapNode(node) {
|
|
41
|
+
if (node != null && typeof node === 'object') {
|
|
42
|
+
const raw = node[RAW_NODE];
|
|
43
|
+
if (raw != null)
|
|
44
|
+
return raw;
|
|
45
|
+
}
|
|
46
|
+
return node;
|
|
47
|
+
}
|
|
48
|
+
function unwrapNodes(nodes) {
|
|
49
|
+
return Array.isArray(nodes) ? nodes.map(unwrapNode) : nodes;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* `node.retainedSize` read off a node inside eval has been observed to come
|
|
53
|
+
* back ~0 for every node on some loads, while the same id read through
|
|
54
|
+
* `snapshot.getNodeById(id).retainedSize` returns the true value. A field that
|
|
55
|
+
* silently yields a wrong NUMBER is worse than one that fails: every ranking
|
|
56
|
+
* built on it looks plausible and is wrong, and nothing in the output says so.
|
|
57
|
+
*
|
|
58
|
+
* So the sandbox refuses the read and names the working call. The docs already
|
|
59
|
+
* carried this as a caveat; a caveat is only as good as the reader's memory of
|
|
60
|
+
* it, and this class of silent-zero bug has produced published errors before.
|
|
61
|
+
*/
|
|
62
|
+
const RETAINED_SIZE_PROPS = new Set(['retainedSize', 'retained_size']);
|
|
26
63
|
function wrapNode(node) {
|
|
27
64
|
if (node == null)
|
|
28
65
|
return node;
|
|
29
66
|
return new Proxy(node, {
|
|
30
67
|
get(target, prop, receiver) {
|
|
68
|
+
if (prop === RAW_NODE)
|
|
69
|
+
return target;
|
|
70
|
+
if (typeof prop === 'string' && RETAINED_SIZE_PROPS.has(prop)) {
|
|
71
|
+
const id = target.id;
|
|
72
|
+
throw new Error(`node.${prop} is not readable inside eval — it can come back ~0 for every node on some loads, which silently corrupts any ranking built on it. ` +
|
|
73
|
+
`Use helpers.retainedSize(${typeof id === 'number' ? id : 'id'}) for one node, helpers.retainedSizes([ids]) for many, or helpers.aggregateRetained([ids]) for a dominator-deduped total. ` +
|
|
74
|
+
'Self size (`node.self_size`) is read directly from the snapshot and IS reliable here.');
|
|
75
|
+
}
|
|
31
76
|
if (typeof prop === 'string' && prop in NODE_PROPERTY_ALIASES) {
|
|
32
77
|
return target[NODE_PROPERTY_ALIASES[prop]];
|
|
33
78
|
}
|
|
@@ -78,7 +123,7 @@ function wrapEdgeIterable(iterable) {
|
|
|
78
123
|
},
|
|
79
124
|
};
|
|
80
125
|
}
|
|
81
|
-
function wrapSnapshot(snapshot) {
|
|
126
|
+
function wrapSnapshot(snapshot, budget) {
|
|
82
127
|
return new Proxy(snapshot, {
|
|
83
128
|
get(target, prop, receiver) {
|
|
84
129
|
if (prop === 'getNodeById') {
|
|
@@ -92,7 +137,15 @@ function wrapSnapshot(snapshot) {
|
|
|
92
137
|
if (nodesProp === 'forEach') {
|
|
93
138
|
const origForEach = nodesTarget.forEach.bind(nodesTarget);
|
|
94
139
|
return (cb) => {
|
|
95
|
-
origForEach((node) =>
|
|
140
|
+
origForEach((node) => {
|
|
141
|
+
if (++budget.visited > budget.max) {
|
|
142
|
+
budget.exceeded = true;
|
|
143
|
+
throw new BudgetExceeded(budget.max);
|
|
144
|
+
}
|
|
145
|
+
// Returning `false` from the callback breaks the walk, so the
|
|
146
|
+
// callback's return value has to be passed through.
|
|
147
|
+
return cb(wrapNode(node));
|
|
148
|
+
});
|
|
96
149
|
};
|
|
97
150
|
}
|
|
98
151
|
return Reflect.get(nodesTarget, nodesProp, nodesReceiver);
|
|
@@ -103,6 +156,23 @@ function wrapSnapshot(snapshot) {
|
|
|
103
156
|
},
|
|
104
157
|
});
|
|
105
158
|
}
|
|
159
|
+
/**
|
|
160
|
+
* Identifiers whose value comes from the dominator / retained-size / shortest-
|
|
161
|
+
* path pass that a LIGHT load skips. On such a snapshot each of these reads
|
|
162
|
+
* back 0 or undefined WITHOUT failing, so eval code using them returns
|
|
163
|
+
* confident zeros — worse than an error. Matched textually before the code
|
|
164
|
+
* runs, so the refusal costs nothing.
|
|
165
|
+
*/
|
|
166
|
+
const RETENTION_IDENTIFIERS = [
|
|
167
|
+
'retainedSize',
|
|
168
|
+
'retainedSizes',
|
|
169
|
+
'retained_size',
|
|
170
|
+
'aggregateRetained',
|
|
171
|
+
'dominatorNode',
|
|
172
|
+
'hasPathEdge',
|
|
173
|
+
'pathEdge',
|
|
174
|
+
'filterLargestObjects',
|
|
175
|
+
];
|
|
106
176
|
export function registerEval(server) {
|
|
107
177
|
server.tool('memlab_eval', 'Execute arbitrary JavaScript code against the loaded heap snapshot. ' +
|
|
108
178
|
'The code runs in a sandboxed VM with access to `snapshot` (IHeapSnapshot), ' +
|
|
@@ -120,19 +190,25 @@ export function registerEval(server) {
|
|
|
120
190
|
'**Iterating all nodes:** `snapshot.nodes.forEach(node => { ... })` — NOT for-of.\n' +
|
|
121
191
|
'**Get node by ID:** `snapshot.getNodeById(id)` returns IHeapNode or null.\n' +
|
|
122
192
|
'**String values:** `node.toStringNode()?.stringValue` for string nodes.\n' +
|
|
123
|
-
'**Caveat — retained_size is unreliable here:** inside eval, `node.retained_size`/`.retainedSize` can read back ~0 for every node on some loads. Node counts, property/edge walks, and string values ARE trustworthy. For authoritative retained sizes call `helpers.retainedSize(id)` / `helpers.retainedSizes([ids])` (they re-resolve the node on the real snapshot
|
|
124
|
-
'**Example — inspect Map entries
|
|
125
|
-
'
|
|
126
|
-
'
|
|
127
|
-
'
|
|
128
|
-
'
|
|
129
|
-
'
|
|
130
|
-
'
|
|
193
|
+
'**Caveat — retained_size is unreliable here:** inside eval, `node.retained_size`/`.retainedSize` can read back ~0 for every node on some loads. Node counts, property/edge walks, and string values ARE trustworthy. For authoritative retained sizes call `helpers.retainedSize(id)` (number) / `helpers.retainedSizes([ids])` (a `Record<id, bytes>` object, NOT an array) — they re-resolve the node on the real snapshot — or use the dedicated tools (`memlab_largest_objects`, `memlab_class_histogram`, `memlab_pinch_points`, `memlab_object_shape`).\n\n' +
|
|
194
|
+
'**Example — inspect Map entries (use the helper; do NOT hand-roll the ' +
|
|
195
|
+
'backing-store walk — browser slots are `internal`-typed and SMI values ' +
|
|
196
|
+
'leave index gaps):**\n' +
|
|
197
|
+
'```\nresult = helpers.mapEntries(12345, 10); // [{key, value}] briefs\n' +
|
|
198
|
+
'// Set: helpers.setElements(setId, 10)\n```\n' +
|
|
199
|
+
'**Example — dedup / composition of a Relay record type:**\n' +
|
|
200
|
+
'```\nconst ids = helpers.byTypename("AdCreativeFeatureSpecAttachment");\n' +
|
|
201
|
+
'const sigs = {};\n' +
|
|
202
|
+
'for (const id of ids) { const s = helpers.shapeSignature(id); sigs[s] = (sigs[s]||0)+1; }\n' +
|
|
203
|
+
'result = {count: ids.length, distinct: Object.keys(sigs).length};\n```\n' +
|
|
204
|
+
'**Multi-step exploration:** pass `save_as` to keep a result set server-side and `helpers.load(name)` to read it back in a later call, so intermediate id lists never have to be printed to the transcript. `mode:"list_saved"` lists them. ' +
|
|
205
|
+
'Runs on a LIGHT snapshot too (counts, names, types, self sizes, string values, edge walks). Code referencing retained sizes, dominators or path edges is refused up front there rather than returning zeros. ' +
|
|
206
|
+
'Pass `max_nodes` to bound a full-heap walk — on overrun the partial `result` is returned with a warning instead of failing, so a broad scan is safe to attempt. Every call reports `nodes_visited`.', {
|
|
131
207
|
mode: z
|
|
132
|
-
.enum(['eval', 'describe_env'])
|
|
208
|
+
.enum(['eval', 'describe_env', 'list_saved'])
|
|
133
209
|
.optional()
|
|
134
210
|
.default('eval')
|
|
135
|
-
.describe('"eval" (default) runs `code`. "describe_env" ignores `code` and returns the in-scope globals, the IHeapNode/IHeapEdge API, and the required calling conventions (`result =`, `.forEach`) so you can self-correct before running.'),
|
|
211
|
+
.describe('"eval" (default) runs `code`. "describe_env" ignores `code` and returns the in-scope globals, the IHeapNode/IHeapEdge API, and the required calling conventions (`result =`, `.forEach`) so you can self-correct before running. "list_saved" ignores `code` and lists the named result sets saved so far for this snapshot.'),
|
|
136
212
|
code: z
|
|
137
213
|
.string()
|
|
138
214
|
.optional()
|
|
@@ -143,8 +219,20 @@ export function registerEval(server) {
|
|
|
143
219
|
'markdownTable, isNodeWorthInspecting, filterLargestObjects, queryNodes, ' +
|
|
144
220
|
'groupReferrersByEdge(nodeId), groupArrayElementsByProperty(arrayNodeId, propName), ' +
|
|
145
221
|
'isOrphaned(nodeId, ownershipEdgeNames[]), countUniqueTargets(arrayNodeId, propName), ' +
|
|
146
|
-
'retainedSize(id), retainedSizes(ids[])
|
|
222
|
+
'retainedSize(id)->number, retainedSizes(ids[])->Record<id,bytes> (an OBJECT keyed by id, NOT an array — index it as sizes[id] or Object.values(sizes)), ' +
|
|
223
|
+
'mapEntries(mapId, limit?)->[{key,value}] & setElements(setId, limit?)->[brief] (correct Map/Set/WeakMap enumeration — handles browser internal-typed slots AND SMI-value gaps, so you never re-derive it wrong), ' +
|
|
224
|
+
'props(nodeOrId)->{prop: scalar | {ref,name,type}} & getProp(nodeOrId, name) & shapeSignature(nodeOrId, {maxStringLen?}) (content signature for dedup checks), ' +
|
|
225
|
+
'byClass(name, {type?})->ids[] & byTypename(name)->ids[] & withProp(name)->ids[] (INDEXED lookups — built once per snapshot then memoized in a session scratch, so repeated questions are index-speed not full-scan; byClass covers EVERY node type, matching memlab_find_nodes_by_class, so closures/strings/arrays/natives are found — pass {type:"object"} to narrow), ' +
|
|
226
|
+
'aggregateRetained(ids[])->{retained,exact} (dominator-deduped retained for a SET of ids, no double-counting), ' +
|
|
227
|
+
'iterByClass(name, {type?})->nodes[] & iterByType(type)->nodes[] (INDEXED iteration — no full scan), ' +
|
|
228
|
+
'classCounts({pattern?, type?, minCount?})->[{name,type,count,selfSize}] (one-pass histogram, cached), ' +
|
|
229
|
+
'entries(nodeOrId)->[{key,value}] (generic Map/Set/WeakMap/Array/object walk, holes filtered), ' +
|
|
230
|
+
'edgeTarget(nodeOrId, edgeName)->node|null, isRealDetached(node)->boolean (same filtering the tools apply internally), ' +
|
|
231
|
+
'dominates(id, {population?, limit?})->{count,selfSize,ids,truncated}, ' +
|
|
232
|
+
'pathBetween(fromId, toId, {maxNodes?})->{found,exhausted,path[]}, ' +
|
|
233
|
+
'save(name, value) / load(name, {allowCrossSnapshot?}) / listSaved() (SESSION-scoped, survives loading another snapshot) }), ' +
|
|
147
234
|
'and standard JS built-ins. ' +
|
|
235
|
+
'NOTE: `node.retainedSize` / `node.retained_size` THROW inside eval — they can read back ~0 for every node on some loads, so a silent wrong number is refused; use helpers.retainedSize(id). `node.self_size` is reliable. ' +
|
|
148
236
|
'Node traversal: use node.references (outgoing) and node.referrers (incoming) with for-of. ' +
|
|
149
237
|
'Edge properties: .name_or_index, .type, .toNode, .fromNode.'),
|
|
150
238
|
timeout_ms: z
|
|
@@ -152,15 +240,75 @@ export function registerEval(server) {
|
|
|
152
240
|
.optional()
|
|
153
241
|
.default(60000)
|
|
154
242
|
.describe('Execution timeout in milliseconds (default 60000). Full-snapshot scans on large heaps may need 120000+.'),
|
|
155
|
-
|
|
243
|
+
save_as: z
|
|
244
|
+
.string()
|
|
245
|
+
.regex(/^[A-Za-z_][A-Za-z0-9_]*$/)
|
|
246
|
+
.optional()
|
|
247
|
+
.describe('Save this call\'s `result` under a name, reusable in later calls via `helpers.load("<name>")`. Lets a multi-step investigation keep intermediate sets (candidate ids, per-id measurements) SERVER-SIDE instead of round-tripping them through the transcript. Save plain data (ids, counts, strings) — not node objects. Scoped to the current snapshot and dropped when it is unloaded.'),
|
|
248
|
+
dry_run: z
|
|
249
|
+
.boolean()
|
|
250
|
+
.optional()
|
|
251
|
+
.default(false)
|
|
252
|
+
.describe('Report what the code WOULD scan and stop, without running it (default false). Returns the snapshot size, whether the code contains a full-heap walk, and the effective max_nodes budget. Use it before an exploratory scan on a multi-million-node heap, where the difference between an indexed lookup and a full walk is the difference between milliseconds and minutes.'),
|
|
253
|
+
max_nodes: z
|
|
254
|
+
.number()
|
|
255
|
+
.int()
|
|
256
|
+
.min(1)
|
|
257
|
+
.optional()
|
|
258
|
+
.default(20000000)
|
|
259
|
+
.describe('Abort a `snapshot.nodes.forEach` walk after this many node visits (default 20000000, i.e. effectively unlimited). On abort the partial `result` is returned with a note instead of failing, so a broad exploratory scan can be attempted safely. Reported back as `nodes_visited` on every call.'),
|
|
260
|
+
}, async ({ mode, code, timeout_ms, save_as, max_nodes, dry_run }) => {
|
|
261
|
+
const budget = { visited: 0, max: max_nodes, exceeded: false };
|
|
156
262
|
try {
|
|
157
263
|
if (mode === 'describe_env') {
|
|
158
264
|
return toolResult(describeEnv());
|
|
159
265
|
}
|
|
266
|
+
if (mode === 'list_saved') {
|
|
267
|
+
return toolResult(describeSaved());
|
|
268
|
+
}
|
|
160
269
|
if (code == null || code.trim() === '') {
|
|
161
270
|
return errorResult(new Error('No code provided. Pass `code`, or use mode:"describe_env" to see the available globals and conventions.'));
|
|
162
271
|
}
|
|
163
|
-
|
|
272
|
+
// Light snapshots are allowed here. Most eval code touches only
|
|
273
|
+
// `name`, `type`, `self_size`, `references` and `referrers`, none of
|
|
274
|
+
// which the dominator pass produces — refusing the whole tool forced a
|
|
275
|
+
// full (2x slower) load for counts-only work on a baseline rung. What
|
|
276
|
+
// IS unavailable is refused precisely instead: by a pre-flight text
|
|
277
|
+
// check below, and by the helpers themselves as a backstop.
|
|
278
|
+
const light = isLightSnapshot();
|
|
279
|
+
const snapshot = getSnapshot({ allowLight: true });
|
|
280
|
+
const currentHandle = getCurrentHandle() ?? '(none)';
|
|
281
|
+
if (light) {
|
|
282
|
+
const needsRetention = RETENTION_IDENTIFIERS.filter(id => new RegExp(`\\b${id}\\b`).test(code));
|
|
283
|
+
if (needsRetention.length > 0) {
|
|
284
|
+
return errorResult(new Error(`This snapshot was loaded in LIGHT mode (no dominator tree, no retained sizes, no shortest-path edges), and the code references ${needsRetention.map(i => `\`${i}\``).join(', ')}. ` +
|
|
285
|
+
'Those would read 0 / undefined rather than fail, so the run is refused instead of returning confident zeros. ' +
|
|
286
|
+
'Reload without `light` for retention work, or drop the reference — counts, names, types, self sizes, string values and edge walks all work fine on a light snapshot. ' +
|
|
287
|
+
'(If the identifier only appears inside a string literal, this is a false match; the same call succeeds on a non-light load.)'));
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
if (dry_run) {
|
|
291
|
+
// Estimate, do not execute. A full-heap walk is detected textually —
|
|
292
|
+
// the honest limit of a pre-flight check, and stated as such rather
|
|
293
|
+
// than implying the code was analysed.
|
|
294
|
+
const meta = getSnapshotMetadata();
|
|
295
|
+
const fullWalk = /\b(?:snapshot\.)?(?:nodes|edges)\s*\.\s*forEach/.test(code);
|
|
296
|
+
const indexed = /helpers\.(byClass|byTypename|withProp|getNode)/.test(code);
|
|
297
|
+
return toolResult([
|
|
298
|
+
'## Dry run — nothing was executed',
|
|
299
|
+
'',
|
|
300
|
+
`Snapshot: ${formatNumber(meta?.nodeCount ?? 0)} nodes, ${formatNumber(meta?.edgeCount ?? 0)} edges.`,
|
|
301
|
+
`Walk budget (\`max_nodes\`): ${formatNumber(budget.max)}.`,
|
|
302
|
+
'',
|
|
303
|
+
fullWalk
|
|
304
|
+
? `⚠ The code contains a full-heap walk, so it will visit up to ${formatNumber(Math.min(budget.max, meta?.nodeCount ?? 0))} nodes. On a heap this size that is seconds to minutes.${indexed ? '' : ' `helpers.byClass` / `byTypename` / `withProp` are indexed and avoid the walk when you know what you are looking for.'}`
|
|
305
|
+
: indexed
|
|
306
|
+
? 'No full-heap walk detected; the code uses the indexed helpers, which do not scan the heap.'
|
|
307
|
+
: 'No full-heap walk detected by text match. This is a textual check, not an analysis — a walk reached indirectly will not be seen here.',
|
|
308
|
+
'',
|
|
309
|
+
'_Re-run without `dry_run` to execute._',
|
|
310
|
+
].join('\n'));
|
|
311
|
+
}
|
|
164
312
|
const consoleOutput = [];
|
|
165
313
|
const capturedConsole = {
|
|
166
314
|
log: (...args) => consoleOutput.push(args.map(String).join(' ')),
|
|
@@ -235,11 +383,22 @@ export function registerEval(server) {
|
|
|
235
383
|
// ~0; these helpers look the node up fresh on the real snapshot (the
|
|
236
384
|
// same path the dedicated tools use) so custom analyses can rank by
|
|
237
385
|
// retained size.
|
|
386
|
+
// On a light snapshot these would return 0 for every id, which is
|
|
387
|
+
// indistinguishable from a genuinely tiny object. Throw instead: the
|
|
388
|
+
// pre-flight check above catches the common case, and this covers code
|
|
389
|
+
// that reaches them indirectly.
|
|
390
|
+
const requireRetention = (what) => {
|
|
391
|
+
if (!light)
|
|
392
|
+
return;
|
|
393
|
+
throw new Error(`helpers.${what} needs retained sizes, which a LIGHT snapshot does not have (it would return 0 for every id). Reload with memlab_load_snapshot({file_path, light: false}).`);
|
|
394
|
+
};
|
|
238
395
|
const retainedSize = (id) => {
|
|
396
|
+
requireRetention('retainedSize');
|
|
239
397
|
const n = snapshot.getNodeById(id);
|
|
240
398
|
return n ? n.retainedSize : 0;
|
|
241
399
|
};
|
|
242
400
|
const retainedSizes = (ids) => {
|
|
401
|
+
requireRetention('retainedSizes');
|
|
243
402
|
const out = {};
|
|
244
403
|
for (const id of ids) {
|
|
245
404
|
const n = snapshot.getNodeById(id);
|
|
@@ -269,14 +428,455 @@ export function registerEval(server) {
|
|
|
269
428
|
}
|
|
270
429
|
return { uniqueCount: uniqueIds.size, totalElements: total };
|
|
271
430
|
};
|
|
431
|
+
// Compact, ready-to-use view of a node (no proxy, values inlined) so
|
|
432
|
+
// custom scripts get data they can JSON-return directly instead of
|
|
433
|
+
// re-deriving `.toStringNode()?.stringValue` etc. `retained_size` is
|
|
434
|
+
// resolved via the trusted `retainedSize(id)` re-lookup (NOT the raw
|
|
435
|
+
// `.retainedSize`, which can read back ~0 inside eval on some loads — the
|
|
436
|
+
// foot-gun this whole tool's description warns about).
|
|
437
|
+
const nodeBrief = (n) => n == null
|
|
438
|
+
? null
|
|
439
|
+
: {
|
|
440
|
+
id: n.id,
|
|
441
|
+
name: n.name,
|
|
442
|
+
type: n.type,
|
|
443
|
+
self_size: n.self_size,
|
|
444
|
+
retained_size: retainedSize(n.id),
|
|
445
|
+
string: n.isString
|
|
446
|
+
? (n.toStringNode()?.stringValue ?? null)
|
|
447
|
+
: null,
|
|
448
|
+
};
|
|
449
|
+
const resolveNode = (nodeOrId) => {
|
|
450
|
+
if (nodeOrId == null)
|
|
451
|
+
return null;
|
|
452
|
+
const id = typeof nodeOrId === 'number' ? nodeOrId : nodeOrId.id;
|
|
453
|
+
return snapshot.getNodeById(id);
|
|
454
|
+
};
|
|
455
|
+
// Correctly enumerate Map/WeakMap entries and Set elements via the shared
|
|
456
|
+
// index-aware backing-store walk (handles browser `internal`-typed slots
|
|
457
|
+
// AND SMI-value gaps). Removes the #1 eval foot-gun: hand-rolling this
|
|
458
|
+
// and silently getting 0 results by filtering on `type === 'element'`.
|
|
459
|
+
const mapEntries = (id, limit = 1000) => {
|
|
460
|
+
const node = snapshot.getNodeById(id);
|
|
461
|
+
if (!node)
|
|
462
|
+
throw new Error(`mapEntries: node @${id} not found`);
|
|
463
|
+
// Guard the node type — enumerateMapEntries assumes key/value slots, so
|
|
464
|
+
// running it on a Set (element/chain layout) would emit each element as
|
|
465
|
+
// a lone key with value:null, which is silently misleading.
|
|
466
|
+
if (node.name !== 'Map' && node.name !== 'WeakMap') {
|
|
467
|
+
throw new Error(`mapEntries: @${id} is a ${node.name} (${node.type}), not a Map/WeakMap. ` +
|
|
468
|
+
`For a Set use helpers.setElements(${id}); otherwise inspect with helpers.props()/get_references.`);
|
|
469
|
+
}
|
|
470
|
+
return enumerateMapEntries(node)
|
|
471
|
+
.slice(0, limit)
|
|
472
|
+
.map(e => ({ key: nodeBrief(e.key), value: nodeBrief(e.value) }));
|
|
473
|
+
};
|
|
474
|
+
const setElements = (id, limit = 1000) => {
|
|
475
|
+
const node = snapshot.getNodeById(id);
|
|
476
|
+
if (!node)
|
|
477
|
+
throw new Error(`setElements: node @${id} not found`);
|
|
478
|
+
if (node.name !== 'Set' && node.name !== 'WeakSet') {
|
|
479
|
+
throw new Error(`setElements: @${id} is a ${node.name} (${node.type}), not a Set/WeakSet. ` +
|
|
480
|
+
`For a Map use helpers.mapEntries(${id}).`);
|
|
481
|
+
}
|
|
482
|
+
return enumerateSetElements(node).slice(0, limit).map(nodeBrief);
|
|
483
|
+
};
|
|
484
|
+
// Read an object's own properties as a plain object: scalars inlined,
|
|
485
|
+
// object-valued props as `{ref, name, type}`. Saves the repetitive
|
|
486
|
+
// `for (const e of n.references) if (e.name_or_index === X)` boilerplate.
|
|
487
|
+
const props = (nodeOrId) => {
|
|
488
|
+
const node = resolveNode(nodeOrId);
|
|
489
|
+
if (!node)
|
|
490
|
+
return {};
|
|
491
|
+
const out = {};
|
|
492
|
+
for (const e of node.references) {
|
|
493
|
+
if (e.type !== 'property')
|
|
494
|
+
continue;
|
|
495
|
+
const name = String(e.name_or_index);
|
|
496
|
+
if (name === '__proto__')
|
|
497
|
+
continue;
|
|
498
|
+
const t = e.toNode;
|
|
499
|
+
if (t.isString)
|
|
500
|
+
out[name] = t.toStringNode()?.stringValue ?? '';
|
|
501
|
+
else if (t.name === 'true')
|
|
502
|
+
out[name] = true;
|
|
503
|
+
else if (t.name === 'false')
|
|
504
|
+
out[name] = false;
|
|
505
|
+
else if (t.name === 'null')
|
|
506
|
+
out[name] = null;
|
|
507
|
+
else if (t.name === 'undefined')
|
|
508
|
+
out[name] = undefined;
|
|
509
|
+
else
|
|
510
|
+
out[name] = { ref: t.id, name: t.name, type: t.type };
|
|
511
|
+
}
|
|
512
|
+
return out;
|
|
513
|
+
};
|
|
514
|
+
const getProp = (nodeOrId, name) => props(nodeOrId)[name];
|
|
515
|
+
const shapeSignature = (nodeOrId, opts) => {
|
|
516
|
+
const node = resolveNode(nodeOrId);
|
|
517
|
+
return node ? objectContentSignature(node, opts ?? {}) : '';
|
|
518
|
+
};
|
|
519
|
+
// Index helpers — build once per snapshot, memoized in the eval scratch
|
|
520
|
+
// so a follow-up call is index-speed, not a fresh 12M-node scan. Ids are
|
|
521
|
+
// only valid for the active snapshot (the scratch is keyed to it).
|
|
522
|
+
const scratch = getEvalScratch();
|
|
523
|
+
const buildClassTypeIndex = () => {
|
|
524
|
+
const cached = scratch.__classTypeIndex;
|
|
525
|
+
if (cached)
|
|
526
|
+
return cached;
|
|
527
|
+
const byClass = new Map();
|
|
528
|
+
const byTypename = new Map();
|
|
529
|
+
snapshot.nodes.forEach((node) => {
|
|
530
|
+
if (node.id <= 3)
|
|
531
|
+
return; // skip oddball/root nodes, matching the histogram/duplicate-objects tools for count parity
|
|
532
|
+
// Index EVERY node type. Restricting this to `object` made the
|
|
533
|
+
// helper silently return [] for closures, strings, arrays and
|
|
534
|
+
// native (`blink::*`) nodes — which is most of what other tools
|
|
535
|
+
// report. Measured: byClass('setComposerLinks_$0') returned [] on a
|
|
536
|
+
// snapshot where a manual walk found 1,011 of them, because the
|
|
537
|
+
// class is a closure; the empty result reads as "does not exist".
|
|
538
|
+
// memlab_find_nodes_by_class matches any type by default and this
|
|
539
|
+
// helper is documented as its indexed equivalent, so the two must
|
|
540
|
+
// agree.
|
|
541
|
+
let a = byClass.get(node.name);
|
|
542
|
+
if (!a) {
|
|
543
|
+
a = [];
|
|
544
|
+
byClass.set(node.name, a);
|
|
545
|
+
}
|
|
546
|
+
a.push(node.id);
|
|
547
|
+
// `__typename` is a JS object property, so only object nodes can
|
|
548
|
+
// carry one; skipping the edge walk for other types keeps the
|
|
549
|
+
// widened index roughly as cheap as the object-only one.
|
|
550
|
+
if (node.type !== 'object')
|
|
551
|
+
return;
|
|
552
|
+
for (const e of node.references) {
|
|
553
|
+
if (e.type === 'property' &&
|
|
554
|
+
String(e.name_or_index) === '__typename') {
|
|
555
|
+
const t = e.toNode;
|
|
556
|
+
const tn = t.isString ? t.toStringNode()?.stringValue : null;
|
|
557
|
+
if (tn) {
|
|
558
|
+
let b = byTypename.get(tn);
|
|
559
|
+
if (!b) {
|
|
560
|
+
b = [];
|
|
561
|
+
byTypename.set(tn, b);
|
|
562
|
+
}
|
|
563
|
+
b.push(node.id);
|
|
564
|
+
}
|
|
565
|
+
break;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
});
|
|
569
|
+
const idx = { byClass, byTypename };
|
|
570
|
+
scratch.__classTypeIndex = idx;
|
|
571
|
+
return idx;
|
|
572
|
+
};
|
|
573
|
+
const byClass = (name, opts) => {
|
|
574
|
+
const ids = buildClassTypeIndex().byClass.get(name) ?? [];
|
|
575
|
+
const want = opts?.type;
|
|
576
|
+
if (want == null)
|
|
577
|
+
return ids;
|
|
578
|
+
return ids.filter(id => snapshot.getNodeById(id)?.type === want);
|
|
579
|
+
};
|
|
580
|
+
const byTypename = (name) => buildClassTypeIndex().byTypename.get(name) ?? [];
|
|
581
|
+
const withProp = (name) => {
|
|
582
|
+
const key = `__withProp:${name}`;
|
|
583
|
+
const cached = scratch[key];
|
|
584
|
+
if (cached)
|
|
585
|
+
return cached;
|
|
586
|
+
const ids = [];
|
|
587
|
+
snapshot.nodes.forEach((node) => {
|
|
588
|
+
if (node.id <= 3)
|
|
589
|
+
return; // skip oddball/root nodes for parity with other tools
|
|
590
|
+
// Every node type is scanned: the `property` edge check below is
|
|
591
|
+
// what constrains the match, and closures do carry named property
|
|
592
|
+
// edges. Restricting the walk to `object` hid them, the same way it
|
|
593
|
+
// hid non-object classes from byClass.
|
|
594
|
+
for (const e of node.references) {
|
|
595
|
+
if (e.type === 'property' && String(e.name_or_index) === name) {
|
|
596
|
+
ids.push(node.id);
|
|
597
|
+
break;
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
});
|
|
601
|
+
scratch[key] = ids;
|
|
602
|
+
return ids;
|
|
603
|
+
};
|
|
604
|
+
// Dominator-deduped retained size for a SET of ids (bounded walk). Unlike
|
|
605
|
+
// summing helpers.retainedSize over the ids, this does not double-count
|
|
606
|
+
// bytes when one id dominates another in the set.
|
|
607
|
+
const aggregateRetained = (ids) => {
|
|
608
|
+
requireRetention('aggregateRetained');
|
|
609
|
+
return boundedDominatorRetainedSize(new NumericSet(ids), snapshot);
|
|
610
|
+
};
|
|
611
|
+
// ---- additional traversal helpers -------------------------------
|
|
612
|
+
// Each of these was hand-written inside `code` during a leak hunt,
|
|
613
|
+
// several of them more than once and with small differences that made
|
|
614
|
+
// results incomparable. Shipping them makes the common traversals both
|
|
615
|
+
// cheaper to write and consistent with what the dedicated tools do.
|
|
616
|
+
// The oddball/root filtering the tools apply internally. Hand-written
|
|
617
|
+
// eval that omits it counts nodes the tools do not, so the two disagree
|
|
618
|
+
// for reasons that have nothing to do with the question being asked.
|
|
619
|
+
const isRealDetached = (node) => {
|
|
620
|
+
const n = unwrapNode(node);
|
|
621
|
+
if (n == null || n.id <= 3)
|
|
622
|
+
return false;
|
|
623
|
+
return n.is_detached || n.name.startsWith('Detached ');
|
|
624
|
+
};
|
|
625
|
+
// Cached type -> ids index, mirroring the class index above, so a
|
|
626
|
+
// second pass over "every closure" does not re-walk the heap.
|
|
627
|
+
const buildTypeIndex = () => {
|
|
628
|
+
const cached = scratch.__typeIndex;
|
|
629
|
+
if (cached)
|
|
630
|
+
return cached;
|
|
631
|
+
const byType = new Map();
|
|
632
|
+
snapshot.nodes.forEach((node) => {
|
|
633
|
+
if (node.id <= 3)
|
|
634
|
+
return;
|
|
635
|
+
let a = byType.get(node.type);
|
|
636
|
+
if (!a) {
|
|
637
|
+
a = [];
|
|
638
|
+
byType.set(node.type, a);
|
|
639
|
+
}
|
|
640
|
+
a.push(node.id);
|
|
641
|
+
});
|
|
642
|
+
scratch.__typeIndex = byType;
|
|
643
|
+
return byType;
|
|
644
|
+
};
|
|
645
|
+
const nodesFromIds = (ids) => {
|
|
646
|
+
const out = [];
|
|
647
|
+
for (const id of ids) {
|
|
648
|
+
const n = snapshot.getNodeById(id);
|
|
649
|
+
if (n)
|
|
650
|
+
out.push(n);
|
|
651
|
+
}
|
|
652
|
+
return out;
|
|
653
|
+
};
|
|
654
|
+
const iterByClass = (name, opts) => nodesFromIds(byClass(name, opts)).map(wrapNode);
|
|
655
|
+
const iterByType = (type) => nodesFromIds(buildTypeIndex().get(type) ?? []).map(wrapNode);
|
|
656
|
+
// One-pass class histogram, cached, optionally filtered. `byClass`
|
|
657
|
+
// answers "where are the X"; this answers "what is in here at all",
|
|
658
|
+
// which otherwise means a full manual walk every time.
|
|
659
|
+
const classCounts = (opts) => {
|
|
660
|
+
const cacheKey = '__classCounts';
|
|
661
|
+
let all = scratch[cacheKey];
|
|
662
|
+
if (!all) {
|
|
663
|
+
const acc = new Map();
|
|
664
|
+
snapshot.nodes.forEach((node) => {
|
|
665
|
+
if (node.id <= 3)
|
|
666
|
+
return;
|
|
667
|
+
const key = `${node.type}::${node.name}`;
|
|
668
|
+
const e = acc.get(key);
|
|
669
|
+
if (e) {
|
|
670
|
+
e.count++;
|
|
671
|
+
e.selfSize += node.self_size;
|
|
672
|
+
}
|
|
673
|
+
else {
|
|
674
|
+
acc.set(key, {
|
|
675
|
+
name: node.name,
|
|
676
|
+
type: node.type,
|
|
677
|
+
count: 1,
|
|
678
|
+
selfSize: node.self_size,
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
});
|
|
682
|
+
all = [...acc.values()].sort((a, b) => b.count - a.count);
|
|
683
|
+
scratch[cacheKey] = all;
|
|
684
|
+
}
|
|
685
|
+
const matches = makeNamePatternTest(opts?.pattern);
|
|
686
|
+
const minCount = opts?.minCount ?? 1;
|
|
687
|
+
return all.filter(r => r.count >= minCount &&
|
|
688
|
+
(opts?.type == null || r.type === opts.type) &&
|
|
689
|
+
matches(r.name));
|
|
690
|
+
};
|
|
691
|
+
// The node behind a named edge. Written from scratch in four separate
|
|
692
|
+
// evals because `props()` returns {ref,name,type} wrappers, which are
|
|
693
|
+
// awkward exactly when the node itself is what you need.
|
|
694
|
+
const edgeTarget = (nodeOrId, edgeName) => {
|
|
695
|
+
const n = typeof nodeOrId === 'number'
|
|
696
|
+
? snapshot.getNodeById(nodeOrId)
|
|
697
|
+
: unwrapNode(nodeOrId);
|
|
698
|
+
if (n == null)
|
|
699
|
+
return null;
|
|
700
|
+
for (const e of n.references) {
|
|
701
|
+
if (e.type === 'hidden')
|
|
702
|
+
continue;
|
|
703
|
+
if (String(e.name_or_index) !== edgeName)
|
|
704
|
+
continue;
|
|
705
|
+
return e.toNode.id > 3 ? wrapNode(e.toNode) : null;
|
|
706
|
+
}
|
|
707
|
+
return null;
|
|
708
|
+
};
|
|
709
|
+
// Generic container walk. `mapEntries` / `setElements` cover Map and
|
|
710
|
+
// Set; WeakMap tables and plain arrays needed a manual `references`
|
|
711
|
+
// walk with hole filtering every time.
|
|
712
|
+
const entries = (nodeOrId) => {
|
|
713
|
+
const n = typeof nodeOrId === 'number'
|
|
714
|
+
? snapshot.getNodeById(nodeOrId)
|
|
715
|
+
: unwrapNode(nodeOrId);
|
|
716
|
+
if (n == null)
|
|
717
|
+
return [];
|
|
718
|
+
if (n.name === 'Map' || n.name === 'WeakMap') {
|
|
719
|
+
return enumerateMapEntries(n).map(e => ({
|
|
720
|
+
key: wrapNode(e.key),
|
|
721
|
+
value: e.value == null ? null : wrapNode(e.value),
|
|
722
|
+
}));
|
|
723
|
+
}
|
|
724
|
+
if (n.name === 'Set' || n.name === 'WeakSet') {
|
|
725
|
+
return enumerateSetElements(n).map(el => ({
|
|
726
|
+
key: null,
|
|
727
|
+
value: wrapNode(el),
|
|
728
|
+
}));
|
|
729
|
+
}
|
|
730
|
+
const out = [];
|
|
731
|
+
for (const e of n.references) {
|
|
732
|
+
if (e.type === 'hidden')
|
|
733
|
+
continue;
|
|
734
|
+
const name = String(e.name_or_index);
|
|
735
|
+
if (name === '__proto__' || name === 'map')
|
|
736
|
+
continue;
|
|
737
|
+
if (e.type === 'element') {
|
|
738
|
+
out.push({
|
|
739
|
+
key: Number(e.name_or_index),
|
|
740
|
+
value: wrapNode(e.toNode),
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
else if (name === 'elements' && e.type === 'internal') {
|
|
744
|
+
for (const el of e.toNode.references) {
|
|
745
|
+
if (el.type !== 'element')
|
|
746
|
+
continue;
|
|
747
|
+
out.push({
|
|
748
|
+
key: Number(el.name_or_index),
|
|
749
|
+
value: wrapNode(el.toNode),
|
|
750
|
+
});
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
else if (e.type === 'property') {
|
|
754
|
+
out.push({ key: name, value: wrapNode(e.toNode) });
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
return out;
|
|
758
|
+
};
|
|
759
|
+
// What does this node actually own? The question behind
|
|
760
|
+
// memlab_dominator_attribution, exposed for ad-hoc populations.
|
|
761
|
+
const dominates = (id, opts) => {
|
|
762
|
+
requireRetention('dominates');
|
|
763
|
+
const limit = opts?.limit ?? 1000;
|
|
764
|
+
const pop = opts?.population;
|
|
765
|
+
let count = 0;
|
|
766
|
+
let selfSize = 0;
|
|
767
|
+
const ids = [];
|
|
768
|
+
let truncated = false;
|
|
769
|
+
snapshot.nodes.forEach((node) => {
|
|
770
|
+
if (node.id <= 3 || node.id === id)
|
|
771
|
+
return;
|
|
772
|
+
if (pop != null && !pop(wrapNode(node)))
|
|
773
|
+
return;
|
|
774
|
+
let cur = node.dominatorNode ?? null;
|
|
775
|
+
let hops = 0;
|
|
776
|
+
while (cur && hops++ < 500) {
|
|
777
|
+
if (cur.id === id) {
|
|
778
|
+
count++;
|
|
779
|
+
selfSize += node.self_size;
|
|
780
|
+
if (ids.length < limit)
|
|
781
|
+
ids.push(node.id);
|
|
782
|
+
else
|
|
783
|
+
truncated = true;
|
|
784
|
+
break;
|
|
785
|
+
}
|
|
786
|
+
const next = cur.dominatorNode ?? null;
|
|
787
|
+
if (!next || next.id === cur.id)
|
|
788
|
+
break;
|
|
789
|
+
cur = next;
|
|
790
|
+
}
|
|
791
|
+
});
|
|
792
|
+
return { count, selfSize, ids, truncated };
|
|
793
|
+
};
|
|
794
|
+
// Shortest reference path a -> b, by BFS over outgoing edges. Bounded,
|
|
795
|
+
// and reports that it gave up rather than returning null as if no path
|
|
796
|
+
// existed.
|
|
797
|
+
const pathBetween = (fromId, toId, opts) => {
|
|
798
|
+
const maxNodes = opts?.maxNodes ?? 200_000;
|
|
799
|
+
const start = snapshot.getNodeById(fromId);
|
|
800
|
+
if (start == null || snapshot.getNodeById(toId) == null) {
|
|
801
|
+
return { found: false, exhausted: false, path: [] };
|
|
802
|
+
}
|
|
803
|
+
const prev = new Map();
|
|
804
|
+
const seen = new Set([fromId]);
|
|
805
|
+
let queue = [start];
|
|
806
|
+
let visited = 0;
|
|
807
|
+
while (queue.length > 0) {
|
|
808
|
+
const next = [];
|
|
809
|
+
for (const node of queue) {
|
|
810
|
+
if (++visited > maxNodes) {
|
|
811
|
+
return { found: false, exhausted: true, path: [] };
|
|
812
|
+
}
|
|
813
|
+
for (const e of node.references) {
|
|
814
|
+
const t = e.toNode;
|
|
815
|
+
if (t.id <= 3 || seen.has(t.id))
|
|
816
|
+
continue;
|
|
817
|
+
seen.add(t.id);
|
|
818
|
+
prev.set(t.id, { via: String(e.name_or_index), from: node.id });
|
|
819
|
+
if (t.id === toId) {
|
|
820
|
+
const path = [];
|
|
821
|
+
let cur = toId;
|
|
822
|
+
while (cur !== fromId) {
|
|
823
|
+
const p = prev.get(cur);
|
|
824
|
+
if (p == null)
|
|
825
|
+
break;
|
|
826
|
+
const n = snapshot.getNodeById(cur);
|
|
827
|
+
path.unshift(`.${p.via} -> @${cur} ${n?.name ?? ''}`);
|
|
828
|
+
cur = p.from;
|
|
829
|
+
}
|
|
830
|
+
path.unshift(`@${fromId} ${start.name}`);
|
|
831
|
+
return { found: true, exhausted: false, path };
|
|
832
|
+
}
|
|
833
|
+
next.push(t);
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
queue = next;
|
|
837
|
+
}
|
|
838
|
+
return { found: false, exhausted: false, path: [] };
|
|
839
|
+
};
|
|
840
|
+
// Named result sets are SESSION-scoped, not snapshot-scoped: comparing
|
|
841
|
+
// a baseline scan against a final scan is the whole job, and the old
|
|
842
|
+
// per-snapshot scratch dropped the baseline the moment the next rung
|
|
843
|
+
// was loaded — exactly when it was needed.
|
|
844
|
+
//
|
|
845
|
+
// Node ids, however, are per-capture. A set of ids saved against one
|
|
846
|
+
// snapshot means nothing against another, so a cross-snapshot load is
|
|
847
|
+
// refused unless the caller opts in. Counts and strings are portable;
|
|
848
|
+
// ids are not, and silently letting them through is the failure this
|
|
849
|
+
// whole class of guard exists to prevent.
|
|
850
|
+
const save = (name, value) => {
|
|
851
|
+
setSavedResult(name, value, currentHandle);
|
|
852
|
+
return value;
|
|
853
|
+
};
|
|
854
|
+
const load = (name, opts) => {
|
|
855
|
+
const entry = getSavedResult(name);
|
|
856
|
+
if (entry == null) {
|
|
857
|
+
throw new Error(`No saved result named "${name}". Saved names: ${savedNames().join(', ') || '(none)'}. ` +
|
|
858
|
+
'Save one with the save_as parameter or helpers.save(name, value).');
|
|
859
|
+
}
|
|
860
|
+
if (entry.handle !== currentHandle &&
|
|
861
|
+
opts?.allowCrossSnapshot !== true) {
|
|
862
|
+
throw new Error(`"${name}" was saved against snapshot "${entry.handle}" and the current snapshot is "${currentHandle}". Node ids are per-capture, so ids from another snapshot resolve to unrelated objects or to nothing. ` +
|
|
863
|
+
'If the value is counts/strings/shapes rather than ids, pass {allowCrossSnapshot: true} to read it anyway.');
|
|
864
|
+
}
|
|
865
|
+
return entry.value;
|
|
866
|
+
};
|
|
867
|
+
const listSaved = () => listSavedResults();
|
|
868
|
+
// Sandbox code holds PROXIED nodes (see wrapNode). The helpers below
|
|
869
|
+
// read `retainedSize`, which the proxy refuses on purpose, so they take
|
|
870
|
+
// the real node: unwrap at the boundary rather than making every caller
|
|
871
|
+
// remember which helpers are proxy-safe.
|
|
272
872
|
const helpers = {
|
|
273
|
-
serializeNodeSummary,
|
|
274
|
-
serializeNodeDetail,
|
|
873
|
+
serializeNodeSummary: (n) => serializeNodeSummary(unwrapNode(n)),
|
|
874
|
+
serializeNodeDetail: (n) => serializeNodeDetail(unwrapNode(n)),
|
|
275
875
|
formatBytes,
|
|
276
876
|
formatNumber,
|
|
277
877
|
markdownTable,
|
|
278
|
-
isNodeWorthInspecting,
|
|
279
|
-
filterLargestObjects,
|
|
878
|
+
isNodeWorthInspecting: (n, ...rest) => isNodeWorthInspecting(unwrapNode(n), ...rest),
|
|
879
|
+
filterLargestObjects: (nodes, ...rest) => filterLargestObjects(Array.isArray(nodes) ? nodes.map(unwrapNode) : nodes, ...rest),
|
|
280
880
|
queryNodes,
|
|
281
881
|
groupReferrersByEdge,
|
|
282
882
|
groupArrayElementsByProperty,
|
|
@@ -284,9 +884,30 @@ export function registerEval(server) {
|
|
|
284
884
|
countUniqueTargets,
|
|
285
885
|
retainedSize,
|
|
286
886
|
retainedSizes,
|
|
887
|
+
nodeBrief,
|
|
888
|
+
mapEntries,
|
|
889
|
+
setElements,
|
|
890
|
+
props,
|
|
891
|
+
getProp,
|
|
892
|
+
shapeSignature,
|
|
893
|
+
byClass,
|
|
894
|
+
byTypename,
|
|
895
|
+
withProp,
|
|
896
|
+
aggregateRetained,
|
|
897
|
+
isRealDetached,
|
|
898
|
+
iterByClass,
|
|
899
|
+
iterByType,
|
|
900
|
+
classCounts,
|
|
901
|
+
edgeTarget,
|
|
902
|
+
entries,
|
|
903
|
+
dominates,
|
|
904
|
+
pathBetween,
|
|
905
|
+
save,
|
|
906
|
+
load,
|
|
907
|
+
listSaved,
|
|
287
908
|
};
|
|
288
909
|
const sandbox = {
|
|
289
|
-
snapshot: wrapSnapshot(snapshot),
|
|
910
|
+
snapshot: wrapSnapshot(snapshot, budget),
|
|
290
911
|
utils,
|
|
291
912
|
helpers,
|
|
292
913
|
console: capturedConsole,
|
|
@@ -319,16 +940,37 @@ export function registerEval(server) {
|
|
|
319
940
|
};
|
|
320
941
|
const context = vm.createContext(sandbox);
|
|
321
942
|
const script = new vm.Script(code, { filename: 'memlab_eval' });
|
|
322
|
-
|
|
943
|
+
// A budget abort is a controlled stop, not a failure: whatever the code
|
|
944
|
+
// had already assigned to `result` is still returned, annotated below.
|
|
945
|
+
try {
|
|
946
|
+
script.runInContext(context, { timeout: timeout_ms });
|
|
947
|
+
}
|
|
948
|
+
catch (err) {
|
|
949
|
+
// Keyed on the error itself, never on `budget.exceeded`: code that
|
|
950
|
+
// catches the abort and then throws for an unrelated reason must
|
|
951
|
+
// still surface that error.
|
|
952
|
+
if (!(err instanceof BudgetExceeded))
|
|
953
|
+
throw err;
|
|
954
|
+
}
|
|
323
955
|
// Actionable hint when nothing was assigned to `result` (the #1 user
|
|
324
956
|
// error — code that `return`s a value or runs a value-returning IIFE
|
|
325
957
|
// never populates `result`, so output is silently "undefined").
|
|
326
|
-
if (sandbox.result === undefined &&
|
|
958
|
+
if (sandbox.result === undefined &&
|
|
959
|
+
consoleOutput.length === 0 &&
|
|
960
|
+
!budget.exceeded) {
|
|
327
961
|
return toolResult('Your code ran without error but never assigned to `result`, so there is nothing to return.\n' +
|
|
328
962
|
'Assign the value you want back to `result` (do NOT use `return` at the top level), e.g.:\n' +
|
|
329
963
|
' `result = someValue;`\n' +
|
|
330
964
|
'Use mode:"describe_env" to see the full calling convention.');
|
|
331
965
|
}
|
|
966
|
+
// `undefined` is never worth persisting: on reload it is
|
|
967
|
+
// indistinguishable from a name that was never saved, and the usual
|
|
968
|
+
// cause is the "never assigned to `result`" mistake — which the hint
|
|
969
|
+
// above only catches when the run produced no console output.
|
|
970
|
+
const nothingToSave = sandbox.result === undefined;
|
|
971
|
+
if (save_as != null && !budget.exceeded && !nothingToSave) {
|
|
972
|
+
setSavedResult(save_as, sandbox.result, currentHandle);
|
|
973
|
+
}
|
|
332
974
|
let output;
|
|
333
975
|
try {
|
|
334
976
|
output = JSON.stringify(sandbox.result, null, 2) ?? 'undefined';
|
|
@@ -341,6 +983,25 @@ export function registerEval(server) {
|
|
|
341
983
|
const consolePart = truncate(consoleOutput.join('\n'), MAX_OUTPUT_SIZE - output.length > 1024 ? 4096 : 1024);
|
|
342
984
|
output += '\n\n--- console output ---\n' + consolePart;
|
|
343
985
|
}
|
|
986
|
+
const footer = [];
|
|
987
|
+
if (budget.exceeded) {
|
|
988
|
+
footer.push(`⚠️ Walk aborted after ${formatNumber(budget.max)} node visits (max_nodes). The value above is PARTIAL. ` +
|
|
989
|
+
'Raise max_nodes, or narrow the scan with an indexed helper (`helpers.byClass` / `byTypename` / `withProp`) instead of a full `snapshot.nodes` walk.');
|
|
990
|
+
if (save_as != null) {
|
|
991
|
+
footer.push(`Not saved as "${save_as}" — a partial result would be indistinguishable from a complete one on reload.`);
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
else if (budget.visited > 0) {
|
|
995
|
+
footer.push(`nodes_visited: ${formatNumber(budget.visited)}`);
|
|
996
|
+
}
|
|
997
|
+
if (save_as != null && !budget.exceeded) {
|
|
998
|
+
footer.push(nothingToSave
|
|
999
|
+
? `Not saved as "${save_as}" — \`result\` was undefined, and a saved \`undefined\` is indistinguishable from a name that was never saved. Assign the value you want to keep to \`result\` (do NOT \`return\` at the top level) and re-run.`
|
|
1000
|
+
: `Saved as "${save_as}" — read it back in a later call with \`helpers.load("${save_as}")\`.`);
|
|
1001
|
+
}
|
|
1002
|
+
if (footer.length > 0) {
|
|
1003
|
+
output += '\n\n--- ' + footer.join('\n');
|
|
1004
|
+
}
|
|
344
1005
|
return toolResult(output);
|
|
345
1006
|
}
|
|
346
1007
|
catch (err) {
|
|
@@ -367,6 +1028,75 @@ function actionableEvalError(err, code) {
|
|
|
367
1028
|
}
|
|
368
1029
|
return msg;
|
|
369
1030
|
}
|
|
1031
|
+
function savedNames() {
|
|
1032
|
+
return listSavedResults()
|
|
1033
|
+
.map(r => r.name)
|
|
1034
|
+
.sort();
|
|
1035
|
+
}
|
|
1036
|
+
/** One-line shape description so `list_saved` is useful without re-dumping the data. */
|
|
1037
|
+
function describeSavedValue(value) {
|
|
1038
|
+
if (value === null)
|
|
1039
|
+
return 'null';
|
|
1040
|
+
if (Array.isArray(value))
|
|
1041
|
+
return `array (${formatNumber(value.length)} items)`;
|
|
1042
|
+
if (typeof value === 'string') {
|
|
1043
|
+
return `string (${formatNumber(value.length)} chars)`;
|
|
1044
|
+
}
|
|
1045
|
+
if (typeof value === 'object') {
|
|
1046
|
+
// The sandbox is seeded with the host realm's Map/Set/Date/typed arrays, so
|
|
1047
|
+
// `instanceof` holds for values built inside eval. Without these cases every
|
|
1048
|
+
// one of them reports `object (0 keys)` — a container holding millions of
|
|
1049
|
+
// entries would look empty here.
|
|
1050
|
+
if (value instanceof Map) {
|
|
1051
|
+
return `Map (${formatNumber(value.size)} entries)`;
|
|
1052
|
+
}
|
|
1053
|
+
if (value instanceof Set) {
|
|
1054
|
+
return `Set (${formatNumber(value.size)} items)`;
|
|
1055
|
+
}
|
|
1056
|
+
if (value instanceof Date) {
|
|
1057
|
+
return `Date (${value.toISOString()})`;
|
|
1058
|
+
}
|
|
1059
|
+
if (ArrayBuffer.isView(value)) {
|
|
1060
|
+
const ctor = value.constructor?.name ?? 'TypedArray';
|
|
1061
|
+
const len = value.length;
|
|
1062
|
+
return typeof len === 'number'
|
|
1063
|
+
? `${ctor} (${formatNumber(len)} items)`
|
|
1064
|
+
: `${ctor} (${formatNumber(value.byteLength)} bytes)`;
|
|
1065
|
+
}
|
|
1066
|
+
const keys = formatNumber(Object.keys(value).length);
|
|
1067
|
+
// A named constructor (WeakMap, a class instance, …) is reported by name so
|
|
1068
|
+
// a `0 keys` line is attributable rather than just puzzling.
|
|
1069
|
+
const ctor = value.constructor?.name;
|
|
1070
|
+
return ctor == null || ctor === 'Object'
|
|
1071
|
+
? `object (${keys} keys)`
|
|
1072
|
+
: `${ctor} (${keys} own keys)`;
|
|
1073
|
+
}
|
|
1074
|
+
return typeof value;
|
|
1075
|
+
}
|
|
1076
|
+
function describeSaved() {
|
|
1077
|
+
const saved = listSavedResults();
|
|
1078
|
+
if (saved.length === 0) {
|
|
1079
|
+
return [
|
|
1080
|
+
'# Saved result sets: (none)',
|
|
1081
|
+
'',
|
|
1082
|
+
'Save one by passing `save_as: "<name>"` on a memlab_eval call, or calling `helpers.save("<name>", value)` inside your code.',
|
|
1083
|
+
'Read it back in a later call with `helpers.load("<name>")`.',
|
|
1084
|
+
'Saved sets last for the SERVER SESSION and survive loading another snapshot, so a baseline scan can be compared against a later rung. The snapshot each was saved against is recorded: reading one back under a different snapshot is refused unless you pass `{allowCrossSnapshot: true}`, because node ids are per-capture.',
|
|
1085
|
+
].join('\n');
|
|
1086
|
+
}
|
|
1087
|
+
const current = getCurrentHandle();
|
|
1088
|
+
return [
|
|
1089
|
+
`# Saved result sets (${saved.length}) — session-scoped`,
|
|
1090
|
+
'',
|
|
1091
|
+
markdownTable(['name', 'saved against', 'shape'], saved.map(r => [
|
|
1092
|
+
r.name,
|
|
1093
|
+
r.handle === current ? `${r.handle} (current)` : r.handle,
|
|
1094
|
+
describeSavedValue(getSavedResult(r.name)?.value),
|
|
1095
|
+
])),
|
|
1096
|
+
'',
|
|
1097
|
+
'Read one back with `helpers.load("<name>")`. Ids saved against another snapshot need `helpers.load("<name>", {allowCrossSnapshot: true})` — and are only meaningful if the value is counts/strings/shapes rather than node ids.',
|
|
1098
|
+
].join('\n');
|
|
1099
|
+
}
|
|
370
1100
|
function describeEnv() {
|
|
371
1101
|
return [
|
|
372
1102
|
'# memlab_eval environment',
|
|
@@ -379,14 +1109,41 @@ function describeEnv() {
|
|
|
379
1109
|
'## In-scope globals',
|
|
380
1110
|
'- `snapshot` — IHeapSnapshot: `.nodes.forEach(cb)`, `.edges.forEach(cb)`, `.getNodeById(id)`.',
|
|
381
1111
|
'- `utils` — @memlab/core utils (e.g. `aggregateDominatorMetrics`, `isFiberNode`, `isDetachedDOMNode`).',
|
|
382
|
-
'- `helpers` — `serializeNodeSummary`, `serializeNodeDetail`, `formatBytes`, `formatNumber`, `markdownTable`, `isNodeWorthInspecting`, `filterLargestObjects`, `queryNodes`, `groupReferrersByEdge(nodeId)`, `groupArrayElementsByProperty(arrayNodeId, prop)`, `isOrphaned(nodeId, ownerEdges[])`, `countUniqueTargets(arrayNodeId, prop)`, `retainedSize(id)`, `retainedSizes(ids[])`.',
|
|
1112
|
+
'- `helpers` — `serializeNodeSummary`, `serializeNodeDetail`, `formatBytes`, `formatNumber`, `markdownTable`, `isNodeWorthInspecting`, `filterLargestObjects`, `queryNodes`, `groupReferrersByEdge(nodeId)`, `groupArrayElementsByProperty(arrayNodeId, prop)`, `isOrphaned(nodeId, ownerEdges[])`, `countUniqueTargets(arrayNodeId, prop)`, `retainedSize(id) -> number`, `retainedSizes(ids[]) -> Record<id, bytes>` (an OBJECT keyed by id, NOT an array — use `sizes[id]` or `Object.values(sizes)`, not `.reduce`/`.map` directly).',
|
|
383
1113
|
'- Standard JS built-ins (Array, Object, Map, Set, JSON, Math, RegExp, …). No require/process/fs/network.',
|
|
384
1114
|
'',
|
|
1115
|
+
'## Collection / shape / index helpers (prefer these over hand-rolling)',
|
|
1116
|
+
'- `helpers.mapEntries(mapId, limit=1000) -> [{key, value}]` and `helpers.setElements(setId, limit=1000) -> [brief]` — CORRECT Map/Set/WeakMap enumeration. Handles browser `internal`-typed backing slots and SMI-value gaps (naive `type === "element"` filtering or positional `[i],[i+1]` pairing silently returns 0 / mispairs). Each brief is `{id, name, type, self_size, retained_size, string}`.',
|
|
1117
|
+
"- `helpers.props(nodeOrId) -> {prop: scalar | {ref, name, type}}` and `helpers.getProp(nodeOrId, name)` — read an object's own properties without the `for (const e of n.references) …` boilerplate. Number-valued props surface as a ref to a `smi number`/`heap number` node; their actual numeric value is not in the snapshot format.",
|
|
1118
|
+
'- `helpers.shapeSignature(nodeOrId, {maxStringLen?}) -> string` — stable shallow content signature (sorted prop names + scalar values) for duplicate-record detection. Numeric values are NOT captured (see `memlab_duplicate_objects`), so records differing only in a number field hash the same.',
|
|
1119
|
+
'- `helpers.byClass(name, {type?}) -> ids[]`, `helpers.byTypename(name) -> ids[]`, `helpers.withProp(name) -> ids[]` — INDEXED id lookups. The class/typename index is built once per snapshot and memoized in a session scratch, so a follow-up call is index-speed, not another full `snapshot.nodes` scan. `byClass` indexes EVERY node type (closure, string, array, native, …), matching `memlab_find_nodes_by_class`; pass `{type: "object"}` to narrow. `byTypename` is object-only because `__typename` is a JS property. (See also the `memlab_duplicate_objects` tool for a ready-made dedup report.)',
|
|
1120
|
+
'- `helpers.iterByClass(name, {type?}) -> node[]` / `helpers.iterByType(type) -> node[]` — indexed iteration; no full scan, index built once per snapshot.',
|
|
1121
|
+
'- `helpers.classCounts({pattern?, type?, minCount?}) -> [{name, type, count, selfSize}]` — one-pass class histogram, cached; `pattern` is a case-insensitive regex (substring fallback).',
|
|
1122
|
+
'- `helpers.entries(nodeOrId) -> [{key, value}]` — generic container walk: Map/WeakMap (paired, SMI gaps handled), Set/WeakSet, Array (both direct `element` edges and the `(object elements)` backing store), plain object properties. Holes and `__proto__`/`map` are filtered.',
|
|
1123
|
+
'- `helpers.edgeTarget(nodeOrId, edgeName) -> node | null` — the node behind a named edge, when you need the node and not the `{ref,name,type}` wrapper `props()` returns.',
|
|
1124
|
+
'- `helpers.isRealDetached(node) -> boolean` — the oddball/root filtering the detached-DOM tools apply internally, so hand-written eval counts the same set they do.',
|
|
1125
|
+
'- `helpers.dominates(id, {population?, limit?}) -> {count, selfSize, ids, truncated}` — what this node actually owns (bounded 500-hop dominator walk). `population` is a predicate over nodes.',
|
|
1126
|
+
'- `helpers.pathBetween(fromId, toId, {maxNodes?}) -> {found, exhausted, path[]}` — BFS over outgoing edges; `exhausted:true` means the budget ran out, which is NOT the same as "no path".',
|
|
1127
|
+
'- `helpers.save(name, value)` / `helpers.load(name, {allowCrossSnapshot?})` / `helpers.listSaved()` — named result sets, SESSION-scoped: they survive loading another snapshot, which is what makes a baseline-vs-final comparison possible. The snapshot each was saved against is recorded, and a cross-snapshot read is refused unless you opt in — node ids are per-capture and mean nothing in another snapshot.',
|
|
1128
|
+
'- `helpers.aggregateRetained(ids[]) -> {retained, exact}` — dominator-deduped retained size for a SET of ids (does not double-count when one id dominates another); `exact:false` means the bounded walk was truncated (upper bound).',
|
|
1129
|
+
'',
|
|
1130
|
+
'## Named result sets (multi-step exploration)',
|
|
1131
|
+
'Keep intermediate sets SERVER-SIDE instead of round-tripping them through the transcript — the ids never have to be printed, so a long investigation costs a fraction of the tokens.',
|
|
1132
|
+
'- `save_as: "<name>"` (tool parameter) — saves this call\'s `result` under that name after it completes. An `undefined` `result` is NOT saved (it would be indistinguishable from an unsaved name); the response says so.',
|
|
1133
|
+
'- `helpers.save(name, value) -> value` — save mid-script (returns the value, so it composes inline).',
|
|
1134
|
+
'- `helpers.load(name) -> value` — read a saved set back in a later call. Throws with the list of known names if it does not exist.',
|
|
1135
|
+
'- `helpers.listSaved() -> names[]`, or call the tool with `mode:"list_saved"` for names + shapes.',
|
|
1136
|
+
'Save plain data (ids, counts, strings) — NOT node objects or proxies. Sets are scoped to the current snapshot and dropped when it is unloaded, so a saved id list can never be read against the wrong snapshot.',
|
|
1137
|
+
'Typical shape: call 1 `save_as:"candidates"` builds the id list; call 2 does `const ids = helpers.load("candidates");` and measures them; call 3 traces only the survivors.',
|
|
1138
|
+
'',
|
|
1139
|
+
'## Traversal budget',
|
|
1140
|
+
'Every call reports `nodes_visited`. Pass `max_nodes` to bound a `snapshot.nodes.forEach` walk: on overrun the walk aborts and the PARTIAL `result` is returned with a warning rather than failing, so a broad exploratory scan is safe to attempt. A partial result is never saved by `save_as`.',
|
|
1141
|
+
'',
|
|
385
1142
|
'## IHeapNode API',
|
|
386
|
-
'`.id`, `.name`, `.type`, `.self_size`, `.
|
|
1143
|
+
'`.id`, `.name`, `.type`, `.self_size`, `.edge_count`, `.is_detached`, `.numOfReferrers` (alias `.referrer_count`), `.isString`, `.toStringNode()?.stringValue`, `.hasPathEdge`, `.pathEdge`, `.dominatorNode`, `.location` (`script_id`/`line`/`column`).',
|
|
387
1144
|
'',
|
|
388
|
-
'##
|
|
389
|
-
'
|
|
1145
|
+
'## `.retainedSize` THROWS here',
|
|
1146
|
+
'`node.retainedSize` / `node.retained_size` raise inside eval instead of returning a number. They have been observed reading back ~0 for every node on some loads while the same id read via `snapshot.getNodeById(id)` returns the true value — and a silently wrong number ranks a whole analysis wrongly with nothing in the output to say so. Use `helpers.retainedSize(id)` (number), `helpers.retainedSizes([ids])` (a `Record<id, bytes>` OBJECT — index it as `sizes[id]` or iterate `Object.values(sizes)`, do not `.map`/`.reduce` it directly), or `helpers.aggregateRetained([ids])` for a dominator-deduped total. `node.self_size` is read straight from the snapshot and is reliable.',
|
|
390
1147
|
'',
|
|
391
1148
|
'## IHeapEdge API',
|
|
392
1149
|
'`.name_or_index`, `.type` (property/element/context/internal/hidden/shortcut), `.toNode`, `.fromNode`.',
|