Skip to content

Commit cc3fc59

Browse files
JacksonGLmeta-codesync[bot]
authored andcommitted
fix(mcp-server): Fix same-object-dup copy factor, sliced-string aggregate, eval doc
Summary: Three correctness/effectiveness improvements to the memlab MCP server. Source of truth is `www/scripts/webspeed/memory_lab/packages/mcp-server/`; the `fbcode/claude-templates/.../memlab-server/src/` copy is synced via the sync script. **1. `auto_investigate` "Same-Object Duplication" reported a wildly wrong copy factor (correctness).** The duplication factor was computed as `sampleCount / cardinality` over *any* unique-looking field, so a CONSTANT field (e.g. `gala_project_id` with 1 distinct value) reported "~200 retained copies" when the real duplication was 2×. The over-stated magnitude is misleading and (in one case) propagated into a diff summary before being caught. Rewrote the per-shape analysis into two passes that derive the factor from the MOST-DISTINCT string-valued field — the record's true discriminator — instead of an arbitrary `*_id`-shaped key. Now 2 real copies read as `≈2×`, genuinely-distinct records produce no false positive (the most-distinct field is ~unique), and the alert is phrased as a sample-based estimate with a "confirm with `memlab_property_distribution`" pointer. **2. `sliced_strings` was blind to the "many medium parents" case (effectiveness).** When `min_parent_size` filtered out every parent (e.g. thousands of ~250 KB JSON records, each sub-MB), the tool returned only counts and no actionable output. Added a `**Total parent bytes pinned by slices:**` aggregate (Σ distinct parent self-sizes) reported regardless of the filter, and a fallback that shows the largest parents (with a note) instead of going silent when nothing meets `min_parent_size`. **3. `eval` `helpers.retainedSizes()` return type was undocumented (DX).** It returns a `Record<id, bytes>` object, not an array; the docs implied an array, leading to `sizes.reduce is not a function`. Documented the object return shape (iterate with `Object.values(sizes)` / index `sizes[id]`) in the `describe_env` output, the tool description, and the agent-facing `SKILL.md`. Versions bumped (the 4 independently-versioned spots): OSS `mcp-server/package.json` 2.5.0 → 2.6.0, the hardcoded MCP handshake version literal in `src/index.ts` 2.5.0 → 2.6.0 (synced to the plugin), plugin `memlab-server/package.json` 2.5.0 → 2.6.0, and plugin `.claude-plugin/plugin.json` 1.12.0 → 1.13.0. ___ Differential Revision: D108944208 fbshipit-source-id: 9f86231b0062c4410eaff2737dff108bee56aeab
1 parent 9618bcf commit cc3fc59

5 files changed

Lines changed: 106 additions & 50 deletions

File tree

packages/mcp-server/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@memlab/mcp-server",
3-
"version": "2.5.0",
3+
"version": "2.6.0",
44
"license": "MIT",
55
"description": "MCP server for MemLab heap snapshot analysis — gives AI coding assistants tools to explore JavaScript heap snapshots, find memory leaks, and identify optimization opportunities",
66
"author": "Liang Gong <lgong@meta.com>",

packages/mcp-server/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ import {registerEventRegistry} from './tools/event-registry.js';
7373

7474
const server = new McpServer({
7575
name: 'memlab',
76-
version: '2.5.0', // keep in sync with package.json
76+
version: '2.6.0', // keep in sync with package.json
7777
});
7878

7979
// Wrap every tool with a wall-clock guardrail (default 90s, override per-call

packages/mcp-server/src/tools/auto-investigate.ts

