Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions src/HeapSnapshotManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,43 @@ export class HeapSnapshotManager {
return await provider.serializeItemsRange(0, Infinity);
}

async getNewNodesByUid(
filePath: string,
baseFilePath: string,
uid: number,
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotModel.ItemsRange> {
const className = await this.resolveClassKeyFromUid(filePath, uid);
if (!className) {
throw new Error(
`Class with UID ${uid} not found in heap snapshot ${filePath}`,
);
}

const snapshotAfter = await this.getSnapshot(filePath);
const snapshotBefore = await this.getSnapshot(baseFilePath);

const interfaceDefs = await snapshotAfter.interfaceDefinitions();
const aggregatesForDiff =
await snapshotBefore.aggregatesForDiff(interfaceDefs);

const baseSnapshotId = path.resolve(baseFilePath);
const diff = await snapshotAfter.calculateSnapshotDiff(
baseSnapshotId,
aggregatesForDiff,
);

const diffForClass = diff[className];
if (!diffForClass || !diffForClass.addedIndexes.length) {
return {items: [], startPosition: 0, endPosition: 0, totalLength: 0};
}

const provider = snapshotAfter.createAddedNodesProvider(
baseSnapshotId,
className,
);
return await provider.serializeItemsRange(0, Infinity);
}

async findNodeIndexById(
filePath: string,
nodeId: number,
Expand Down Expand Up @@ -150,6 +187,24 @@ export class HeapSnapshotManager {
return await provider.serializeItemsRange(0, Infinity);
}

async compareSnapshots(
beforeFilePath: string,
afterFilePath: string,
): Promise<
Record<string, DevTools.HeapSnapshotModel.HeapSnapshotModel.Diff>
> {
const snapshotBefore = await this.getSnapshot(beforeFilePath);
const snapshotAfter = await this.getSnapshot(afterFilePath);

const interfaceDefs = await snapshotAfter.interfaceDefinitions();
const aggregatesForDiff =
await snapshotBefore.aggregatesForDiff(interfaceDefs);
return await snapshotAfter.calculateSnapshotDiff(
'before',
aggregatesForDiff,
);
}

#getCachedSnapshot(filePath: string) {
const absolutePath = path.resolve(filePath);
const cached = this.#snapshots.get(absolutePath);
Expand Down
28 changes: 28 additions & 0 deletions src/McpContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -831,10 +831,38 @@ export class McpContext implements Context {
return await this.#heapSnapshotManager.getNodesByUid(filePath, uid);
}

async getHeapSnapshotNewNodesByUid(
filePath: string,
baseFilePath: string,
uid: number,
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotModel.ItemsRange> {
this.validatePath(filePath);
this.validatePath(baseFilePath);
return await this.#heapSnapshotManager.getNewNodesByUid(
filePath,
baseFilePath,
uid,
);
}

async getHeapSnapshotRetainers(
filePath: string,
nodeId: number,
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotModel.ItemsRange> {
return await this.#heapSnapshotManager.getRetainers(filePath, nodeId);
}

async compareHeapSnapshots(
beforeFilePath: string,
afterFilePath: string,
): Promise<
Record<string, DevTools.HeapSnapshotModel.HeapSnapshotModel.Diff>
> {
this.validatePath(beforeFilePath);
this.validatePath(afterFilePath);
return await this.#heapSnapshotManager.compareSnapshots(
beforeFilePath,
afterFilePath,
);
}
}
43 changes: 42 additions & 1 deletion src/McpResponse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {WebMCPTool} from 'puppeteer-core';

import type {ParsedArguments} from './bin/chrome-devtools-mcp-cli-options.js';
import {ConsoleFormatter} from './formatters/ConsoleFormatter.js';
import {HeapDiffFormatter} from './formatters/HeapDiffFormatter.js';
import {HeapSnapshotFormatter} from './formatters/HeapSnapshotFormatter.js';
import {isEdgeLike, isNodeLike} from './formatters/HeapSnapshotFormatter.js';
import {IssueFormatter} from './formatters/IssueFormatter.js';
Expand Down Expand Up @@ -111,7 +112,7 @@ async function getToolGroup(
objectId: windowHandle.remoteObject().objectId,
});
if (listeners.find(l => l.type === 'devtoolstooldiscovery') === undefined) {
return;
return undefined;
}

