From 263e29bb5a262f85731b45278111932cc5fc0066 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Inf=C3=BChr?= Date: Thu, 16 Jul 2026 16:21:53 +0200 Subject: [PATCH] Filter heap snapshot objects by native context --- docs/tool-reference.md | 6 ++- src/HeapSnapshotManager.ts | 32 ++++++++++++--- src/McpContext.ts | 9 +++- src/bin/chrome-devtools-cli-options.ts | 20 +++++++++ src/telemetry/tool_call_metrics.json | 8 ++++ src/tools/ToolDefinition.ts | 2 + src/tools/memory.ts | 17 ++++++++ tests/tools/memory.test.js.snapshot | 32 +++++++++++++++ tests/tools/memory.test.ts | 57 ++++++++++++++++++++++++++ 9 files changed, 174 insertions(+), 9 deletions(-) diff --git a/docs/tool-reference.md b/docs/tool-reference.md index f2a1c9833..bb8bc6b7d 100644 --- a/docs/tool-reference.md +++ b/docs/tool-reference.md @@ -485,7 +485,8 @@ in the DevTools Elements panel (if any). - **filePath** (string) **(required)**: A path to a .heapsnapshot file to read. - **id** (number) **(required)**: The ID for the class, obtained from details. -- **filterName** (enum: "objectsRetainedByDetachedDomNodes", "objectsRetainedByConsole", "objectsRetainedByEventHandlers", "objectsRetainedByContexts") _(optional)_: An optional filter to apply to the nodes. +- **filterName** (enum: "objectsRetainedByDetachedDomNodes", "objectsRetainedByConsole", "objectsRetainedByEventHandlers", "objectsRetainedByContexts", "sharedNativeContext", "noNativeContext", "attributedToSpecificNativeContext") _(optional)_: An optional filter to apply to the nodes. +- **objectId** (number) _(optional)_: The object ID (nodeId) of the specific native context to filter by when filterName is attributedToSpecificNativeContext. - **pageIdx** (number) _(optional)_: The page index for pagination. - **pageSize** (number) _(optional)_: The page size for pagination. @@ -498,7 +499,8 @@ in the DevTools Elements panel (if any). **Parameters:** - **filePath** (string) **(required)**: A path to a .heapsnapshot file to read. -- **filterName** (enum: "objectsRetainedByDetachedDomNodes", "objectsRetainedByConsole", "objectsRetainedByEventHandlers", "objectsRetainedByContexts") _(optional)_: An optional filter to apply to the aggregates. +- **filterName** (enum: "objectsRetainedByDetachedDomNodes", "objectsRetainedByConsole", "objectsRetainedByEventHandlers", "objectsRetainedByContexts", "sharedNativeContext", "noNativeContext", "attributedToSpecificNativeContext") _(optional)_: An optional filter to apply to the aggregates. +- **objectId** (number) _(optional)_: The object ID (nodeId) of the specific native context to filter by when filterName is attributedToSpecificNativeContext. - **pageIdx** (number) _(optional)_: The page index for pagination of aggregates. - **pageSize** (number) _(optional)_: The page size for pagination of aggregates. diff --git a/src/HeapSnapshotManager.ts b/src/HeapSnapshotManager.ts index 9ef3e4995..949e6c855 100644 --- a/src/HeapSnapshotManager.ts +++ b/src/HeapSnapshotManager.ts @@ -77,16 +77,37 @@ export class HeapSnapshotManager { return snapshot; } + async #applyNodeFilter( + snapshot: DevTools.HeapSnapshotModel.HeapSnapshotProxy.HeapSnapshotProxy, + filter: DevTools.HeapSnapshotModel.HeapSnapshotModel.NodeFilter, + filterName?: string, + objectId?: number, + ): Promise { + if (filterName === 'attributedToSpecificNativeContext') { + if (objectId === undefined) { + throw new Error( + 'objectId is required when filterName is attributedToSpecificNativeContext', + ); + } + const nodeIndex = await snapshot.nodeIndexForId(objectId); + if (nodeIndex === undefined) { + throw new Error(`Node with ID ${objectId} not found`); + } + filter.filterName = `nativeContext_${nodeIndex}`; + } else if (filterName) { + filter.filterName = filterName; + } + } + async getAggregates( filePath: string, filterName?: string, + objectId?: number, ): Promise { const snapshot = await this.getSnapshot(filePath); const filter = new DevTools.HeapSnapshotModel.HeapSnapshotModel.NodeFilter(); - if (filterName) { - filter.filterName = filterName; - } + await this.#applyNodeFilter(snapshot, filter, filterName, objectId); const aggregates: Record = await snapshot.aggregatesWithFilter(filter); let objectCount = 0; @@ -145,13 +166,12 @@ export class HeapSnapshotManager { filePath: string, id: number, filterName?: string, + objectId?: number, ): Promise { const snapshot = await this.getSnapshot(filePath); const filter = new DevTools.HeapSnapshotModel.HeapSnapshotModel.NodeFilter(); - if (filterName) { - filter.filterName = filterName; - } + await this.#applyNodeFilter(snapshot, filter, filterName, objectId); const className = await this.resolveClassKeyFromId(filePath, id); if (!className) { throw new Error(`Class with ID ${id} not found in heap snapshot`); diff --git a/src/McpContext.ts b/src/McpContext.ts index b5834cbc1..2601883a2 100644 --- a/src/McpContext.ts +++ b/src/McpContext.ts @@ -646,8 +646,13 @@ export class McpContext implements Context { async getHeapSnapshotAggregates( filePath: string, filterName?: string, + objectId?: number, ): Promise { - return await this.#heapSnapshotManager.getAggregates(filePath, filterName); + return await this.#heapSnapshotManager.getAggregates( + filePath, + filterName, + objectId, + ); } async getHeapSnapshotDuplicateStrings( @@ -678,11 +683,13 @@ export class McpContext implements Context { filePath: string, id: number, filterName?: string, + objectId?: number, ): Promise { return await this.#heapSnapshotManager.getNodesById( filePath, id, filterName, + objectId, ); } diff --git a/src/bin/chrome-devtools-cli-options.ts b/src/bin/chrome-devtools-cli-options.ts index d82cb1e2a..191d1fb37 100644 --- a/src/bin/chrome-devtools-cli-options.ts +++ b/src/bin/chrome-devtools-cli-options.ts @@ -359,8 +359,18 @@ export const commands: Commands = { 'objectsRetainedByConsole', 'objectsRetainedByEventHandlers', 'objectsRetainedByContexts', + 'sharedNativeContext', + 'noNativeContext', + 'attributedToSpecificNativeContext', ], }, + objectId: { + name: 'objectId', + type: 'number', + description: + 'The object ID (nodeId) of the specific native context to filter by when filterName is attributedToSpecificNativeContext.', + required: false, + }, pageIdx: { name: 'pageIdx', type: 'number', @@ -396,8 +406,18 @@ export const commands: Commands = { 'objectsRetainedByConsole', 'objectsRetainedByEventHandlers', 'objectsRetainedByContexts', + 'sharedNativeContext', + 'noNativeContext', + 'attributedToSpecificNativeContext', ], }, + objectId: { + name: 'objectId', + type: 'number', + description: + 'The object ID (nodeId) of the specific native context to filter by when filterName is attributedToSpecificNativeContext.', + required: false, + }, pageIdx: { name: 'pageIdx', type: 'number', diff --git a/src/telemetry/tool_call_metrics.json b/src/telemetry/tool_call_metrics.json index a41da140e..194ac0ddd 100644 --- a/src/telemetry/tool_call_metrics.json +++ b/src/telemetry/tool_call_metrics.json @@ -688,6 +688,10 @@ { "name": "filter_name", "argType": "string" + }, + { + "name": "object_id", + "argType": "number" } ] }, @@ -709,6 +713,10 @@ { "name": "filter_name", "argType": "string" + }, + { + "name": "object_id", + "argType": "number" } ] }, diff --git a/src/tools/ToolDefinition.ts b/src/tools/ToolDefinition.ts index 841accde5..7689e5a0d 100644 --- a/src/tools/ToolDefinition.ts +++ b/src/tools/ToolDefinition.ts @@ -237,6 +237,7 @@ export type Context = Readonly<{ getHeapSnapshotAggregates( filePath: string, filterName?: string, + objectId?: number, ): Promise; getHeapSnapshotDuplicateStrings( filePath: string, @@ -254,6 +255,7 @@ export type Context = Readonly<{ filePath: string, id: number, filterName?: string, + objectId?: number, ): Promise; getHeapSnapshotRetainers( filePath: string, diff --git a/src/tools/memory.ts b/src/tools/memory.ts index 2f1267db3..7b9a1a9b3 100644 --- a/src/tools/memory.ts +++ b/src/tools/memory.ts @@ -14,6 +14,9 @@ const HEAP_SNAPSHOT_FILTERS: readonly [string, ...string[]] = [ 'objectsRetainedByConsole', 'objectsRetainedByEventHandlers', 'objectsRetainedByContexts', + 'sharedNativeContext', + 'noNativeContext', + 'attributedToSpecificNativeContext', ]; export const takeHeapSnapshot = definePageTool({ @@ -87,6 +90,12 @@ export const getHeapSnapshotDetails = defineTool({ .enum(HEAP_SNAPSHOT_FILTERS) .optional() .describe('An optional filter to apply to the aggregates.'), + objectId: zod + .number() + .optional() + .describe( + 'The object ID (nodeId) of the specific native context to filter by when filterName is attributedToSpecificNativeContext.', + ), pageIdx: zod .number() .optional() @@ -102,6 +111,7 @@ export const getHeapSnapshotDetails = defineTool({ const aggregates = await context.getHeapSnapshotAggregates( request.params.filePath, request.params.filterName, + request.params.objectId, ); response.setHeapSnapshotAggregates(aggregates, { @@ -127,6 +137,12 @@ export const getHeapSnapshotClassNodes = defineTool({ .enum(HEAP_SNAPSHOT_FILTERS) .optional() .describe('An optional filter to apply to the nodes.'), + objectId: zod + .number() + .optional() + .describe( + 'The object ID (nodeId) of the specific native context to filter by when filterName is attributedToSpecificNativeContext.', + ), pageIdx: zod.number().optional().describe('The page index for pagination.'), pageSize: zod.number().optional().describe('The page size for pagination.'), }, @@ -137,6 +153,7 @@ export const getHeapSnapshotClassNodes = defineTool({ request.params.filePath, request.params.id, request.params.filterName, + request.params.objectId, ); response.setHeapSnapshotNodes(nodes, { diff --git a/tests/tools/memory.test.js.snapshot b/tests/tools/memory.test.js.snapshot index 03b54643a..b277a7ca0 100644 --- a/tests/tools/memory.test.js.snapshot +++ b/tests/tools/memory.test.js.snapshot @@ -61,6 +61,25 @@ nodeId,nodeName,type,distance,selfSize,retainedSize Showing 1-6 of 6 (Page 1 of 1). `; +exports[`memory > get_heapsnapshot_details > with attributedToSpecificNativeContext filterName and objectId 1`] = ` +## Heap Snapshot Data +Objects: 4589 +Total shallow size: 350 kB +Showing 1-10 of 150 (Page 1 of 15). +Next page: 1 +id,name,count,selfSize,maxRetainedSize +1,(system),1739,115 kB,350 kB +7,Function,1095,34.9 kB,72.2 kB +11,(object shape),769,52.6 kB,53.3 kB +10,Window (global*) / https://example.com,1,40.5 kB,51.0 kB +36,(array),6,9.1 kB,46.0 kB +17,Window (prototype) / https://example.com,2,26.7 kB,26.9 kB +4,(compiled code),323,19.3 kB,19.4 kB +3,(string),376,10.5 kB,10.5 kB +19,Window (internal cache) / https://example.com,2,0.2 kB,10.1 kB +72,Document,1,8.4 kB,8.7 kB +`; + exports[`memory > get_heapsnapshot_details > with default options 1`] = ` ## Heap Snapshot Data Objects: 11940 @@ -238,6 +257,19 @@ id,name,count,selfSize,maxRetainedSize 4,Array,4,0.3 kB,0.3 kB `; +exports[`memory > get_heapsnapshot_details > with sharedNativeContext filterName 1`] = ` +## Heap Snapshot Data +Objects: 558 +Total shallow size: 22.9 kB +Showing 1-5 of 5 (Page 1 of 1). +id,name,count,selfSize,maxRetainedSize +3,(string),443,16.6 kB,16.6 kB +1,(compiled code),85,5.9 kB,9.8 kB +4,(system),12,0.2 kB,0.6 kB +2,(number),16,0.2 kB,0.2 kB +5,(concatenated string),2,0.0 kB,0.1 kB +`; + exports[`memory > get_heapsnapshot_dominators > with valid nodeId 1`] = ` ## Heap Snapshot Data ### Dominator Chain diff --git a/tests/tools/memory.test.ts b/tests/tools/memory.test.ts index 300b7710e..23ac1da5a 100644 --- a/tests/tools/memory.test.ts +++ b/tests/tools/memory.test.ts @@ -131,6 +131,63 @@ describe('memory', () => { t.assert.snapshot(output); }); }); + + it('with sharedNativeContext filterName', async t => { + await withMcpContext(async (response, context) => { + const filePath = join( + process.cwd(), + 'tests/fixtures/example.heapsnapshot', + ); + + await getHeapSnapshotDetails.handler( + {params: {filePath, filterName: 'sharedNativeContext'}}, + response, + context, + ); + + const responseData = await response.handle( + getHeapSnapshotDetails.name, + context, + ); + const output = responseData.content + .map(c => (c.type === 'text' ? c.text : '')) + .join('\n'); + + t.assert.snapshot(output); + }); + }); + + it('with attributedToSpecificNativeContext filterName and objectId', async t => { + await withMcpContext(async (response, context) => { + const filePath = join( + process.cwd(), + 'tests/fixtures/example.heapsnapshot', + ); + + await getHeapSnapshotDetails.handler( + { + params: { + filePath, + filterName: 'attributedToSpecificNativeContext', + objectId: 7249, + pageSize: 10, + }, + }, + response, + context, + ); + + const responseData = await response.handle( + getHeapSnapshotDetails.name, + context, + ); + const output = responseData.content + .map(c => (c.type === 'text' ? c.text : '')) + .join('\n'); + + t.assert.snapshot(output); + }); + }); }); describe('get_heapsnapshot_class_nodes', () => {