Lines changed: 69 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1890,8 +1890,19 @@ export function registerAutoInvestigate(server: McpServer): void {
18901890
for (const s of qualifyingShapes) {
18911891
const sampleNodes = shapeBuckets.get(s.properties.join(',')) ?? [];
18921892
if (sampleNodes.length < 50) continue;
1893-
1894-
// Analyze each property's cardinality and value characteristics
1893+
const sampleCount = sampleNodes.length;
1894+
const shapeKey = s.properties.join(',');
1895+
1896+
// Pass 1: per-property cardinality / value characteristics over the
1897+
// sample.
1898+
interface PropStat {
1899+
name: string;
1900+
cardinality: number;
1901+
stringValueCount: number;
1902+
totalValueSize: number;
1903+
allUnique: boolean;
1904+
}
1905+
const propStats: PropStat[] = [];
18951906
for (const propName of s.properties) {
18961907
// Skip prototype / internal pseudo-properties — they are not data
18971908
// columns, so "pre-filter / intern this column" advice is noise
@@ -1924,60 +1935,78 @@ export function registerAutoInvestigate(server: McpServer): void {
19241935
}
19251936
}
19261937
}
1938+
propStats.push({
1939+
name: propName,
1940+
cardinality: values.size,
1941+
stringValueCount,
1942+
totalValueSize,
1943+
allUnique,
1944+
});
1945+
}
19271946