const toolGroup = await page.pptrPage.evaluate(() => {
Expand Down Expand Up @@ -174,6 +175,11 @@ export class McpResponse implements Response {
#attachedLighthouseResult?: LighthouseData;
#textResponseLines: string[] = [];
#images: ImageContentData[] = [];
#heapDiffOptions?: {
include: boolean;
diff: Record<string, DevTools.HeapSnapshotModel.HeapSnapshotModel.Diff>;
pagination?: PaginationOptions;
};
#heapSnapshotOptions?: {
include: boolean;
aggregates?: Record<
Expand Down Expand Up @@ -394,6 +400,17 @@ export class McpResponse implements Response {
this.#attachedWaitForResult = result;
}

setHeapDiff(
diff: Record<string, DevTools.HeapSnapshotModel.HeapSnapshotModel.Diff>,
options?: PaginationOptions,
) {
this.#heapDiffOptions = {
include: true,
diff,
pagination: options,
};
}

setHeapSnapshotAggregates(
aggregates: Record<
string,
Expand Down Expand Up @@ -742,6 +759,7 @@ export class McpResponse implements Response {
};
heapSnapshotData?: object[];
heapSnapshotNodes?: readonly object[];
heapDiff?: object[];
extensionServiceWorkers?: object[];
extensionPages?: object[];
errorMessage?: string;
Expand Down Expand Up @@ -959,6 +977,29 @@ Call ${handleDialog.name} to handle it before continuing.`);
}
}

if (this.#heapDiffOptions?.include) {
const diff = this.#heapDiffOptions.diff;
const entries = Object.entries(diff);

const sortedEntries = entries.sort(
(a, b) => b[1].sizeDelta - a[1].sizeDelta,
);

const paginationData = this.#dataWithPagination(
sortedEntries,
this.#heapDiffOptions.pagination,
);

structuredContent.pagination = paginationData.pagination;
response.push(...paginationData.info);

const paginatedRecord = Object.fromEntries(paginationData.items);
const formatter = new HeapDiffFormatter(paginatedRecord);

response.push(formatter.toString());
structuredContent.heapDiff = formatter.toJSON();
}

if (this.#heapSnapshotOptions?.include) {
response.push('## Heap Snapshot Data');
const stats = this.#heapSnapshotOptions.stats;
Expand Down
58 changes: 58 additions & 0 deletions src/formatters/HeapDiffFormatter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import {DevTools} from '../third_party/index.js';

export interface FormattedDiffEntry {
className: string;
added: number;
deleted: number;
deltaSize: string;
}

export class HeapDiffFormatter {
#diff: Record<string, DevTools.HeapSnapshotModel.HeapSnapshotModel.Diff>;

constructor(
diff: Record<string, DevTools.HeapSnapshotModel.HeapSnapshotModel.Diff>,
) {
this.#diff = diff;
}

#getSortedDiffs(): DevTools.HeapSnapshotModel.HeapSnapshotModel.Diff[] {
return Object.values(this.#diff).sort((a, b) => b.sizeDelta - a.sizeDelta);
}

toString(): string {
const sorted = this.#getSortedDiffs();
const lines: string[] = [];
lines.push('className,added,deleted,deltaSize');

for (const d of sorted) {
lines.push(
`${d.name},${d.addedCount},${d.removedCount},${DevTools.I18n.ByteUtilities.formatBytesToKb(d.sizeDelta)}`,
);
}

return lines.join('\n');
}

toJSON(): FormattedDiffEntry[] {
const sorted = this.#getSortedDiffs();
return sorted.map(d => ({
className: d.name,
added: d.addedCount,
deleted: d.removedCount,
deltaSize: DevTools.I18n.ByteUtilities.formatBytesToKb(d.sizeDelta),
}));
}

static sort(
diff: Record<string, DevTools.HeapSnapshotModel.HeapSnapshotModel.Diff>,
): Array<[string, DevTools.HeapSnapshotModel.HeapSnapshotModel.Diff]> {
return Object.entries(diff).sort((a, b) => b[1].sizeDelta - a[1].sizeDelta);
}
}
13 changes: 13 additions & 0 deletions src/tools/ToolDefinition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,10 @@ export interface DevToolsData {

export interface Response {
appendResponseLine(value: string): void;
setHeapDiff(
diff: Record<string, DevTools.HeapSnapshotModel.HeapSnapshotModel.Diff>,
options?: PaginationOptions,
): void;
setHeapSnapshotAggregates(
aggregates: Record<
string,
Expand Down Expand Up @@ -246,10 +250,19 @@ export type Context = Readonly<{
filePath: string,
uid: number,
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotModel.ItemsRange>;
getHeapSnapshotNewNodesByUid(
filePath: string,
baseFilePath: string,
uid: number,
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotModel.ItemsRange>;
getHeapSnapshotRetainers(
filePath: string,
nodeId: number,
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotModel.ItemsRange>;
compareHeapSnapshots(
beforeFilePath: string,
afterFilePath: string,
): Promise<Record<string, DevTools.HeapSnapshotModel.HeapSnapshotModel.Diff>>;
}>;

/**
Expand Down
54 changes: 49 additions & 5 deletions src/tools/memory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ export const getMemorySnapshotDetails = defineTool({
export const getNodesByClass = defineTool({
name: 'get_nodes_by_class',
description:
'Loads a memory heapsnapshot and returns instances of a specific class with their stable IDs.',
'Loads a memory heapsnapshot and returns instances of a specific class with their stable IDs. Supports filtering by base snapshot to show only new nodes.',
annotations: {
category: ToolCategory.MEMORY,
readOnlyHint: true,
Expand All @@ -111,16 +111,31 @@ export const getNodesByClass = defineTool({
.describe(
'The unique UID for the class, obtained from aggregates listing.',
),
baseFilePath: zod
.string()
.optional()
.describe('Optional base snapshot file path to show only new nodes.'),
pageIdx: zod.number().optional().describe('The page index for pagination.'),
pageSize: zod.number().optional().describe('The page size for pagination.'),
},
blockedByDialog: false,
handler: async (request, response, context) => {
context.validatePath(request.params.filePath);
const nodes = await context.getHeapSnapshotNodesByUid(
request.params.filePath,
request.params.uid,
);

let nodes;
if (request.params.baseFilePath) {
context.validatePath(request.params.baseFilePath);
nodes = await context.getHeapSnapshotNewNodesByUid(
request.params.filePath,
request.params.baseFilePath,
request.params.uid,
);
} else {
nodes = await context.getHeapSnapshotNodesByUid(
request.params.filePath,
request.params.uid,
);
}

response.setHeapSnapshotNodes(nodes, {
pageIdx: request.params.pageIdx,
Expand Down Expand Up @@ -159,3 +174,32 @@ export const getNodeRetainers = defineTool({
});
},
});

export const compareMemorySnapshots = definePageTool({
name: 'compare_memory_snapshots',
description: 'Compare two heap snapshots and return the diff.',
annotations: {
category: ToolCategory.PERFORMANCE,
readOnlyHint: true,
},
schema: {
beforeFilePath: zod.string().describe('Path to the before snapshot.'),
afterFilePath: zod.string().describe('Path to the after snapshot.'),
pageSize: zod.number().optional().describe('Page size for pagination.'),
pageIdx: zod.number().optional().describe('Page index for pagination.'),
},
blockedByDialog: false,
handler: async (request, response, context) => {
const {beforeFilePath, afterFilePath, pageSize, pageIdx} = request.params;

try {
const diff = await context.compareHeapSnapshots(
beforeFilePath,
afterFilePath,
);
response.setHeapDiff(diff, {pageSize, pageIdx});
} catch (err) {
response.appendResponseLine(`Comparison failed: ${err}`);
}
},
});
11 changes: 11 additions & 0 deletions tests/tools/memory.test.js.snapshot
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
exports[`memory > compare_memory_snapshots > with valid snapshots 1`] = `
Showing 1-0 of 0 (Page 1 of 1).

`;

exports[`memory > get_memory_snapshot_details > with default options 1`] = `
## Heap Snapshot Data
Showing 1-157 of 157 (Page 1 of 1).
Expand Down Expand Up @@ -170,6 +175,12 @@ edgeIndex,edgeName,edgeType,targetNodeId,targetNodeName
Showing 1-3 of 3 (Page 1 of 1).
`;

exports[`memory > get_nodes_by_class > with baseFilePath (diff filtering) 1`] = `
## Heap Snapshot Data

Showing 1-0 of 0 (Page 1 of 1).
`;

exports[`memory > get_nodes_by_class > with default options 1`] = `
## Heap Snapshot Data
id,name,type,distance,selfSize,retainedSize
Expand Down
Loading
Loading