Skip to content

Commit b2b05a0

Browse files
chore(memory): expose retainer logic (#2056)
Allows the LLM to query the heapsnapshot further to understand what the retainer path logic.
1 parent 41944b3 commit b2b05a0

14 files changed

Lines changed: 466 additions & 190 deletions

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -514,9 +514,10 @@ If you run into any issues, checkout our [troubleshooting guide](./docs/troubles
514514
- [`take_snapshot`](docs/tool-reference.md#take_snapshot)
515515
- [`screencast_start`](docs/tool-reference.md#screencast_start)
516516
- [`screencast_stop`](docs/tool-reference.md#screencast_stop)
517-
- **Memory** (4 tools)
517+
- **Memory** (5 tools)
518518
- [`take_memory_snapshot`](docs/tool-reference.md#take_memory_snapshot)
519519
- [`get_memory_snapshot_details`](docs/tool-reference.md#get_memory_snapshot_details)
520+
- [`get_node_retainers`](docs/tool-reference.md#get_node_retainers)
520521
- [`get_nodes_by_class`](docs/tool-reference.md#get_nodes_by_class)
521522
- [`load_memory_snapshot`](docs/tool-reference.md#load_memory_snapshot)
522523
- **Extensions** (5 tools)

docs/tool-reference.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,10 @@
3939
- [`take_snapshot`](#take_snapshot)
4040
- [`screencast_start`](#screencast_start)
4141
- [`screencast_stop`](#screencast_stop)
42-
- **[Memory](#memory)** (4 tools)
42+
- **[Memory](#memory)** (5 tools)
4343
- [`take_memory_snapshot`](#take_memory_snapshot)
4444
- [`get_memory_snapshot_details`](#get_memory_snapshot_details)
45+
- [`get_node_retainers`](#get_node_retainers)
4546
- [`get_nodes_by_class`](#get_nodes_by_class)
4647
- [`load_memory_snapshot`](#load_memory_snapshot)
4748
- **[Extensions](#extensions)** (5 tools)
@@ -464,6 +465,19 @@ in the DevTools Elements panel (if any).
464465

465466
---
466467

468+
### `get_node_retainers`
469+
470+
**Description:** Loads a memory heapsnapshot and returns retainers for a specific node ID. (requires flag: --experimentalMemory=true)
471+
472+
**Parameters:**
473+
474+
- **filePath** (string) **(required)**: A path to a .heapsnapshot file to read.
475+
- **nodeId** (number) **(required)**: The stable node ID to get retainers for.
476+
- **pageIdx** (number) _(optional)_: The page index for pagination.
477+
- **pageSize** (number) _(optional)_: The page size for pagination.
478+
479+
---
480+
467481
### `get_nodes_by_class`
468482

469483
**Description:** Loads a memory heapsnapshot and returns instances of a specific class with their stable IDs. (requires flag: --experimentalMemory=true)

src/HeapSnapshotManager.ts

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import fsSync from 'node:fs';
88
import path from 'node:path';
99

10+
import {isNodeLike} from './formatters/HeapSnapshotFormatter.js';
1011
import {DevTools} from './third_party/index.js';
1112
import {
1213
createIdGenerator,
@@ -112,8 +113,41 @@ export class HeapSnapshotManager {
112113
}
113114
const provider = snapshot.createNodesProviderForClass(className, filter);
114115

115-
const range = await provider.serializeItemsRange(0, 1);
116-
return await provider.serializeItemsRange(0, range.totalLength);
116+
return await provider.serializeItemsRange(0, Infinity);
117+
}
118+
119+
async findNodeIndexById(
120+
filePath: string,
121+
nodeId: number,
122+
): Promise<number | undefined> {
123+
const snapshot = await this.getSnapshot(filePath);
124+
const aggregates = await this.getAggregates(filePath);
125+
const filter =
126+
new DevTools.HeapSnapshotModel.HeapSnapshotModel.NodeFilter();
127+
128+
for (const classKey of Object.keys(aggregates)) {
129+
const provider = snapshot.createNodesProviderForClass(classKey, filter);
130+
const range = await provider.serializeItemsRange(0, Infinity);
131+
for (const item of range.items) {
132+
if (isNodeLike(item) && item.id === nodeId) {
133+
return item.nodeIndex;
134+
}
135+
}
136+
}
137+
return undefined;
138+
}
139+
140+
async getRetainers(
141+
filePath: string,
142+
nodeId: number,
143+
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotModel.ItemsRange> {
144+
const nodeIndex = await this.findNodeIndexById(filePath, nodeId);
145+
if (nodeIndex === undefined) {
146+
throw new Error(`Node with ID ${nodeId} not found`);
147+
}
148+
const snapshot = await this.getSnapshot(filePath);
149+
const provider = snapshot.createRetainingEdgesProvider(nodeIndex);
150+
return await provider.serializeItemsRange(0, Infinity);
117151
}
118152

119153
#getCachedSnapshot(filePath: string) {

src/McpContext.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -830,4 +830,11 @@ export class McpContext implements Context {
830830
this.validatePath(filePath);
831831
return await this.#heapSnapshotManager.getNodesByUid(filePath, uid);
832832
}
833+
834+
async getHeapSnapshotRetainers(
835+
filePath: string,
836+
nodeId: number,
837+
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotModel.ItemsRange> {
838+
return await this.#heapSnapshotManager.getRetainers(filePath, nodeId);
839+
}
833840
}

src/McpResponse.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import type {WebMCPTool} from 'puppeteer-core';
99
import type {ParsedArguments} from './bin/chrome-devtools-mcp-cli-options.js';
1010
import {ConsoleFormatter} from './formatters/ConsoleFormatter.js';
1111
import {HeapSnapshotFormatter} from './formatters/HeapSnapshotFormatter.js';
12-
import {isNodeLike} from './formatters/HeapSnapshotFormatter.js';
12+
import {isEdgeLike, isNodeLike} from './formatters/HeapSnapshotFormatter.js';
1313
import {IssueFormatter} from './formatters/IssueFormatter.js';
1414
import {NetworkFormatter} from './formatters/NetworkFormatter.js';
1515
import {SnapshotFormatter} from './formatters/SnapshotFormatter.js';
@@ -984,12 +984,20 @@ Call ${handleDialog.name} to handle it before continuing.`);
984984
}
985985
const nodes = this.#heapSnapshotOptions.nodes;
986986
if (nodes) {
987-
const sortedItems = nodes.items
988-
.filter(isNodeLike)
989-
.sort((a, b) => b.retainedSize - a.retainedSize);
987+
let items = Array.from(nodes.items);
988+
const firstItem = nodes.items[0];
989+
if (firstItem) {
990+
if (isNodeLike(firstItem)) {
991+
items = items
992+
.filter(isNodeLike)
993+
.sort((a, b) => b.retainedSize - a.retainedSize);
994+
} else if (isEdgeLike(firstItem)) {
995+
items = items.filter(isEdgeLike);
996+
}
997+
}
990998

991999
const paginationData = this.#dataWithPagination(
992-
sortedItems,
1000+
items,
9931001
this.#heapSnapshotOptions.pagination,
9941002
);
9951003

src/bin/chrome-devtools-cli-options.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,37 @@ export const commands: Commands = {
337337
},
338338
},
339339
},
340+
get_node_retainers: {
341+
description:
342+
'Loads a memory heapsnapshot and returns retainers for a specific node ID. (requires flag: --experimentalMemory=true)',
343+
category: 'Memory',
344+
args: {
345+
filePath: {
346+
name: 'filePath',
347+
type: 'string',
348+
description: 'A path to a .heapsnapshot file to read.',
349+
required: true,
350+
},
351+
nodeId: {
352+
name: 'nodeId',
353+
type: 'number',
354+
description: 'The stable node ID to get retainers for.',
355+
required: true,
356+
},
357+
pageIdx: {
358+
name: 'pageIdx',
359+
type: 'number',
360+
description: 'The page index for pagination.',
361+
required: false,
362+
},
363+
pageSize: {
364+
name: 'pageSize',
365+
type: 'number',
366+
description: 'The page size for pagination.',
367+
required: false,
368+
},
369+
},
370+
},
340371
get_nodes_by_class: {
341372
description:
342373
'Loads a memory heapsnapshot and returns instances of a specific class with their stable IDs. (requires flag: --experimentalMemory=true)',

src/formatters/HeapSnapshotFormatter.ts

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,15 @@
55
*/
66

77
import type {AggregatedInfoWithUid} from '../HeapSnapshotManager.js';
8-
import type {DevTools} from '../third_party/index.js';
8+
import {DevTools} from '../third_party/index.js';
99
import {stableIdSymbol} from '../utils/id.js';
1010

1111
export interface FormattedSnapshotEntry {
1212
className: string;
1313
classUid?: number;
1414
count: number;
15-
selfSize: number;
16-
retainedSize: number;
15+
selfSize: string;
16+
retainedSize: string;
1717
}
1818

1919
export function isNodeLike(
@@ -24,6 +24,22 @@ export function isNodeLike(
2424
);
2525
}
2626

27+
export function isEdgeLike(
28+
item: unknown,
29+
): item is DevTools.HeapSnapshotModel.HeapSnapshotModel.Edge {
30+
return (
31+
typeof item === 'object' &&
32+
item !== null &&
33+
'name' in item &&
34+
'node' in item &&
35+
'type' in item &&
36+
typeof item.node === 'object' &&
37+
item.node !== null &&
38+
'id' in item.node &&
39+
'name' in item.node
40+
);
41+
}
42+
2743
export class HeapSnapshotFormatter {
2844
#aggregates: Record<string, AggregatedInfoWithUid>;
2945

@@ -39,14 +55,23 @@ export class HeapSnapshotFormatter {
3955
): string {
4056
const lines: string[] = [];
4157

42-
if (items.length > 0 && isNodeLike(items[0])) {
43-
lines.push('id,name,type,distance,selfSize,retainedSize');
58+
if (items.length > 0) {
59+
const firstItem = items[0];
60+
if (isNodeLike(firstItem)) {
61+
lines.push('id,name,type,distance,selfSize,retainedSize');
62+
} else if (isEdgeLike(firstItem)) {
63+
lines.push('edgeIndex,edgeName,edgeType,targetNodeId,targetNodeName');
64+
}
4465
}
4566

4667
for (const item of items) {
4768
if (isNodeLike(item)) {
4869
lines.push(
49-
`${item.id},"${item.name}",${item.type},${item.distance},${item.selfSize},${item.retainedSize}`,
70+
`${item.id},${item.name},${item.type},${item.distance},${DevTools.I18n.ByteUtilities.formatBytesToKb(item.selfSize)},${DevTools.I18n.ByteUtilities.formatBytesToKb(item.retainedSize)}`,
71+
);
72+
} else if (isEdgeLike(item)) {
73+
lines.push(
74+
`${item.edgeIndex},${item.name},${item.type},${item.node.id},${item.node.name}`,
5075
);
5176
}
5277
}
@@ -66,7 +91,7 @@ export class HeapSnapshotFormatter {
6691
for (const info of sorted) {
6792
const uid = info[stableIdSymbol] ?? '';
6893
lines.push(
69-
`${uid},"${info.name}",${info.count},${info.self},${info.maxRet}`,
94+
`${uid},${info.name},${info.count},${DevTools.I18n.ByteUtilities.formatBytesToKb(info.self)},${DevTools.I18n.ByteUtilities.formatBytesToKb(info.maxRet)}`,
7095
);
7196
}
7297

@@ -79,8 +104,8 @@ export class HeapSnapshotFormatter {
79104
uid: info[stableIdSymbol],
80105
className: info.name,
81106
count: info.count,
82-
selfSize: info.self,
83-
retainedSize: info.maxRet,
107+
selfSize: DevTools.I18n.ByteUtilities.formatBytesToKb(info.self),
108+
retainedSize: DevTools.I18n.ByteUtilities.formatBytesToKb(info.maxRet),
84109
}));
85110
}
86111

src/telemetry/tool_call_metrics.json

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -631,5 +631,26 @@
631631
{
632632
"name": "list3p_developer_tools",
633633
"args": []
634+
},
635+
{
636+
"name": "get_node_retainers",
637+
"args": [
638+
{
639+
"name": "file_path_length",
640+
"argType": "number"
641+
},
642+
{
643+
"name": "node_id",
644+
"argType": "number"
645+
},
646+
{
647+
"name": "page_idx",
648+
"argType": "number"
649+
},
650+
{
651+
"name": "page_size",
652+
"argType": "number"
653+
}
654+
]
634655
}
635656
]

src/tools/ToolDefinition.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,10 @@ export type Context = Readonly<{
246246
filePath: string,
247247
uid: number,
248248
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotModel.ItemsRange>;
249+
getHeapSnapshotRetainers(
250+
filePath: string,
251+
nodeId: number,
252+
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotModel.ItemsRange>;
249253
}>;
250254

251255
/**

src/tools/memory.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,3 +128,34 @@ export const getNodesByClass = defineTool({
128128
});
129129
},
130130
});
131+
132+
export const getNodeRetainers = defineTool({
133+
name: 'get_node_retainers',
134+
description:
135+
'Loads a memory heapsnapshot and returns retainers for a specific node ID.',
136+
annotations: {
137+
category: ToolCategory.MEMORY,
138+
readOnlyHint: true,
139+
conditions: ['experimentalMemory'],
140+
},
141+
blockedByDialog: false,
142+
schema: {
143+
filePath: zod.string().describe('A path to a .heapsnapshot file to read.'),
144+
nodeId: zod.number().describe('The stable node ID to get retainers for.'),
145+
pageIdx: zod.number().optional().describe('The page index for pagination.'),
146+
pageSize: zod.number().optional().describe('The page size for pagination.'),
147+
},
148+
handler: async (request, response, context) => {
149+
context.validatePath(request.params.filePath);
150+
151+
const retainers = await context.getHeapSnapshotRetainers(
152+
request.params.filePath,
153+
request.params.nodeId,
154+
);
155+
156+
response.setHeapSnapshotNodes(retainers, {
157+
pageIdx: request.params.pageIdx,
158+
pageSize: request.params.pageSize,
159+
});
160+
},
161+
});

0 commit comments

Comments
 (0)