Skip to content

Commit 3a128be

Browse files
chore(memory): expose way to get the nodes
1 parent 57648b7 commit 3a128be

8 files changed

Lines changed: 200 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
@@ -922,4 +922,11 @@ export class McpContext implements Context {
922922
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotModel.StaticData | null> {
923923
return await this.#heapSnapshotManager.getStaticData(filePath);
924924
}
925+
926+
async getHeapSnapshotNodesByUid(
927+
filePath: string,
928+
uid: number,
929+
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotModel.ItemsRange> {
930+
return await this.#heapSnapshotManager.getNodesByUid(filePath, uid);
931+
}
925932
}

src/McpResponse.ts

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

407+
setHeapSnapshotNodes(
408+
nodes: DevTools.HeapSnapshotModel.HeapSnapshotModel.ItemsRange,
409+
options?: PaginationOptions,
410+
) {
411+
this.#heapSnapshotOptions = {
412+
...this.#heapSnapshotOptions,
413+
include: true,
414+
nodes,
415+
pagination: options,
416+
};
417+
}
418+
406419
attachImage(value: ImageContentData): void {
407420
this.#images.push(value);
408421
}
@@ -704,6 +717,7 @@ export class McpResponse implements Response {
704717
staticData?: object;
705718
};
706719
heapSnapshotData?: object[];
720+
heapSnapshotNodes?: object[];
707721
extensionServiceWorkers?: object[];
708722
extensionPages?: object[];
709723
} = {};
@@ -932,6 +946,20 @@ Call ${handleDialog.name} to handle it before continuing.`);
932946
response.push(formatter.toString());
933947
structuredContent.heapSnapshotData = formatter.toJSON();
934948
}
949+
const nodes = this.#heapSnapshotOptions.nodes;
950+
if (nodes) {
951+
const paginationData = this.#dataWithPagination(
952+
nodes.items,
953+
this.#heapSnapshotOptions.pagination,
954+
);
955+
956+
response.push(HeapSnapshotFormatter.formatNodes(paginationData.items));
957+
958+
structuredContent.pagination = paginationData.pagination;
959+
response.push(...paginationData.info);
960+
961+
structuredContent.heapSnapshotNodes = nodes.items;
962+
}
935963
}
936964

937965
if (data.detailedNetworkRequest) {

src/formatters/HeapSnapshotFormatter.ts

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

19+
export interface FormattedNodeEntry {
20+
id: number;
21+
name: string;
22+
type: string;
23+
distance: number;
24+
selfSize: number;
25+
retainedSize: number;
26+
}
27+
1928
export class HeapSnapshotFormatter {
2029
#aggregates: Record<string, AggregatedInfoWithUid>;
2130

2231
constructor(aggregates: Record<string, AggregatedInfoWithUid>) {
2332
this.#aggregates = aggregates;
2433
}
2534

35+
static formatNodes(items: readonly unknown[]): string {
36+
const lines: string[] = [];
37+
lines.push('id,name,type,distance,selfSize,retainedSize');
38+
39+
for (const item of items) {
40+
if (typeof item === 'object' && item !== null) {
41+
if (
42+
'id' in item &&
43+
'name' in item &&
44+
'type' in item &&
45+
'distance' in item &&
46+
'selfSize' in item &&
47+
'retainedSize' in item
48+
) {
49+
lines.push(
50+
`${item.id},"${item.name}",${item.type},${item.distance},${item.selfSize},${item.retainedSize}`,
51+
);
52+
}
53+
}
54+
}
55+
56+
return lines.join('\n');
57+
}
58+
2659
#getSortedAggregates(): AggregatedInfoWithUid[] {
2760
return Object.values(this.#aggregates).sort((a, b) => b.self - a.self);
2861
}

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,
@@ -235,6 +239,10 @@ export type Context = Readonly<{
235239
getHeapSnapshotStaticData(
236240
filePath: string,
237241
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotModel.StaticData | null>;
242+
getHeapSnapshotNodesByUid(
243+
filePath: string,
244+
uid: number,
245+
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotModel.ItemsRange>;
238246
}>;
239247

240248
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: 53 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,56 @@ 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+
132+
133+
it('with non-existent class name', async () => {
134+
await withMcpContext(async (response, context) => {
135+
const filePath = join(
136+
process.cwd(),
137+
'tests/fixtures/example.heapsnapshot',
138+
);
139+
140+
await context.getHeapSnapshotAggregates(filePath);
141+
142+
await assert.rejects(
143+
getNodesByClass.handler(
144+
{params: {filePath, uid: 999999}},
145+
response,
146+
context,
147+
),
148+
{message: 'Class with UID 999999 not found in heap snapshot'},
149+
);
150+
});
151+
});
152+
});
100153
});

0 commit comments

Comments
 (0)