Skip to content

Commit 9f5dd62

Browse files
chore(memory): expose way to get the nodes (#1916)
The next step for the memory debugging is to provide the nodes that the aggregates have, from which you can get the retainers of the object.
1 parent 1277c19 commit 9f5dd62

9 files changed

Lines changed: 213 additions & 0 deletions

File tree

src/HeapSnapshotManager.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,23 @@ export class HeapSnapshotManager {
9999
return uid;
100100
}
101101

102+
async getNodesByUid(
103+
filePath: string,
104+
uid: number,
105+
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotModel.ItemsRange> {
106+
const snapshot = await this.getSnapshot(filePath);
107+
const filter =
108+
new DevTools.HeapSnapshotModel.HeapSnapshotModel.NodeFilter();
109+
const className = await this.resolveClassKeyFromUid(filePath, uid);
110+
if (!className) {
111+
throw new Error(`Class with UID ${uid} not found in heap snapshot`);
112+
}
113+
const provider = snapshot.createNodesProviderForClass(className, filter);
114+
115+
const range = await provider.serializeItemsRange(0, 1);
116+
return await provider.serializeItemsRange(0, range.totalLength);
117+
}
118+
102119
#getCachedSnapshot(filePath: string) {
103120
const absolutePath = path.resolve(filePath);
104121
const cached = this.#snapshots.get(absolutePath);
@@ -108,6 +125,14 @@ export class HeapSnapshotManager {
108125
return cached;
109126
}
110127

128+
async resolveClassKeyFromUid(
129+
filePath: string,
130+
uid: number,
131+
): Promise<string | undefined> {
132+
const cached = this.#getCachedSnapshot(filePath);
133+
return cached.uidToClassKey.get(uid);
134+
}
135+
111136
async #loadSnapshot(absolutePath: string): Promise<{
112137
snapshot: DevTools.HeapSnapshotModel.HeapSnapshotProxy.HeapSnapshotProxy;
113138
worker: DevTools.HeapSnapshotModel.HeapSnapshotProxy.HeapSnapshotWorkerProxy;

src/McpContext.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -765,4 +765,11 @@ export class McpContext implements Context {
765765
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotModel.StaticData | null> {
766766
return await this.#heapSnapshotManager.getStaticData(filePath);
767767
}
768+
769+
async getHeapSnapshotNodesByUid(
770+
filePath: string,
771+
uid: number,
772+
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotModel.ItemsRange> {
773+
return await this.#heapSnapshotManager.getNodesByUid(filePath, uid);
774+
}
768775
}

src/McpResponse.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,7 @@ export class McpResponse implements Response {
179179
pagination?: PaginationOptions;
180180
stats?: DevTools.HeapSnapshotModel.HeapSnapshotModel.Statistics;
181181
staticData?: DevTools.HeapSnapshotModel.HeapSnapshotModel.StaticData | null;
182+
nodes?: DevTools.HeapSnapshotModel.HeapSnapshotModel.ItemsRange;
182183
};
183184
#networkRequestsOptions?: {
184185
include: boolean;
@@ -404,6 +405,18 @@ export class McpResponse implements Response {
404405
};
405406
}
406407

408+
setHeapSnapshotNodes(
409+
nodes: DevTools.HeapSnapshotModel.HeapSnapshotModel.ItemsRange,
410+
options?: PaginationOptions,
411+
) {
412+
this.#heapSnapshotOptions = {
413+
...this.#heapSnapshotOptions,
414+
include: true,
415+
nodes,
416+
pagination: options,
417+
};
418+
}
419+
407420
attachImage(value: ImageContentData): void {
408421
this.#images.push(value);
409422
}
@@ -701,6 +714,7 @@ export class McpResponse implements Response {
701714
staticData?: object;
702715
};
703716
heapSnapshotData?: object[];
717+
heapSnapshotNodes?: readonly object[];
704718
extensionServiceWorkers?: object[];
705719
extensionPages?: object[];
706720
} = {};
@@ -929,6 +943,20 @@ Call ${handleDialog.name} to handle it before continuing.`);
929943
response.push(formatter.toString());
930944
structuredContent.heapSnapshotData = formatter.toJSON();
931945
}
946+
const nodes = this.#heapSnapshotOptions.nodes;
947+
if (nodes) {
948+
const paginationData = this.#dataWithPagination(
949+
nodes.items,
950+
this.#heapSnapshotOptions.pagination,
951+
);
952+
953+
response.push(HeapSnapshotFormatter.formatNodes(paginationData.items));
954+
955+
structuredContent.pagination = paginationData.pagination;
956+
response.push(...paginationData.info);
957+
958+
structuredContent.heapSnapshotNodes = paginationData.items;
959+
}
932960
}
933961

934962
if (data.detailedNetworkRequest) {

src/formatters/HeapSnapshotFormatter.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,44 @@ export interface FormattedSnapshotEntry {
1616
retainedSize: number;
1717
}
1818

19+
function isNodeLike(
20+
item: unknown,
21+
): item is DevTools.HeapSnapshotModel.HeapSnapshotModel.Node {
22+
return (
23+
typeof item === 'object' && item !== null && 'id' in item && 'name' in item
24+
);
25+
}
26+
1927
export class HeapSnapshotFormatter {
2028
#aggregates: Record<string, AggregatedInfoWithUid>;
2129

2230
constructor(aggregates: Record<string, AggregatedInfoWithUid>) {
2331
this.#aggregates = aggregates;
2432
}
2533

34+
static formatNodes(
35+
items: ReadonlyArray<
36+
| DevTools.HeapSnapshotModel.HeapSnapshotModel.Node
37+
| DevTools.HeapSnapshotModel.HeapSnapshotModel.Edge
38+
>,
39+
): string {
40+
const lines: string[] = [];
41+
42+
if (items.length > 0 && isNodeLike(items[0])) {
43+
lines.push('id,name,type,distance,selfSize,retainedSize');
44+
}
45+
46+
for (const item of items) {
47+
if (isNodeLike(item)) {
48+
lines.push(
49+
`${item.id},"${item.name}",${item.type},${item.distance},${item.selfSize},${item.retainedSize}`,
50+
);
51+
}
52+
}
53+
54+
return lines.join('\n');
55+
}
56+
2657
#getSortedAggregates(): AggregatedInfoWithUid[] {
2758
return Object.values(this.#aggregates).sort((a, b) => b.self - a.self);
2859
}

src/telemetry/tool_call_metrics.json

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -591,5 +591,22 @@
591591
"argType": "number"
592592
}
593593
]
594+
},
595+
{
596+
"name": "get_nodes_by_class",
597+
"args": [
598+
{
599+
"name": "file_path_length",
600+
"argType": "number"
601+
},
602+
{
603+
"name": "page_idx",
604+
"argType": "number"
605+
},
606+
{
607+
"name": "page_size",
608+
"argType": "number"
609+
}
610+
]
594611
}
595612
]

src/tools/ToolDefinition.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,10 @@ export interface Response {
112112
stats: DevTools.HeapSnapshotModel.HeapSnapshotModel.Statistics,
113113
staticData: DevTools.HeapSnapshotModel.HeapSnapshotModel.StaticData | null,
114114
): void;
115+
setHeapSnapshotNodes(
116+
nodes: DevTools.HeapSnapshotModel.HeapSnapshotModel.ItemsRange,
117+
options?: PaginationOptions,
118+
): void;
115119
setIncludePages(value: boolean): void;
116120
setIncludeNetworkRequests(
117121
value: boolean,
@@ -234,6 +238,10 @@ export type Context = Readonly<{
234238
getHeapSnapshotStaticData(
235239
filePath: string,
236240
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotModel.StaticData | null>;
241+
getHeapSnapshotNodesByUid(
242+
filePath: string,
243+
uid: number,
244+
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotModel.ItemsRange>;
237245
}>;
238246

239247
export type ContextPage = Readonly<{

src/tools/memory.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,3 +88,35 @@ export const getMemorySnapshotDetails = defineTool({
8888
});
8989
},
9090
});
91+
92+
export const getNodesByClass = defineTool({
93+
name: 'get_nodes_by_class',
94+
description:
95+
'Loads a memory heapsnapshot and returns instances of a specific class with their stable IDs.',
96+
annotations: {
97+
category: ToolCategory.MEMORY,
98+
readOnlyHint: true,
99+
conditions: ['experimentalMemory'],
100+
},
101+
schema: {
102+
filePath: zod.string().describe('A path to a .heapsnapshot file to read.'),
103+
uid: zod
104+
.number()
105+
.describe(
106+
'The unique UID for the class, obtained from aggregates listing.',
107+
),
108+
pageIdx: zod.number().optional().describe('The page index for pagination.'),
109+
pageSize: zod.number().optional().describe('The page size for pagination.'),
110+
},
111+
handler: async (request, response, context) => {
112+
const nodes = await context.getHeapSnapshotNodesByUid(
113+
request.params.filePath,
114+
request.params.uid,
115+
);
116+
117+
response.setHeapSnapshotNodes(nodes, {
118+
pageIdx: request.params.pageIdx,
119+
pageSize: request.params.pageSize,
120+
});
121+
},
122+
});

tests/tools/memory.test.js.snapshot

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,20 @@ uid,className,count,selfSize,maxRetainedSize
161161
108,"HTMLBodyElement (internal cache) / https://example.com",1,16,16
162162
`;
163163

