feat: Support filter with heap snapshots aggregates (#2323)
This exposes DevTools named filters in get_heapsnapshot_details and get_heapsnapshot_class_nodes tools. This allows the client to list e.g. all objects retained through detached DOM objects. Co-authored-by: Dominik Inführ <dinfuehr@chromium.org>
This commit is contained in:
@@ -484,6 +484,7 @@ 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.
|
||||
- **pageIdx** (number) _(optional)_: The page index for pagination.
|
||||
- **pageSize** (number) _(optional)_: The page size for pagination.
|
||||
|
||||
@@ -496,6 +497,7 @@ 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.
|
||||
- **pageIdx** (number) _(optional)_: The page index for pagination of aggregates.
|
||||
- **pageSize** (number) _(optional)_: The page size for pagination of aggregates.
|
||||
|
||||
|
||||
@@ -73,10 +73,14 @@ export class HeapSnapshotManager {
|
||||
|
||||
async getAggregates(
|
||||
filePath: string,
|
||||
filterName?: string,
|
||||
): Promise<Record<string, AggregatedInfoWithId>> {
|
||||
const snapshot = await this.getSnapshot(filePath);
|
||||
const filter =
|
||||
new DevTools.HeapSnapshotModel.HeapSnapshotModel.NodeFilter();
|
||||
if (filterName) {
|
||||
filter.filterName = filterName;
|
||||
}
|
||||
const aggregates: Record<string, AggregatedInfoWithId> =
|
||||
await snapshot.aggregatesWithFilter(filter);
|
||||
|
||||
@@ -122,10 +126,14 @@ export class HeapSnapshotManager {
|
||||
async getNodesById(
|
||||
filePath: string,
|
||||
id: number,
|
||||
filterName?: string,
|
||||
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotModel.ItemsRange> {
|
||||
const snapshot = await this.getSnapshot(filePath);
|
||||
const filter =
|
||||
new DevTools.HeapSnapshotModel.HeapSnapshotModel.NodeFilter();
|
||||
if (filterName) {
|
||||
filter.filterName = filterName;
|
||||
}
|
||||
const className = await this.resolveClassKeyFromId(filePath, id);
|
||||
if (!className) {
|
||||
throw new Error(`Class with ID ${id} not found in heap snapshot`);
|
||||
|
||||
+8
-2
@@ -952,8 +952,9 @@ export class McpContext implements Context {
|
||||
|
||||
async getHeapSnapshotAggregates(
|
||||
filePath: string,
|
||||
filterName?: string,
|
||||
): Promise<Record<string, AggregatedInfoWithId>> {
|
||||
return await this.#heapSnapshotManager.getAggregates(filePath);
|
||||
return await this.#heapSnapshotManager.getAggregates(filePath, filterName);
|
||||
}
|
||||
|
||||
async getHeapSnapshotDuplicateStrings(
|
||||
@@ -977,8 +978,13 @@ export class McpContext implements Context {
|
||||
async getHeapSnapshotNodesById(
|
||||
filePath: string,
|
||||
id: number,
|
||||
filterName?: string,
|
||||
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotModel.ItemsRange> {
|
||||
return await this.#heapSnapshotManager.getNodesById(filePath, id);
|
||||
return await this.#heapSnapshotManager.getNodesById(
|
||||
filePath,
|
||||
id,
|
||||
filterName,
|
||||
);
|
||||
}
|
||||
|
||||
async getHeapSnapshotRetainers(
|
||||
|
||||
@@ -349,6 +349,18 @@ export const commands: Commands = {
|
||||
description: 'The ID for the class, obtained from details.',
|
||||
required: true,
|
||||
},
|
||||
filterName: {
|
||||
name: 'filterName',
|
||||
type: 'string',
|
||||
description: 'An optional filter to apply to the nodes.',
|
||||
required: false,
|
||||
enum: [
|
||||
'objectsRetainedByDetachedDomNodes',
|
||||
'objectsRetainedByConsole',
|
||||
'objectsRetainedByEventHandlers',
|
||||
'objectsRetainedByContexts',
|
||||
],
|
||||
},
|
||||
pageIdx: {
|
||||
name: 'pageIdx',
|
||||
type: 'number',
|
||||
@@ -374,6 +386,18 @@ export const commands: Commands = {
|
||||
description: 'A path to a .heapsnapshot file to read.',
|
||||
required: true,
|
||||
},
|
||||
filterName: {
|
||||
name: 'filterName',
|
||||
type: 'string',
|
||||
description: 'An optional filter to apply to the aggregates.',
|
||||
required: false,
|
||||
enum: [
|
||||
'objectsRetainedByDetachedDomNodes',
|
||||
'objectsRetainedByConsole',
|
||||
'objectsRetainedByEventHandlers',
|
||||
'objectsRetainedByContexts',
|
||||
],
|
||||
},
|
||||
pageIdx: {
|
||||
name: 'pageIdx',
|
||||
type: 'number',
|
||||
|
||||
@@ -684,6 +684,10 @@
|
||||
{
|
||||
"name": "id",
|
||||
"argType": "number"
|
||||
},
|
||||
{
|
||||
"name": "filter_name",
|
||||
"argType": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -701,6 +705,10 @@
|
||||
{
|
||||
"name": "page_size",
|
||||
"argType": "number"
|
||||
},
|
||||
{
|
||||
"name": "filter_name",
|
||||
"argType": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -257,6 +257,7 @@ export type Context = Readonly<{
|
||||
): string | undefined;
|
||||
getHeapSnapshotAggregates(
|
||||
filePath: string,
|
||||
filterName?: string,
|
||||
): Promise<Record<string, AggregatedInfoWithId>>;
|
||||
getHeapSnapshotDuplicateStrings(
|
||||
filePath: string,
|
||||
@@ -270,6 +271,7 @@ export type Context = Readonly<{
|
||||
getHeapSnapshotNodesById(
|
||||
filePath: string,
|
||||
id: number,
|
||||
filterName?: string,
|
||||
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotModel.ItemsRange>;
|
||||
getHeapSnapshotRetainers(
|
||||
filePath: string,
|
||||
|
||||
@@ -9,6 +9,13 @@ import {zod} from '../third_party/index.js';
|
||||
import {ToolCategory} from './categories.js';
|
||||
import {definePageTool, defineTool} from './ToolDefinition.js';
|
||||
|
||||
const HEAP_SNAPSHOT_FILTERS: readonly [string, ...string[]] = [
|
||||
'objectsRetainedByDetachedDomNodes',
|
||||
'objectsRetainedByConsole',
|
||||
'objectsRetainedByEventHandlers',
|
||||
'objectsRetainedByContexts',
|
||||
];
|
||||
|
||||
export const takeHeapSnapshot = definePageTool({
|
||||
name: 'take_heapsnapshot',
|
||||
description: `Capture a heap snapshot of the currently selected page. Use to analyze the memory distribution of JavaScript objects and debug memory leaks.`,
|
||||
@@ -73,6 +80,10 @@ export const getHeapSnapshotDetails = defineTool({
|
||||
},
|
||||
schema: {
|
||||
filePath: zod.string().describe('A path to a .heapsnapshot file to read.'),
|
||||
filterName: zod
|
||||
.enum(HEAP_SNAPSHOT_FILTERS)
|
||||
.optional()
|
||||
.describe('An optional filter to apply to the aggregates.'),
|
||||
pageIdx: zod
|
||||
.number()
|
||||
.optional()
|
||||
@@ -87,6 +98,7 @@ export const getHeapSnapshotDetails = defineTool({
|
||||
handler: async (request, response, context) => {
|
||||
const aggregates = await context.getHeapSnapshotAggregates(
|
||||
request.params.filePath,
|
||||
request.params.filterName,
|
||||
);
|
||||
|
||||
response.setHeapSnapshotAggregates(aggregates, {
|
||||
@@ -108,6 +120,10 @@ export const getHeapSnapshotClassNodes = defineTool({
|
||||
schema: {
|
||||
filePath: zod.string().describe('A path to a .heapsnapshot file to read.'),
|
||||
id: zod.number().describe('The ID for the class, obtained from details.'),
|
||||
filterName: zod
|
||||
.enum(HEAP_SNAPSHOT_FILTERS)
|
||||
.optional()
|
||||
.describe('An optional filter to apply to the nodes.'),
|
||||
pageIdx: zod.number().optional().describe('The page index for pagination.'),
|
||||
pageSize: zod.number().optional().describe('The page size for pagination.'),
|
||||
},
|
||||
@@ -117,6 +133,7 @@ export const getHeapSnapshotClassNodes = defineTool({
|
||||
const nodes = await context.getHeapSnapshotNodesById(
|
||||
request.params.filePath,
|
||||
request.params.id,
|
||||
request.params.filterName,
|
||||
);
|
||||
|
||||
response.setHeapSnapshotNodes(nodes, {
|
||||
|
||||
@@ -49,6 +49,18 @@ nodeId,nodeName,type,distance,selfSize,retainedSize
|
||||
Showing 1-8 of 8 (Page 1 of 1).
|
||||
`;
|
||||
|
||||
exports[`memory > get_heapsnapshot_class_nodes > with objectsRetainedByContexts filterName 1`] = `
|
||||
## Heap Snapshot Data
|
||||
nodeId,nodeName,type,distance,selfSize,retainedSize
|
||||
45897,makeCallable,closure,5,0.0 kB,0.2 kB
|
||||
46147,makeCallable,closure,5,0.0 kB,0.2 kB
|
||||
45893,Apply,closure,5,0.0 kB,0.0 kB
|
||||
45895,Save,closure,5,0.0 kB,0.0 kB
|
||||
46143,Apply,closure,5,0.0 kB,0.0 kB
|
||||
46145,Save,closure,5,0.0 kB,0.0 kB
|
||||
Showing 1-6 of 6 (Page 1 of 1).
|
||||
`;
|
||||
|
||||
exports[`memory > get_heapsnapshot_details > with default options 1`] = `
|
||||
## Heap Snapshot Data
|
||||
Showing 1-157 of 157 (Page 1 of 1).
|
||||
@@ -212,6 +224,16 @@ id,name,count,selfSize,maxRetainedSize
|
||||
108,HTMLBodyElement (internal cache) / https://example.com,1,0.0 kB,0.0 kB
|
||||
`;
|
||||
|
||||
exports[`memory > get_heapsnapshot_details > with objectsRetainedByContexts filterName 1`] = `
|
||||
## Heap Snapshot Data
|
||||
Showing 1-4 of 4 (Page 1 of 1).
|
||||
id,name,count,selfSize,maxRetainedSize
|
||||
1,system / Context,116,2.5 kB,3.5 kB
|
||||
3,Function,6,0.2 kB,0.7 kB
|
||||
2,(compiled code),22,0.5 kB,0.5 kB
|
||||
4,Array,4,0.3 kB,0.3 kB
|
||||
`;
|
||||
|
||||
exports[`memory > get_heapsnapshot_dominators > with valid nodeId 1`] = `
|
||||
## Heap Snapshot Data
|
||||
### Dominator Chain
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
compareHeapSnapshots,
|
||||
getHeapSnapshotDuplicateStrings,
|
||||
} from '../../src/tools/memory.js';
|
||||
import {stableIdSymbol} from '../../src/utils/id.js';
|
||||
import {withMcpContext} from '../utils.js';
|
||||
|
||||
describe('memory', () => {
|
||||
@@ -104,6 +105,31 @@ describe('memory', () => {
|
||||
t.assert.snapshot(output);
|
||||
});
|
||||
});
|
||||
|
||||
it('with objectsRetainedByContexts filterName', async t => {
|
||||
await withMcpContext(async (response, context) => {
|
||||
const filePath = join(
|
||||
process.cwd(),
|
||||
'tests/fixtures/example.heapsnapshot',
|
||||
);
|
||||
|
||||
await getHeapSnapshotDetails.handler(
|
||||
{params: {filePath, filterName: 'objectsRetainedByContexts'}},
|
||||
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', () => {
|
||||
@@ -135,6 +161,43 @@ describe('memory', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('with objectsRetainedByContexts filterName', async t => {
|
||||
await withMcpContext(async (response, context) => {
|
||||
const filePath = join(
|
||||
process.cwd(),
|
||||
'tests/fixtures/example.heapsnapshot',
|
||||
);
|
||||
|
||||
const aggregates = await context.getHeapSnapshotAggregates(
|
||||
filePath,
|
||||
'objectsRetainedByContexts',
|
||||
);
|
||||
const aggregate = Object.values(aggregates).find(
|
||||
a => a.name === 'Function',
|
||||
);
|
||||
assert.ok(aggregate);
|
||||
const id = aggregate[stableIdSymbol];
|
||||
assert.ok(id);
|
||||
|
||||
await getHeapSnapshotClassNodes.handler(
|
||||
{params: {filePath, id, filterName: 'objectsRetainedByContexts'}},
|
||||
response,
|
||||
context,
|
||||
);
|
||||
|
||||
const responseData = await response.handle(
|
||||
getHeapSnapshotClassNodes.name,
|
||||
context,
|
||||
);
|
||||
|
||||
const output = responseData.content
|
||||
.map(c => (c.type === 'text' ? c.text : ''))
|
||||
.join('\n');
|
||||
|
||||
t.assert.snapshot(output);
|
||||
});
|
||||
});
|
||||
|
||||
it('with non-existent class name', async () => {
|
||||
await withMcpContext(async (response, context) => {
|
||||
const filePath = join(
|
||||
|
||||
Reference in New Issue
Block a user