1928-
const cardinality = values.size;
1929-
const sampleCount = sampleNodes.length;
1930-
const uniqueKey = looksUniqueKey(propName);
1931-
const shapeKey = s.properties.join(',');
1932-
1933-
// Same-object duplication vs genuine low cardinality (§1b): a field
1934-
// that *should* be unique per record but has far fewer distinct
1935-
// values than sampled instances means the SAME records are retained
1936-
// N times — a retention/concurrency fix, not interning.
1937-
if (
1938-
uniqueKey &&
1939-
sampleCount >= 100 &&
1940-
cardinality >= 1 &&
1941-
cardinality < sampleCount * 0.6 &&
1942-
!dupShapesSeen.has(shapeKey)
1943-
) {
1944-
const dupFactor = Math.round(
1945-
sampleCount / Math.max(1, cardinality),
1947+
// Same-object duplication (§1b), copy-factor corrected (round 4 §1a):
1948+
// derive the factor from the MOST-DISTINCT string-valued field — the
1949+
// record's true discriminator — NOT from an arbitrary unique-looking
1950+
// field. The old code did `sampleCount / cardinality` on any
1951+
// `*_id`-shaped key, so a CONSTANT field (e.g. `gala_project_id`
1952+
// with 1 value) reported "~200 copies" when the real duplication was
1953+
// 2×. If even the most-distinct field repeats, the whole record is
1954+
// duplicated; if the most-distinct field is ~unique, there is no
1955+
// duplication regardless of how many constant columns exist.
1956+
const stringStats = propStats.filter(
1957+
p => p.stringValueCount >= sampleCount * 0.5,
1958+
);
1959+
let maxCard = 0;
1960+
let discrim = '';
1961+
for (const p of stringStats) {
1962+
if (p.cardinality > maxCard) {
1963+
maxCard = p.cardinality;
1964+
discrim = p.name;
1965+
}
1966+
}
1967+
if (
1968+
stringStats.length > 0 &&
1969+
maxCard >= 1 &&
1970+
maxCard < sampleCount * 0.6 &&
1971+
sampleCount >= 100 &&
1972+
!dupShapesSeen.has(shapeKey)
1973+
) {
1974+
const copyFactor = Math.round(sampleCount / maxCard);
1975+
if (copyFactor >= 2) {
1976+
dupShapesSeen.add(shapeKey);
1977+
duplicationAlerts.push(
1978+
`- \`{${s.properties.slice(0, 4).join(', ')}${s.properties.length > 4 ? ', …' : ''}}\` (${formatNumber(s.count)} total instances): even the most-distinct field \`${discrim}\` has only **${formatNumber(maxCard)} distinct value(s)** across ${formatNumber(sampleCount)} sampled — the same records appear to be retained **≈${copyFactor}×** (sample-based estimate; confirm the exact factor with \`memlab_property_distribution\` on \`${discrim}\`, or a content-hash count). Investigate **retention/concurrency** (e.g. many concurrent requests each holding a full copy of this dataset, or the same listing materialized twice), NOT string interning.`,
19461979
);
1947-
if (dupFactor >= 2) {
1948-
dupShapesSeen.add(shapeKey);
1949-
duplicationAlerts.push(
1950-
`- \`${propName}\` (a unique-looking key) has only **${formatNumber(cardinality)} distinct value(s)** across ${formatNumber(sampleCount)} sampled of ${formatNumber(s.count)} total \`{${s.properties.slice(0, 4).join(', ')}${s.properties.length > 4 ? ', …' : ''}}\` instances — looks like **~${dupFactor} retained copies of the same records**, not a low-cardinality column. Investigate **retention/concurrency** (e.g. many concurrent requests each holding a full copy of this dataset), NOT string interning.`,
1951-
);
1952-
}
1953-
} else if (
1954-
!uniqueKey &&
1955-
cardinality <= 20 &&
1956-
sampleCount >= 100
1957-
) {
1980+
}
1981+
}
1982+
1983+
// Pass 2: per-property low-cardinality + high-cost-unique notes.
1984+
for (const p of propStats) {
1985+
const uniqueKey = looksUniqueKey(p.name);
1986+
if (!uniqueKey && p.cardinality <= 20 && sampleCount >= 100) {
19581987
// Genuine low-cardinality column. Only suggest string interning
19591988
// when the column actually holds strings — interning a numeric
19601989
// (SMI/heap-number) or boolean column is meaningless
19611990
// (Feedback round 4 §2).
1962-
const isStringColumn = stringValueCount >= sampleCount * 0.5;
1991+
const isStringColumn = p.stringValueCount >= sampleCount * 0.5;
19631992
columnAlerts.push(
19641993
isStringColumn
1965-
? `- Property \`${propName}\` has only **${cardinality} unique value(s)** across ${formatNumber(s.count)} instances — low-cardinality column suitable for pre-filtering at the data source or string interning`
1966-
: `- Property \`${propName}\` has only **${cardinality} unique value(s)** across ${formatNumber(s.count)} instances — low-cardinality column suitable for pre-filtering at the data source`,
1994+
? `- Property \`${p.name}\` has only **${p.cardinality} unique value(s)** across ${formatNumber(s.count)} instances — low-cardinality column suitable for pre-filtering at the data source or string interning`
1995+
: `- Property \`${p.name}\` has only **${p.cardinality} unique value(s)** across ${formatNumber(s.count)} instances — low-cardinality column suitable for pre-filtering at the data source`,
19671996
);
19681997
}
19691998

1970-
// High-cost unique: all values unique, string, large average size
1999+
// High-cost unique: all values unique, string, large average size.
19712000
if (
1972-
allUnique &&
1973-
stringValueCount > sampleCount * 0.8 &&
1974-
cardinality > sampleCount * 0.9
2001+
p.allUnique &&
2002+
p.stringValueCount > sampleCount * 0.8 &&
2003+
p.cardinality > sampleCount * 0.9
19752004
) {
1976-
const avgSize = totalValueSize / stringValueCount;
2005+
const avgSize = p.totalValueSize / p.stringValueCount;
19772006
if (avgSize > 50 && s.count > 10_000) {
19782007
const estimatedWaste = avgSize * s.count;
19792008
columnAlerts.push(
1980-
`- Property \`${propName}\` has **all unique string values** (avg ${Math.round(avgSize)}B each × ${formatNumber(s.count)} instances = ~${formatBytes(estimatedWaste)}) — verify this field is needed by consumers`,
2009+
`- Property \`${p.name}\` has **all unique string values** (avg ${Math.round(avgSize)}B each × ${formatNumber(s.count)} instances = ~${formatBytes(estimatedWaste)}) — verify this field is needed by consumers`,
19812010
);
19822011
}
19832012
}

packages/mcp-server/src/tools/eval.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ export function registerEval(server: McpServer): void {
142142
'**Iterating all nodes:** `snapshot.nodes.forEach(node => { ... })` — NOT for-of.\n' +
143143
'**Get node by ID:** `snapshot.getNodeById(id)` returns IHeapNode or null.\n' +
144144
'**String values:** `node.toStringNode()?.stringValue` for string nodes.\n' +
145-
'**Caveat — retained_size is unreliable here:** inside eval, `node.retained_size`/`.retainedSize` can read back ~0 for every node on some loads. Node counts, property/edge walks, and string values ARE trustworthy. For authoritative retained sizes call `helpers.retainedSize(id)` / `helpers.retainedSizes([ids])` (they re-resolve the node on the real snapshot), or use the dedicated tools (`memlab_largest_objects`, `memlab_class_histogram`, `memlab_pinch_points`, `memlab_object_shape`).\n\n' +
145+
'**Caveat — retained_size is unreliable here:** inside eval, `node.retained_size`/`.retainedSize` can read back ~0 for every node on some loads. Node counts, property/edge walks, and string values ARE trustworthy. For authoritative retained sizes call `helpers.retainedSize(id)` (number) / `helpers.retainedSizes([ids])` (a `Record<id, bytes>` object, NOT an array) — they re-resolve the node on the real snapshot or use the dedicated tools (`memlab_largest_objects`, `memlab_class_histogram`, `memlab_pinch_points`, `memlab_object_shape`).\n\n' +
146146
'**Example — inspect Map entries:**\n' +
147147
'```\nconst map = snapshot.getNodeById(12345);\nconst entries = [];\n' +
148148
'for (const edge of map.references) {\n' +
@@ -169,7 +169,7 @@ export function registerEval(server: McpServer): void {
169169
'markdownTable, isNodeWorthInspecting, filterLargestObjects, queryNodes, ' +
170170
'groupReferrersByEdge(nodeId), groupArrayElementsByProperty(arrayNodeId, propName), ' +
171171
'isOrphaned(nodeId, ownershipEdgeNames[]), countUniqueTargets(arrayNodeId, propName), ' +
172-
'retainedSize(id), retainedSizes(ids[]) }), ' +
172+
'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)) }), ' +
173173
'and standard JS built-ins. ' +
174174
'Node traversal: use node.references (outgoing) and node.referrers (incoming) with for-of. ' +
175175
'Edge properties: .name_or_index, .type, .toNode, .fromNode.',
@@ -437,14 +437,14 @@ function describeEnv(): string {
437437
'## In-scope globals',
438438
'- `snapshot` — IHeapSnapshot: `.nodes.forEach(cb)`, `.edges.forEach(cb)`, `.getNodeById(id)`.',
439439
'- `utils` — @memlab/core utils (e.g. `aggregateDominatorMetrics`, `isFiberNode`, `isDetachedDOMNode`).',
440-
'- `helpers` — `serializeNodeSummary`, `serializeNodeDetail`, `formatBytes`, `formatNumber`, `markdownTable`, `isNodeWorthInspecting`, `filterLargestObjects`, `queryNodes`, `groupReferrersByEdge(nodeId)`, `groupArrayElementsByProperty(arrayNodeId, prop)`, `isOrphaned(nodeId, ownerEdges[])`, `countUniqueTargets(arrayNodeId, prop)`, `retainedSize(id)`, `retainedSizes(ids[])`.',
440+
'- `helpers` — `serializeNodeSummary`, `serializeNodeDetail`, `formatBytes`, `formatNumber`, `markdownTable`, `isNodeWorthInspecting`, `filterLargestObjects`, `queryNodes`, `groupReferrersByEdge(nodeId)`, `groupArrayElementsByProperty(arrayNodeId, prop)`, `isOrphaned(nodeId, ownerEdges[])`, `countUniqueTargets(arrayNodeId, prop)`, `retainedSize(id) -> number`, `retainedSizes(ids[]) -> Record<id, bytes>` (an OBJECT keyed by id, NOT an array — use `sizes[id]` or `Object.values(sizes)`, not `.reduce`/`.map` directly).',
441441
'- Standard JS built-ins (Array, Object, Map, Set, JSON, Math, RegExp, …). No require/process/fs/network.',
442442
'',
443443
'## IHeapNode API',
444444
'`.id`, `.name`, `.type`, `.self_size`, `.retainedSize` (alias `.retained_size`), `.edge_count`, `.is_detached`, `.numOfReferrers` (alias `.referrer_count`), `.isString`, `.toStringNode()?.stringValue`, `.hasPathEdge`, `.pathEdge`, `.dominatorNode`, `.location` (`script_id`/`line`/`column`).',
445445
'',
446446
'## Caveat: retained_size',
447-
'Inside eval, `.retainedSize`/`.retained_size` can read back ~0 for every node on some loads. Counts, property/edge walks, and string values are reliable. For authoritative retained sizes call `helpers.retainedSize(id)` or `helpers.retainedSizes([ids])` (they re-resolve the node on the real snapshot, so you can rank custom analyses by retained size), or use `memlab_largest_objects`, `memlab_class_histogram`, `memlab_pinch_points`, or `memlab_object_shape`.',
447+
'Inside eval, `.retainedSize`/`.retained_size` can read back ~0 for every node on some loads. Counts, property/edge walks, and string values are reliable. For authoritative retained sizes call `helpers.retainedSize(id)` (returns a number) or `helpers.retainedSizes([ids])` (returns a `Record<id, bytes>` OBJECT — not an array; iterate with `Object.values(sizes)` / index with `sizes[id]`) — both re-resolve the node on the real snapshot, so you can rank custom analyses by retained size. Or use `memlab_largest_objects`, `memlab_class_histogram`, `memlab_pinch_points`, or `memlab_object_shape`.',
448448
'',
449449
'## IHeapEdge API',
450450
'`.name_or_index`, `.type` (property/element/context/internal/hidden/shortcut), `.toNode`, `.fromNode`.',

packages/mcp-server/src/tools/sliced-strings.ts

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -110,11 +110,31 @@ export function registerSlicedStrings(server: McpServer): void {
110110
);
111111
}
112112

113-
let parents = [...parentMap.values()];
113+
const allParents = [...parentMap.values()];
114+
const totalPinnedBytes = allParents.reduce(
115+
(sum, p) => sum + p.self_size,
116+
0,
117+
);
118+
119+
// Don't go silent when `min_parent_size` filters everything out. On the
120+
// common "many medium parents" case (each parent sub-MB but thousands of
121+
// them, e.g. ~250 KB JSON records) the filter used to hide every row and
122+
// the tool reported nothing actionable. Fall back to the largest parents
123+
// with a note, and ALWAYS report the aggregate bytes pinned so the "lots
124+
// of small parents" case is visible (Feedback round 4 §3a).
125+
let parents = allParents;
126+
let filteredNote = '';
114127
if (min_parent_size != null) {
115-
parents = parents.filter(p => p.self_size >= min_parent_size);
128+
const filtered = allParents.filter(
129+
p => p.self_size >= min_parent_size,
130+
);
131+
if (filtered.length > 0) {
132+
parents = filtered;
133+
} else {
134+
filteredNote = ` (no parent ≥ ${formatBytes(min_parent_size)}; showing the largest instead)`;
135+
}
116136
}
117-
parents.sort((a, b) => b.self_size - a.self_size);
137+
parents = [...parents].sort((a, b) => b.self_size - a.self_size);
118138
const topParents = parents.slice(0, limit);
119139

120140
const lines: string[] = [];
@@ -129,6 +149,11 @@ export function registerSlicedStrings(server: McpServer): void {
129149
lines.push(
130150
`**Unique parent strings referenced:** ${formatNumber(parentMap.size)}`,
131151
);
152+
if (allParents.length > 0) {
153+
lines.push(
154+
`**Total parent bytes pinned by slices:** ${formatBytes(totalPinnedBytes)} across ${formatNumber(allParents.length)} parent string(s) — substrings keep these alive even when each individual parent is small.`,
155+
);
156+
}
132157
lines.push('');
133158

134159
if (topParents.length > 0) {
@@ -149,7 +174,9 @@ export function registerSlicedStrings(server: McpServer): void {
149174
formatNumber(p.sliced_ref_count),
150175
formatBytes(p.sliced_total_size),
151176
]);
152-
lines.push('### Top parent strings (keeping slices alive)');
177+
lines.push(
178+
`### Top parent strings (keeping slices alive)${filteredNote}`,
179+
);
153180
lines.push('');
154181
lines.push(markdownTable(headers, rows, rightCols));
155182

0 commit comments

Comments
 (0)