164+
exports[`memory > get_nodes_by_class > with default options 1`] = `
165+
## Heap Snapshot Data
166+
id,name,type,distance,selfSize,retainedSize
167+
25307,"Array",object,2,192,2056
168+
33187,"Array",object,2,192,1664
169+
36255,"Array",object,2,192,1664
170+
45899,"Array",object,5,56,56
171+
45901,"Array",object,5,88,88
172+
46149,"Array",object,5,56,56
173+
46151,"Array",object,5,88,88
174+
46355,"Array",object,2,192,2056
175+
Showing 1-8 of 8 (Page 1 of 1).
176+
`;
177+
164178
exports[`memory > load_memory_snapshot > with default options 1`] = `
165179
## Heap Snapshot Data
166180
Statistics: {

tests/tools/memory.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
takeMemorySnapshot,
1616
exploreMemorySnapshot,
1717
getMemorySnapshotDetails,
18+
getNodesByClass,
1819
} from '../../src/tools/memory.js';
1920
import {withMcpContext} from '../utils.js';
2021

@@ -97,4 +98,54 @@ describe('memory', () => {
9798
});
9899
});
99100
});
101+
102+
describe('get_nodes_by_class', () => {
103+
it('with default options', async t => {
104+
await withMcpContext(async (response, context) => {
105+
const filePath = join(
106+
process.cwd(),
107+
'tests/fixtures/example.heapsnapshot',
108+
);
109+
110+
await context.getHeapSnapshotAggregates(filePath);
111+
112+
await getNodesByClass.handler(
113+
{params: {filePath, uid: 19}},
114+
response,
115+
context,
116+
);
117+
118+
const responseData = await response.handle(
119+
getNodesByClass.name,
120+
context,
121+
);
122+
123+
const output = responseData.content
124+
.map(c => (c.type === 'text' ? c.text : ''))
125+
.join('\n');
126+
127+
t.assert.snapshot?.(output);
128+
});
129+
});
130+
131+
it('with non-existent class name', async () => {
132+
await withMcpContext(async (response, context) => {
133+
const filePath = join(
134+
process.cwd(),
135+
'tests/fixtures/example.heapsnapshot',
136+
);
137+
138+
await context.getHeapSnapshotAggregates(filePath);
139+
140+
await assert.rejects(
141+
getNodesByClass.handler(
142+
{params: {filePath, uid: 999999}},
143+
response,
144+
context,
145+
),
146+
{message: 'Class with UID 999999 not found in heap snapshot'},
147+
);
148+
});
149+
});
150+
});
100151
});

0 commit comments

Comments
 (0)