@memlab/mcp-server 2.52.0 → 2.86.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 +22 -2
- package/dist/anonymized-snapshot.d.ts +75 -0
- package/dist/anonymized-snapshot.d.ts.map +1 -0
- package/dist/anonymized-snapshot.js +101 -0
- package/dist/anonymized-snapshot.js.map +1 -0
- package/dist/eval-hints.d.ts +41 -0
- package/dist/eval-hints.d.ts.map +1 -0
- package/dist/eval-hints.js +115 -0
- package/dist/eval-hints.js.map +1 -0
- package/dist/heap-budget.d.ts +32 -0
- package/dist/heap-budget.d.ts.map +1 -0
- package/dist/heap-budget.js +90 -0
- package/dist/heap-budget.js.map +1 -0
- package/dist/heap-shapes.d.ts +148 -0
- package/dist/heap-shapes.d.ts.map +1 -0
- package/dist/heap-shapes.js +193 -0
- package/dist/heap-shapes.js.map +1 -0
- package/dist/heap-state.d.ts +20 -0
- package/dist/heap-state.d.ts.map +1 -1
- package/dist/heap-state.js +8 -1
- package/dist/heap-state.js.map +1 -1
- package/dist/index.js +64 -1
- package/dist/index.js.map +1 -1
- package/dist/react-shapes.d.ts +42 -0
- package/dist/react-shapes.d.ts.map +1 -0
- package/dist/react-shapes.js +149 -0
- package/dist/react-shapes.js.map +1 -0
- package/dist/server-version.d.ts +19 -0
- package/dist/server-version.d.ts.map +1 -0
- package/dist/server-version.js +19 -0
- package/dist/server-version.js.map +1 -0
- package/dist/snapshot-borrow.d.ts +67 -0
- package/dist/snapshot-borrow.d.ts.map +1 -0
- package/dist/snapshot-borrow.js +113 -0
- package/dist/snapshot-borrow.js.map +1 -0
- package/dist/string-index.d.ts.map +1 -1
- package/dist/string-index.js +8 -0
- package/dist/string-index.js.map +1 -1
- package/dist/tools/analyze-run.d.ts.map +1 -1
- package/dist/tools/analyze-run.js +79 -18
- package/dist/tools/analyze-run.js.map +1 -1
- package/dist/tools/anonymize-snapshot.d.ts +12 -0
- package/dist/tools/anonymize-snapshot.d.ts.map +1 -0
- package/dist/tools/anonymize-snapshot.js +159 -0
- package/dist/tools/anonymize-snapshot.js.map +1 -0
- package/dist/tools/app-config.d.ts +12 -0
- package/dist/tools/app-config.d.ts.map +1 -0
- package/dist/tools/app-config.js +217 -0
- package/dist/tools/app-config.js.map +1 -0
- package/dist/tools/app-heap.d.ts +18 -0
- package/dist/tools/app-heap.d.ts.map +1 -1
- package/dist/tools/app-heap.js +12 -46
- package/dist/tools/app-heap.js.map +1 -1
- package/dist/tools/artifact-budget.d.ts +12 -0
- package/dist/tools/artifact-budget.d.ts.map +1 -0
- package/dist/tools/artifact-budget.js +219 -0
- package/dist/tools/artifact-budget.js.map +1 -0
- package/dist/tools/async-census.d.ts +12 -0
- package/dist/tools/async-census.d.ts.map +1 -0
- package/dist/tools/async-census.js +243 -0
- package/dist/tools/async-census.js.map +1 -0
- package/dist/tools/auto-investigate.d.ts.map +1 -1
- package/dist/tools/auto-investigate.js +54 -5
- package/dist/tools/auto-investigate.js.map +1 -1
- package/dist/tools/batch.d.ts.map +1 -1
- package/dist/tools/batch.js +33 -5
- package/dist/tools/batch.js.map +1 -1
- package/dist/tools/cache-analysis.d.ts +15 -0
- package/dist/tools/cache-analysis.d.ts.map +1 -1
- package/dist/tools/cache-analysis.js +85 -10
- package/dist/tools/cache-analysis.js.map +1 -1
- package/dist/tools/census-diff.d.ts +55 -0
- package/dist/tools/census-diff.d.ts.map +1 -0
- package/dist/tools/census-diff.js +228 -0
- package/dist/tools/census-diff.js.map +1 -0
- package/dist/tools/chain-walk.d.ts +12 -0
- package/dist/tools/chain-walk.d.ts.map +1 -0
- package/dist/tools/chain-walk.js +184 -0
- package/dist/tools/chain-walk.js.map +1 -0
- package/dist/tools/collection-diff.d.ts +44 -0
- package/dist/tools/collection-diff.d.ts.map +1 -0
- package/dist/tools/collection-diff.js +343 -0
- package/dist/tools/collection-diff.js.map +1 -0
- package/dist/tools/collection-trend.d.ts.map +1 -1
- package/dist/tools/collection-trend.js +2 -2
- package/dist/tools/collection-trend.js.map +1 -1
- package/dist/tools/detached-dom.d.ts +3 -0
- package/dist/tools/detached-dom.d.ts.map +1 -1
- package/dist/tools/detached-dom.js +78 -3
- package/dist/tools/detached-dom.js.map +1 -1
- package/dist/tools/dev-artifacts.d.ts.map +1 -1
- package/dist/tools/dev-artifacts.js +221 -3
- 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 +18 -2
- package/dist/tools/diff-snapshots.js.map +1 -1
- package/dist/tools/dom-audit.d.ts +37 -0
- package/dist/tools/dom-audit.d.ts.map +1 -0
- package/dist/tools/dom-audit.js +247 -0
- package/dist/tools/dom-audit.js.map +1 -0
- package/dist/tools/duplicated-strings.d.ts.map +1 -1
- package/dist/tools/duplicated-strings.js +3 -2
- package/dist/tools/duplicated-strings.js.map +1 -1
- package/dist/tools/eval-across.d.ts +12 -0
- package/dist/tools/eval-across.d.ts.map +1 -0
- package/dist/tools/eval-across.js +135 -0
- package/dist/tools/eval-across.js.map +1 -0
- package/dist/tools/eval.d.ts +78 -0
- package/dist/tools/eval.d.ts.map +1 -1
- package/dist/tools/eval.js +2270 -701
- package/dist/tools/eval.js.map +1 -1
- package/dist/tools/event-listener-leaks.d.ts.map +1 -1
- package/dist/tools/event-listener-leaks.js +42 -17
- package/dist/tools/event-listener-leaks.js.map +1 -1
- package/dist/tools/event-registry.d.ts.map +1 -1
- package/dist/tools/event-registry.js +42 -8
- package/dist/tools/event-registry.js.map +1 -1
- package/dist/tools/explain-delta.d.ts.map +1 -1
- package/dist/tools/explain-delta.js +61 -10
- package/dist/tools/explain-delta.js.map +1 -1
- package/dist/tools/finding-index.d.ts +181 -0
- package/dist/tools/finding-index.d.ts.map +1 -1
- package/dist/tools/finding-index.js +343 -24
- package/dist/tools/finding-index.js.map +1 -1
- package/dist/tools/get-value.d.ts +16 -0
- package/dist/tools/get-value.d.ts.map +1 -1
- package/dist/tools/get-value.js +96 -2
- package/dist/tools/get-value.js.map +1 -1
- package/dist/tools/hypothesis.d.ts.map +1 -1
- package/dist/tools/hypothesis.js +117 -58
- package/dist/tools/hypothesis.js.map +1 -1
- package/dist/tools/id-space-audit.d.ts +12 -0
- package/dist/tools/id-space-audit.d.ts.map +1 -0
- package/dist/tools/id-space-audit.js +262 -0
- package/dist/tools/id-space-audit.js.map +1 -0
- package/dist/tools/identify.d.ts +12 -0
- package/dist/tools/identify.d.ts.map +1 -0
- package/dist/tools/identify.js +205 -0
- package/dist/tools/identify.js.map +1 -0
- package/dist/tools/identity-diff.d.ts +12 -0
- package/dist/tools/identity-diff.d.ts.map +1 -0
- package/dist/tools/identity-diff.js +188 -0
- package/dist/tools/identity-diff.js.map +1 -0
- package/dist/tools/intern-opportunities.d.ts.map +1 -1
- package/dist/tools/intern-opportunities.js +4 -3
- package/dist/tools/intern-opportunities.js.map +1 -1
- package/dist/tools/ladder-probe.d.ts +65 -0
- package/dist/tools/ladder-probe.d.ts.map +1 -0
- package/dist/tools/ladder-probe.js +445 -0
- package/dist/tools/ladder-probe.js.map +1 -0
- package/dist/tools/ladder.d.ts.map +1 -1
- package/dist/tools/ladder.js +5 -5
- package/dist/tools/ladder.js.map +1 -1
- package/dist/tools/leak-report.d.ts +23 -0
- package/dist/tools/leak-report.d.ts.map +1 -1
- package/dist/tools/leak-report.js +153 -11
- package/dist/tools/leak-report.js.map +1 -1
- package/dist/tools/load-snapshot.d.ts +11 -0
- package/dist/tools/load-snapshot.d.ts.map +1 -1
- package/dist/tools/load-snapshot.js +83 -10
- package/dist/tools/load-snapshot.js.map +1 -1
- package/dist/tools/metric.d.ts +13 -0
- package/dist/tools/metric.d.ts.map +1 -0
- package/dist/tools/metric.js +185 -0
- package/dist/tools/metric.js.map +1 -0
- package/dist/tools/module-attribution.d.ts +36 -0
- package/dist/tools/module-attribution.d.ts.map +1 -0
- package/dist/tools/module-attribution.js +235 -0
- package/dist/tools/module-attribution.js.map +1 -0
- package/dist/tools/next-measurement.d.ts +26 -0
- package/dist/tools/next-measurement.d.ts.map +1 -0
- package/dist/tools/next-measurement.js +149 -0
- package/dist/tools/next-measurement.js.map +1 -0
- package/dist/tools/object-cost-breakdown.d.ts.map +1 -1
- package/dist/tools/object-cost-breakdown.js +64 -0
- package/dist/tools/object-cost-breakdown.js.map +1 -1
- package/dist/tools/population-vs-owners.d.ts +12 -0
- package/dist/tools/population-vs-owners.d.ts.map +1 -0
- package/dist/tools/population-vs-owners.js +183 -0
- package/dist/tools/population-vs-owners.js.map +1 -0
- package/dist/tools/property-names.d.ts +29 -0
- package/dist/tools/property-names.d.ts.map +1 -0
- package/dist/tools/property-names.js +131 -0
- package/dist/tools/property-names.js.map +1 -0
- package/dist/tools/quick-diagnosis.d.ts.map +1 -1
- package/dist/tools/quick-diagnosis.js +7 -1
- package/dist/tools/quick-diagnosis.js.map +1 -1
- package/dist/tools/react-owners.d.ts +12 -0
- package/dist/tools/react-owners.d.ts.map +1 -0
- package/dist/tools/react-owners.js +167 -0
- package/dist/tools/react-owners.js.map +1 -0
- package/dist/tools/react-update-queues.d.ts +12 -0
- package/dist/tools/react-update-queues.d.ts.map +1 -0
- package/dist/tools/react-update-queues.js +173 -0
- package/dist/tools/react-update-queues.js.map +1 -0
- package/dist/tools/replicate.d.ts +49 -0
- package/dist/tools/replicate.d.ts.map +1 -0
- package/dist/tools/replicate.js +241 -0
- package/dist/tools/replicate.js.map +1 -0
- package/dist/tools/retainer-diff.d.ts +12 -0
- package/dist/tools/retainer-diff.d.ts.map +1 -0
- package/dist/tools/retainer-diff.js +168 -0
- package/dist/tools/retainer-diff.js.map +1 -0
- package/dist/tools/retainer-summary.d.ts +17 -0
- package/dist/tools/retainer-summary.d.ts.map +1 -1
- package/dist/tools/retainer-summary.js +85 -22
- 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 +11 -1
- package/dist/tools/retainer-trace.js.map +1 -1
- package/dist/tools/round-audit.d.ts +66 -0
- package/dist/tools/round-audit.d.ts.map +1 -0
- package/dist/tools/round-audit.js +258 -0
- package/dist/tools/round-audit.js.map +1 -0
- package/dist/tools/round-digest.d.ts +12 -0
- package/dist/tools/round-digest.d.ts.map +1 -0
- package/dist/tools/round-digest.js +190 -0
- package/dist/tools/round-digest.js.map +1 -0
- package/dist/tools/script-census.d.ts +12 -0
- package/dist/tools/script-census.d.ts.map +1 -0
- package/dist/tools/script-census.js +182 -0
- package/dist/tools/script-census.js.map +1 -0
- package/dist/tools/sequence-analysis.d.ts +4 -0
- package/dist/tools/sequence-analysis.d.ts.map +1 -1
- package/dist/tools/sequence-analysis.js +53 -9
- package/dist/tools/sequence-analysis.js.map +1 -1
- package/dist/tools/server-status.d.ts.map +1 -1
- package/dist/tools/server-status.js +4 -1
- package/dist/tools/server-status.js.map +1 -1
- package/dist/tools/settle-check.d.ts +12 -0
- package/dist/tools/settle-check.d.ts.map +1 -0
- package/dist/tools/settle-check.js +188 -0
- package/dist/tools/settle-check.js.map +1 -0
- package/dist/tools/sliced-strings.d.ts.map +1 -1
- package/dist/tools/sliced-strings.js +2 -1
- package/dist/tools/sliced-strings.js.map +1 -1
- package/dist/tools/snapshots.d.ts.map +1 -1
- package/dist/tools/snapshots.js +24 -6
- package/dist/tools/snapshots.js.map +1 -1
- package/dist/tools/sparse-elements.d.ts +12 -0
- package/dist/tools/sparse-elements.d.ts.map +1 -0
- package/dist/tools/sparse-elements.js +215 -0
- package/dist/tools/sparse-elements.js.map +1 -0
- package/dist/tools/stale-collections.d.ts +17 -0
- package/dist/tools/stale-collections.d.ts.map +1 -1
- package/dist/tools/stale-collections.js +28 -0
- package/dist/tools/stale-collections.js.map +1 -1
- package/dist/tools/string-patterns.d.ts.map +1 -1
- package/dist/tools/string-patterns.js +2 -1
- package/dist/tools/string-patterns.js.map +1 -1
- package/dist/tools/tools-index.d.ts.map +1 -1
- package/dist/tools/tools-index.js +155 -5
- package/dist/tools/tools-index.js.map +1 -1
- package/dist/tools/trace-all.d.ts +42 -0
- package/dist/tools/trace-all.d.ts.map +1 -0
- package/dist/tools/trace-all.js +263 -0
- package/dist/tools/trace-all.js.map +1 -0
- package/dist/tools/unit-cost.d.ts +12 -0
- package/dist/tools/unit-cost.d.ts.map +1 -0
- package/dist/tools/unit-cost.js +219 -0
- package/dist/tools/unit-cost.js.map +1 -0
- package/dist/tools/verify-fix.d.ts.map +1 -1
- package/dist/tools/verify-fix.js +11 -3
- package/dist/tools/verify-fix.js.map +1 -1
- package/dist/tools/weakref-census.d.ts +15 -0
- package/dist/tools/weakref-census.d.ts.map +1 -0
- package/dist/tools/weakref-census.js +147 -0
- package/dist/tools/weakref-census.js.map +1 -0
- package/dist/tools/what-if.d.ts +12 -0
- package/dist/tools/what-if.d.ts.map +1 -0
- package/dist/tools/what-if.js +285 -0
- package/dist/tools/what-if.js.map +1 -0
- package/dist/utils.d.ts +92 -2
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +172 -5
- package/dist/utils.js.map +1 -1
- package/package.json +3 -3
package/dist/tools/eval.js
CHANGED
|
@@ -8,18 +8,156 @@
|
|
|
8
8
|
* @oncall memory_lab
|
|
9
9
|
*/
|
|
10
10
|
import { z } from 'zod';
|
|
11
|
+
import fs from 'fs';
|
|
12
|
+
import os from 'os';
|
|
13
|
+
import path from 'path';
|
|
11
14
|
import vm from 'node:vm';
|
|
12
15
|
import memlabCore from '@memlab/core';
|
|
13
16
|
const { utils, NumericSet } = memlabCore;
|
|
17
|
+
import { nearestFiber, fiberComponentName } from '../react-shapes.js';
|
|
18
|
+
import { readElements as readElementsInfo } from '../heap-shapes.js';
|
|
14
19
|
import { getCurrentHandle, getSavedResult, getSnapshot, isLightSnapshot, listSavedResults, setSavedResult, getEvalScratch, getSnapshotMetadata, } from '../heap-state.js';
|
|
15
|
-
import {
|
|
20
|
+
import { beginAnalysisBudget } from '../analysis-budget.js';
|
|
21
|
+
import { formatEvalHints, hintsForEval } from '../eval-hints.js';
|
|
22
|
+
import { abbreviateBlinkTypeName, errorResult, toolResult, serializeNodeSummary, serializeNodeDetail, formatBytes, formatNumber, markdownTable, isNodeWorthInspecting, makeNamePatternTest, filterLargestObjects, queryNodes, enumerateMapEntries, enumerateSetElements, objectContentSignature, boundedDominatorRetainedSize, } from '../utils.js';
|
|
16
23
|
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.
|
|
19
24
|
function truncate(str, max) {
|
|
20
25
|
if (str.length <= max)
|
|
21
26
|
return str;
|
|
22
|
-
return str.slice(0, max) +
|
|
27
|
+
return (str.slice(0, max) +
|
|
28
|
+
`\n... [truncated, output exceeded ${Math.round(max / 1024)}KB]`);
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Wall-clock budget for one eval, scaled from the loaded graph.
|
|
32
|
+
*
|
|
33
|
+
* A fixed 60 s default is right for an indexed lookup and wrong for everything
|
|
34
|
+
* else: a full-heap walk on a 6M-node graph takes two to four minutes, so every
|
|
35
|
+
* large-snapshot eval aborted on its first attempt and had to be re-issued with
|
|
36
|
+
* an explicit `timeout_ms`. That is a guaranteed wasted call per question, and
|
|
37
|
+
* the node count needed to avoid it is already known.
|
|
38
|
+
*
|
|
39
|
+
* The floor stays at 60 s so small heaps behave exactly as before.
|
|
40
|
+
*/
|
|
41
|
+
export function scaledEvalTimeoutMs() {
|
|
42
|
+
const nodes = getSnapshotMetadata()?.nodeCount ?? 0;
|
|
43
|
+
return Math.max(60000, Math.ceil((nodes / 40000) * 1000));
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Default `max_nodes`, scaled to the loaded graph.
|
|
47
|
+
*
|
|
48
|
+
* A probe that makes several passes over the heap is the normal shape — one
|
|
49
|
+
* pass to census classes, one to match shapes, one to size things — so the
|
|
50
|
+
* budget has to be a MULTIPLE of the node count, not a constant. At the old
|
|
51
|
+
* flat 20,000,000 a routine two-pass probe on an 8.24M-node capture aborted
|
|
52
|
+
* mid-walk and returned a partial value; because the result was assigned after
|
|
53
|
+
* the loops, what came back was `undefined` with a note, which reads as a
|
|
54
|
+
* legitimate empty answer.
|
|
55
|
+
*
|
|
56
|
+
* Six passes is generous on purpose: the wall-clock timeout above is the real
|
|
57
|
+
* guard against a runaway eval, and this budget exists to stop pathological
|
|
58
|
+
* traversals, not ordinary multi-pass analysis.
|
|
59
|
+
*/
|
|
60
|
+
export function scaledWalkBudget() {
|
|
61
|
+
const nodes = getSnapshotMetadata()?.nodeCount ?? 0;
|
|
62
|
+
return Math.max(20000000, nodes * 6);
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Shorten a heap result STRUCTURALLY rather than mid-string.
|
|
66
|
+
*
|
|
67
|
+
* A census result is an array of rows or a `{key: count}` map, and cutting the
|
|
68
|
+
* JSON at a byte offset yields unparseable output plus a re-run with a
|
|
69
|
+
* hand-written limit — which is what actually happened to a detached-class
|
|
70
|
+
* census whose `blink::HeapVectorBacking<…>` keys blew the cap. Dropping whole
|
|
71
|
+
* entries keeps the value valid JSON and keeps the largest rows, which are the
|
|
72
|
+
* ones the question was about.
|
|
73
|
+
*
|
|
74
|
+
* Blink's C++ template names are elided first: they are frequently most of the
|
|
75
|
+
* payload and none of the information, and `abbreviateBlinkTypeName` is the same
|
|
76
|
+
* elision the table renderers already use.
|
|
77
|
+
*/
|
|
78
|
+
export function shrinkResult(value, maxBytes) {
|
|
79
|
+
const size = (v) => {
|
|
80
|
+
try {
|
|
81
|
+
return JSON.stringify(v)?.length ?? 0;
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return String(v).length;
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
if (size(value) <= maxBytes) {
|
|
88
|
+
return { value, truncated: false, droppedEntries: 0, keptEntries: -1 };
|
|
89
|
+
}
|
|
90
|
+
// Abbreviate VALUES freely, but keys only where it cannot lose data.
|
|
91
|
+
//
|
|
92
|
+
// `abbreviateBlinkTypeName` collapses everything between the first `<` and
|
|
93
|
+
// the last `>`, so `blink::HeapVectorBacking<Foo>` and
|
|
94
|
+
// `blink::HeapVectorBacking<Bar>` abbreviate to the SAME string. On a census
|
|
95
|
+
// map — `{className: count}`, the exact shape this exists to shrink — the
|
|
96
|
+
// second entry would overwrite the first and the count would silently
|
|
97
|
+
// disappear before any trimming happened. So a key is only shortened when the
|
|
98
|
+
// shortened form is still unique within its object; otherwise the full key is
|
|
99
|
+
// kept, because a longer result is recoverable and a wrong one is not.
|
|
100
|
+
const abbreviate = (v) => {
|
|
101
|
+
if (typeof v === 'string')
|
|
102
|
+
return abbreviateBlinkTypeName(v);
|
|
103
|
+
if (Array.isArray(v))
|
|
104
|
+
return v.map(abbreviate);
|
|
105
|
+
if (v != null && typeof v === 'object') {
|
|
106
|
+
const entries = Object.entries(v);
|
|
107
|
+
const shortened = entries.map(([k]) => abbreviateBlinkTypeName(k));
|
|
108
|
+
const collides = new Set(shortened).size !== shortened.length;
|
|
109
|
+
const out = {};
|
|
110
|
+
entries.forEach(([k, val], i) => {
|
|
111
|
+
out[collides ? k : shortened[i]] = abbreviate(val);
|
|
112
|
+
});
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
return v;
|
|
116
|
+
};
|
|
117
|
+
let shrunk = abbreviate(value);
|
|
118
|
+
if (size(shrunk) <= maxBytes) {
|
|
119
|
+
return {
|
|
120
|
+
value: shrunk,
|
|
121
|
+
truncated: false,
|
|
122
|
+
droppedEntries: 0,
|
|
123
|
+
keptEntries: -1,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
// Binary-search the entry count that fits, so a 40k-row result does not cost
|
|
127
|
+
// 40k serializations to trim.
|
|
128
|
+
const entriesOf = (v) => {
|
|
129
|
+
if (Array.isArray(v)) {
|
|
130
|
+
return { length: v.length, take: n => v.slice(0, n) };
|
|
131
|
+
}
|
|
132
|
+
if (v != null && typeof v === 'object') {
|
|
133
|
+
const pairs = Object.entries(v);
|
|
134
|
+
return {
|
|
135
|
+
length: pairs.length,
|
|
136
|
+
take: n => Object.fromEntries(pairs.slice(0, n)),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
return null;
|
|
140
|
+
};
|
|
141
|
+
const entries = entriesOf(shrunk);
|
|
142
|
+
if (entries == null || entries.length === 0) {
|
|
143
|
+
return { value: shrunk, truncated: true, droppedEntries: 0, keptEntries: -1 };
|
|
144
|
+
}
|
|
145
|
+
let lo = 0;
|
|
146
|
+
let hi = entries.length;
|
|
147
|
+
while (lo < hi) {
|
|
148
|
+
const mid = Math.ceil((lo + hi) / 2);
|
|
149
|
+
if (size(entries.take(mid)) <= maxBytes)
|
|
150
|
+
lo = mid;
|
|
151
|
+
else
|
|
152
|
+
hi = mid - 1;
|
|
153
|
+
}
|
|
154
|
+
shrunk = entries.take(lo);
|
|
155
|
+
return {
|
|
156
|
+
value: shrunk,
|
|
157
|
+
truncated: true,
|
|
158
|
+
droppedEntries: entries.length - lo,
|
|
159
|
+
keptEntries: lo,
|
|
160
|
+
};
|
|
23
161
|
}
|
|
24
162
|
class BudgetExceeded extends Error {
|
|
25
163
|
constructor(max) {
|
|
@@ -45,9 +183,6 @@ export function unwrapNode(node) {
|
|
|
45
183
|
}
|
|
46
184
|
return node;
|
|
47
185
|
}
|
|
48
|
-
function unwrapNodes(nodes) {
|
|
49
|
-
return Array.isArray(nodes) ? nodes.map(unwrapNode) : nodes;
|
|
50
|
-
}
|
|
51
186
|
/**
|
|
52
187
|
* `node.retainedSize` read off a node inside eval has been observed to come
|
|
53
188
|
* back ~0 for every node on some loads, while the same id read through
|
|
@@ -126,6 +261,11 @@ function wrapEdgeIterable(iterable) {
|
|
|
126
261
|
function wrapSnapshot(snapshot, budget) {
|
|
127
262
|
return new Proxy(snapshot, {
|
|
128
263
|
get(target, prop, receiver) {
|
|
264
|
+
// Same escape hatch the node proxy carries, for the same reason: helpers
|
|
265
|
+
// that take a whole snapshot walk it with the real `forEach` and read
|
|
266
|
+
// `retainedSize` off the nodes it yields, which this proxy refuses.
|
|
267
|
+
if (prop === RAW_NODE)
|
|
268
|
+
return target;
|
|
129
269
|
if (prop === 'getNodeById') {
|
|
130
270
|
const orig = target.getNodeById.bind(target);
|
|
131
271
|
return (id) => wrapNode(orig(id));
|
|
@@ -137,7 +277,25 @@ function wrapSnapshot(snapshot, budget) {
|
|
|
137
277
|
if (nodesProp === 'forEach') {
|
|
138
278
|
const origForEach = nodesTarget.forEach.bind(nodesTarget);
|
|
139
279
|
return (cb) => {
|
|
280
|
+
const stride = budget.sampleEvery ?? 1;
|
|
281
|
+
const only = budget.restrictTo;
|
|
140
282
|
origForEach((node) => {
|
|
283
|
+
// `restrict_to_ids` promotes a cheap triage pass to an exact
|
|
284
|
+
// one without re-walking the graph blind: the same code runs,
|
|
285
|
+
// but only over the candidates the triage pass surfaced.
|
|
286
|
+
// Filtering here rather than in user code means the budget and
|
|
287
|
+
// the reported `nodes_visited` describe the real work.
|
|
288
|
+
if (only != null &&
|
|
289
|
+
!only.has(node.id ?? -1)) {
|
|
290
|
+
return undefined;
|
|
291
|
+
}
|
|
292
|
+
// Sampling is a STRIDE, not a random draw: two calls over the
|
|
293
|
+
// same snapshot visit the same nodes, so a follow-up question
|
|
294
|
+
// lands on the objects the first answer described.
|
|
295
|
+
const idx = budget.seen ?? 0;
|
|
296
|
+
budget.seen = idx + 1;
|
|
297
|
+
if (stride > 1 && idx % stride !== 0)
|
|
298
|
+
return undefined;
|
|
141
299
|
if (++budget.visited > budget.max) {
|
|
142
300
|
budget.exceeded = true;
|
|
143
301
|
throw new BudgetExceeded(budget.max);
|
|
@@ -173,6 +331,244 @@ const RETENTION_IDENTIFIERS = [
|
|
|
173
331
|
'pathEdge',
|
|
174
332
|
'filterLargestObjects',
|
|
175
333
|
];
|
|
334
|
+
/**
|
|
335
|
+
* Words that make a bare `size` mean BYTES rather than a count of things.
|
|
336
|
+
*
|
|
337
|
+
* `size` on its own is ambiguous and mostly is not bytes: `sample_size`,
|
|
338
|
+
* `arraySize`, `queueSize`, `mapSize` and `chainSize` all count items, and any
|
|
339
|
+
* of them can legitimately exceed the heap's byte total on a small snapshot —
|
|
340
|
+
* which would publish a confident "exceeds the whole snapshot" warning about a
|
|
341
|
+
* field that was never measured in bytes. The warning below is only ever a
|
|
342
|
+
* warning, so missing one is cheap and crying wolf is not; `size` therefore
|
|
343
|
+
* needs corroboration from elsewhere in the key.
|
|
344
|
+
*/
|
|
345
|
+
const BYTE_CONTEXT_WORDS = new Set([
|
|
346
|
+
'alloc',
|
|
347
|
+
'allocated',
|
|
348
|
+
'byte',
|
|
349
|
+
'bytes',
|
|
350
|
+
'footprint',
|
|
351
|
+
'heap',
|
|
352
|
+
'mem',
|
|
353
|
+
'memory',
|
|
354
|
+
'retained',
|
|
355
|
+
'self',
|
|
356
|
+
'store',
|
|
357
|
+
]);
|
|
358
|
+
/**
|
|
359
|
+
* Scale factor for a key that names a byte quantity, or null if it names
|
|
360
|
+
* something else.
|
|
361
|
+
*
|
|
362
|
+
* The unit is read from the key's last WORD rather than from a suffix match.
|
|
363
|
+
* `mb`, `kb` and `gb` are two letters that also end ordinary English words, so
|
|
364
|
+
* a suffix test rescales `numb`, `dumb`, `thumb` and `climb` by 1024^n — enough
|
|
365
|
+
* to trip the implausibility warning below on a field holding no bytes at all.
|
|
366
|
+
* Splitting on `_` and camelCase boundaries makes `heap_mb` and `heapMB` units
|
|
367
|
+
* while leaving `dumb` a word.
|
|
368
|
+
*/
|
|
369
|
+
function byteUnitScale(key) {
|
|
370
|
+
const words = key
|
|
371
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
372
|
+
.split(/[^A-Za-z0-9]+/)
|
|
373
|
+
.filter(Boolean)
|
|
374
|
+
.map(w => w.toLowerCase());
|
|
375
|
+
switch (words[words.length - 1]) {
|
|
376
|
+
case 'gb':
|
|
377
|
+
return 1024 ** 3;
|
|
378
|
+
case 'mb':
|
|
379
|
+
return 1024 ** 2;
|
|
380
|
+
case 'kb':
|
|
381
|
+
return 1024;
|
|
382
|
+
case 'byte':
|
|
383
|
+
case 'bytes':
|
|
384
|
+
return 1;
|
|
385
|
+
case 'size':
|
|
386
|
+
// `retainedSize` and `self_size` are the fields this guard was written
|
|
387
|
+
// for; `arraySize` is not. See BYTE_CONTEXT_WORDS.
|
|
388
|
+
return words.some(w => BYTE_CONTEXT_WORDS.has(w)) ? 1 : null;
|
|
389
|
+
default:
|
|
390
|
+
return null;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* Flag a numeric field that claims more bytes than the whole heap contains.
|
|
395
|
+
*
|
|
396
|
+
* A total larger than the snapshot is not a rounding error, it is a
|
|
397
|
+
* double-count, and it is the easiest way for an eval to publish a confident
|
|
398
|
+
* wrong number. Measured case: a verification walk over 820 Map backing tables
|
|
399
|
+
* reported 7,005 MB of backing stores in a 425 MB heap — a Map's `table` yields
|
|
400
|
+
* KEYS as well as values, and the key objects were members of every one of the
|
|
401
|
+
* 820 tables, so each store was counted a few hundred times. It was caught only
|
|
402
|
+
* because 7 GB in a 425 MB heap is absurd; a 1.4x error would have shipped.
|
|
403
|
+
*
|
|
404
|
+
* A warning, never an error: summing `retainedSize` over an arbitrary set
|
|
405
|
+
* legitimately exceeds the heap (subtrees overlap), which is a real thing to
|
|
406
|
+
* measure — `helpers.aggregateRetained` exists for the deduplicated version.
|
|
407
|
+
*/
|
|
408
|
+
function implausibleByteFields(value, heapBytes) {
|
|
409
|
+
if (heapBytes <= 0)
|
|
410
|
+
return [];
|
|
411
|
+
const hits = [];
|
|
412
|
+
const seen = new Set();
|
|
413
|
+
const visit = (node, path, depth) => {
|
|
414
|
+
if (hits.length >= 4 || depth > 6 || node == null)
|
|
415
|
+
return;
|
|
416
|
+
if (typeof node === 'object') {
|
|
417
|
+
if (seen.has(node))
|
|
418
|
+
return;
|
|
419
|
+
seen.add(node);
|
|
420
|
+
if (Array.isArray(node)) {
|
|
421
|
+
// Only a bounded prefix: a 10k-row census would otherwise be walked in
|
|
422
|
+
// full to warn about at most four fields.
|
|
423
|
+
for (let i = 0; i < Math.min(node.length, 200); i++) {
|
|
424
|
+
visit(node[i], `${path}[${i}]`, depth + 1);
|
|
425
|
+
}
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
for (const [k, v] of Object.entries(node)) {
|
|
429
|
+
if (typeof v === 'number') {
|
|
430
|
+
const scale = byteUnitScale(k);
|
|
431
|
+
if (scale != null && v * scale > heapBytes) {
|
|
432
|
+
hits.push({ path: path ? `${path}.${k}` : k, bytes: v * scale });
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
else {
|
|
436
|
+
visit(v, path ? `${path}.${k}` : k, depth + 1);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
};
|
|
441
|
+
visit(value, '', 0);
|
|
442
|
+
return hits;
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* Deepest nesting of a `.references` / `.referrers` traversal inside another
|
|
446
|
+
* loop, by brace depth.
|
|
447
|
+
*
|
|
448
|
+
* Textual, like the full-walk check next to it — the honest limit of a
|
|
449
|
+
* pre-flight, and the alternative is parsing the code. It exists because
|
|
450
|
+
* `max_nodes` bounds the OUTER loop and nothing warns that the inner one turns
|
|
451
|
+
* a 7M-node pass into a 41M-edge one. A measured probe with two nested
|
|
452
|
+
* traversals ran for ten minutes and returned nothing.
|
|
453
|
+
*/
|
|
454
|
+
function maxTraversalNesting(code) {
|
|
455
|
+
const TRAVERSAL = /\.\s*(?:references|referrers)\b/g;
|
|
456
|
+
const LOOP = /\b(?:for\s*\(|while\s*\(|\.forEach\s*\(|\.map\s*\(|\.filter\s*\(|\.flatMap\s*\()/g;
|
|
457
|
+
// Character-based, not line-based. The first version tested one line at a
|
|
458
|
+
// time and scored `nodes.forEach(n => { for (const e of n.references) ... })`
|
|
459
|
+
// as depth 0, because the loop it is nested in opens on the same line — which
|
|
460
|
+
// is how this gets written most of the time.
|
|
461
|
+
/**
|
|
462
|
+
* Whether the construct whose opening paren ends at `from` will actually open
|
|
463
|
+
* a brace body.
|
|
464
|
+
*
|
|
465
|
+
* A brace-less callback — `nodes.map(n => n.name)` — never does, and counting
|
|
466
|
+
* it as pending made the NEXT unrelated `{` in the source (an `if` block, an
|
|
467
|
+
* object literal) read as that loop's body, so every traversal after it was
|
|
468
|
+
* reported one level deeper than it is. Scan from the paren: a `{` before the
|
|
469
|
+
* parens balance is a callback body, a `{` immediately after they balance is
|
|
470
|
+
* a `for`/`while` body, and anything else means there is no body to wait for.
|
|
471
|
+
*/
|
|
472
|
+
const opensBraceBody = (from) => {
|
|
473
|
+
let parens = 1;
|
|
474
|
+
for (let i = from; i < code.length; i++) {
|
|
475
|
+
const c = code[i];
|
|
476
|
+
if (c === '{')
|
|
477
|
+
return true;
|
|
478
|
+
if (c === '(') {
|
|
479
|
+
parens++;
|
|
480
|
+
}
|
|
481
|
+
else if (c === ')') {
|
|
482
|
+
parens--;
|
|
483
|
+
if (parens === 0) {
|
|
484
|
+
let j = i + 1;
|
|
485
|
+
while (j < code.length && /\s/.test(code[j]))
|
|
486
|
+
j++;
|
|
487
|
+
return code[j] === '{';
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
return false;
|
|
492
|
+
};
|
|
493
|
+
const events = [];
|
|
494
|
+
for (const m of code.matchAll(LOOP)) {
|
|
495
|
+
const pos = m.index ?? 0;
|
|
496
|
+
// Every LOOP alternative ends with its opening paren.
|
|
497
|
+
if (opensBraceBody(pos + m[0].length)) {
|
|
498
|
+
events.push({ pos, kind: 'loop' });
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
for (const m of code.matchAll(TRAVERSAL)) {
|
|
502
|
+
events.push({ pos: m.index ?? 0, kind: 'traversal' });
|
|
503
|
+
}
|
|
504
|
+
events.sort((a, b) => a.pos - b.pos);
|
|
505
|
+
const loopDepths = [];
|
|
506
|
+
let depth = 0;
|
|
507
|
+
let pendingLoops = 0;
|
|
508
|
+
let deepest = 0;
|
|
509
|
+
let next = 0;
|
|
510
|
+
for (let i = 0; i < code.length; i++) {
|
|
511
|
+
while (next < events.length && events[next].pos === i) {
|
|
512
|
+
const ev = events[next++];
|
|
513
|
+
if (ev.kind === 'loop') {
|
|
514
|
+
pendingLoops++;
|
|
515
|
+
}
|
|
516
|
+
else {
|
|
517
|
+
deepest = Math.max(deepest, loopDepths.length);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
const ch = code[i];
|
|
521
|
+
if (ch === '{') {
|
|
522
|
+
// The first `{` after a loop header opens that loop's body.
|
|
523
|
+
if (pendingLoops > 0) {
|
|
524
|
+
loopDepths.push(depth);
|
|
525
|
+
pendingLoops--;
|
|
526
|
+
}
|
|
527
|
+
depth++;
|
|
528
|
+
}
|
|
529
|
+
else if (ch === '}') {
|
|
530
|
+
depth--;
|
|
531
|
+
while (loopDepths.length > 0 &&
|
|
532
|
+
loopDepths[loopDepths.length - 1] >= depth) {
|
|
533
|
+
loopDepths.pop();
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
return deepest;
|
|
538
|
+
}
|
|
539
|
+
/**
|
|
540
|
+
* The helper names from the most recent eval build in this process.
|
|
541
|
+
*
|
|
542
|
+
* `mode: "lint"` runs WITHOUT a snapshot, so it cannot construct the helpers
|
|
543
|
+
* object to read its keys. Recording them here keeps the lint check honest:
|
|
544
|
+
* when the list is unknown (a fresh server that has not run an eval yet) the
|
|
545
|
+
* check is SKIPPED and said to be skipped, rather than reporting every real
|
|
546
|
+
* helper as unknown.
|
|
547
|
+
*/
|
|
548
|
+
let lastKnownHelperNames = null;
|
|
549
|
+
/** True when `a` and `b` are within `max` single-character edits. Cheap bail-out. */
|
|
550
|
+
function editDistanceWithin(a, b, max) {
|
|
551
|
+
if (Math.abs(a.length - b.length) > max)
|
|
552
|
+
return false;
|
|
553
|
+
const prev = new Array(b.length + 1);
|
|
554
|
+
const cur = new Array(b.length + 1);
|
|
555
|
+
for (let j = 0; j <= b.length; j++)
|
|
556
|
+
prev[j] = j;
|
|
557
|
+
for (let i = 1; i <= a.length; i++) {
|
|
558
|
+
cur[0] = i;
|
|
559
|
+
let best = cur[0];
|
|
560
|
+
for (let j = 1; j <= b.length; j++) {
|
|
561
|
+
cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
562
|
+
if (cur[j] < best)
|
|
563
|
+
best = cur[j];
|
|
564
|
+
}
|
|
565
|
+
if (best > max)
|
|
566
|
+
return false;
|
|
567
|
+
for (let j = 0; j <= b.length; j++)
|
|
568
|
+
prev[j] = cur[j];
|
|
569
|
+
}
|
|
570
|
+
return prev[b.length] <= max;
|
|
571
|
+
}
|
|
176
572
|
export function registerEval(server) {
|
|
177
573
|
server.tool('memlab_eval', 'Execute arbitrary JavaScript code against the loaded heap snapshot. ' +
|
|
178
574
|
'The code runs in a sandboxed VM with access to `snapshot` (IHeapSnapshot), ' +
|
|
@@ -205,10 +601,10 @@ export function registerEval(server) {
|
|
|
205
601
|
'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
602
|
'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`.', {
|
|
207
603
|
mode: z
|
|
208
|
-
.enum(['eval', 'describe_env', 'list_saved'])
|
|
604
|
+
.enum(['eval', 'describe_env', 'list_saved', 'lint'])
|
|
209
605
|
.optional()
|
|
210
606
|
.default('eval')
|
|
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.'),
|
|
607
|
+
.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 — narrow it with `section` to avoid paying for all ~10 KB. "lint" syntax-checks `code`, lists the helpers it references and flags unknown ones, and estimates traversal nesting — all WITHOUT a snapshot, so a typo in a 40-line eval costs seconds instead of a 2-4 minute load. "list_saved" ignores `code` and lists the named result sets saved so far for this snapshot.'),
|
|
212
608
|
code: z
|
|
213
609
|
.string()
|
|
214
610
|
.optional()
|
|
@@ -222,24 +618,36 @@ export function registerEval(server) {
|
|
|
222
618
|
'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
619
|
'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
620
|
'props(nodeOrId)->{prop: scalar | {ref,name,type}} & getProp(nodeOrId, name) & shapeSignature(nodeOrId, {maxStringLen?}) (content signature for dedup checks), ' +
|
|
621
|
+
'shapeKeys(nodeOrId)->Set<string> & ownProps(nodeOrId) & hasShape(nodeOrId, [names], {exact?,exclude?}) (own JS properties ONLY — USE THESE FOR SHAPE MATCHING; props() falls back to an internal-edge walk and injects length/map/__via/__note, which makes a props()-based shape test silently return zero matches), ' +
|
|
622
|
+
'rootPath(nodeOrId, {maxHops?})->[{id,name,type,edge}] (GC-root path, root first — the retainer_trace walk, callable inside an eval), ' +
|
|
225
623
|
'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
624
|
'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), ' +
|
|
625
|
+
'iterByClass(name, {type?, instancesOnly?})->nodes[] & iterByType(type)->nodes[] (INDEXED iteration — no full scan; instancesOnly defaults TRUE and drops the constructor closure, the `Foo (prototype)` object and `system/SharedFunctionInfo/Foo`, which otherwise come back as class members whose only "properties" are length/map and make a per-instance loop produce garbage), ' +
|
|
228
626
|
'classCounts({pattern?, type?, minCount?})->[{name,type,count,selfSize}] (one-pass histogram, cached), ' +
|
|
229
627
|
'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), ' +
|
|
628
|
+
'edgeTarget(nodeOrId, edgeName)->node|null, walkChain(startOrNode, edgeName, {maxHops?, collectIds?})->{length, terminated:"cycle"|"end"|"cap", truncated} (USE THIS instead of a hand-written `while` over `.next` — a hand-rolled loop cannot tell a circular list from its own hop cap, and reports the cap as if it were the length), isRealDetached(node)->boolean (same filtering the tools apply internally), ' +
|
|
231
629
|
'dominates(id, {population?, limit?})->{count,selfSize,ids,truncated}, ' +
|
|
630
|
+
'remember(name, value)/recall(name?) (persist ACROSS sessions), ' +
|
|
631
|
+
'sample(items, n) (deterministic, evenly spaced), ' +
|
|
632
|
+
'owner(idOrNode, {maxHops?})->{id,name,type,hops,selfSize,named}|null, ' +
|
|
633
|
+
'histogram(ids, keyFn, {limit?})->[{key,count}], ' +
|
|
232
634
|
'pathBetween(fromId, toId, {maxNodes?})->{found,exhausted,path[]}, ' +
|
|
233
635
|
'save(name, value) / load(name, {allowCrossSnapshot?}) / listSaved() (SESSION-scoped, survives loading another snapshot) }), ' +
|
|
234
636
|
'and standard JS built-ins. ' +
|
|
637
|
+
'NOTE: `helpers.byClass()` returns IDS, and not every id resolves — `snapshot.getNodeById()` returns null for many native classes (AudioContext, OpusRecorder, …), so `byClass(x).map(id => getNodeById(id).referrers)` throws on the first try. Use `helpers.nodesByClass(name)` / `helpers.iterByClass(name)`, which return node objects and skip the unresolvable ones. `helpers.props()` on an unresolvable node now returns `{__unavailable: true}` rather than `{}`, so "no properties" and "could not read properties" are distinguishable. ' +
|
|
235
638
|
'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. ' +
|
|
236
639
|
'Node traversal: use node.references (outgoing) and node.referrers (incoming) with for-of. ' +
|
|
237
640
|
'Edge properties: .name_or_index, .type, .toNode, .fromNode.'),
|
|
238
641
|
timeout_ms: z
|
|
239
642
|
.number()
|
|
240
643
|
.optional()
|
|
241
|
-
.
|
|
242
|
-
|
|
644
|
+
.describe('Execution timeout in milliseconds. Defaults to a value SCALED from the loaded snapshot (60s floor, ~1s per 40k nodes), because a full-heap walk on a 6M-node graph takes minutes and a fixed 60s default made the first attempt abort on every large capture.'),
|
|
645
|
+
max_result_bytes: z
|
|
646
|
+
.number()
|
|
647
|
+
.int()
|
|
648
|
+
.min(1024)
|
|
649
|
+
.optional()
|
|
650
|
+
.describe(`Byte budget for the serialized \`result\` (default ${MAX_OUTPUT_SIZE}). Over budget, whole ENTRIES are dropped from the end of an array/object rather than the JSON being cut mid-string, and \`truncated: true\` is reported — so a large census stays valid and readable instead of needing a re-run with a hand-written limit.`),
|
|
243
651
|
save_as: z
|
|
244
652
|
.string()
|
|
245
653
|
.regex(/^[A-Za-z_][A-Za-z0-9_]*$/)
|
|
@@ -250,770 +658,1712 @@ export function registerEval(server) {
|
|
|
250
658
|
.optional()
|
|
251
659
|
.default(false)
|
|
252
660
|
.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.'),
|
|
661
|
+
sample: z
|
|
662
|
+
.number()
|
|
663
|
+
.int()
|
|
664
|
+
.min(1)
|
|
665
|
+
.optional()
|
|
666
|
+
.default(1)
|
|
667
|
+
.describe('TRIAGE MODE: visit only every Nth node in a `snapshot.nodes.forEach` walk (1 = every node, the default). A full-heap walk on a multi-million-node graph takes 1-2 minutes, which is enough friction that most exploratory ideas never get run at all; `sample: 200` answers "is there anything here?" in about a second, and you pay for the exact walk only once an idea looks worth it. The stride is deterministic, not random, so a follow-up question lands on the same objects. COUNTS COME BACK ~N TIMES LOW and the result is labelled an ESTIMATE — never record a sampled number as a measurement, and never conclude ABSENCE from one (a population of 50 is easily missed at stride 200).'),
|
|
668
|
+
section: z
|
|
669
|
+
.string()
|
|
670
|
+
.optional()
|
|
671
|
+
.describe('For mode:"describe_env" only — return just the section(s) whose heading matches this text (case-insensitive substring), e.g. "collection", "populations", "traversal", "IHeapNode". The calling conventions and the section list are always included. The full document is ~10 KB of tokens and is usually read to write one eval.'),
|
|
672
|
+
restrict_to_ids: z
|
|
673
|
+
.array(z.number())
|
|
674
|
+
.optional()
|
|
675
|
+
.describe('Restrict `snapshot.nodes.forEach` to these node ids, so the SAME code runs over a candidate set instead of the whole graph. This is how a `sample`-based triage pass is promoted to an exact answer without paying for a second blind full walk: run `sample: 200` with `save_as: "candidates"` collecting ids, then re-run with `restrict_to_ids: helpers.load("candidates")` and `sample: 1`. Filtering happens inside the walk, so `nodes_visited` reports the real work.'),
|
|
253
676
|
max_nodes: z
|
|
254
677
|
.number()
|
|
255
678
|
.int()
|
|
256
679
|
.min(1)
|
|
257
680
|
.optional()
|
|
258
|
-
.
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
681
|
+
.describe('Abort a `snapshot.nodes.forEach` walk after this many node visits. Defaults to a budget SCALED from the loaded graph (6 full passes, floored at 20,000,000) — a probe that makes several passes is the normal shape, so a flat cap silently truncated ordinary multi-pass analysis on large heaps. On abort the partial `result` is returned with a note; an abort that produced NO result is refused outright, because an empty value is indistinguishable from a genuine empty census. Reported back as `nodes_visited` on every call.'),
|
|
682
|
+
}, async (args) => runEval({ ...args, ownsScanBudget: true }));
|
|
683
|
+
}
|
|
684
|
+
/**
|
|
685
|
+
* The `memlab_eval` handler, callable directly so a caller can run the same
|
|
686
|
+
* code against several snapshots (see memlab_eval_across) with identical
|
|
687
|
+
* sandbox semantics — one definition of the helper surface, not two.
|
|
688
|
+
*/
|
|
689
|
+
/**
|
|
690
|
+
* Cross-session scratch for `helpers.remember` / `helpers.recall`. Kept beside
|
|
691
|
+
* the metric store (same MEMLAB_STATE_DIR) because it answers the same problem
|
|
692
|
+
* from the other end: `memlab_metric` persists a NUMBER worth quoting, this
|
|
693
|
+
* persists whatever an exploration derived on the way to it.
|
|
694
|
+
*/
|
|
695
|
+
function evalStorePath() {
|
|
696
|
+
const dir = process.env.MEMLAB_STATE_DIR ?? path.join(os.homedir(), '.memlab');
|
|
697
|
+
return path.join(dir, 'eval-store.json');
|
|
698
|
+
}
|
|
699
|
+
function readEvalStore() {
|
|
700
|
+
try {
|
|
701
|
+
const parsed = JSON.parse(fs.readFileSync(evalStorePath(), 'utf8'));
|
|
702
|
+
if (parsed != null && typeof parsed === 'object') {
|
|
703
|
+
return parsed;
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
catch {
|
|
707
|
+
// A missing or corrupt store must not fail the eval that is writing to it.
|
|
708
|
+
}
|
|
709
|
+
return {};
|
|
710
|
+
}
|
|
711
|
+
function writeEvalStore(store) {
|
|
712
|
+
const file = evalStorePath();
|
|
713
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
714
|
+
fs.writeFileSync(file, JSON.stringify(store, null, 2));
|
|
715
|
+
}
|
|
716
|
+
export async function runEval({ mode, code, section, timeout_ms, save_as, max_nodes, sample, restrict_to_ids, dry_run, max_result_bytes, ownsScanBudget, }) {
|
|
717
|
+
// Schema defaults are applied by the MCP layer for tool calls; a direct
|
|
718
|
+
// caller (memlab_eval_across) gets them here so both paths behave alike.
|
|
719
|
+
//
|
|
720
|
+
// Anything that needs to scale with the LOADED SNAPSHOT therefore must NOT
|
|
721
|
+
// carry a `.default()` in the zod schema — the MCP layer fills that in before
|
|
722
|
+
// this function runs, so the `?? scaled...()` below never fires and the
|
|
723
|
+
// scaling is silently dead for every tool call. `max_nodes` shipped with
|
|
724
|
+
// exactly that bug: the schema default of 20,000,000 shadowed the scaled
|
|
725
|
+
// budget, and a three-pass probe on an 8.06M-node capture still aborted at
|
|
726
|
+
// 20,000,000 while the error text correctly reported the graph as 8,055,593
|
|
727
|
+
// nodes. Both schemas now leave it optional-with-no-default.
|
|
728
|
+
mode = mode ?? 'eval';
|
|
729
|
+
const scaledTimeout = timeout_ms ?? scaledEvalTimeoutMs();
|
|
730
|
+
if (ownsScanBudget === true && timeout_ms == null && scaledTimeout > 0) {
|
|
731
|
+
// Raising only the VM script timeout is not enough, and the half-fix is
|
|
732
|
+
// worse than none because it looks like it worked. `guardrail.ts` arms the
|
|
733
|
+
// whole-heap scan budget from the tool's INCOMING `timeout_ms` before this
|
|
734
|
+
// handler runs; with no explicit value it arms the 90s default, so a scaled
|
|
735
|
+
// 150s eval on a 6M-node graph is still killed at 90s — the exact failure
|
|
736
|
+
// the scaling exists to remove. Re-arm the scan budget to match.
|
|
737
|
+
//
|
|
738
|
+
// Gated on `ownsScanBudget` because `beginAnalysisBudget` RESETS the clock
|
|
739
|
+
// rather than extending it, and the registry's invariant is exactly one
|
|
740
|
+
// budget per MCP call. `memlab_eval_across` runs one eval per rung inside a
|
|
741
|
+
// single call, so an ungated re-arm would hand every rung a fresh budget
|
|
742
|
+
// and leave the batch's total wall clock effectively unbounded — the guard
|
|
743
|
+
// the budget exists to be.
|
|
744
|
+
beginAnalysisBudget(scaledTimeout);
|
|
745
|
+
}
|
|
746
|
+
timeout_ms = scaledTimeout;
|
|
747
|
+
// Scale the walk budget to the graph, the way the timeout already scales.
|
|
748
|
+
// A flat 20M was under one snapshot's worth of visits for any probe that
|
|
749
|
+
// makes more than two passes over a large heap: on an 8.06M-node capture a
|
|
750
|
+
// routine three-pass probe hit the cap and returned a PARTIAL result, which —
|
|
751
|
+
// because the accumulator was assigned at the end — printed as a bare
|
|
752
|
+
// `undefined`. A budget that silently converts "too big" into "no answer"
|
|
753
|
+
// is worse than one that is simply large.
|
|
754
|
+
max_nodes = max_nodes ?? scaledWalkBudget();
|
|
755
|
+
dry_run = dry_run ?? false;
|
|
756
|
+
const budget = {
|
|
757
|
+
visited: 0,
|
|
758
|
+
max: max_nodes,
|
|
759
|
+
exceeded: false,
|
|
760
|
+
sampleEvery: sample != null && sample > 1 ? Math.floor(sample) : 1,
|
|
761
|
+
seen: 0,
|
|
762
|
+
restrictTo: restrict_to_ids != null && restrict_to_ids.length > 0
|
|
763
|
+
? new Set(restrict_to_ids)
|
|
764
|
+
: undefined,
|
|
765
|
+
};
|
|
766
|
+
try {
|
|
767
|
+
if (mode === 'lint') {
|
|
768
|
+
return toolResult(lintEval(code ?? '', lastKnownHelperNames));
|
|
769
|
+
}
|
|
770
|
+
if (mode === 'describe_env') {
|
|
771
|
+
return toolResult(describeEnv(section));
|
|
772
|
+
}
|
|
773
|
+
if (mode === 'list_saved') {
|
|
774
|
+
return toolResult(describeSaved());
|
|
775
|
+
}
|
|
776
|
+
if (code == null || code.trim() === '') {
|
|
777
|
+
return errorResult(new Error('No code provided. Pass `code`, or use mode:"describe_env" to see the available globals and conventions.'));
|
|
778
|
+
}
|
|
779
|
+
// Light snapshots are allowed here. Most eval code touches only
|
|
780
|
+
// `name`, `type`, `self_size`, `references` and `referrers`, none of
|
|
781
|
+
// which the dominator pass produces — refusing the whole tool forced a
|
|
782
|
+
// full (2x slower) load for counts-only work on a baseline rung. What
|
|
783
|
+
// IS unavailable is refused precisely instead: by a pre-flight text
|
|
784
|
+
// check below, and by the helpers themselves as a backstop.
|
|
785
|
+
const light = isLightSnapshot();
|
|
786
|
+
const snapshot = getSnapshot({ allowLight: true });
|
|
787
|
+
const currentHandle = getCurrentHandle() ?? '(none)';
|
|
788
|
+
if (light) {
|
|
789
|
+
const needsRetention = RETENTION_IDENTIFIERS.filter(id => new RegExp(`\\b${id}\\b`).test(code));
|
|
790
|
+
if (needsRetention.length > 0) {
|
|
791
|
+
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(', ')}. ` +
|
|
792
|
+
'Those would read 0 / undefined rather than fail, so the run is refused instead of returning confident zeros. ' +
|
|
793
|
+
'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. ' +
|
|
794
|
+
'(If the identifier only appears inside a string literal, this is a false match; the same call succeeds on a non-light load.)'));
|
|
268
795
|
}
|
|
269
|
-
|
|
270
|
-
|
|
796
|
+
}
|
|
797
|
+
if (dry_run) {
|
|
798
|
+
// Estimate, do not execute. A full-heap walk is detected textually —
|
|
799
|
+
// the honest limit of a pre-flight check, and stated as such rather
|
|
800
|
+
// than implying the code was analysed.
|
|
801
|
+
const meta = getSnapshotMetadata();
|
|
802
|
+
const fullWalk = /\b(?:snapshot\.)?(?:nodes|edges)\s*\.\s*forEach/.test(code);
|
|
803
|
+
const indexed = /helpers\.(byClass|byTypename|withProp|byReferrerEdge|getNode)/.test(code);
|
|
804
|
+
const nestingDepth = maxTraversalNesting(code);
|
|
805
|
+
const meanOutDegree = (meta?.edgeCount ?? 0) / Math.max(1, meta?.nodeCount ?? 1);
|
|
806
|
+
// A nested `.references` walk inside a full-heap walk costs EDGE visits,
|
|
807
|
+
// not node visits, and the difference is the difference between seconds
|
|
808
|
+
// and "the probe died at 600s having produced nothing". `max_nodes`
|
|
809
|
+
// bounds the outer loop only, so nothing else warns about this.
|
|
810
|
+
const estimatedVisits = fullWalk && nestingDepth > 0
|
|
811
|
+
? (meta?.nodeCount ?? 0) * Math.pow(meanOutDegree, nestingDepth)
|
|
812
|
+
: null;
|
|
813
|
+
return toolResult([
|
|
814
|
+
'## Dry run — nothing was executed',
|
|
815
|
+
'',
|
|
816
|
+
`Snapshot: ${formatNumber(meta?.nodeCount ?? 0)} nodes, ${formatNumber(meta?.edgeCount ?? 0)} edges.`,
|
|
817
|
+
`Walk budget (\`max_nodes\`): ${formatNumber(budget.max)}.`,
|
|
818
|
+
'',
|
|
819
|
+
fullWalk
|
|
820
|
+
? `⚠ 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` / `byReferrerEdge` are indexed and avoid the walk when you know what you are looking for.'}`
|
|
821
|
+
: indexed
|
|
822
|
+
? 'No full-heap walk detected; the code uses the indexed helpers, which do not scan the heap.'
|
|
823
|
+
: '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.',
|
|
824
|
+
estimatedVisits != null && nestingDepth >= 1
|
|
825
|
+
? `⚠ NESTED TRAVERSAL, depth ${nestingDepth}: the walk iterates \`.references\`/\`.referrers\` inside the outer loop, so the real cost is EDGE visits — roughly ${formatNumber(Math.round(estimatedVisits))} (${formatNumber(meta?.nodeCount ?? 0)} nodes x mean out-degree ${meanOutDegree.toFixed(1)}^${nestingDepth}). \`max_nodes\` bounds the OUTER loop only and will not stop this.${nestingDepth >= 2 ? ' At depth 2 or more, expect minutes: hoist the inner lookup, or collect candidate ids in a cheap pass and re-run with `restrict_to_ids`.' : ''}`
|
|
826
|
+
: '',
|
|
827
|
+
'',
|
|
828
|
+
'_Re-run without `dry_run` to execute._',
|
|
829
|
+
].join('\n'));
|
|
830
|
+
}
|
|
831
|
+
const consoleOutput = [];
|
|
832
|
+
const capturedConsole = {
|
|
833
|
+
log: (...args) => consoleOutput.push(args.map(String).join(' ')),
|
|
834
|
+
warn: (...args) => consoleOutput.push('[warn] ' + args.map(String).join(' ')),
|
|
835
|
+
error: (...args) => consoleOutput.push('[error] ' + args.map(String).join(' ')),
|
|
836
|
+
info: (...args) => consoleOutput.push('[info] ' + args.map(String).join(' ')),
|
|
837
|
+
};
|
|
838
|
+
const groupReferrersByEdge = (nodeId) => {
|
|
839
|
+
const target = snapshot.getNodeById(nodeId);
|
|
840
|
+
if (!target)
|
|
841
|
+
return {};
|
|
842
|
+
const groups = {};
|
|
843
|
+
for (const edge of target.referrers) {
|
|
844
|
+
const eName = String(edge.name_or_index);
|
|
845
|
+
const from = edge.fromNode;
|
|
846
|
+
if (!groups[eName])
|
|
847
|
+
groups[eName] = [];
|
|
848
|
+
if (groups[eName].length < 10) {
|
|
849
|
+
groups[eName].push({
|
|
850
|
+
fromName: from.name,
|
|
851
|
+
fromType: from.type,
|
|
852
|
+
fromId: from.id,
|
|
853
|
+
});
|
|
854
|
+
}
|
|
271
855
|
}
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
const
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
856
|
+
return groups;
|
|
857
|
+
};
|
|
858
|
+
const groupArrayElementsByProperty = (arrayNodeId, propertyName) => {
|
|
859
|
+
const arrNode = snapshot.getNodeById(arrayNodeId);
|
|
860
|
+
if (!arrNode)
|
|
861
|
+
return { error: 'Node not found' };
|
|
862
|
+
const groups = {};
|
|
863
|
+
let missing = 0;
|
|
864
|
+
let total = 0;
|
|
865
|
+
for (const edge of arrNode.references) {
|
|
866
|
+
if (edge.type !== 'element')
|
|
867
|
+
continue;
|
|
868
|
+
const elem = edge.toNode;
|
|
869
|
+
if (elem.id <= 3)
|
|
870
|
+
continue;
|
|
871
|
+
total++;
|
|
872
|
+
let found = false;
|
|
873
|
+
for (const propEdge of elem.references) {
|
|
874
|
+
if (String(propEdge.name_or_index) === propertyName) {
|
|
875
|
+
const target = propEdge.toNode;
|
|
876
|
+
const key = target.name;
|
|
877
|
+
if (!groups[key])
|
|
878
|
+
groups[key] = { count: 0, exampleId: target.id };
|
|
879
|
+
groups[key].count++;
|
|
880
|
+
found = true;
|
|
881
|
+
break;
|
|
882
|
+
}
|
|
288
883
|
}
|
|
884
|
+
if (!found)
|
|
885
|
+
missing++;
|
|
289
886
|
}
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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'));
|
|
887
|
+
return { groups, total, missing };
|
|
888
|
+
};
|
|
889
|
+
const isOrphaned = (nodeId, ownershipEdgeNames) => {
|
|
890
|
+
const target = snapshot.getNodeById(nodeId);
|
|
891
|
+
if (!target)
|
|
892
|
+
return false;
|
|
893
|
+
const ownerSet = new Set(ownershipEdgeNames);
|
|
894
|
+
for (const edge of target.referrers) {
|
|
895
|
+
if (ownerSet.has(String(edge.name_or_index)))
|
|
896
|
+
return false;
|
|
311
897
|
}
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
898
|
+
return true;
|
|
899
|
+
};
|
|
900
|
+
// Authoritative retained sizes (Feedback round 3 §3b). Reading
|
|
901
|
+
// `.retainedSize` off proxied/iterated nodes inside eval can come back
|
|
902
|
+
// ~0; these helpers look the node up fresh on the real snapshot (the
|
|
903
|
+
// same path the dedicated tools use) so custom analyses can rank by
|
|
904
|
+
// retained size.
|
|
905
|
+
// On a light snapshot these would return 0 for every id, which is
|
|
906
|
+
// indistinguishable from a genuinely tiny object. Throw instead: the
|
|
907
|
+
// pre-flight check above catches the common case, and this covers code
|
|
908
|
+
// that reaches them indirectly.
|
|
909
|
+
const requireRetention = (what) => {
|
|
910
|
+
if (!light)
|
|
911
|
+
return;
|
|
912
|
+
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}).`);
|
|
913
|
+
};
|
|
914
|
+
const retainedSize = (id) => {
|
|
915
|
+
requireRetention('retainedSize');
|
|
916
|
+
const n = snapshot.getNodeById(id);
|
|
917
|
+
return n ? n.retainedSize : 0;
|
|
918
|
+
};
|
|
919
|
+
const retainedSizes = (ids) => {
|
|
920
|
+
requireRetention('retainedSizes');
|
|
921
|
+
const out = {};
|
|
922
|
+
for (const id of ids) {
|
|
923
|
+
const n = snapshot.getNodeById(id);
|
|
924
|
+
out[id] = n ? n.retainedSize : 0;
|
|
925
|
+
}
|
|
926
|
+
return out;
|
|
927
|
+
};
|
|
928
|
+
const countUniqueTargets = (arrayNodeId, propertyName) => {
|
|
929
|
+
const arrNode = snapshot.getNodeById(arrayNodeId);
|
|
930
|
+
if (!arrNode)
|
|
931
|
+
return { error: 'Node not found' };
|
|
932
|
+
const uniqueIds = new Set();
|
|
933
|
+
let total = 0;
|
|
934
|
+
for (const edge of arrNode.references) {
|
|
935
|
+
if (edge.type !== 'element')
|
|
936
|
+
continue;
|
|
937
|
+
const elem = edge.toNode;
|
|
938
|
+
if (elem.id <= 3)
|
|
939
|
+
continue;
|
|
940
|
+
total++;
|
|
941
|
+
for (const propEdge of elem.references) {
|
|
942
|
+
if (String(propEdge.name_or_index) === propertyName) {
|
|
943
|
+
uniqueIds.add(propEdge.toNode.id);
|
|
944
|
+
break;
|
|
335
945
|
}
|
|
336
946
|
}
|
|
337
|
-
|
|
947
|
+
}
|
|
948
|
+
return { uniqueCount: uniqueIds.size, totalElements: total };
|
|
949
|
+
};
|
|
950
|
+
// Compact, ready-to-use view of a node (no proxy, values inlined) so
|
|
951
|
+
// custom scripts get data they can JSON-return directly instead of
|
|
952
|
+
// re-deriving `.toStringNode()?.stringValue` etc. `retained_size` is
|
|
953
|
+
// resolved via the trusted `retainedSize(id)` re-lookup (NOT the raw
|
|
954
|
+
// `.retainedSize`, which can read back ~0 inside eval on some loads — the
|
|
955
|
+
// foot-gun this whole tool's description warns about).
|
|
956
|
+
//
|
|
957
|
+
// On a LIGHT snapshot it is reported as `null` rather than thrown:
|
|
958
|
+
// `mapEntries` / `setElements` are built on this and are legitimate
|
|
959
|
+
// there (keys, values, names and self sizes all survive a light load),
|
|
960
|
+
// so propagating `retainedSize`'s refusal would refuse them too. A null
|
|
961
|
+
// reads as "not measured"; a 0 would read as "measured and tiny".
|
|
962
|
+
const nodeBrief = (n) => n == null
|
|
963
|
+
? null
|
|
964
|
+
: {
|
|
965
|
+
id: n.id,
|
|
966
|
+
name: n.name,
|
|
967
|
+
type: n.type,
|
|
968
|
+
self_size: n.self_size,
|
|
969
|
+
retained_size: light ? null : retainedSize(n.id),
|
|
970
|
+
string: n.isString ? (n.toStringNode()?.stringValue ?? null) : null,
|
|
338
971
|
};
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
972
|
+
const resolveNode = (nodeOrId) => {
|
|
973
|
+
if (nodeOrId == null)
|
|
974
|
+
return null;
|
|
975
|
+
const id = typeof nodeOrId === 'number' ? nodeOrId : nodeOrId.id;
|
|
976
|
+
return snapshot.getNodeById(id);
|
|
977
|
+
};
|
|
978
|
+
// Correctly enumerate Map/WeakMap entries and Set elements via the shared
|
|
979
|
+
// index-aware backing-store walk (handles browser `internal`-typed slots
|
|
980
|
+
// AND SMI-value gaps). Removes the #1 eval foot-gun: hand-rolling this
|
|
981
|
+
// and silently getting 0 results by filtering on `type === 'element'`.
|
|
982
|
+
const mapEntries = (id, limit = 1000) => {
|
|
983
|
+
const node = snapshot.getNodeById(id);
|
|
984
|
+
if (!node)
|
|
985
|
+
throw new Error(`mapEntries: node @${id} not found`);
|
|
986
|
+
// Guard the node type — enumerateMapEntries assumes key/value slots, so
|
|
987
|
+
// running it on a Set (element/chain layout) would emit each element as
|
|
988
|
+
// a lone key with value:null, which is silently misleading.
|
|
989
|
+
if (node.name !== 'Map' && node.name !== 'WeakMap') {
|
|
990
|
+
throw new Error(`mapEntries: @${id} is a ${node.name} (${node.type}), not a Map/WeakMap. ` +
|
|
991
|
+
`For a Set use helpers.setElements(${id}); otherwise inspect with helpers.props()/get_references.`);
|
|
992
|
+
}
|
|
993
|
+
return enumerateMapEntries(node)
|
|
994
|
+
.slice(0, limit)
|
|
995
|
+
.map(e => ({ key: nodeBrief(e.key), value: nodeBrief(e.value) }));
|
|
996
|
+
};
|
|
997
|
+
const setElements = (id, limit = 1000) => {
|
|
998
|
+
const node = snapshot.getNodeById(id);
|
|
999
|
+
if (!node)
|
|
1000
|
+
throw new Error(`setElements: node @${id} not found`);
|
|
1001
|
+
if (node.name !== 'Set' && node.name !== 'WeakSet') {
|
|
1002
|
+
throw new Error(`setElements: @${id} is a ${node.name} (${node.type}), not a Set/WeakSet. ` +
|
|
1003
|
+
`For a Map use helpers.mapEntries(${id}).`);
|
|
1004
|
+
}
|
|
1005
|
+
return enumerateSetElements(node).slice(0, limit).map(nodeBrief);
|
|
1006
|
+
};
|
|
1007
|
+
// Read an object's own properties as a plain object: scalars inlined,
|
|
1008
|
+
// object-valued props as `{ref, name, type}`. Saves the repetitive
|
|
1009
|
+
// `for (const e of n.references) if (e.name_or_index === X)` boilerplate.
|
|
1010
|
+
const describeTarget = (t) => {
|
|
1011
|
+
if (t.isString)
|
|
1012
|
+
return t.toStringNode()?.stringValue ?? '';
|
|
1013
|
+
if (t.name === 'true')
|
|
1014
|
+
return true;
|
|
1015
|
+
if (t.name === 'false')
|
|
1016
|
+
return false;
|
|
1017
|
+
if (t.name === 'null')
|
|
1018
|
+
return null;
|
|
1019
|
+
if (t.name === 'undefined')
|
|
1020
|
+
return undefined;
|
|
1021
|
+
return { ref: t.id, name: t.name, type: t.type };
|
|
1022
|
+
};
|
|
1023
|
+
const props = (nodeOrId) => {
|
|
1024
|
+
const node = resolveNode(nodeOrId);
|
|
1025
|
+
// An empty object used to mean three different things — unresolvable
|
|
1026
|
+
// node, no property edges, and properties held under a non-`property`
|
|
1027
|
+
// edge type — and the caller could not tell which. Reading `{}` as "this
|
|
1028
|
+
// object has no fields" when it plainly does is what makes shape
|
|
1029
|
+
// inspection unreliable and sends people back to manual edge walks.
|
|
1030
|
+
if (!node) {
|
|
1031
|
+
return {
|
|
1032
|
+
__unavailable: true,
|
|
1033
|
+
__reason: 'node not resolvable in the active snapshot — ids from ' +
|
|
1034
|
+
'helpers.byClass() are not all resolvable (natives especially); ' +
|
|
1035
|
+
'use helpers.iterByClass() to get node objects directly',
|
|
1036
|
+
};
|
|
1037
|
+
}
|
|
1038
|
+
const out = {};
|
|
1039
|
+
for (const e of node.references) {
|
|
1040
|
+
if (e.type !== 'property')
|
|
1041
|
+
continue;
|
|
1042
|
+
const name = String(e.name_or_index);
|
|
1043
|
+
if (name === '__proto__')
|
|
1044
|
+
continue;
|
|
1045
|
+
out[name] = describeTarget(e.toNode);
|
|
1046
|
+
}
|
|
1047
|
+
if (Object.keys(out).length > 0)
|
|
1048
|
+
return out;
|
|
1049
|
+
// Fall back to a named-edge walk. Natives, closures and some internal
|
|
1050
|
+
// objects carry their fields under `internal`/`shortcut`/`hidden` edges
|
|
1051
|
+
// rather than `property`, so the fast path legitimately finds nothing on
|
|
1052
|
+
// objects that visibly have state. Provenance is marked so a caller
|
|
1053
|
+
// cannot mistake these for real own-properties.
|
|
1054
|
+
let found = 0;
|
|
1055
|
+
for (const e of node.references) {
|
|
1056
|
+
if (e.type === 'element')
|
|
1057
|
+
continue;
|
|
1058
|
+
const name = String(e.name_or_index);
|
|
1059
|
+
if (name === '' || name === '__proto__' || /^\d+$/.test(name))
|
|
1060
|
+
continue;
|
|
1061
|
+
out[name] = describeTarget(e.toNode);
|
|
1062
|
+
found++;
|
|
1063
|
+
}
|
|
1064
|
+
if (found === 0)
|
|
1065
|
+
return out;
|
|
1066
|
+
out.__via = 'edge-walk';
|
|
1067
|
+
out.__note =
|
|
1068
|
+
'no `property` edges on this node; these are named non-property edges ' +
|
|
1069
|
+
'(internal/shortcut/hidden) and are NOT own JS properties';
|
|
1070
|
+
return out;
|
|
1071
|
+
};
|
|
1072
|
+
const getProp = (nodeOrId, name) => props(nodeOrId)[name];
|
|
1073
|
+
/**
|
|
1074
|
+
* Own JS properties ONLY — no `internal`/`shortcut`/`hidden` edges, no
|
|
1075
|
+
* `__via`/`__note` provenance keys, no fallback.
|
|
1076
|
+
*
|
|
1077
|
+
* `props()` deliberately falls back to a named-edge walk so that natives and
|
|
1078
|
+
* closures show their state, and marks the result. That is right for
|
|
1079
|
+
* INSPECTION and wrong for SHAPE MATCHING, and the failure is silent in the
|
|
1080
|
+
* worst direction: a shape test written as
|
|
1081
|
+
* `new Set(Object.keys(helpers.props(id)))` … `s.size === 2 && s.has('element')`
|
|
1082
|
+
* returns ZERO matches on objects that plainly have that shape, because the
|
|
1083
|
+
* fallback injected `length`/`map`/`__via`/`__note`. An empty result reads as
|
|
1084
|
+
* "this pattern is not in the heap" and gets written up as a negative.
|
|
1085
|
+
*
|
|
1086
|
+
* Use `ownProps`/`shapeKeys` whenever the question is "what shape is this",
|
|
1087
|
+
* and `props` when the question is "what is in this".
|
|
1088
|
+
*/
|
|
1089
|
+
const ownProps = (nodeOrId) => {
|
|
1090
|
+
const node = resolveNode(nodeOrId);
|
|
1091
|
+
if (!node)
|
|
1092
|
+
return {};
|
|
1093
|
+
const out = {};
|
|
1094
|
+
for (const e of node.references) {
|
|
1095
|
+
if (e.type !== 'property')
|
|
1096
|
+
continue;
|
|
1097
|
+
const name = String(e.name_or_index);
|
|
1098
|
+
if (name === '__proto__')
|
|
1099
|
+
continue;
|
|
1100
|
+
out[name] = describeTarget(e.toNode);
|
|
1101
|
+
}
|
|
1102
|
+
return out;
|
|
1103
|
+
};
|
|
1104
|
+
const shapeKeys = (nodeOrId) => {
|
|
1105
|
+
const node = resolveNode(nodeOrId);
|
|
1106
|
+
const out = new Set();
|
|
1107
|
+
if (!node)
|
|
1108
|
+
return out;
|
|
1109
|
+
for (const e of node.references) {
|
|
1110
|
+
if (e.type !== 'property')
|
|
1111
|
+
continue;
|
|
1112
|
+
const name = String(e.name_or_index);
|
|
1113
|
+
if (name === '__proto__')
|
|
1114
|
+
continue;
|
|
1115
|
+
out.add(name);
|
|
1116
|
+
}
|
|
1117
|
+
return out;
|
|
1118
|
+
};
|
|
1119
|
+
const hasShape = (nodeOrId, required, opts) => {
|
|
1120
|
+
const keys = shapeKeys(nodeOrId);
|
|
1121
|
+
for (const r of required)
|
|
1122
|
+
if (!keys.has(r))
|
|
1123
|
+
return false;
|
|
1124
|
+
for (const x of opts?.exclude ?? [])
|
|
1125
|
+
if (keys.has(x))
|
|
1126
|
+
return false;
|
|
1127
|
+
if (opts?.exact === true && keys.size !== required.length)
|
|
1128
|
+
return false;
|
|
1129
|
+
return true;
|
|
1130
|
+
};
|
|
1131
|
+
const shapeSignature = (nodeOrId, opts) => {
|
|
1132
|
+
const node = resolveNode(nodeOrId);
|
|
1133
|
+
return node ? objectContentSignature(node, opts ?? {}) : '';
|
|
1134
|
+
};
|
|
1135
|
+
// Index helpers — build once per snapshot, memoized in the eval scratch
|
|
1136
|
+
// so a follow-up call is index-speed, not a fresh 12M-node scan. Ids are
|
|
1137
|
+
// only valid for the active snapshot (the scratch is keyed to it).
|
|
1138
|
+
const scratch = getEvalScratch();
|
|
1139
|
+
const buildClassTypeIndex = () => {
|
|
1140
|
+
const cached = scratch.__classTypeIndex;
|
|
1141
|
+
if (cached)
|
|
1142
|
+
return cached;
|
|
1143
|
+
const byClass = new Map();
|
|
1144
|
+
const byTypename = new Map();
|
|
1145
|
+
snapshot.nodes.forEach((node) => {
|
|
1146
|
+
if (node.id <= 3)
|
|
1147
|
+
return; // skip oddball/root nodes, matching the histogram/duplicate-objects tools for count parity
|
|
1148
|
+
// Index EVERY node type. Restricting this to `object` made the
|
|
1149
|
+
// helper silently return [] for closures, strings, arrays and
|
|
1150
|
+
// native (`blink::*`) nodes — which is most of what other tools
|
|
1151
|
+
// report. Measured: byClass('setComposerLinks_$0') returned [] on a
|
|
1152
|
+
// snapshot where a manual walk found 1,011 of them, because the
|
|
1153
|
+
// class is a closure; the empty result reads as "does not exist".
|
|
1154
|
+
// memlab_find_nodes_by_class matches any type by default and this
|
|
1155
|
+
// helper is documented as its indexed equivalent, so the two must
|
|
1156
|
+
// agree.
|
|
1157
|
+
let a = byClass.get(node.name);
|
|
1158
|
+
if (!a) {
|
|
1159
|
+
a = [];
|
|
1160
|
+
byClass.set(node.name, a);
|
|
1161
|
+
}
|
|
1162
|
+
a.push(node.id);
|
|
1163
|
+
// `__typename` is a JS object property, so only object nodes can
|
|
1164
|
+
// carry one; skipping the edge walk for other types keeps the
|
|
1165
|
+
// widened index roughly as cheap as the object-only one.
|
|
1166
|
+
if (node.type !== 'object')
|
|
1167
|
+
return;
|
|
1168
|
+
for (const e of node.references) {
|
|
1169
|
+
if (e.type === 'property' &&
|
|
1170
|
+
String(e.name_or_index) === '__typename') {
|
|
1171
|
+
const t = e.toNode;
|
|
1172
|
+
const tn = t.isString ? t.toStringNode()?.stringValue : null;
|
|
1173
|
+
if (tn) {
|
|
1174
|
+
let b = byTypename.get(tn);
|
|
1175
|
+
if (!b) {
|
|
1176
|
+
b = [];
|
|
1177
|
+
byTypename.set(tn, b);
|
|
1178
|
+
}
|
|
1179
|
+
b.push(node.id);
|
|
363
1180
|
}
|
|
1181
|
+
break;
|
|
364
1182
|
}
|
|
365
|
-
if (!found)
|
|
366
|
-
missing++;
|
|
367
|
-
}
|
|
368
|
-
return { groups, total, missing };
|
|
369
|
-
};
|
|
370
|
-
const isOrphaned = (nodeId, ownershipEdgeNames) => {
|
|
371
|
-
const target = snapshot.getNodeById(nodeId);
|
|
372
|
-
if (!target)
|
|
373
|
-
return false;
|
|
374
|
-
const ownerSet = new Set(ownershipEdgeNames);
|
|
375
|
-
for (const edge of target.referrers) {
|
|
376
|
-
if (ownerSet.has(String(edge.name_or_index)))
|
|
377
|
-
return false;
|
|
378
1183
|
}
|
|
1184
|
+
});
|
|
1185
|
+
const idx = { byClass, byTypename };
|
|
1186
|
+
scratch.__classTypeIndex = idx;
|
|
1187
|
+
return idx;
|
|
1188
|
+
};
|
|
1189
|
+
/**
|
|
1190
|
+
* A class index entry is not necessarily an INSTANCE of that class. V8 names
|
|
1191
|
+
* the constructor closure, the prototype object and the `SharedFunctionInfo`
|
|
1192
|
+
* after the class too, so `iterByClass('Resolvable')` hands back nodes whose
|
|
1193
|
+
* only "properties" are `length`/`map` — and a per-instance loop over them
|
|
1194
|
+
* produces confident garbage. (Measured: `Resolvable` and `JobInfoEvent`
|
|
1195
|
+
* both came back looking like empty objects for exactly this reason.)
|
|
1196
|
+
*
|
|
1197
|
+
* `instancesOnly` (default true) drops those three shapes. Pass false to get
|
|
1198
|
+
* the raw index back.
|
|
1199
|
+
*/
|
|
1200
|
+
const isClassScaffolding = (id, className) => {
|
|
1201
|
+
const n = snapshot.getNodeById(id);
|
|
1202
|
+
if (!n)
|
|
1203
|
+
return false;
|
|
1204
|
+
// `Foo (prototype) / https://…` and `system / SharedFunctionInfo / Foo`.
|
|
1205
|
+
if (n.name !== className)
|
|
379
1206
|
return true;
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
//
|
|
383
|
-
//
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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
|
-
};
|
|
395
|
-
const retainedSize = (id) => {
|
|
396
|
-
requireRetention('retainedSize');
|
|
397
|
-
const n = snapshot.getNodeById(id);
|
|
398
|
-
return n ? n.retainedSize : 0;
|
|
399
|
-
};
|
|
400
|
-
const retainedSizes = (ids) => {
|
|
401
|
-
requireRetention('retainedSizes');
|
|
402
|
-
const out = {};
|
|
403
|
-
for (const id of ids) {
|
|
404
|
-
const n = snapshot.getNodeById(id);
|
|
405
|
-
out[id] = n ? n.retainedSize : 0;
|
|
1207
|
+
if (n.type === 'code' || n.type === 'synthetic')
|
|
1208
|
+
return true;
|
|
1209
|
+
// The constructor closure itself: a closure named exactly like the class
|
|
1210
|
+
// whose only outgoing named edge is `prototype`.
|
|
1211
|
+
if (n.type === 'closure') {
|
|
1212
|
+
for (const e of n.references) {
|
|
1213
|
+
if (e.type === 'property' &&
|
|
1214
|
+
String(e.name_or_index) === 'prototype') {
|
|
1215
|
+
return true;
|
|
1216
|
+
}
|
|
406
1217
|
}
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
1218
|
+
}
|
|
1219
|
+
return false;
|
|
1220
|
+
};
|
|
1221
|
+
const byClass = (name, opts) => {
|
|
1222
|
+
const raw = buildClassTypeIndex().byClass.get(name) ?? [];
|
|
1223
|
+
const want = opts?.type;
|
|
1224
|
+
const ids = want == null
|
|
1225
|
+
? raw
|
|
1226
|
+
: raw.filter(id => snapshot.getNodeById(id)?.type === want);
|
|
1227
|
+
if (opts?.instancesOnly === false)
|
|
1228
|
+
return ids;
|
|
1229
|
+
const filtered = ids.filter(id => !isClassScaffolding(id, name));
|
|
1230
|
+
// If the filter would empty a non-empty class, the heuristic is wrong for
|
|
1231
|
+
// this shape — hand back what we had rather than report "not present".
|
|
1232
|
+
return filtered.length > 0 || ids.length === 0 ? filtered : ids;
|
|
1233
|
+
};
|
|
1234
|
+
const byTypename = (name) => buildClassTypeIndex().byTypename.get(name) ?? [];
|
|
1235
|
+
const withProp = (name) => {
|
|
1236
|
+
const key = `__withProp:${name}`;
|
|
1237
|
+
const cached = scratch[key];
|
|
1238
|
+
if (cached)
|
|
1239
|
+
return cached;
|
|
1240
|
+
const ids = [];
|
|
1241
|
+
snapshot.nodes.forEach((node) => {
|
|
1242
|
+
if (node.id <= 3)
|
|
1243
|
+
return; // skip oddball/root nodes for parity with other tools
|
|
1244
|
+
// Every node type is scanned: the `property` edge check below is
|
|
1245
|
+
// what constrains the match, and closures do carry named property
|
|
1246
|
+
// edges. Restricting the walk to `object` hid them, the same way it
|
|
1247
|
+
// hid non-object classes from byClass.
|
|
1248
|
+
for (const e of node.references) {
|
|
1249
|
+
if (e.type === 'property' && String(e.name_or_index) === name) {
|
|
1250
|
+
ids.push(node.id);
|
|
1251
|
+
break;
|
|
427
1252
|
}
|
|
428
1253
|
}
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
1254
|
+
});
|
|
1255
|
+
scratch[key] = ids;
|
|
1256
|
+
return ids;
|
|
1257
|
+
};
|
|
1258
|
+
/**
|
|
1259
|
+
* The GC-root path for one node, as `retainer_trace` walks it — but callable
|
|
1260
|
+
* from inside an eval.
|
|
1261
|
+
*
|
|
1262
|
+
* This exact `pathEdge` loop was hand-written five separate times in one
|
|
1263
|
+
* session, once per probe that needed to name an owner, each time with
|
|
1264
|
+
* slightly different truncation. Shipping it makes the traversal consistent
|
|
1265
|
+
* with the dedicated tool and removes the boilerplate that discourages
|
|
1266
|
+
* asking "who holds this?" in the middle of a larger eval.
|
|
1267
|
+
*
|
|
1268
|
+
* Root first, target last. `maxHops` bounds pathological chains.
|
|
1269
|
+
*/
|
|
1270
|
+
const rootPath = (nodeOrId, opts) => {
|
|
1271
|
+
const maxHops = opts?.maxHops ?? 24;
|
|
1272
|
+
// Collapsing is ON by default. Inside a linked-list leak — React update
|
|
1273
|
+
// queues, LRU chains, intrusive `.prev` lists — every hop is the SAME
|
|
1274
|
+
// edge, so the whole budget is spent walking the leak instead of escaping
|
|
1275
|
+
// it. Measured: 114 of 120 sampled update records returned
|
|
1276
|
+
// `Object.next -> Object.next -> ... (18 hops, no root)`, which
|
|
1277
|
+
// attributes nothing and then dominates the "top root paths" histogram
|
|
1278
|
+
// with an artifact of the walk.
|
|
1279
|
+
const collapse = opts?.collapseRepeatedEdges ?? true;
|
|
1280
|
+
let cur = resolveNode(nodeOrId);
|
|
1281
|
+
const out = [];
|
|
1282
|
+
const seen = new Set();
|
|
1283
|
+
let distinctHops = 0;
|
|
1284
|
+
let lastEdgeKey = null;
|
|
1285
|
+
let runLength = 0;
|
|
1286
|
+
while (cur != null && distinctHops < maxHops && !seen.has(cur.id)) {
|
|
1287
|
+
seen.add(cur.id);
|
|
1288
|
+
const edge = cur.hasPathEdge && cur.pathEdge ? cur.pathEdge : null;
|
|
1289
|
+
const edgeStr = edge
|
|
1290
|
+
? `${String(edge.name_or_index)} [${edge.type}]`
|
|
1291
|
+
: null;
|
|
1292
|
+
// A run is the same edge name arriving at the same class — that is the
|
|
1293
|
+
// shape a chain makes, and it keeps two unrelated `.value` hops apart.
|
|
1294
|
+
const edgeKey = edgeStr == null ? null : `${cur.name}\u0000${edgeStr}`;
|
|
1295
|
+
if (collapse && edgeKey != null && edgeKey === lastEdgeKey) {
|
|
1296
|
+
runLength++;
|
|
1297
|
+
const prev = out[out.length - 1];
|
|
1298
|
+
prev.repeated = runLength + 1;
|
|
469
1299
|
}
|
|
470
|
-
|
|
471
|
-
.
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
1300
|
+
else {
|
|
1301
|
+
out.push({
|
|
1302
|
+
id: cur.id,
|
|
1303
|
+
name: cur.name,
|
|
1304
|
+
type: cur.type,
|
|
1305
|
+
edge: edgeStr,
|
|
1306
|
+
});
|
|
1307
|
+
lastEdgeKey = edgeKey;
|
|
1308
|
+
runLength = 0;
|
|
1309
|
+
// Only a DISTINCT hop spends budget; a 2,000-link chain should cost
|
|
1310
|
+
// one, so the remaining hops can reach the actual owner.
|
|
1311
|
+
distinctHops++;
|
|
481
1312
|
}
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
1313
|
+
if (!edge)
|
|
1314
|
+
break;
|
|
1315
|
+
cur = edge.fromNode;
|
|
1316
|
+
}
|
|
1317
|
+
return out.reverse();
|
|
1318
|
+
};
|
|
1319
|
+
// Dominator-deduped retained size for a SET of ids (bounded walk). Unlike
|
|
1320
|
+
// summing helpers.retainedSize over the ids, this does not double-count
|
|
1321
|
+
// bytes when one id dominates another in the set.
|
|
1322
|
+
const aggregateRetained = (ids) => {
|
|
1323
|
+
requireRetention('aggregateRetained');
|
|
1324
|
+
return boundedDominatorRetainedSize(new NumericSet(ids), snapshot);
|
|
1325
|
+
};
|
|
1326
|
+
// ---- additional traversal helpers -------------------------------
|
|
1327
|
+
// Each of these was hand-written inside `code` during a leak hunt,
|
|
1328
|
+
// several of them more than once and with small differences that made
|
|
1329
|
+
// results incomparable. Shipping them makes the common traversals both
|
|
1330
|
+
// cheaper to write and consistent with what the dedicated tools do.
|
|
1331
|
+
// The oddball/root filtering the tools apply internally. Hand-written
|
|
1332
|
+
// eval that omits it counts nodes the tools do not, so the two disagree
|
|
1333
|
+
// for reasons that have nothing to do with the question being asked.
|
|
1334
|
+
const isRealDetached = (node) => {
|
|
1335
|
+
const n = unwrapNode(node);
|
|
1336
|
+
if (n == null || n.id <= 3)
|
|
1337
|
+
return false;
|
|
1338
|
+
return n.is_detached || n.name.startsWith('Detached ');
|
|
1339
|
+
};
|
|
1340
|
+
/**
|
|
1341
|
+
* The scope object a closure captured, or null if it captured nothing.
|
|
1342
|
+
*
|
|
1343
|
+
* Exists because the hand-written version is wrong in a way that returns a
|
|
1344
|
+
* clean zero: the hop is an `internal` edge NAMED `context`, not a
|
|
1345
|
+
* `context`-TYPED edge (that type only appears on the edges INSIDE the
|
|
1346
|
+
* scope). Filtering on `e.type === 'context'` matches nothing on any heap.
|
|
1347
|
+
*/
|
|
1348
|
+
const contextOf = (nodeOrId) => {
|
|
1349
|
+
const n = resolveNode(nodeOrId);
|
|
1350
|
+
if (n == null)
|
|
1351
|
+
return null;
|
|
1352
|
+
for (const e of n.references) {
|
|
1353
|
+
if (String(e.name_or_index) === 'context' && e.type === 'internal') {
|
|
1354
|
+
return wrapNode(e.toNode);
|
|
511
1355
|
}
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
const
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
const byClass = new Map();
|
|
528
|
-
const byTypename = new Map();
|
|
1356
|
+
}
|
|
1357
|
+
return null;
|
|
1358
|
+
};
|
|
1359
|
+
/**
|
|
1360
|
+
* Every closure class, with how many of them captured a scope.
|
|
1361
|
+
*
|
|
1362
|
+
* A per-name count alone does not separate "1,000 copies of a function" from
|
|
1363
|
+
* "1,000 copies each pinning a distinct scope", and only the second is a
|
|
1364
|
+
* retention story. Cached, since it is a full pass.
|
|
1365
|
+
*/
|
|
1366
|
+
const closureCensus = (opts) => {
|
|
1367
|
+
const key = '__closureCensus';
|
|
1368
|
+
let all = scratch[key];
|
|
1369
|
+
if (!all) {
|
|
1370
|
+
const acc = new Map();
|
|
529
1371
|
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')
|
|
1372
|
+
if (node.id <= 3 || node.type !== 'closure')
|
|
551
1373
|
return;
|
|
1374
|
+
let rec = acc.get(node.name);
|
|
1375
|
+
if (!rec) {
|
|
1376
|
+
rec = { count: 0, withScope: 0 };
|
|
1377
|
+
acc.set(node.name, rec);
|
|
1378
|
+
}
|
|
1379
|
+
rec.count++;
|
|
552
1380
|
for (const e of node.references) {
|
|
553
|
-
if (e.
|
|
554
|
-
|
|
555
|
-
|
|
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
|
-
}
|
|
1381
|
+
if (String(e.name_or_index) === 'context' &&
|
|
1382
|
+
e.type === 'internal') {
|
|
1383
|
+
rec.withScope++;
|
|
565
1384
|
break;
|
|
566
1385
|
}
|
|
567
1386
|
}
|
|
568
1387
|
});
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
1388
|
+
all = [...acc.entries()]
|
|
1389
|
+
.map(([name, r]) => ({ name, ...r }))
|
|
1390
|
+
.sort((a, b) => b.count - a.count);
|
|
1391
|
+
scratch[key] = all;
|
|
1392
|
+
}
|
|
1393
|
+
const minCount = opts?.minCount ?? 1;
|
|
1394
|
+
const re = opts?.pattern != null ? makeNamePatternTest(opts.pattern) : null;
|
|
1395
|
+
return all.filter(r => r.count >= minCount && (re == null || re(r.name)));
|
|
1396
|
+
};
|
|
1397
|
+
/**
|
|
1398
|
+
* Objects shaped like an event-listener record — carrying BOTH a
|
|
1399
|
+
* callback-ish and a context-ish property.
|
|
1400
|
+
*
|
|
1401
|
+
* This walk gets rewritten by hand almost every round, slightly differently
|
|
1402
|
+
* each time, which makes two rounds' numbers incomparable for reasons that
|
|
1403
|
+
* have nothing to do with the app. `callbackNamed` narrows to records whose
|
|
1404
|
+
* callback is a specific closure class, which is the form the question is
|
|
1405
|
+
* actually asked in ("how many `subscribe_$0` records are held?").
|
|
1406
|
+
*/
|
|
1407
|
+
const listenerRecords = (callbackNamed) => {
|
|
1408
|
+
const key = '__listenerRecords';
|
|
1409
|
+
let all = scratch[key];
|
|
1410
|
+
if (!all) {
|
|
1411
|
+
const found = [];
|
|
587
1412
|
snapshot.nodes.forEach((node) => {
|
|
588
|
-
if (node.id <= 3)
|
|
589
|
-
return;
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
// edges. Restricting the walk to `object` hid them, the same way it
|
|
593
|
-
// hid non-object classes from byClass.
|
|
1413
|
+
if (node.id <= 3 || node.type !== 'object')
|
|
1414
|
+
return;
|
|
1415
|
+
let cb = null;
|
|
1416
|
+
let ctx = null;
|
|
594
1417
|
for (const e of node.references) {
|
|
595
|
-
if (e.type
|
|
596
|
-
|
|
1418
|
+
if (e.type !== 'property')
|
|
1419
|
+
continue;
|
|
1420
|
+
const p = String(e.name_or_index);
|
|
1421
|
+
if (cb == null && LISTENER_CALLBACK_PROPS.has(p))
|
|
1422
|
+
cb = e.toNode;
|
|
1423
|
+
else if (ctx == null && LISTENER_CONTEXT_PROPS.has(p))
|
|
1424
|
+
ctx = e.toNode;
|
|
1425
|
+
if (cb != null && ctx != null)
|
|
597
1426
|
break;
|
|
598
|
-
|
|
1427
|
+
}
|
|
1428
|
+
if (cb != null && ctx != null) {
|
|
1429
|
+
found.push({ id: node.id, callback: cb.name, context: ctx.name });
|
|
599
1430
|
}
|
|
600
1431
|
});
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
}
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
1432
|
+
all = found;
|
|
1433
|
+
scratch[key] = all;
|
|
1434
|
+
}
|
|
1435
|
+
return callbackNamed == null
|
|
1436
|
+
? all
|
|
1437
|
+
: all.filter(r => r.callback === callbackNamed);
|
|
1438
|
+
};
|
|
1439
|
+
/**
|
|
1440
|
+
* Detached nodes whose CLASS NAME contains `needle`.
|
|
1441
|
+
*
|
|
1442
|
+
* Note what this cannot do, because the reflexive attempt returns a clean
|
|
1443
|
+
* zero: a detached node's `name` is its element or Blink class — `Detached
|
|
1444
|
+
* EventListener`, `Detached blink::RegisteredEventListener`, `Detached
|
|
1445
|
+
* HTMLDivElement` — and never a `data-testid`. Measured on a real capture:
|
|
1446
|
+
* 908 detached nodes, whose top names were `Detached EventListener` (148),
|
|
1447
|
+
* `Detached blink::RegisteredEventListener` (148) and `Detached
|
|
1448
|
+
* V8EventListener` (146). Filtering these for an app-level testid matches
|
|
1449
|
+
* nothing on any heap. For "which UI element leaked", go through the
|
|
1450
|
+
* retainer path (`memlab_detached_dom` groups by nearest non-detached
|
|
1451
|
+
* dominator) rather than the node name.
|
|
1452
|
+
*/
|
|
1453
|
+
const detachedNamed = (needle) => {
|
|
1454
|
+
const lowered = needle.toLowerCase();
|
|
1455
|
+
const out = [];
|
|
1456
|
+
snapshot.nodes.forEach((node) => {
|
|
1457
|
+
if (node.id <= 3)
|
|
1458
|
+
return;
|
|
1459
|
+
if (!node.is_detached && !node.name.startsWith('Detached '))
|
|
1460
|
+
return;
|
|
1461
|
+
if (!node.name.toLowerCase().includes(lowered))
|
|
1462
|
+
return;
|
|
1463
|
+
out.push({ id: node.id, name: node.name });
|
|
1464
|
+
});
|
|
1465
|
+
return out;
|
|
1466
|
+
};
|
|
1467
|
+
// Cached type -> ids index, mirroring the class index above, so a
|
|
1468
|
+
// second pass over "every closure" does not re-walk the heap.
|
|
1469
|
+
const buildTypeIndex = () => {
|
|
1470
|
+
const cached = scratch.__typeIndex;
|
|
1471
|
+
if (cached)
|
|
1472
|
+
return cached;
|
|
1473
|
+
const byType = new Map();
|
|
1474
|
+
snapshot.nodes.forEach((node) => {
|
|
1475
|
+
if (node.id <= 3)
|
|
1476
|
+
return;
|
|
1477
|
+
let a = byType.get(node.type);
|
|
1478
|
+
if (!a) {
|
|
1479
|
+
a = [];
|
|
1480
|
+
byType.set(node.type, a);
|
|
1481
|
+
}
|
|
1482
|
+
a.push(node.id);
|
|
1483
|
+
});
|
|
1484
|
+
scratch.__typeIndex = byType;
|
|
1485
|
+
return byType;
|
|
1486
|
+
};
|
|
1487
|
+
const nodesFromIds = (ids) => {
|
|
1488
|
+
const out = [];
|
|
1489
|
+
for (const id of ids) {
|
|
1490
|
+
const n = snapshot.getNodeById(id);
|
|
1491
|
+
if (n)
|
|
1492
|
+
out.push(n);
|
|
1493
|
+
}
|
|
1494
|
+
return out;
|
|
1495
|
+
};
|
|
1496
|
+
const iterByClass = (name, opts) => nodesFromIds(byClass(name, opts)).map(wrapNode);
|
|
1497
|
+
// Same thing under the name people actually reach for. `byClass` returns
|
|
1498
|
+
// IDS, and not all of them resolve through `snapshot.getNodeById` — native
|
|
1499
|
+
// classes (AudioContext, OpusRecorder, Recorder) come back null, so the
|
|
1500
|
+
// reflexive `byClass(x).map(id => getNodeById(id).referrers)` throws
|
|
1501
|
+
// "Cannot read properties of null" on the first attempt, every time. This
|
|
1502
|
+
// returns node objects and skips the unresolvable ones.
|
|
1503
|
+
const nodesByClass = iterByClass;
|
|
1504
|
+
const iterByType = (type) => nodesFromIds(buildTypeIndex().get(type) ?? []).map(wrapNode);
|
|
1505
|
+
// One-pass class histogram, cached, optionally filtered. `byClass`
|
|
1506
|
+
// answers "where are the X"; this answers "what is in here at all",
|
|
1507
|
+
// which otherwise means a full manual walk every time.
|
|
1508
|
+
const classCounts = (opts) => {
|
|
1509
|
+
const cacheKey = '__classCounts';
|
|
1510
|
+
let all = scratch[cacheKey];
|
|
1511
|
+
if (!all) {
|
|
1512
|
+
const acc = new Map();
|
|
632
1513
|
snapshot.nodes.forEach((node) => {
|
|
633
1514
|
if (node.id <= 3)
|
|
634
1515
|
return;
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
1516
|
+
const key = `${node.type}::${node.name}`;
|
|
1517
|
+
const e = acc.get(key);
|
|
1518
|
+
if (e) {
|
|
1519
|
+
e.count++;
|
|
1520
|
+
e.selfSize += node.self_size;
|
|
1521
|
+
}
|
|
1522
|
+
else {
|
|
1523
|
+
acc.set(key, {
|
|
1524
|
+
name: node.name,
|
|
1525
|
+
type: node.type,
|
|
1526
|
+
count: 1,
|
|
1527
|
+
selfSize: node.self_size,
|
|
1528
|
+
});
|
|
639
1529
|
}
|
|
640
|
-
a.push(node.id);
|
|
641
1530
|
});
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
}
|
|
645
|
-
const
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
1531
|
+
all = [...acc.values()].sort((a, b) => b.count - a.count);
|
|
1532
|
+
scratch[cacheKey] = all;
|
|
1533
|
+
}
|
|
1534
|
+
const matches = makeNamePatternTest(opts?.pattern);
|
|
1535
|
+
const minCount = opts?.minCount ?? 1;
|
|
1536
|
+
return all.filter(r => r.count >= minCount &&
|
|
1537
|
+
(opts?.type == null || r.type === opts.type) &&
|
|
1538
|
+
matches(r.name));
|
|
1539
|
+
};
|
|
1540
|
+
// Walk a dotted path of edges, reporting WHERE it stopped.
|
|
1541
|
+
//
|
|
1542
|
+
// The one-level-off mistake is the most common way an eval produces a
|
|
1543
|
+
// clean-looking wrong answer: a probe tested `_PSD` on an LRU wrapper when
|
|
1544
|
+
// the field lived on `wrapper.value`, got 0 across 300 samples, and the
|
|
1545
|
+
// zero was reported before the level was rechecked. `edgeTarget` chained by
|
|
1546
|
+
// hand hides that, because a null halfway looks the same as a null at the
|
|
1547
|
+
// end.
|
|
1548
|
+
const derefPath = (nodeOrId, path) => {
|
|
1549
|
+
let cur = typeof nodeOrId === 'number'
|
|
1550
|
+
? snapshot.getNodeById(nodeOrId)
|
|
1551
|
+
: unwrapNode(nodeOrId);
|
|
1552
|
+
if (cur == null)
|
|
1553
|
+
return { found: false, stoppedAt: '(start)', node: null };
|
|
1554
|
+
const parts = path.split('.').filter(Boolean);
|
|
1555
|
+
const walked = [];
|
|
1556
|
+
for (const part of parts) {
|
|
1557
|
+
let next = null;
|
|
1558
|
+
const node = cur;
|
|
1559
|
+
for (const e of node.references) {
|
|
1560
|
+
if (e.type === 'hidden')
|
|
1561
|
+
continue;
|
|
1562
|
+
if (String(e.name_or_index) === part) {
|
|
1563
|
+
next = e.toNode;
|
|
1564
|
+
break;
|
|
1565
|
+
}
|
|
651
1566
|
}
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
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;
|
|
1567
|
+
if (next == null) {
|
|
1568
|
+
return {
|
|
1569
|
+
found: false,
|
|
1570
|
+
stoppedAt: walked.length > 0 ? walked.join('.') : '(start)',
|
|
1571
|
+
missingEdge: part,
|
|
1572
|
+
node: null,
|
|
1573
|
+
available: [...node.references]
|
|
1574
|
+
.filter(e => e.type === 'property')
|
|
1575
|
+
.slice(0, 20)
|
|
1576
|
+
.map(e => String(e.name_or_index)),
|
|
1577
|
+
};
|
|
684
1578
|
}
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
1579
|
+
walked.push(part);
|
|
1580
|
+
cur = next;
|
|
1581
|
+
}
|
|
1582
|
+
return { found: true, stoppedAt: walked.join('.'), node: wrapNode(cur) };
|
|
1583
|
+
};
|
|
1584
|
+
// "Is there anything matching this within N hops?" — answers the question
|
|
1585
|
+
// derefPath needs you to already know the answer to.
|
|
1586
|
+
const findWithin = (nodeOrId, edgeName, opts) => {
|
|
1587
|
+
const start = typeof nodeOrId === 'number'
|
|
1588
|
+
? snapshot.getNodeById(nodeOrId)
|
|
1589
|
+
: unwrapNode(nodeOrId);
|
|
1590
|
+
if (start == null)
|
|
1591
|
+
return [];
|
|
1592
|
+
const maxDepth = Math.max(1, Math.min(opts?.maxDepth ?? 3, 6));
|
|
1593
|
+
const seen = new Set([start.id]);
|
|
1594
|
+
const hits = [];
|
|
1595
|
+
const queue = [
|
|
1596
|
+
{ node: start, path: '', depth: 0 },
|
|
1597
|
+
];
|
|
1598
|
+
while (queue.length > 0 && hits.length < 25) {
|
|
1599
|
+
const item = queue.shift();
|
|
1600
|
+
if (!item)
|
|
1601
|
+
break;
|
|
1602
|
+
for (const e of item.node.references) {
|
|
701
1603
|
if (e.type === 'hidden')
|
|
702
1604
|
continue;
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
1605
|
+
const name = String(e.name_or_index);
|
|
1606
|
+
const path = item.path ? `${item.path}.${name}` : name;
|
|
1607
|
+
if (name === edgeName) {
|
|
1608
|
+
hits.push({ path, id: e.toNode.id, name: e.toNode.name });
|
|
1609
|
+
if (hits.length >= 25)
|
|
1610
|
+
break;
|
|
1611
|
+
}
|
|
1612
|
+
if (item.depth + 1 < maxDepth && !seen.has(e.toNode.id)) {
|
|
1613
|
+
seen.add(e.toNode.id);
|
|
1614
|
+
queue.push({ node: e.toNode, path, depth: item.depth + 1 });
|
|
1615
|
+
}
|
|
706
1616
|
}
|
|
1617
|
+
}
|
|
1618
|
+
return hits;
|
|
1619
|
+
};
|
|
1620
|
+
// The node behind a named edge. Written from scratch in four separate
|
|
1621
|
+
// evals because `props()` returns {ref,name,type} wrappers, which are
|
|
1622
|
+
// awkward exactly when the node itself is what you need.
|
|
1623
|
+
const edgeTarget = (nodeOrId, edgeName) => {
|
|
1624
|
+
const n = typeof nodeOrId === 'number'
|
|
1625
|
+
? snapshot.getNodeById(nodeOrId)
|
|
1626
|
+
: unwrapNode(nodeOrId);
|
|
1627
|
+
if (n == null)
|
|
707
1628
|
return null;
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
1629
|
+
for (const e of n.references) {
|
|
1630
|
+
if (e.type === 'hidden')
|
|
1631
|
+
continue;
|
|
1632
|
+
if (String(e.name_or_index) !== edgeName)
|
|
1633
|
+
continue;
|
|
1634
|
+
return e.toNode.id > 3 ? wrapNode(e.toNode) : null;
|
|
1635
|
+
}
|
|
1636
|
+
return null;
|
|
1637
|
+
};
|
|
1638
|
+
// Walk a repeated linked structure and say HOW it ended.
|
|
1639
|
+
//
|
|
1640
|
+
// Hand-written versions of this loop are the single most reliable way to
|
|
1641
|
+
// publish a wrong number from an eval. React's `queue.pending` is CIRCULAR,
|
|
1642
|
+
// so `while (next && next.id !== start)` with a `hops < 800` guard reported
|
|
1643
|
+
// `longestNextChain: 800` — the cap, presented as a measurement. The true
|
|
1644
|
+
// length was 2,066, and nothing in the output distinguished "the list ended"
|
|
1645
|
+
// from "I stopped counting". The discriminator is the point of this helper:
|
|
1646
|
+
// `terminated: 'cap'` means the number is a floor, not a length.
|
|
1647
|
+
const walkChain = (startOrNode, edgeName, opts) => {
|
|
1648
|
+
const maxHops = Math.max(1, opts?.maxHops ?? 100000);
|
|
1649
|
+
const start = typeof startOrNode === 'number'
|
|
1650
|
+
? snapshot.getNodeById(startOrNode)
|
|
1651
|
+
: unwrapNode(startOrNode);
|
|
1652
|
+
if (start == null) {
|
|
1653
|
+
throw new Error(`walkChain: start node not found`);
|
|
1654
|
+
}
|
|
1655
|
+
const seen = new Set();
|
|
1656
|
+
const ids = [];
|
|
1657
|
+
let cur = start;
|
|
1658
|
+
let terminated = 'end';
|
|
1659
|
+
while (cur != null) {
|
|
1660
|
+
if (seen.has(cur.id)) {
|
|
1661
|
+
terminated = 'cycle';
|
|
1662
|
+
break;
|
|
723
1663
|
}
|
|
724
|
-
if (
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
value: wrapNode(el),
|
|
728
|
-
}));
|
|
1664
|
+
if (seen.size >= maxHops) {
|
|
1665
|
+
terminated = 'cap';
|
|
1666
|
+
break;
|
|
729
1667
|
}
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
if (
|
|
1668
|
+
seen.add(cur.id);
|
|
1669
|
+
if (opts?.collectIds)
|
|
1670
|
+
ids.push(cur.id);
|
|
1671
|
+
let nextNode = null;
|
|
1672
|
+
for (const e of cur.references) {
|
|
1673
|
+
if (String(e.name_or_index) !== edgeName)
|
|
736
1674
|
continue;
|
|
737
|
-
|
|
1675
|
+
nextNode = e.toNode.id > 3 ? e.toNode : null;
|
|
1676
|
+
break;
|
|
1677
|
+
}
|
|
1678
|
+
cur = nextNode;
|
|
1679
|
+
}
|
|
1680
|
+
return {
|
|
1681
|
+
length: seen.size,
|
|
1682
|
+
terminated,
|
|
1683
|
+
truncated: terminated === 'cap',
|
|
1684
|
+
...(opts?.collectIds ? { ids } : {}),
|
|
1685
|
+
};
|
|
1686
|
+
};
|
|
1687
|
+
// Generic container walk. `mapEntries` / `setElements` cover Map and
|
|
1688
|
+
// Set; WeakMap tables and plain arrays needed a manual `references`
|
|
1689
|
+
// walk with hole filtering every time.
|
|
1690
|
+
const entries = (nodeOrId) => {
|
|
1691
|
+
const n = typeof nodeOrId === 'number'
|
|
1692
|
+
? snapshot.getNodeById(nodeOrId)
|
|
1693
|
+
: unwrapNode(nodeOrId);
|
|
1694
|
+
if (n == null)
|
|
1695
|
+
return [];
|
|
1696
|
+
if (n.name === 'Map' || n.name === 'WeakMap') {
|
|
1697
|
+
return enumerateMapEntries(n).map(e => ({
|
|
1698
|
+
key: wrapNode(e.key),
|
|
1699
|
+
value: e.value == null ? null : wrapNode(e.value),
|
|
1700
|
+
}));
|
|
1701
|
+
}
|
|
1702
|
+
if (n.name === 'Set' || n.name === 'WeakSet') {
|
|
1703
|
+
return enumerateSetElements(n).map(el => ({
|
|
1704
|
+
key: null,
|
|
1705
|
+
value: wrapNode(el),
|
|
1706
|
+
}));
|
|
1707
|
+
}
|
|
1708
|
+
const out = [];
|
|
1709
|
+
for (const e of n.references) {
|
|
1710
|
+
if (e.type === 'hidden')
|
|
1711
|
+
continue;
|
|
1712
|
+
const name = String(e.name_or_index);
|
|
1713
|
+
if (name === '__proto__')
|
|
1714
|
+
continue;
|
|
1715
|
+
// V8 hangs the hidden class off an internal edge literally named
|
|
1716
|
+
// `map`; dropping it unconditionally also dropped a real property
|
|
1717
|
+
// named `map` (a config object with a `.map` field), which then
|
|
1718
|
+
// read as "the object does not have one".
|
|
1719
|
+
if (name === 'map' && e.type !== 'property')
|
|
1720
|
+
continue;
|
|
1721
|
+
if (e.type === 'element') {
|
|
1722
|
+
out.push({
|
|
1723
|
+
key: Number(e.name_or_index),
|
|
1724
|
+
value: wrapNode(e.toNode),
|
|
1725
|
+
});
|
|
1726
|
+
}
|
|
1727
|
+
else if (name === 'elements' && e.type === 'internal') {
|
|
1728
|
+
for (const el of e.toNode.references) {
|
|
1729
|
+
if (el.type !== 'element')
|
|
1730
|
+
continue;
|
|
738
1731
|
out.push({
|
|
739
|
-
key: Number(
|
|
740
|
-
value: wrapNode(
|
|
1732
|
+
key: Number(el.name_or_index),
|
|
1733
|
+
value: wrapNode(el.toNode),
|
|
741
1734
|
});
|
|
742
1735
|
}
|
|
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
1736
|
}
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
cur = next;
|
|
1737
|
+
else if (e.type === 'property') {
|
|
1738
|
+
out.push({ key: name, value: wrapNode(e.toNode) });
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
return out;
|
|
1742
|
+
};
|
|
1743
|
+
// What does this node actually own? The question behind
|
|
1744
|
+
// memlab_dominator_attribution, exposed for ad-hoc populations.
|
|
1745
|
+
const dominates = (id, opts) => {
|
|
1746
|
+
requireRetention('dominates');
|
|
1747
|
+
const limit = opts?.limit ?? 1000;
|
|
1748
|
+
const pop = opts?.population;
|
|
1749
|
+
let count = 0;
|
|
1750
|
+
let selfSize = 0;
|
|
1751
|
+
const ids = [];
|
|
1752
|
+
let truncated = false;
|
|
1753
|
+
snapshot.nodes.forEach((node) => {
|
|
1754
|
+
if (node.id <= 3 || node.id === id)
|
|
1755
|
+
return;
|
|
1756
|
+
if (pop != null && !pop(wrapNode(node)))
|
|
1757
|
+
return;
|
|
1758
|
+
let cur = node.dominatorNode ?? null;
|
|
1759
|
+
let hops = 0;
|
|
1760
|
+
while (cur && hops++ < 500) {
|
|
1761
|
+
if (cur.id === id) {
|
|
1762
|
+
count++;
|
|
1763
|
+
selfSize += node.self_size;
|
|
1764
|
+
if (ids.length < limit)
|
|
1765
|
+
ids.push(node.id);
|
|
1766
|
+
else
|
|
1767
|
+
truncated = true;
|
|
1768
|
+
break;
|
|
790
1769
|
}
|
|
791
|
-
|
|
792
|
-
|
|
1770
|
+
const next = cur.dominatorNode ?? null;
|
|
1771
|
+
if (!next || next.id === cur.id)
|
|
1772
|
+
break;
|
|
1773
|
+
cur = next;
|
|
1774
|
+
}
|
|
1775
|
+
});
|
|
1776
|
+
return { count, selfSize, ids, truncated };
|
|
1777
|
+
};
|
|
1778
|
+
// Walk up the dominator chain to the nearest node that carries a class
|
|
1779
|
+
// identity, skipping V8's containers and system objects. "Who owns this?"
|
|
1780
|
+
// is a loop every investigation rewrites by hand — and writes slightly
|
|
1781
|
+
// differently each time, which is why the same population gets attributed
|
|
1782
|
+
// to different owners on different days.
|
|
1783
|
+
//
|
|
1784
|
+
// A single-letter name is NOT skipped. In a minified bundle `t` and `e`
|
|
1785
|
+
// are the only class identity that exists; treating them as meaningless
|
|
1786
|
+
// walks straight past the owner and reports the system container above it.
|
|
1787
|
+
// Pair the name with `memlab_identify` to find out what it is.
|
|
1788
|
+
const CONTAINER_OWNER = /^(Object|Array|system(\s*\/.*)?|\(.*\))$/;
|
|
1789
|
+
const owner = (nodeOrId, opts) => {
|
|
1790
|
+
requireRetention('owner');
|
|
1791
|
+
const start = typeof nodeOrId === 'number'
|
|
1792
|
+
? snapshot.getNodeById(nodeOrId)
|
|
1793
|
+
: unwrapNode(nodeOrId);
|
|
1794
|
+
if (start == null)
|
|
1795
|
+
return null;
|
|
1796
|
+
const maxHops = opts?.maxHops ?? 50;
|
|
1797
|
+
let cur = start.dominatorNode ?? null;
|
|
1798
|
+
let last = null;
|
|
1799
|
+
let hops = 1;
|
|
1800
|
+
let lastHops = 0;
|
|
1801
|
+
while (cur != null && cur.id > 3 && hops <= maxHops) {
|
|
1802
|
+
last = cur;
|
|
1803
|
+
lastHops = hops;
|
|
1804
|
+
if (!CONTAINER_OWNER.test(cur.name)) {
|
|
1805
|
+
return {
|
|
1806
|
+
id: cur.id,
|
|
1807
|
+
name: cur.name,
|
|
1808
|
+
type: cur.type,
|
|
1809
|
+
hops,
|
|
1810
|
+
selfSize: cur.self_size,
|
|
1811
|
+
named: true,
|
|
1812
|
+
};
|
|
1813
|
+
}
|
|
1814
|
+
const next = cur.dominatorNode ?? null;
|
|
1815
|
+
if (next == null || next.id === cur.id)
|
|
1816
|
+
break;
|
|
1817
|
+
cur = next;
|
|
1818
|
+
hops++;
|
|
1819
|
+
}
|
|
1820
|
+
// React fallback. Hooks, update queues and fibers are all plain `Object`,
|
|
1821
|
+
// so the container filter above walks straight past every one of them and
|
|
1822
|
+
// reports nothing: on one population `recordsByOwner` came back
|
|
1823
|
+
// `[["(none)", 1645]]` for 100% of the records. A fiber is recognisable
|
|
1824
|
+
// by its own fields rather than by its class name, and its component name
|
|
1825
|
+
// is the answer the caller actually wanted.
|
|
1826
|
+
const fiber = nearestFiber(start, maxHops);
|
|
1827
|
+
if (fiber != null) {
|
|
1828
|
+
const componentName = fiberComponentName(fiber);
|
|
1829
|
+
return {
|
|
1830
|
+
id: fiber.id,
|
|
1831
|
+
name: componentName ?? fiber.name,
|
|
1832
|
+
type: fiber.type,
|
|
1833
|
+
hops: 0,
|
|
1834
|
+
selfSize: fiber.self_size,
|
|
1835
|
+
named: componentName != null,
|
|
1836
|
+
};
|
|
1837
|
+
}
|
|
1838
|
+
// Nothing but containers all the way up is itself the answer — report
|
|
1839
|
+
// the furthest node reached with named:false rather than null, which
|
|
1840
|
+
// would be indistinguishable from "no such node".
|
|
1841
|
+
if (last == null)
|
|
1842
|
+
return null;
|
|
1843
|
+
return {
|
|
1844
|
+
id: last.id,
|
|
1845
|
+
name: last.name,
|
|
1846
|
+
type: last.type,
|
|
1847
|
+
hops: lastHops,
|
|
1848
|
+
selfSize: last.self_size,
|
|
1849
|
+
named: false,
|
|
793
1850
|
};
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
1851
|
+
};
|
|
1852
|
+
// Elements backing store, read correctly. Five separate facts are needed
|
|
1853
|
+
// (owner-vs-store edge split, SMI invisibility, unmeasurability, slot-width
|
|
1854
|
+
// calibration, dictionary detection) and every one of them was got wrong
|
|
1855
|
+
// once before it was got right — see src/heap-shapes.ts. Hand-deriving this
|
|
1856
|
+
// in an eval is how an all-SMI array reads as 100% wasted.
|
|
1857
|
+
const elements = (nodeOrId) => {
|
|
1858
|
+
const n = typeof nodeOrId === 'number'
|
|
1859
|
+
? snapshot.getNodeById(nodeOrId)
|
|
1860
|
+
: unwrapNode(nodeOrId);
|
|
1861
|
+
if (n == null)
|
|
1862
|
+
return null;
|
|
1863
|
+
return readElementsInfo(snapshot, n);
|
|
1864
|
+
};
|
|
1865
|
+
// DISTINCT nodes pointed at by an edge with this name — the mirror of
|
|
1866
|
+
// `withProp`, which finds nodes that HAVE the property. Asking "what is
|
|
1867
|
+
// stored under `.logs` anywhere in the heap" previously needed a full scan.
|
|
1868
|
+
//
|
|
1869
|
+
// Deduplicated because a shared target reached from N referrers is one
|
|
1870
|
+
// storage site, not N: the obvious use ("how many distinct places hold a
|
|
1871
|
+
// `.logs`") would otherwise multiply-count every shared array, and nothing
|
|
1872
|
+
// in the returned ids says which of them were duplicates.
|
|
1873
|
+
const byReferrerEdge = (edgeName) => {
|
|
1874
|
+
const hits = new Set();
|
|
1875
|
+
snapshot.nodes.forEach(node => {
|
|
1876
|
+
for (const e of node.references) {
|
|
1877
|
+
if (String(e.name_or_index) !== edgeName)
|
|
1878
|
+
continue;
|
|
1879
|
+
if (e.toNode.id > 3)
|
|
1880
|
+
hits.add(e.toNode.id);
|
|
1881
|
+
break;
|
|
802
1882
|
}
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
1883
|
+
});
|
|
1884
|
+
return [...hits];
|
|
1885
|
+
};
|
|
1886
|
+
// Group-and-count over ids. The single most-rewritten block in ad-hoc eval
|
|
1887
|
+
// code, and the one whose hand-written versions most often silently drop
|
|
1888
|
+
// the undefined bucket.
|
|
1889
|
+
const histogram = (ids, keyFn, opts) => {
|
|
1890
|
+
const counts = new Map();
|
|
1891
|
+
for (const id of ids) {
|
|
1892
|
+
const node = snapshot.getNodeById(id);
|
|
1893
|
+
if (node == null)
|
|
1894
|
+
continue;
|
|
1895
|
+
const raw = keyFn(wrapNode(node), id);
|
|
1896
|
+
const key = raw == null ? '(none)' : String(raw);
|
|
1897
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
1898
|
+
}
|
|
1899
|
+
const rows = [...counts.entries()]
|
|
1900
|
+
.map(([key, count]) => ({ key, count }))
|
|
1901
|
+
.sort((a, b) => b.count - a.count);
|
|
1902
|
+
return opts?.limit != null ? rows.slice(0, opts.limit) : rows;
|
|
1903
|
+
};
|
|
1904
|
+
// Shortest reference path a -> b, by BFS over outgoing edges. Bounded,
|
|
1905
|
+
// and reports that it gave up rather than returning null as if no path
|
|
1906
|
+
// existed.
|
|
1907
|
+
const pathBetween = (fromId, toId, opts) => {
|
|
1908
|
+
const maxNodes = opts?.maxNodes ?? 200_000;
|
|
1909
|
+
const start = snapshot.getNodeById(fromId);
|
|
1910
|
+
if (start == null || snapshot.getNodeById(toId) == null) {
|
|
1911
|
+
return { found: false, exhausted: false, path: [] };
|
|
1912
|
+
}
|
|
1913
|
+
// `seen` is seeded with `fromId`, so the BFS below can never reach it
|
|
1914
|
+
// again and the trivial 0-hop path would come back as "no path".
|
|
1915
|
+
if (fromId === toId) {
|
|
1916
|
+
return {
|
|
1917
|
+
found: true,
|
|
1918
|
+
exhausted: false,
|
|
1919
|
+
path: [`@${fromId} ${start.name}`],
|
|
1920
|
+
};
|
|
1921
|
+
}
|
|
1922
|
+
const prev = new Map();
|
|
1923
|
+
const seen = new Set([fromId]);
|
|
1924
|
+
let queue = [start];
|
|
1925
|
+
let visited = 0;
|
|
1926
|
+
while (queue.length > 0) {
|
|
1927
|
+
const next = [];
|
|
1928
|
+
for (const node of queue) {
|
|
1929
|
+
if (++visited > maxNodes) {
|
|
1930
|
+
return { found: false, exhausted: true, path: [] };
|
|
1931
|
+
}
|
|
1932
|
+
for (const e of node.references) {
|
|
1933
|
+
const t = e.toNode;
|
|
1934
|
+
if (t.id <= 3 || seen.has(t.id))
|
|
1935
|
+
continue;
|
|
1936
|
+
seen.add(t.id);
|
|
1937
|
+
prev.set(t.id, { via: String(e.name_or_index), from: node.id });
|
|
1938
|
+
if (t.id === toId) {
|
|
1939
|
+
const path = [];
|
|
1940
|
+
let cur = toId;
|
|
1941
|
+
while (cur !== fromId) {
|
|
1942
|
+
const p = prev.get(cur);
|
|
1943
|
+
if (p == null)
|
|
1944
|
+
break;
|
|
1945
|
+
const n = snapshot.getNodeById(cur);
|
|
1946
|
+
path.unshift(`.${p.via} -> @${cur} ${n?.name ?? ''}`);
|
|
1947
|
+
cur = p.from;
|
|
832
1948
|
}
|
|
833
|
-
|
|
1949
|
+
path.unshift(`@${fromId} ${start.name}`);
|
|
1950
|
+
return { found: true, exhausted: false, path };
|
|
834
1951
|
}
|
|
1952
|
+
next.push(t);
|
|
835
1953
|
}
|
|
836
|
-
queue = next;
|
|
837
1954
|
}
|
|
838
|
-
|
|
839
|
-
}
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
1955
|
+
queue = next;
|
|
1956
|
+
}
|
|
1957
|
+
return { found: false, exhausted: false, path: [] };
|
|
1958
|
+
};
|
|
1959
|
+
// Named result sets are SESSION-scoped, not snapshot-scoped: comparing
|
|
1960
|
+
// a baseline scan against a final scan is the whole job, and the old
|
|
1961
|
+
// per-snapshot scratch dropped the baseline the moment the next rung
|
|
1962
|
+
// was loaded — exactly when it was needed.
|
|
1963
|
+
//
|
|
1964
|
+
// Node ids, however, are per-capture. A set of ids saved against one
|
|
1965
|
+
// snapshot means nothing against another, so a cross-snapshot load is
|
|
1966
|
+
// refused unless the caller opts in. Counts and strings are portable;
|
|
1967
|
+
// ids are not, and silently letting them through is the failure this
|
|
1968
|
+
// whole class of guard exists to prevent.
|
|
1969
|
+
const save = (name, value) => {
|
|
1970
|
+
setSavedResult(name, value, currentHandle);
|
|
1971
|
+
return value;
|
|
1972
|
+
};
|
|
1973
|
+
const load = (name, opts) => {
|
|
1974
|
+
const entry = getSavedResult(name);
|
|
1975
|
+
if (entry == null) {
|
|
1976
|
+
throw new Error(`No saved result named "${name}". Saved names: ${savedNames().join(', ') || '(none)'}. ` +
|
|
1977
|
+
'Save one with the save_as parameter or helpers.save(name, value).');
|
|
1978
|
+
}
|
|
1979
|
+
if (entry.handle !== currentHandle && opts?.allowCrossSnapshot !== true) {
|
|
1980
|
+
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. ` +
|
|
1981
|
+
'If the value is counts/strings/shapes rather than ids, pass {allowCrossSnapshot: true} to read it anyway.');
|
|
1982
|
+
}
|
|
1983
|
+
return entry.value;
|
|
1984
|
+
};
|
|
1985
|
+
// Cross-SESSION persistence. `save`/`load` above are scoped to the current
|
|
1986
|
+
// snapshot and dropped when it is unloaded, which is right for an id list
|
|
1987
|
+
// (ids are per-capture) and wrong for a derived fact — a decoded cap, a
|
|
1988
|
+
// per-entry cost, a conclusion. Those are what a later session needs and
|
|
1989
|
+
// the only thing that can meaningfully outlive the heap they came from.
|
|
1990
|
+
const remember = (name, value) => {
|
|
1991
|
+
writeEvalStore({ ...readEvalStore(), [name]: value });
|
|
1992
|
+
return value;
|
|
1993
|
+
};
|
|
1994
|
+
const recall = (name) => {
|
|
1995
|
+
const store = readEvalStore();
|
|
1996
|
+
return name == null ? Object.keys(store) : store[name];
|
|
1997
|
+
};
|
|
1998
|
+
// Evenly-spaced sampling, not random: two calls over the same population
|
|
1999
|
+
// return the same members, so a follow-up question lands on the objects
|
|
2000
|
+
// the first answer described. Math.random() here would silently make
|
|
2001
|
+
// every re-run a different measurement.
|
|
2002
|
+
const sample = (items, n) => {
|
|
2003
|
+
const arr = Array.isArray(items) ? items : [...items];
|
|
2004
|
+
if (n <= 0 || arr.length === 0)
|
|
2005
|
+
return [];
|
|
2006
|
+
if (arr.length <= n)
|
|
2007
|
+
return arr.slice();
|
|
2008
|
+
const step = arr.length / n;
|
|
2009
|
+
const out = [];
|
|
2010
|
+
for (let i = 0; i < n; i++)
|
|
2011
|
+
out.push(arr[Math.floor(i * step)]);
|
|
2012
|
+
return out;
|
|
2013
|
+
};
|
|
2014
|
+
const listSaved = () => listSavedResults();
|
|
2015
|
+
// Sandbox code holds PROXIED nodes (see wrapNode). The helpers below
|
|
2016
|
+
// read `retainedSize`, which the proxy refuses on purpose, so they take
|
|
2017
|
+
// the real node: unwrap at the boundary rather than making every caller
|
|
2018
|
+
// remember which helpers are proxy-safe.
|
|
2019
|
+
// The retention-bearing fields of a serialized node, blanked on a LIGHT
|
|
2020
|
+
// snapshot. The dominator pass never ran there, so they serialize as 0 /
|
|
2021
|
+
// null-by-accident — a confident wrong number. Blanked rather than
|
|
2022
|
+
// refused because everything else these two return (id, name, type,
|
|
2023
|
+
// self size, edge/referrer counts, string value) is genuinely available
|
|
2024
|
+
// on a light load, and refusing would take that away too.
|
|
2025
|
+
const blankRetentionOnLight = (s) => {
|
|
2026
|
+
if (!light)
|
|
2027
|
+
return { ...s };
|
|
2028
|
+
const out = { ...s, retained_size: null };
|
|
2029
|
+
if ('dominator_id' in s)
|
|
2030
|
+
out.dominator_id = null;
|
|
2031
|
+
return out;
|
|
2032
|
+
};
|
|
2033
|
+
const helpersImpl = {
|
|
2034
|
+
serializeNodeSummary: (n) => blankRetentionOnLight(serializeNodeSummary(unwrapNode(n))),
|
|
2035
|
+
serializeNodeDetail: (n) => blankRetentionOnLight(serializeNodeDetail(unwrapNode(n))),
|
|
2036
|
+
formatBytes,
|
|
2037
|
+
formatNumber,
|
|
2038
|
+
markdownTable,
|
|
2039
|
+
isNodeWorthInspecting: (n, ...rest) => isNodeWorthInspecting(unwrapNode(n), ...rest),
|
|
2040
|
+
// Both of these RANK by `node.retainedSize`, so both need the real
|
|
2041
|
+
// snapshot and a non-light load. The previous wrapper renamed the
|
|
2042
|
+
// first parameter `nodes` and mapped it as an array, which matched
|
|
2043
|
+
// neither utility's signature — `filterLargestObjects(snapshot,
|
|
2044
|
+
// filter, limit)` — and did nothing. Unwrapping is what was actually
|
|
2045
|
+
// needed: sandbox code only ever holds the PROXIED snapshot, whose
|
|
2046
|
+
// nodes refuse the very `retainedSize` read these two rank on.
|
|
844
2047
|
//
|
|
845
|
-
//
|
|
846
|
-
//
|
|
847
|
-
//
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
2048
|
+
// `RETENTION_IDENTIFIERS` catches `filterLargestObjects` textually
|
|
2049
|
+
// before the code runs; these runtime guards cover both, and cover
|
|
2050
|
+
// them precisely (a `queryNodes` count needs no retention at all).
|
|
2051
|
+
filterLargestObjects: (snap, filter, limit) => {
|
|
2052
|
+
requireRetention('filterLargestObjects');
|
|
2053
|
+
return filterLargestObjects(unwrapNode(snap), filter, limit);
|
|
2054
|
+
},
|
|
2055
|
+
queryNodes: (snap, filter, opts) => {
|
|
2056
|
+
if (opts?.outputMode !== 'count')
|
|
2057
|
+
requireRetention('queryNodes');
|
|
2058
|
+
return queryNodes(unwrapNode(snap), filter, opts);
|
|
2059
|
+
},
|
|
2060
|
+
groupReferrersByEdge,
|
|
2061
|
+
groupArrayElementsByProperty,
|
|
2062
|
+
isOrphaned,
|
|
2063
|
+
countUniqueTargets,
|
|
2064
|
+
retainedSize,
|
|
2065
|
+
retainedSizes,
|
|
2066
|
+
nodeBrief,
|
|
2067
|
+
mapEntries,
|
|
2068
|
+
setElements,
|
|
2069
|
+
props,
|
|
2070
|
+
getProp,
|
|
2071
|
+
ownProps,
|
|
2072
|
+
shapeKeys,
|
|
2073
|
+
hasShape,
|
|
2074
|
+
rootPath,
|
|
2075
|
+
shapeSignature,
|
|
2076
|
+
byClass,
|
|
2077
|
+
byTypename,
|
|
2078
|
+
withProp,
|
|
2079
|
+
aggregateRetained,
|
|
2080
|
+
contextOf,
|
|
2081
|
+
closureCensus,
|
|
2082
|
+
listenerRecords,
|
|
2083
|
+
detachedNamed,
|
|
2084
|
+
isRealDetached,
|
|
2085
|
+
iterByClass,
|
|
2086
|
+
nodesByClass,
|
|
2087
|
+
iterByType,
|
|
2088
|
+
classCounts,
|
|
2089
|
+
edgeTarget,
|
|
2090
|
+
walkChain,
|
|
2091
|
+
elements,
|
|
2092
|
+
byReferrerEdge,
|
|
2093
|
+
derefPath,
|
|
2094
|
+
findWithin,
|
|
2095
|
+
entries,
|
|
2096
|
+
dominates,
|
|
2097
|
+
owner,
|
|
2098
|
+
histogram,
|
|
2099
|
+
pathBetween,
|
|
2100
|
+
save,
|
|
2101
|
+
load,
|
|
2102
|
+
listSaved,
|
|
2103
|
+
remember,
|
|
2104
|
+
recall,
|
|
2105
|
+
sample,
|
|
2106
|
+
};
|
|
2107
|
+
/**
|
|
2108
|
+
* A mistyped helper name used to fail as `helpers.foo is not a function`,
|
|
2109
|
+
* with no clue what the right name was. The surface is 40+ helpers and
|
|
2110
|
+
* `describe_env` is a separate round trip that costs ~10 KB of tokens, so
|
|
2111
|
+
* the cheapest fix is to answer the question at the point it is asked.
|
|
2112
|
+
*/
|
|
2113
|
+
const helperNames = Object.keys(helpersImpl).sort();
|
|
2114
|
+
lastKnownHelperNames = helperNames;
|
|
2115
|
+
const helpers = new Proxy(helpersImpl, {
|
|
2116
|
+
get(target, prop, receiver) {
|
|
2117
|
+
if (typeof prop === 'string' && !(prop in target)) {
|
|
2118
|
+
const lower = prop.toLowerCase();
|
|
2119
|
+
const near = helperNames.filter(n => n.toLowerCase().includes(lower) ||
|
|
2120
|
+
lower.includes(n.toLowerCase()) ||
|
|
2121
|
+
editDistanceWithin(n.toLowerCase(), lower, 2));
|
|
2122
|
+
throw new Error(`helpers.${prop} does not exist.` +
|
|
2123
|
+
(near.length > 0
|
|
2124
|
+
? ` Did you mean: ${near.slice(0, 5).join(', ')}?`
|
|
2125
|
+
: '') +
|
|
2126
|
+
` All helpers: ${helperNames.join(', ')}.` +
|
|
2127
|
+
' Use mode:"describe_env" for signatures.');
|
|
864
2128
|
}
|
|
865
|
-
return
|
|
866
|
-
}
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
Map,
|
|
919
|
-
Set,
|
|
920
|
-
JSON,
|
|
921
|
-
Math,
|
|
922
|
-
RegExp,
|
|
923
|
-
String,
|
|
924
|
-
Number,
|
|
925
|
-
Boolean,
|
|
926
|
-
Date,
|
|
927
|
-
Error,
|
|
928
|
-
TypeError,
|
|
929
|
-
RangeError,
|
|
930
|
-
WeakMap,
|
|
931
|
-
WeakSet,
|
|
932
|
-
Symbol,
|
|
933
|
-
parseInt,
|
|
934
|
-
parseFloat,
|
|
935
|
-
isNaN,
|
|
936
|
-
isFinite,
|
|
937
|
-
Infinity,
|
|
938
|
-
NaN,
|
|
939
|
-
undefined,
|
|
940
|
-
};
|
|
941
|
-
const context = vm.createContext(sandbox);
|
|
942
|
-
const script = new vm.Script(code, { filename: 'memlab_eval' });
|
|
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 });
|
|
2129
|
+
return Reflect.get(target, prop, receiver);
|
|
2130
|
+
},
|
|
2131
|
+
});
|
|
2132
|
+
const sandbox = {
|
|
2133
|
+
snapshot: wrapSnapshot(snapshot, budget),
|
|
2134
|
+
utils,
|
|
2135
|
+
helpers,
|
|
2136
|
+
console: capturedConsole,
|
|
2137
|
+
result: undefined,
|
|
2138
|
+
// Standard JS globals
|
|
2139
|
+
Array,
|
|
2140
|
+
Object,
|
|
2141
|
+
Map,
|
|
2142
|
+
Set,
|
|
2143
|
+
JSON,
|
|
2144
|
+
Math,
|
|
2145
|
+
RegExp,
|
|
2146
|
+
String,
|
|
2147
|
+
Number,
|
|
2148
|
+
Boolean,
|
|
2149
|
+
Date,
|
|
2150
|
+
Error,
|
|
2151
|
+
TypeError,
|
|
2152
|
+
RangeError,
|
|
2153
|
+
WeakMap,
|
|
2154
|
+
WeakSet,
|
|
2155
|
+
Symbol,
|
|
2156
|
+
parseInt,
|
|
2157
|
+
parseFloat,
|
|
2158
|
+
isNaN,
|
|
2159
|
+
isFinite,
|
|
2160
|
+
Infinity,
|
|
2161
|
+
NaN,
|
|
2162
|
+
undefined,
|
|
2163
|
+
};
|
|
2164
|
+
const context = vm.createContext(sandbox);
|
|
2165
|
+
const script = new vm.Script(code, { filename: 'memlab_eval' });
|
|
2166
|
+
// A budget abort is a controlled stop, not a failure: whatever the code
|
|
2167
|
+
// had already assigned to `result` is still returned, annotated below.
|
|
2168
|
+
let wallClockTimedOut = false;
|
|
2169
|
+
try {
|
|
2170
|
+
script.runInContext(context, { timeout: timeout_ms });
|
|
2171
|
+
}
|
|
2172
|
+
catch (err) {
|
|
2173
|
+
// A wall-clock timeout used to discard everything and return only
|
|
2174
|
+
// "Execution timed out", while a `max_nodes` overrun returned the partial
|
|
2175
|
+
// value with a warning. That asymmetry is the expensive one: a probe that
|
|
2176
|
+
// ran 120s, was backgrounded and died at 600s produced ZERO information,
|
|
2177
|
+
// which is a strong argument against ever writing an ambitious eval
|
|
2178
|
+
// again. V8 terminates the script but the sandbox keeps whatever was
|
|
2179
|
+
// already assigned, so the partial value is right there.
|
|
2180
|
+
if (isScriptTimeout(err) && sandbox.result !== undefined) {
|
|
2181
|
+
wallClockTimedOut = true;
|
|
947
2182
|
}
|
|
948
|
-
|
|
2183
|
+
else if (!(err instanceof BudgetExceeded)) {
|
|
949
2184
|
// Keyed on the error itself, never on `budget.exceeded`: code that
|
|
950
2185
|
// catches the abort and then throws for an unrelated reason must
|
|
951
2186
|
// still surface that error.
|
|
952
|
-
|
|
953
|
-
throw err;
|
|
954
|
-
}
|
|
955
|
-
// Actionable hint when nothing was assigned to `result` (the #1 user
|
|
956
|
-
// error — code that `return`s a value or runs a value-returning IIFE
|
|
957
|
-
// never populates `result`, so output is silently "undefined").
|
|
958
|
-
if (sandbox.result === undefined &&
|
|
959
|
-
consoleOutput.length === 0 &&
|
|
960
|
-
!budget.exceeded) {
|
|
961
|
-
return toolResult('Your code ran without error but never assigned to `result`, so there is nothing to return.\n' +
|
|
962
|
-
'Assign the value you want back to `result` (do NOT use `return` at the top level), e.g.:\n' +
|
|
963
|
-
' `result = someValue;`\n' +
|
|
964
|
-
'Use mode:"describe_env" to see the full calling convention.');
|
|
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
|
-
}
|
|
974
|
-
let output;
|
|
975
|
-
try {
|
|
976
|
-
output = JSON.stringify(sandbox.result, null, 2) ?? 'undefined';
|
|
2187
|
+
throw err;
|
|
977
2188
|
}
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
2189
|
+
}
|
|
2190
|
+
// Actionable hint when nothing was assigned to `result` (the #1 user
|
|
2191
|
+
// error — code that `return`s a value or runs a value-returning IIFE
|
|
2192
|
+
// never populates `result`, so output is silently "undefined").
|
|
2193
|
+
if (sandbox.result === undefined &&
|
|
2194
|
+
consoleOutput.length === 0 &&
|
|
2195
|
+
!budget.exceeded) {
|
|
2196
|
+
return toolResult('Your code ran without error but never assigned to `result`, so there is nothing to return.\n' +
|
|
2197
|
+
'Assign the value you want back to `result` (do NOT use `return` at the top level), e.g.:\n' +
|
|
2198
|
+
' `result = someValue;`\n' +
|
|
2199
|
+
'Use mode:"describe_env" to see the full calling convention.');
|
|
2200
|
+
}
|
|
2201
|
+
// `undefined` is never worth persisting: on reload it is
|
|
2202
|
+
// indistinguishable from a name that was never saved, and the usual
|
|
2203
|
+
// cause is the "never assigned to `result`" mistake — which the hint
|
|
2204
|
+
// above only catches when the run produced no console output.
|
|
2205
|
+
const nothingToSave = sandbox.result === undefined;
|
|
2206
|
+
if (save_as != null &&
|
|
2207
|
+
!budget.exceeded &&
|
|
2208
|
+
!wallClockTimedOut &&
|
|
2209
|
+
!nothingToSave) {
|
|
2210
|
+
setSavedResult(save_as, sandbox.result, currentHandle);
|
|
2211
|
+
}
|
|
2212
|
+
const budgetBytes = max_result_bytes ?? MAX_OUTPUT_SIZE;
|
|
2213
|
+
const shrunk = shrinkResult(sandbox.result, budgetBytes);
|
|
2214
|
+
let output;
|
|
2215
|
+
try {
|
|
2216
|
+
output = JSON.stringify(shrunk.value, null, 2) ?? 'undefined';
|
|
2217
|
+
}
|
|
2218
|
+
catch {
|
|
2219
|
+
output = String(shrunk.value);
|
|
2220
|
+
}
|
|
2221
|
+
// Pretty-printing adds indentation the byte budget above did not account
|
|
2222
|
+
// for, so the string clamp stays as a backstop; it should rarely fire now.
|
|
2223
|
+
output = truncate(output, budgetBytes * 2);
|
|
2224
|
+
if (consoleOutput.length > 0) {
|
|
2225
|
+
const consolePart = truncate(consoleOutput.join('\n'), MAX_OUTPUT_SIZE - output.length > 1024 ? 4096 : 1024);
|
|
2226
|
+
output += '\n\n--- console output ---\n' + consolePart;
|
|
2227
|
+
}
|
|
2228
|
+
const footer = [];
|
|
2229
|
+
const heapBytes = getSnapshotMetadata()?.totalSize ?? 0;
|
|
2230
|
+
const implausible = implausibleByteFields(sandbox.result, heapBytes);
|
|
2231
|
+
if (implausible.length > 0) {
|
|
2232
|
+
footer.push(`⚠️ IMPLAUSIBLE TOTAL — ${implausible
|
|
2233
|
+
.map(h => `\`${h.path}\` = ${formatBytes(h.bytes)}`)
|
|
2234
|
+
.join(', ')} exceeds the whole snapshot (${formatBytes(heapBytes)}). ` +
|
|
2235
|
+
'A byte total larger than the heap is a DOUBLE-COUNT, not a big number. The usual cause is walking a ' +
|
|
2236
|
+
'Map/Set backing `table`, which yields KEYS as well as values and whose members are frequently shared ' +
|
|
2237
|
+
'across many containers, so the same node is measured once per container. Deduplicate by node id ' +
|
|
2238
|
+
'(`const seen = new Set()`) and re-run. If you meant to sum overlapping retained subtrees, use ' +
|
|
2239
|
+
'`helpers.aggregateRetained(ids)` for the dominator-deduplicated figure.');
|
|
2240
|
+
}
|
|
2241
|
+
// Attached to the RESULT of the call that hand-rolled a built-in, because
|
|
2242
|
+
// that is the one moment the caller is guaranteed to read. Never suppresses
|
|
2243
|
+
// or alters the value above it.
|
|
2244
|
+
const hintText = code != null ? formatEvalHints(hintsForEval(code)) : null;
|
|
2245
|
+
if (hintText != null)
|
|
2246
|
+
footer.push(hintText);
|
|
2247
|
+
if (shrunk.truncated) {
|
|
2248
|
+
const nothingFit = shrunk.keptEntries === 0 && shrunk.droppedEntries > 0;
|
|
2249
|
+
footer.push(`⚠️ truncated: true — the result exceeded ${formatNumber(budgetBytes)} bytes` +
|
|
2250
|
+
(nothingFit
|
|
2251
|
+
? `, and NOT EVEN ONE of the ${formatNumber(shrunk.droppedEntries)} entries fit inside it, ` +
|
|
2252
|
+
'so the value above is EMPTY. Nothing was kept — do not read it as a leading subset. ' +
|
|
2253
|
+
'A single entry is larger than the whole budget, so raise `max_result_bytes` ' +
|
|
2254
|
+
'substantially or return less per entry.'
|
|
2255
|
+
: shrunk.droppedEntries > 0
|
|
2256
|
+
? `, so the last ${formatNumber(shrunk.droppedEntries)} entr${shrunk.droppedEntries === 1 ? 'y was' : 'ies were'} dropped. ` +
|
|
2257
|
+
'Entries are dropped whole, so what is shown is still valid and still the leading rows — ' +
|
|
2258
|
+
'sort your result before assigning it if the ones you want are not first.'
|
|
2259
|
+
: '.') +
|
|
2260
|
+
' Raise `max_result_bytes`, or aggregate in the eval instead of returning raw rows.');
|
|
2261
|
+
}
|
|
2262
|
+
if (wallClockTimedOut) {
|
|
2263
|
+
footer.push(`⚠️ WALL-CLOCK TIMEOUT after ${formatNumber(timeout_ms)}ms — the value above is PARTIAL. ` +
|
|
2264
|
+
'It is whatever your code had assigned to `result` when the script was terminated, so treat every count in ' +
|
|
2265
|
+
'it as a FLOOR, not a measurement. Raise `timeout_ms`, narrow the scan with an indexed helper ' +
|
|
2266
|
+
'(`helpers.byClass` / `byTypename` / `withProp` / `byReferrerEdge`), or run `dry_run: true` first — a nested ' +
|
|
2267
|
+
'walk over `.references` inside a `nodes.forEach` costs edge-visits, not node-visits.');
|
|
2268
|
+
if (save_as != null) {
|
|
2269
|
+
footer.push(`Not saved as "${save_as}" — a partial result would be indistinguishable from a complete one on reload.`);
|
|
985
2270
|
}
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
2271
|
+
}
|
|
2272
|
+
if (budget.exceeded) {
|
|
2273
|
+
// A partial walk that also produced NO value is not a degraded answer, it
|
|
2274
|
+
// is no answer — and it prints as a bare `undefined`, which is exactly
|
|
2275
|
+
// what a legitimate empty census looks like. Refuse it instead: the
|
|
2276
|
+
// accumulator is usually assigned after the loops, so this is the common
|
|
2277
|
+
// shape of the failure, not an edge case.
|
|
2278
|
+
const producedNothing = sandbox.result === undefined || sandbox.result === null;
|
|
2279
|
+
if (producedNothing) {
|
|
2280
|
+
return errorResult(`Walk aborted after ${formatNumber(budget.max)} node visits (max_nodes) and the code assigned no \`result\`. ` +
|
|
2281
|
+
'Refusing to return the empty value: an aborted walk that produced nothing is indistinguishable from a genuine empty result, ' +
|
|
2282
|
+
'and reads as "this pattern is not in the heap".\n\n' +
|
|
2283
|
+
`This snapshot has ${formatNumber(getSnapshotMetadata()?.nodeCount ?? 0)} nodes, so the default budget allows ~6 full passes. ` +
|
|
2284
|
+
'Either raise `max_nodes`, narrow the scan with an indexed helper (`helpers.byClass` / `byTypename` / `withProp`) instead of a full ' +
|
|
2285
|
+
'`snapshot.nodes` walk, or assign to `result` incrementally so a partial answer is still meaningful — then re-run.');
|
|
993
2286
|
}
|
|
994
|
-
|
|
995
|
-
|
|
2287
|
+
footer.push(`⚠️ Walk aborted after ${formatNumber(budget.max)} node visits (max_nodes). The value above is PARTIAL. ` +
|
|
2288
|
+
'Raise max_nodes, or narrow the scan with an indexed helper (`helpers.byClass` / `byTypename` / `withProp`) instead of a full `snapshot.nodes` walk.');
|
|
2289
|
+
if (save_as != null) {
|
|
2290
|
+
footer.push(`Not saved as "${save_as}" — a partial result would be indistinguishable from a complete one on reload.`);
|
|
996
2291
|
}
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
2292
|
+
}
|
|
2293
|
+
else if (budget.visited > 0) {
|
|
2294
|
+
footer.push(`nodes_visited: ${formatNumber(budget.visited)}`);
|
|
2295
|
+
const stride = budget.sampleEvery ?? 1;
|
|
2296
|
+
if (stride > 1) {
|
|
2297
|
+
footer.push(`⚠️ ESTIMATE — sampled 1-in-${formatNumber(stride)}: the walk considered ` +
|
|
2298
|
+
`${formatNumber(budget.seen ?? 0)} nodes and ran your callback on ` +
|
|
2299
|
+
`${formatNumber(budget.visited)} of them. **Counts above are roughly ` +
|
|
2300
|
+
`${formatNumber(stride)}x low**; multiply to estimate, and re-run with ` +
|
|
2301
|
+
'`sample: 1` before recording any number. A ZERO here is not absence — ' +
|
|
2302
|
+
`a population smaller than ~${formatNumber(stride)} is easily missed entirely.`);
|
|
1001
2303
|
}
|
|
1002
|
-
|
|
1003
|
-
|
|
2304
|
+
// A whole-heap walk that matched NOTHING is reported as a confident
|
|
2305
|
+
// negative — "there are no closures with captured scopes" — when the
|
|
2306
|
+
// overwhelmingly likelier cause is a predicate that cannot match.
|
|
2307
|
+
//
|
|
2308
|
+
// The edge-type filter is the canonical way to get here: a JSFunction's
|
|
2309
|
+
// hop to its Context is an `internal` edge NAMED `context`, so the
|
|
2310
|
+
// reflexive `e.type === 'context'` matches zero edges on every heap and
|
|
2311
|
+
// returns a clean 0 with no error. That silent zero is worse than a
|
|
2312
|
+
// throw, because nothing in the output suggests re-checking the filter.
|
|
2313
|
+
if ((budget.sampleEvery ?? 1) === 1 &&
|
|
2314
|
+
budget.visited >= ZERO_MATCH_WALK_THRESHOLD &&
|
|
2315
|
+
isEmptyCensusResult(sandbox.result)) {
|
|
2316
|
+
footer.push(`⚠️ ZERO matches over ${formatNumber(budget.visited)} node visits. This may be a real negative — ` +
|
|
2317
|
+
'but a whole-heap walk that matches nothing is more often a predicate that cannot match. ' +
|
|
2318
|
+
'Check the edge-type filter first: a closure→scope hop is an `internal` edge NAMED `context` ' +
|
|
2319
|
+
"(`e.name_or_index === 'context'`), NOT `e.type === 'context'`; `props()` adds provenance keys so " +
|
|
2320
|
+
'shape tests must use `helpers.shapeKeys`/`hasShape`; and `byClass` needs the exact class name. ' +
|
|
2321
|
+
'Confirm with a deliberately broad version of the same predicate before recording this as "not present".');
|
|
1004
2322
|
}
|
|
1005
|
-
return toolResult(output);
|
|
1006
2323
|
}
|
|
1007
|
-
|
|
1008
|
-
|
|
2324
|
+
if (save_as != null && !budget.exceeded) {
|
|
2325
|
+
footer.push(nothingToSave
|
|
2326
|
+
? `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.`
|
|
2327
|
+
: `Saved as "${save_as}" — read it back in a later call with \`helpers.load("${save_as}")\`.`);
|
|
1009
2328
|
}
|
|
1010
|
-
|
|
2329
|
+
if (footer.length > 0) {
|
|
2330
|
+
output += '\n\n--- ' + footer.join('\n');
|
|
2331
|
+
}
|
|
2332
|
+
return toolResult(output);
|
|
2333
|
+
}
|
|
2334
|
+
catch (err) {
|
|
2335
|
+
return errorResult(new Error(actionableEvalError(err, code)));
|
|
2336
|
+
}
|
|
2337
|
+
}
|
|
2338
|
+
/**
|
|
2339
|
+
* True for the `vm` module's wall-clock timeout.
|
|
2340
|
+
*
|
|
2341
|
+
* Keyed on `err.code`, which is a documented Node error code, with the message
|
|
2342
|
+
* text only as a fallback. Matching the prose alone couples salvaging a partial
|
|
2343
|
+
* result to V8's exact wording: a reworded message would silently stop matching
|
|
2344
|
+
* and quietly revert to throwing away the partial value, which is the behaviour
|
|
2345
|
+
* this branch exists to prevent.
|
|
2346
|
+
*/
|
|
2347
|
+
function isScriptTimeout(err) {
|
|
2348
|
+
// Deliberately NOT `err instanceof Error`. `vm` raises this from the script's
|
|
2349
|
+
// OWN realm, whose `Error` is a different constructor, so a cross-realm
|
|
2350
|
+
// instanceof is false — measured, not assumed. Gating on it made the salvage
|
|
2351
|
+
// below unreachable: every wall-clock timeout fell through to `throw err` and
|
|
2352
|
+
// the partial `result` was discarded, which is the exact behaviour this
|
|
2353
|
+
// branch was written to end.
|
|
2354
|
+
if (err == null || typeof err !== 'object')
|
|
2355
|
+
return false;
|
|
2356
|
+
const e = err;
|
|
2357
|
+
if (e.code === 'ERR_SCRIPT_EXECUTION_TIMEOUT')
|
|
2358
|
+
return true;
|
|
2359
|
+
return (typeof e.message === 'string' &&
|
|
2360
|
+
e.message.includes('Script execution timed out'));
|
|
1011
2361
|
}
|
|
1012
2362
|
// Map the opaque VM errors that the documented calling-convention mistakes
|
|
1013
2363
|
// produce into actionable guidance (Feedback §3).
|
|
1014
2364
|
function actionableEvalError(err, code) {
|
|
1015
2365
|
const msg = err instanceof Error ? err.message : String(err);
|
|
1016
|
-
if (
|
|
2366
|
+
if (isScriptTimeout(err)) {
|
|
1017
2367
|
return `Execution timed out. Increase timeout_ms, or narrow the scan (filter earlier, use a dedicated tool like memlab_find_by_property/memlab_property_distribution instead of a full snapshot.nodes walk).`;
|
|
1018
2368
|
}
|
|
1019
2369
|
if (msg.includes('Illegal return statement')) {
|
|
@@ -1097,7 +2447,200 @@ function describeSaved() {
|
|
|
1097
2447
|
'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
2448
|
].join('\n');
|
|
1099
2449
|
}
|
|
1100
|
-
|
|
2450
|
+
/**
|
|
2451
|
+
* A result that says "none of them" — 0, `[]`, `{}`, or an empty Map/Set.
|
|
2452
|
+
*
|
|
2453
|
+
* `undefined`/`null` are deliberately NOT included: those mean "the code
|
|
2454
|
+
* assigned nothing", which is a different failure and is already handled.
|
|
2455
|
+
*/
|
|
2456
|
+
export function isEmptyCensusResult(v) {
|
|
2457
|
+
if (v === 0)
|
|
2458
|
+
return true;
|
|
2459
|
+
if (Array.isArray(v))
|
|
2460
|
+
return v.length === 0;
|
|
2461
|
+
if (v instanceof Map || v instanceof Set)
|
|
2462
|
+
return v.size === 0;
|
|
2463
|
+
if (v != null && typeof v === 'object') {
|
|
2464
|
+
return Object.keys(v).length === 0;
|
|
2465
|
+
}
|
|
2466
|
+
return false;
|
|
2467
|
+
}
|
|
2468
|
+
/**
|
|
2469
|
+
* How many node visits make a zero result worth questioning.
|
|
2470
|
+
*
|
|
2471
|
+
* Below this the caller probably scanned an indexed subset and legitimately
|
|
2472
|
+
* found nothing; above it they walked the whole graph and got no match, which
|
|
2473
|
+
* is far more often a wrong predicate than an empty heap.
|
|
2474
|
+
*/
|
|
2475
|
+
const ZERO_MATCH_WALK_THRESHOLD = 100000;
|
|
2476
|
+
/**
|
|
2477
|
+
* Property names that make an object look like an event-listener record. Kept
|
|
2478
|
+
* identical to `stale-collections.ts` on purpose: two tools disagreeing about
|
|
2479
|
+
* what a listener record IS produces two incomparable counts of the same thing.
|
|
2480
|
+
*/
|
|
2481
|
+
const LISTENER_CALLBACK_PROPS = new Set([
|
|
2482
|
+
'callback',
|
|
2483
|
+
'fn',
|
|
2484
|
+
'handler',
|
|
2485
|
+
'listener',
|
|
2486
|
+
]);
|
|
2487
|
+
const LISTENER_CONTEXT_PROPS = new Set([
|
|
2488
|
+
'context',
|
|
2489
|
+
'ctx',
|
|
2490
|
+
'this',
|
|
2491
|
+
'target',
|
|
2492
|
+
'scope',
|
|
2493
|
+
]);
|
|
2494
|
+
/** Helper names mentioned as `helpers.x(` anywhere in the describe_env text. */
|
|
2495
|
+
function documentedHelperNames() {
|
|
2496
|
+
const doc = describeEnvLines().join('\n');
|
|
2497
|
+
return [
|
|
2498
|
+
...new Set([...doc.matchAll(/helpers\.([A-Za-z_][A-Za-z0-9_]*)/g)].map(m => m[1])),
|
|
2499
|
+
].sort();
|
|
2500
|
+
}
|
|
2501
|
+
/**
|
|
2502
|
+
* Syntax-check an eval and report what it references — WITHOUT a snapshot.
|
|
2503
|
+
*
|
|
2504
|
+
* The only way to discover a typo in a 40-line eval used to be to pay a 2–4
|
|
2505
|
+
* minute snapshot load first, which is enough friction that most exploratory
|
|
2506
|
+
* ideas were never written down. Everything here is decidable from the source
|
|
2507
|
+
* text alone, so the cost of a wrong guess drops from minutes to seconds.
|
|
2508
|
+
*/
|
|
2509
|
+
function lintEval(code, knownHelpers) {
|
|
2510
|
+
const lines = ['## memlab_eval lint — nothing was executed', ''];
|
|
2511
|
+
if (code.trim() === '') {
|
|
2512
|
+
return 'Pass `code` to lint. Nothing to check.';
|
|
2513
|
+
}
|
|
2514
|
+
let syntaxOk = true;
|
|
2515
|
+
try {
|
|
2516
|
+
// Compiling does not run anything; it is the same parse the real call does.
|
|
2517
|
+
new vm.Script(code, { filename: 'memlab_eval_lint' });
|
|
2518
|
+
lines.push('- **Syntax:** parses cleanly.');
|
|
2519
|
+
}
|
|
2520
|
+
catch (err) {
|
|
2521
|
+
syntaxOk = false;
|
|
2522
|
+
lines.push(`- **Syntax ERROR:** ${err instanceof Error ? err.message : String(err)}`);
|
|
2523
|
+
if (err instanceof SyntaxError &&
|
|
2524
|
+
/Illegal return/.test(err.message ?? '')) {
|
|
2525
|
+
lines.push(' Assign to `result` instead of using a top-level `return`.');
|
|
2526
|
+
}
|
|
2527
|
+
}
|
|
2528
|
+
if (!/\bresult\s*=/.test(code)) {
|
|
2529
|
+
lines.push('- ⚠ **No assignment to `result`** — the call will run and return nothing. This is the single most common eval mistake.');
|
|
2530
|
+
}
|
|
2531
|
+
const referenced = [
|
|
2532
|
+
...new Set([...code.matchAll(/helpers\.([A-Za-z_][A-Za-z0-9_]*)/g)].map(m => m[1])),
|
|
2533
|
+
].sort();
|
|
2534
|
+
if (referenced.length > 0) {
|
|
2535
|
+
lines.push(`- **Helpers referenced:** ${referenced.join(', ')}.`);
|
|
2536
|
+
// On a fresh server the live table has not been built yet (it needs a
|
|
2537
|
+
// snapshot). The documented list is the next best source and is always
|
|
2538
|
+
// available — worded as "not documented" so an undocumented-but-real helper
|
|
2539
|
+
// is not reported as a typo.
|
|
2540
|
+
const documented = documentedHelperNames();
|
|
2541
|
+
const table = knownHelpers ?? documented;
|
|
2542
|
+
const authoritative = knownHelpers != null;
|
|
2543
|
+
{
|
|
2544
|
+
const unknown = referenced.filter(n => !table.includes(n));
|
|
2545
|
+
if (unknown.length === 0) {
|
|
2546
|
+
lines.push(authoritative
|
|
2547
|
+
? ' All exist.'
|
|
2548
|
+
: ' All are documented (checked against `describe_env`; this server has not built the live helper table yet).');
|
|
2549
|
+
}
|
|
2550
|
+
else {
|
|
2551
|
+
for (const name of unknown) {
|
|
2552
|
+
const lower = name.toLowerCase();
|
|
2553
|
+
const near = table.filter(n => n.toLowerCase().includes(lower) ||
|
|
2554
|
+
lower.includes(n.toLowerCase()) ||
|
|
2555
|
+
editDistanceWithin(n.toLowerCase(), lower, 2));
|
|
2556
|
+
lines.push(` - ❌ \`helpers.${name}\` does not exist.` +
|
|
2557
|
+
(near.length > 0
|
|
2558
|
+
? ` Did you mean ${near.slice(0, 4).join(', ')}?`
|
|
2559
|
+
: ''));
|
|
2560
|
+
}
|
|
2561
|
+
}
|
|
2562
|
+
}
|
|
2563
|
+
}
|
|
2564
|
+
const fullWalk = /\b(?:snapshot\.)?(?:nodes|edges)\s*\.\s*forEach/.test(code);
|
|
2565
|
+
const nesting = maxTraversalNesting(code);
|
|
2566
|
+
if (fullWalk) {
|
|
2567
|
+
lines.push(`- **Full-heap walk:** yes${nesting > 0 ? `, with a nested \`.references\`/\`.referrers\` traversal at depth ${nesting}` : ''}.` +
|
|
2568
|
+
(nesting >= 2
|
|
2569
|
+
? ' At depth 2 or more the cost is edge-visits and typically runs for minutes — run `dry_run: true` against the loaded snapshot for a concrete estimate.'
|
|
2570
|
+
: ''));
|
|
2571
|
+
}
|
|
2572
|
+
else {
|
|
2573
|
+
lines.push('- **Full-heap walk:** none detected by text match.');
|
|
2574
|
+
}
|
|
2575
|
+
if (/for\s*\(\s*const\s+\w+\s+of\s+snapshot\.nodes/.test(code)) {
|
|
2576
|
+
lines.push('- ❌ `snapshot.nodes` is NOT for-of iterable. Use `snapshot.nodes.forEach(node => { ... })`.');
|
|
2577
|
+
}
|
|
2578
|
+
if (/\.\s*retained_?[Ss]ize\b/.test(code) &&
|
|
2579
|
+
!/helpers\.retainedSize/.test(code)) {
|
|
2580
|
+
lines.push('- ⚠ `node.retainedSize` / `node.retained_size` THROW inside eval (they read back ~0 on some loads). Use `helpers.retainedSize(id)` / `helpers.retainedSizes([ids])` / `helpers.aggregateRetained([ids])`.');
|
|
2581
|
+
}
|
|
2582
|
+
lines.push('', syntaxOk
|
|
2583
|
+
? '_Re-run with `mode:"eval"` (the default) to execute._'
|
|
2584
|
+
: '_Fix the syntax error first._');
|
|
2585
|
+
return lines.join('\n');
|
|
2586
|
+
}
|
|
2587
|
+
function describeEnv(section) {
|
|
2588
|
+
const all = describeEnvLines();
|
|
2589
|
+
if (section == null || section.trim() === '')
|
|
2590
|
+
return all.join('\n');
|
|
2591
|
+
const wanted = section.trim().toLowerCase();
|
|
2592
|
+
// Split on `## ` headings and keep the ones that match. The calling
|
|
2593
|
+
// conventions always travel with the answer: they are what an eval gets wrong
|
|
2594
|
+
// when it is written from a partial read of this document.
|
|
2595
|
+
const blocks = [];
|
|
2596
|
+
let current = [];
|
|
2597
|
+
for (const line of all) {
|
|
2598
|
+
if (line.startsWith('## ')) {
|
|
2599
|
+
if (current.length > 0)
|
|
2600
|
+
blocks.push(current);
|
|
2601
|
+
current = [line];
|
|
2602
|
+
}
|
|
2603
|
+
else {
|
|
2604
|
+
current.push(line);
|
|
2605
|
+
}
|
|
2606
|
+
}
|
|
2607
|
+
if (current.length > 0)
|
|
2608
|
+
blocks.push(current);
|
|
2609
|
+
const headings = blocks
|
|
2610
|
+
.map(b => b[0])
|
|
2611
|
+
.filter(h => h.startsWith('## '))
|
|
2612
|
+
.map(h => h.slice(3));
|
|
2613
|
+
const isConventions = (heading) => heading.toLowerCase().includes('calling convention');
|
|
2614
|
+
const required = blocks.filter(b => isConventions(b[0]));
|
|
2615
|
+
const matched = blocks.filter(b => b[0].startsWith('## ') &&
|
|
2616
|
+
b[0].toLowerCase().includes(wanted) &&
|
|
2617
|
+
!isConventions(b[0]));
|
|
2618
|
+
// Naming the calling conventions is not a miss. That block is kept out of
|
|
2619
|
+
// `matched` only because `required` always emits it, so treating it as
|
|
2620
|
+
// unmatched answered `section: "calling"` — or "convention", or the
|
|
2621
|
+
// "(REQUIRED)" in its own heading — with `no section matches`, for the one
|
|
2622
|
+
// section guaranteed to be in every reply.
|
|
2623
|
+
const wantedConventions = required.some(b => b[0].toLowerCase().includes(wanted));
|
|
2624
|
+
if (matched.length === 0 && !wantedConventions) {
|
|
2625
|
+
return [
|
|
2626
|
+
`# memlab_eval environment — no section matches "${section}"`,
|
|
2627
|
+
'',
|
|
2628
|
+
`Sections: ${headings.map(h => `"${h}"`).join(', ')}.`,
|
|
2629
|
+
'Omit `section` for the whole document.',
|
|
2630
|
+
].join('\n');
|
|
2631
|
+
}
|
|
2632
|
+
return [
|
|
2633
|
+
'# memlab_eval environment (filtered)',
|
|
2634
|
+
'',
|
|
2635
|
+
...required.flatMap(b => [...b, '']),
|
|
2636
|
+
...matched.flatMap(b => [...b, '']),
|
|
2637
|
+
`_Other sections: ${headings
|
|
2638
|
+
.filter(h => !isConventions(h) && !matched.some(b => b[0].slice(3) === h))
|
|
2639
|
+
.map(h => `"${h}"`)
|
|
2640
|
+
.join(', ')}. Omit \`section\` for all of them._`,
|
|
2641
|
+
].join('\n');
|
|
2642
|
+
}
|
|
2643
|
+
function describeEnvLines() {
|
|
1101
2644
|
return [
|
|
1102
2645
|
'# memlab_eval environment',
|
|
1103
2646
|
'',
|
|
@@ -1114,19 +2657,36 @@ function describeEnv() {
|
|
|
1114
2657
|
'',
|
|
1115
2658
|
'## Collection / shape / index helpers (prefer these over hand-rolling)',
|
|
1116
2659
|
'- `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.",
|
|
2660
|
+
"- `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. ⚠️ **`props()` is for INSPECTION, not for SHAPE MATCHING** — on a node with no `property` edges it falls back to a named internal/shortcut/hidden edge walk and adds `length`/`map`/`__via`/`__note`, so a shape test written against `Object.keys(props(id))` returns ZERO matches on objects that plainly have the shape. Use the next line for that.",
|
|
2661
|
+
'- `helpers.shapeKeys(nodeOrId) -> Set<string>`, `helpers.ownProps(nodeOrId) -> {…}`, `helpers.hasShape(nodeOrId, ["a","b"], {exact?, exclude?}) -> boolean` — own JS properties ONLY (`property` edges, no `__proto__`, no fallback, no provenance keys). **This is the correct way to ask "what shape is this object".** `hasShape(id, ["element","record"], {exact: true})` is the whole test.',
|
|
2662
|
+
'- `helpers.rootPath(nodeOrId, {maxHops?}) -> [{id, name, type, edge}]` — the GC-root path for one node, root first, exactly as `memlab_retainer_trace` walks it. Saves hand-writing the `while (cur.hasPathEdge) cur = cur.pathEdge.fromNode` loop inside a larger eval (which gets rewritten, slightly differently, every time a probe needs to name an owner).',
|
|
1118
2663
|
'- `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
2664
|
'- `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.)',
|
|
2665
|
+
'- `helpers.nodesByClass(name, {type?}) -> node[]` (alias of `iterByClass`) — the same lookup returning NODE OBJECTS. Prefer it over `byClass`: ids from `byClass` are not all resolvable through `snapshot.getNodeById` — native classes such as `AudioContext` / `OpusRecorder` come back null — so the reflexive `byClass(x).map(id => getNodeById(id).referrers)` throws `Cannot read properties of null` and needs defensive `if (!n) continue` boilerplate on every native-touching eval.',
|
|
1120
2666
|
'- `helpers.iterByClass(name, {type?}) -> node[]` / `helpers.iterByType(type) -> node[]` — indexed iteration; no full scan, index built once per snapshot.',
|
|
1121
2667
|
'- `helpers.classCounts({pattern?, type?, minCount?}) -> [{name, type, count, selfSize}]` — one-pass class histogram, cached; `pattern` is a case-insensitive regex (substring fallback).',
|
|
1122
2668
|
'- `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.',
|
|
2669
|
+
'- `helpers.derefPath(nodeOrId, "value._PSD.trans") -> {found, stoppedAt, missingEdge?, available?, node}` — walk a dotted edge path and, on failure, say WHICH hop failed and what was there instead. Use this instead of chaining edgeTarget: a null halfway looks identical to a null at the end, which is how a probe tests the wrong level and reports a confident zero.',
|
|
2670
|
+
'- `helpers.findWithin(nodeOrId, edgeName, {maxDepth}) -> [{path,id,name}]` — is this property anywhere within N hops, and at what path? Answers "which level is it on?" in one call.',
|
|
1123
2671
|
'- `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.',
|
|
2672
|
+
'- `helpers.walkChain(startOrNode, edgeName, {maxHops?, collectIds?}) -> {length, terminated: "cycle" | "end" | "cap", truncated}` — walk a linked structure (`.next` update queues, `.prev` closure chains, LRU lists) and report HOW it ended. Use this rather than a hand-written loop: React update queues are CIRCULAR, and a hand-rolled `while (next && next.id !== start)` with a hop guard reports the guard as the length. A measured case printed 800 for a chain of 2,066. `terminated: "cap"` means the length is a floor. For the full per-link report (what each link captures, distinct vs repeated) use the `memlab_chain_walk` tool.',
|
|
1124
2673
|
'- `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
2674
|
'- `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.',
|
|
2675
|
+
'- `helpers.owner(idOrNode, {maxHops?}) -> {id, name, type, hops, selfSize, named} | null` — nearest dominator carrying a class identity, skipping V8 containers (`Object`, `Array`, `system / …`, `(closure)`). Minified single-letter names are KEPT: in a production bundle they are the only identity there is — pair with `memlab_identify`. `named:false` means the walk found only containers and is reporting the furthest node reached.',
|
|
2676
|
+
'- `helpers.remember(name, value)` / `helpers.recall(name?)` — persist a derived fact to disk (`~/.memlab/eval-store.json`, override with `MEMLAB_STATE_DIR`) and read it back in a LATER session. `save`/`load` are per-snapshot and dropped on unload, which is right for id lists (ids are per-capture) and wrong for a conclusion. `recall()` with no name lists the keys.',
|
|
2677
|
+
'- `helpers.sample(items, n) -> items[]` — evenly-spaced sample, NOT random: two calls over the same population return the same members, so a follow-up question lands on the objects the first answer described.',
|
|
2678
|
+
'- `helpers.histogram(ids, keyFn, {limit?}) -> [{key, count}]` — group-and-count over ids, sorted by count; a null/undefined key becomes `(none)` rather than being dropped.',
|
|
1126
2679
|
'- `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
2680
|
'- `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
2681
|
'- `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
2682
|
'',
|
|
2683
|
+
'## Populations that get hand-rolled every round (use these instead)',
|
|
2684
|
+
"Each of these was rewritten by hand in round after round, slightly differently each time — which makes two rounds' numbers incomparable for reasons that have nothing to do with the app, and in one case (the edge filter) returns a confident zero.",
|
|
2685
|
+
'- `helpers.detachedNamed(substr) -> [{id, name}]` — detached nodes whose CLASS NAME contains `substr`, with the same oddball/root filtering the detached-DOM tools apply. ⚠️ A detached node\'s name is its element or Blink class (`Detached EventListener`, `Detached blink::RegisteredEventListener`, `Detached HTMLDivElement`) and **never a `data-testid`** — filtering these for an app-level testid matches nothing on any heap. For "which UI element leaked", use `memlab_detached_dom`, which groups by nearest non-detached dominator.',
|
|
2686
|
+
'- `helpers.listenerRecords(callbackName?) -> [{id, callback, context}]` — objects carrying BOTH a callback-ish and a context-ish property, i.e. event-listener records. Optionally narrowed to one callback class, which is how the question is actually asked ("how many `subscribe_$0` records are held?"). Cached; the definition matches `memlab_stale_collections` exactly.',
|
|
2687
|
+
'- `helpers.contextOf(nodeOrId) -> node | null` — the scope a closure captured, e.g. `system / Context / scope @767271`. **Do not hand-roll this**: the hop is an `internal` edge NAMED `context`, not a `context`-TYPED edge, so the reflexive filter returns null on every closure in the heap (see the edge-type section below). Returns null for a non-closure — note `helpers.byClass`/`nodesByClass` also match the class-NAME STRING node, so filter on `type === "closure"` before asking for a scope.',
|
|
2688
|
+
'- `helpers.closureCensus({minCount?, pattern?}) -> [{name, count, withScope}]` — closure classes with how many instances captured a scope. `count` alone cannot separate "1,000 copies of a function" from "1,000 copies each pinning a distinct scope", and only the second is a retention story. Cached.',
|
|
2689
|
+
'',
|
|
1130
2690
|
'## Named result sets (multi-step exploration)',
|
|
1131
2691
|
'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
2692
|
'- `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.',
|
|
@@ -1148,12 +2708,21 @@ function describeEnv() {
|
|
|
1148
2708
|
'## IHeapEdge API',
|
|
1149
2709
|
'`.name_or_index`, `.type` (property/element/context/internal/hidden/shortcut), `.toNode`, `.fromNode`.',
|
|
1150
2710
|
'',
|
|
2711
|
+
'### Edge TYPE vs edge NAME — the silent-zero trap',
|
|
2712
|
+
'These are different fields and the reflexive guess is wrong for the most-asked question. A closure and its captured scope are linked like this:',
|
|
2713
|
+
'```',
|
|
2714
|
+
"closure --(type: 'internal', name_or_index: 'context')--> 'system / Context / scope @<id>'",
|
|
2715
|
+
"'system / Context / scope @<id>' --(type: 'context', name_or_index: '<varName>')--> captured value",
|
|
2716
|
+
'```',
|
|
2717
|
+
"So the hop FROM a function TO its scope is an **`internal` edge named `context`** — filtering a function's `.references` on `e.type === 'context'` matches **zero edges on every heap** and returns a clean `0`. The `context` TYPE only appears on the edges INSIDE the scope object, one per captured variable. (Measured on a 2.9M-node browser capture: 37,791 `context`-NAMED edges out of closures, every one of them `internal`, and not a single `context`-TYPED edge among the 255,084 edges leaving closures; the scope objects they point at emit 60,513 `context`-typed edges between them.) Rule of thumb: match `e.name_or_index` for a specific named hop, `e.type` only for a category.",
|
|
2718
|
+
'The same shape bites elsewhere: array backing stores hang off an `internal` edge named `elements`, and Map/Set contents live behind `internal` `table` — which is why `helpers.entries` / `mapEntries` exist.',
|
|
2719
|
+
'',
|
|
1151
2720
|
'## Runnable example',
|
|
1152
2721
|
'```',
|
|
1153
2722
|
'const counts = {};',
|
|
1154
2723
|
'snapshot.nodes.forEach(node => { counts[node.type] = (counts[node.type] || 0) + 1; });',
|
|
1155
2724
|
'result = counts;',
|
|
1156
2725
|
'```',
|
|
1157
|
-
]
|
|
2726
|
+
];
|
|
1158
2727
|
}
|
|
1159
2728
|
//# sourceMappingURL=eval.js.map
|