mirror of
https://github.com/ChromeDevTools/chrome-devtools-mcp.git
synced 2026-09-14 19:45:30 +08:00
feat: Add retaining paths MCP tool (#2187)
This PR adds the `get_heapsnapshot_retaining_paths` MCP tool. This can be used to find the paths from the target object to the GC roots which keep that object alive. Co-authored-by: Dominik Inführ <dinfuehr@chromium.org>
This commit is contained in:
@@ -514,12 +514,13 @@ If you run into any issues, checkout our [troubleshooting guide](./docs/troubles
|
||||
- [`take_snapshot`](docs/tool-reference.md#take_snapshot)
|
||||
- [`screencast_start`](docs/tool-reference.md#screencast_start)
|
||||
- [`screencast_stop`](docs/tool-reference.md#screencast_stop)
|
||||
- **Memory** (6 tools)
|
||||
- **Memory** (7 tools)
|
||||
- [`take_heapsnapshot`](docs/tool-reference.md#take_heapsnapshot)
|
||||
- [`close_heapsnapshot`](docs/tool-reference.md#close_heapsnapshot)
|
||||
- [`get_heapsnapshot_class_nodes`](docs/tool-reference.md#get_heapsnapshot_class_nodes)
|
||||
- [`get_heapsnapshot_details`](docs/tool-reference.md#get_heapsnapshot_details)
|
||||
- [`get_heapsnapshot_retainers`](docs/tool-reference.md#get_heapsnapshot_retainers)
|
||||
- [`get_heapsnapshot_retaining_paths`](docs/tool-reference.md#get_heapsnapshot_retaining_paths)
|
||||
- [`get_heapsnapshot_summary`](docs/tool-reference.md#get_heapsnapshot_summary)
|
||||
- **Extensions** (5 tools)
|
||||
- [`install_extension`](docs/tool-reference.md#install_extension)
|
||||
|
||||
+16
-1
@@ -39,12 +39,13 @@
|
||||
- [`take_snapshot`](#take_snapshot)
|
||||
- [`screencast_start`](#screencast_start)
|
||||
- [`screencast_stop`](#screencast_stop)
|
||||
- **[Memory](#memory)** (6 tools)
|
||||
- **[Memory](#memory)** (7 tools)
|
||||
- [`take_heapsnapshot`](#take_heapsnapshot)
|
||||
- [`close_heapsnapshot`](#close_heapsnapshot)
|
||||
- [`get_heapsnapshot_class_nodes`](#get_heapsnapshot_class_nodes)
|
||||
- [`get_heapsnapshot_details`](#get_heapsnapshot_details)
|
||||
- [`get_heapsnapshot_retainers`](#get_heapsnapshot_retainers)
|
||||
- [`get_heapsnapshot_retaining_paths`](#get_heapsnapshot_retaining_paths)
|
||||
- [`get_heapsnapshot_summary`](#get_heapsnapshot_summary)
|
||||
- **[Extensions](#extensions)** (5 tools)
|
||||
- [`install_extension`](#install_extension)
|
||||
@@ -504,6 +505,20 @@ in the DevTools Elements panel (if any).
|
||||
|
||||
---
|
||||
|
||||
### `get_heapsnapshot_retaining_paths`
|
||||
|
||||
**Description:** Loads a memory heapsnapshot and returns retaining paths for a specific node ID. This helps to understand why a node is not being garbage collected. (requires flag: --memoryDebugging=true)
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- **filePath** (string) **(required)**: A path to a .heapsnapshot file to read.
|
||||
- **nodeId** (number) **(required)**: The node ID to get retaining paths for.
|
||||
- **maxDepth** (number) _(optional)_: The maximum depth to search for retaining paths.
|
||||
- **maxNodes** (number) _(optional)_: The maximum number of nodes to return.
|
||||
- **maxSiblings** (number) _(optional)_: The maximum number of siblings to return.
|
||||
|
||||
---
|
||||
|
||||
### `get_heapsnapshot_summary`
|
||||
|
||||
**Description:** Loads a memory heapsnapshot and returns snapshot summary stats. (requires flag: --memoryDebugging=true)
|
||||
|
||||
@@ -150,6 +150,26 @@ export class HeapSnapshotManager {
|
||||
return await provider.serializeItemsRange(0, Infinity);
|
||||
}
|
||||
|
||||
async getRetainingPaths(
|
||||
filePath: string,
|
||||
nodeId: number,
|
||||
maxDepth?: number,
|
||||
maxNodes?: number,
|
||||
maxSiblings?: number,
|
||||
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotModel.RetainingPaths> {
|
||||
const nodeIndex = await this.findNodeIndexById(filePath, nodeId);
|
||||
if (nodeIndex === undefined) {
|
||||
throw new Error(`Node with ID ${nodeId} not found`);
|
||||
}
|
||||
const snapshot = await this.getSnapshot(filePath);
|
||||
return await snapshot.getRetainingPaths(
|
||||
nodeIndex,
|
||||
maxDepth,
|
||||
maxNodes,
|
||||
maxSiblings,
|
||||
);
|
||||
}
|
||||
|
||||
#getCachedSnapshot(filePath: string) {
|
||||
const absolutePath = path.resolve(filePath);
|
||||
const cached = this.#snapshots.get(absolutePath);
|
||||
|
||||
@@ -924,4 +924,20 @@ export class McpContext implements Context {
|
||||
hasHeapSnapshots(): boolean {
|
||||
return this.#heapSnapshotManager.hasSnapshots();
|
||||
}
|
||||
|
||||
async getHeapSnapshotRetainingPaths(
|
||||
filePath: string,
|
||||
nodeId: number,
|
||||
maxDepth?: number,
|
||||
maxNodes?: number,
|
||||
maxSiblings?: number,
|
||||
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotModel.RetainingPaths> {
|
||||
return await this.#heapSnapshotManager.getRetainingPaths(
|
||||
filePath,
|
||||
nodeId,
|
||||
maxDepth,
|
||||
maxNodes,
|
||||
maxSiblings,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,6 +219,7 @@ export class McpResponse implements Response {
|
||||
stats?: DevTools.HeapSnapshotModel.HeapSnapshotModel.Statistics;
|
||||
staticData?: DevTools.HeapSnapshotModel.HeapSnapshotModel.StaticData | null;
|
||||
nodes?: DevTools.HeapSnapshotModel.HeapSnapshotModel.ItemsRange;
|
||||
retainingPaths?: DevTools.HeapSnapshotModel.HeapSnapshotModel.RetainingPaths;
|
||||
};
|
||||
#networkRequestsOptions?: {
|
||||
include: boolean;
|
||||
@@ -475,6 +476,16 @@ export class McpResponse implements Response {
|
||||
};
|
||||
}
|
||||
|
||||
setHeapSnapshotRetainingPaths(
|
||||
retainingPaths: DevTools.HeapSnapshotModel.HeapSnapshotModel.RetainingPaths,
|
||||
) {
|
||||
this.#heapSnapshotOptions = {
|
||||
...this.#heapSnapshotOptions,
|
||||
include: true,
|
||||
retainingPaths,
|
||||
};
|
||||
}
|
||||
|
||||
attachImage(value: ImageContentData): void {
|
||||
this.#images.push(value);
|
||||
}
|
||||
@@ -801,6 +812,7 @@ export class McpResponse implements Response {
|
||||
};
|
||||
heapSnapshotData?: object[];
|
||||
heapSnapshotNodes?: readonly object[];
|
||||
heapSnapshotRetainingPaths?: object;
|
||||
extensionServiceWorkers?: object[];
|
||||
extensionPages?: object[];
|
||||
errorMessage?: string;
|
||||
@@ -1085,6 +1097,26 @@ Call ${handleDialog.name} to handle it before continuing.`);
|
||||
|
||||
structuredContent.heapSnapshotNodes = paginationData.items;
|
||||
}
|
||||
const retainingPaths = this.#heapSnapshotOptions.retainingPaths;
|
||||
if (retainingPaths) {
|
||||
response.push('### Retaining Paths');
|
||||
const {paths, limitsReached} = retainingPaths;
|
||||
if (paths.length === 0) {
|
||||
response.push('No retaining paths found.');
|
||||
} else {
|
||||
response.push(HeapSnapshotFormatter.formatRetainingPaths(paths));
|
||||
}
|
||||
const reached = Object.entries(limitsReached)
|
||||
.filter(([, hit]) => hit)
|
||||
.map(([limit]) => limit);
|
||||
if (reached.length > 0) {
|
||||
response.push(
|
||||
`Note: results are truncated, the following limits were reached: ${reached.join(', ')}.`,
|
||||
);
|
||||
}
|
||||
structuredContent.heapSnapshotRetainingPaths =
|
||||
retainingPaths as unknown as object;
|
||||
}
|
||||
}
|
||||
|
||||
if (data.detailedNetworkRequest) {
|
||||
|
||||
@@ -391,6 +391,43 @@ export const commands: Commands = {
|
||||
},
|
||||
},
|
||||
},
|
||||
get_heapsnapshot_retaining_paths: {
|
||||
description:
|
||||
'Loads a memory heapsnapshot and returns retaining paths for a specific node ID. This helps to understand why a node is not being garbage collected. (requires flag: --memoryDebugging=true)',
|
||||
category: 'Memory',
|
||||
args: {
|
||||
filePath: {
|
||||
name: 'filePath',
|
||||
type: 'string',
|
||||
description: 'A path to a .heapsnapshot file to read.',
|
||||
required: true,
|
||||
},
|
||||
nodeId: {
|
||||
name: 'nodeId',
|
||||
type: 'number',
|
||||
description: 'The node ID to get retaining paths for.',
|
||||
required: true,
|
||||
},
|
||||
maxDepth: {
|
||||
name: 'maxDepth',
|
||||
type: 'number',
|
||||
description: 'The maximum depth to search for retaining paths.',
|
||||
required: false,
|
||||
},
|
||||
maxNodes: {
|
||||
name: 'maxNodes',
|
||||
type: 'number',
|
||||
description: 'The maximum number of nodes to return.',
|
||||
required: false,
|
||||
},
|
||||
maxSiblings: {
|
||||
name: 'maxSiblings',
|
||||
type: 'number',
|
||||
description: 'The maximum number of siblings to return.',
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
get_heapsnapshot_summary: {
|
||||
description:
|
||||
'Loads a memory heapsnapshot and returns snapshot summary stats. (requires flag: --memoryDebugging=true)',
|
||||
|
||||
@@ -79,6 +79,31 @@ export class HeapSnapshotFormatter {
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
static formatRetainingPaths(
|
||||
retainingPaths: readonly DevTools.HeapSnapshotModel.HeapSnapshotModel.RetainingEdge[],
|
||||
): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
function formatEdge(
|
||||
edge: DevTools.HeapSnapshotModel.HeapSnapshotModel.RetainingEdge,
|
||||
depth: number,
|
||||
) {
|
||||
const indent = ' '.repeat(depth);
|
||||
lines.push(
|
||||
`${indent}<- @${edge.nodeId} ${edge.nodeName} via ${edge.edgeType} ${edge.edgeName} (distance: ${edge.distance})`,
|
||||
);
|
||||
for (const child of edge.children) {
|
||||
formatEdge(child, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
for (const path of retainingPaths) {
|
||||
formatEdge(path, 0);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
#getSortedAggregates(): AggregatedInfoWithId[] {
|
||||
return Object.values(this.#aggregates).sort((a, b) => b.maxRet - a.maxRet);
|
||||
}
|
||||
|
||||
@@ -751,5 +751,30 @@
|
||||
"argType": "number"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "get_heapsnapshot_retaining_paths",
|
||||
"args": [
|
||||
{
|
||||
"name": "file_path_length",
|
||||
"argType": "number"
|
||||
},
|
||||
{
|
||||
"name": "node_id",
|
||||
"argType": "number"
|
||||
},
|
||||
{
|
||||
"name": "max_depth",
|
||||
"argType": "number"
|
||||
},
|
||||
{
|
||||
"name": "max_nodes",
|
||||
"argType": "number"
|
||||
},
|
||||
{
|
||||
"name": "max_siblings",
|
||||
"argType": "number"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -116,6 +116,9 @@ export interface Response {
|
||||
nodes: DevTools.HeapSnapshotModel.HeapSnapshotModel.ItemsRange,
|
||||
options?: PaginationOptions,
|
||||
): void;
|
||||
setHeapSnapshotRetainingPaths(
|
||||
retainingPaths: DevTools.HeapSnapshotModel.HeapSnapshotModel.RetainingPaths,
|
||||
): void;
|
||||
setIncludePages(value: boolean): void;
|
||||
setIncludeNetworkRequests(
|
||||
value: boolean,
|
||||
@@ -250,6 +253,13 @@ export type Context = Readonly<{
|
||||
nodeId: number,
|
||||
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotModel.ItemsRange>;
|
||||
closeHeapSnapshot(filePath: string): Promise<boolean>;
|
||||
getHeapSnapshotRetainingPaths(
|
||||
filePath: string,
|
||||
nodeId: number,
|
||||
maxDepth?: number,
|
||||
maxNodes?: number,
|
||||
maxSiblings?: number,
|
||||
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotModel.RetainingPaths>;
|
||||
}>;
|
||||
|
||||
/**
|
||||
|
||||
@@ -183,3 +183,43 @@ export const closeHeapSnapshot = defineTool({
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const getHeapSnapshotRetainingPaths = defineTool({
|
||||
name: 'get_heapsnapshot_retaining_paths',
|
||||
description:
|
||||
'Loads a memory heapsnapshot and returns retaining paths for a specific node ID. This helps to understand why a node is not being garbage collected.',
|
||||
annotations: {
|
||||
category: ToolCategory.MEMORY,
|
||||
readOnlyHint: true,
|
||||
conditions: ['memoryDebugging'],
|
||||
},
|
||||
verifyFilesSchema: ['filePath'],
|
||||
blockedByDialog: false,
|
||||
schema: {
|
||||
filePath: zod.string().describe('A path to a .heapsnapshot file to read.'),
|
||||
nodeId: zod.number().describe('The node ID to get retaining paths for.'),
|
||||
maxDepth: zod
|
||||
.number()
|
||||
.optional()
|
||||
.describe('The maximum depth to search for retaining paths.'),
|
||||
maxNodes: zod
|
||||
.number()
|
||||
.optional()
|
||||
.describe('The maximum number of nodes to return.'),
|
||||
maxSiblings: zod
|
||||
.number()
|
||||
.optional()
|
||||
.describe('The maximum number of siblings to return.'),
|
||||
},
|
||||
handler: async (request, response, context) => {
|
||||
const retainingPaths = await context.getHeapSnapshotRetainingPaths(
|
||||
request.params.filePath,
|
||||
request.params.nodeId,
|
||||
request.params.maxDepth,
|
||||
request.params.maxNodes,
|
||||
request.params.maxSiblings,
|
||||
);
|
||||
|
||||
response.setHeapSnapshotRetainingPaths(retainingPaths);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -157,4 +157,41 @@ describe('HeapSnapshotFormatter', () => {
|
||||
assert.strictEqual(result[1][0], 'ObjectB');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatRetainingPaths', () => {
|
||||
it('formats retaining paths correctly', () => {
|
||||
const mockRetainingPaths = [
|
||||
{
|
||||
edgeIndex: 0,
|
||||
edgeName: 'foo',
|
||||
edgeType: 'property',
|
||||
nodeId: 10,
|
||||
nodeIndex: 1,
|
||||
nodeName: 'ClassA',
|
||||
distance: 2,
|
||||
children: [
|
||||
{
|
||||
edgeIndex: 0,
|
||||
edgeName: 'bar',
|
||||
edgeType: 'element',
|
||||
nodeId: 20,
|
||||
nodeIndex: 2,
|
||||
nodeName: 'ClassB',
|
||||
distance: 1,
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
] as unknown as DevTools.HeapSnapshotModel.HeapSnapshotModel.RetainingEdge[];
|
||||
|
||||
const result =
|
||||
HeapSnapshotFormatter.formatRetainingPaths(mockRetainingPaths);
|
||||
const expected = [
|
||||
'<- @10 ClassA via property foo (distance: 2)',
|
||||
' <- @20 ClassB via element bar (distance: 1)',
|
||||
].join('\n');
|
||||
|
||||
assert.strictEqual(result, expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -184,6 +184,32 @@ initial_string_prototype,internal,7199,system / NativeContext
|
||||
Showing 1-3 of 3 (Page 1 of 1).
|
||||
`;
|
||||
|
||||
exports[`memory > get_heapsnapshot_retaining_paths > with valid nodeId 1`] = `
|
||||
## Heap Snapshot Data
|
||||
### Retaining Paths
|
||||
<- @45891 system / Context via context builtinToJSONs (distance: 4)
|
||||
<- @34237 safe via internal context (distance: 3)
|
||||
<- @27635 Window (global*) / https://example.com via property <symbol extensions::SafeBuiltins::Promise> (distance: 2)
|
||||
<- @34235 system / PropertyCell via internal value (distance: 4)
|
||||
<- @50423 (object properties) via internal 1912 (distance: 3)
|
||||
<- @27635 Window (global*) / https://example.com via internal properties (distance: 2)
|
||||
<- @34213 safe via internal context (distance: 3)
|
||||
<- @27635 Window (global*) / https://example.com via property <symbol extensions::SafeBuiltins::Error> (distance: 2)
|
||||
<- @34197 safe via internal context (distance: 3)
|
||||
<- @27635 Window (global*) / https://example.com via property <symbol extensions::SafeBuiltins::RegExp> (distance: 2)
|
||||
<- @34185 safe via internal context (distance: 3)
|
||||
<- @27635 Window (global*) / https://example.com via property <symbol extensions::SafeBuiltins::String> (distance: 2)
|
||||
<- @34145 safe via internal context (distance: 3)
|
||||
<- @27635 Window (global*) / https://example.com via property <symbol extensions::SafeBuiltins::Array> (distance: 2)
|
||||
<- @34069 safe via internal context (distance: 3)
|
||||
<- @27635 Window (global*) / https://example.com via property <symbol extensions::SafeBuiltins::Function> (distance: 2)
|
||||
<- @34047 safe via internal context (distance: 3)
|
||||
<- @27635 Window (global*) / https://example.com via property <symbol extensions::SafeBuiltins::Object> (distance: 2)
|
||||
<- @46063 stringify via internal context (distance: 4)
|
||||
<- @34247 {parse, stringify} via property stringify (distance: 3)
|
||||
<- @27635 Window (global*) / https://example.com via property <symbol extensions::SafeBuiltins::JSON> (distance: 2)
|
||||
`;
|
||||
|
||||
exports[`memory > get_heapsnapshot_summary > with default options 1`] = `
|
||||
## Heap Snapshot Data
|
||||
Statistics: {
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
getHeapSnapshotClassNodes,
|
||||
getHeapSnapshotRetainers,
|
||||
closeHeapSnapshot,
|
||||
getHeapSnapshotRetainingPaths,
|
||||
} from '../../src/tools/memory.js';
|
||||
import {withMcpContext} from '../utils.js';
|
||||
|
||||
@@ -223,4 +224,60 @@ describe('memory', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('get_heapsnapshot_retaining_paths', () => {
|
||||
it('with valid nodeId', async t => {
|
||||
await withMcpContext(async (response, context) => {
|
||||
const filePath = join(
|
||||
process.cwd(),
|
||||
'tests/fixtures/example.heapsnapshot',
|
||||
);
|
||||
|
||||
await getHeapSnapshotRetainingPaths.handler(
|
||||
{params: {filePath, nodeId: 45901}},
|
||||
response,
|
||||
context,
|
||||
);
|
||||
|
||||
const responseData = await response.handle(
|
||||
getHeapSnapshotRetainingPaths.name,
|
||||
context,
|
||||
);
|
||||
const output = responseData.content
|
||||
.map(c => (c.type === 'text' ? c.text : ''))
|
||||
.join('\n');
|
||||
|
||||
t.assert.snapshot(output);
|
||||
});
|
||||
});
|
||||
|
||||
it('reports when limits are reached', async () => {
|
||||
await withMcpContext(async (response, context) => {
|
||||
const filePath = join(
|
||||
process.cwd(),
|
||||
'tests/fixtures/example.heapsnapshot',
|
||||
);
|
||||
|
||||
await getHeapSnapshotRetainingPaths.handler(
|
||||
{params: {filePath, nodeId: 45901, maxDepth: 1}},
|
||||
response,
|
||||
context,
|
||||
);
|
||||
|
||||
const responseData = await response.handle(
|
||||
getHeapSnapshotRetainingPaths.name,
|
||||
context,
|
||||
);
|
||||
const output = responseData.content
|
||||
.map(c => (c.type === 'text' ? c.text : ''))
|
||||
.join('\n');
|
||||
|
||||
assert.match(output, /No retaining paths found\./);
|
||||
assert.match(
|
||||
output,
|
||||
/Note: results are truncated, the following limits were reached: depth\./,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user