mirror of
https://github.com/ChromeDevTools/chrome-devtools-mcp.git
synced 2026-09-28 11:22:57 +08:00
fix(performance): prevent memory leak by scoping trace engine model per parse (#2720)
## Rationale DevTools' `TraceEngine.TraceModel.Model` permanently retains parsed traces in an internal recordings array (`#traces`). Calling `engine.resetProcessor()` between runs only resets the underlying processor and does not clear `#traces`. Because `engine` was previously instantiated as a module-scoped singleton, every performance trace recorded or parsed in a session remained permanently held in heap memory for the lifetime of the process. ## Changes - In `src/processors/PerformanceTrace.ts`, instantiate `DevTools.TraceEngine.TraceModel.Model.createWithAllHandlers()` per invocation of `parseRawTraceBuffer()`. - Remove the module-scoped `engine` singleton and the obsolete `engine.resetProcessor()` call. - Add a unit test in `tests/trace-processing/parse.test.ts` verifying that consecutive trace parses construct isolated `Model` instances and keep internal trace retention bounded at 1. - Add a profiling regression scenario in `scripts/profile/scenarios/performance_trace.ts` to guard against memory retention across consecutive traces during `npm run test:memory`.
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type {ScenarioArgs, ScenarioIterations, ToolCall} from '../types.ts';
|
||||
|
||||
/**
|
||||
* Defines iteration counts for the performance trace profiling scenario.
|
||||
*
|
||||
* Keeps iteration counts bounded so profiling remains fast while still providing
|
||||
* enough repetitions to detect memory retention across consecutive traces.
|
||||
*/
|
||||
export function getNumIterations(): ScenarioIterations {
|
||||
return {
|
||||
iterations: 5,
|
||||
warmupIterations: 3,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the sequence of MCP tool calls to execute per iteration.
|
||||
*
|
||||
* Each iteration navigates to the target URL to ensure a known page state,
|
||||
* begins a performance trace with page reload (`autoStop: false` to avoid
|
||||
* the default 5-second sleep), and explicitly stops the trace to trigger
|
||||
* parsing, summary generation, and model teardown.
|
||||
*
|
||||
* @param args Arguments supplied to the scenario, including the target URL.
|
||||
*/
|
||||
export function get(args: ScenarioArgs): ToolCall[] {
|
||||
return [
|
||||
{
|
||||
name: 'navigate_page',
|
||||
arguments: {type: 'url', url: args.targetUrl},
|
||||
},
|
||||
{
|
||||
name: 'performance_start_trace',
|
||||
arguments: {reload: true, autoStop: false},
|
||||
},
|
||||
{
|
||||
name: 'performance_stop_trace',
|
||||
arguments: {},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -7,8 +7,6 @@
|
||||
import {DevTools} from '../third_party/index.js';
|
||||
import {logger} from '../utils/logger.js';
|
||||
|
||||
const engine = DevTools.TraceEngine.TraceModel.Model.createWithAllHandlers();
|
||||
|
||||
export interface TraceResult {
|
||||
parsedTrace: DevTools.TraceEngine.TraceModel.ParsedTrace;
|
||||
insights: DevTools.TraceEngine.Insights.Types.TraceInsightSets | null;
|
||||
@@ -24,6 +22,17 @@ export interface TraceParseError {
|
||||
error: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses raw JSON trace buffer bytes into a DevTools TraceEngine representation.
|
||||
*
|
||||
* Accepts either a JSON array of trace events or an object with a `traceEvents` field.
|
||||
* A new trace engine model is created per call to ensure session isolation and prevent
|
||||
* memory retention.
|
||||
*
|
||||
* @param buffer Raw UTF-8 encoded JSON bytes representing trace data.
|
||||
* @param metadata Optional throttling configurations applied during recording.
|
||||
* @returns A {@link TraceResult} with parsed traces and insights, or a {@link TraceParseError} on failure.
|
||||
*/
|
||||
export async function parseRawTraceBuffer(
|
||||
buffer: Uint8Array<ArrayBufferLike> | undefined,
|
||||
metadata?: {
|
||||
@@ -31,7 +40,6 @@ export async function parseRawTraceBuffer(
|
||||
networkThrottling?: string;
|
||||
},
|
||||
): Promise<TraceResult | TraceParseError> {
|
||||
engine.resetProcessor();
|
||||
if (!buffer) {
|
||||
return {
|
||||
error: 'No buffer was provided.',
|
||||
@@ -51,6 +59,11 @@ export async function parseRawTraceBuffer(
|
||||
| DevTools.TraceEngine.Types.Events.Event[];
|
||||
|
||||
const events = Array.isArray(data) ? data : data.traceEvents;
|
||||
// Instantiate a fresh TraceModel per invocation because Model permanently
|
||||
// retains parsed traces in its internal `#traces` array, which causes an
|
||||
// unbounded memory leak if reused across sessions.
|
||||
const engine =
|
||||
DevTools.TraceEngine.TraceModel.Model.createWithAllHandlers();
|
||||
await engine.parse(events, {metadata});
|
||||
const parsedTrace = engine.parsedTrace();
|
||||
if (!parsedTrace) {
|
||||
@@ -59,7 +72,7 @@ export async function parseRawTraceBuffer(
|
||||
};
|
||||
}
|
||||
|
||||
const insights = parsedTrace?.insights ?? null;
|
||||
const insights = parsedTrace.insights ?? null;
|
||||
|
||||
return {
|
||||
parsedTrace,
|
||||
|
||||
@@ -5,24 +5,58 @@
|
||||
*/
|
||||
|
||||
import assert from 'node:assert';
|
||||
import {describe, it} from 'node:test';
|
||||
import {afterEach, describe, it} from 'node:test';
|
||||
|
||||
import sinon from 'sinon';
|
||||
|
||||
import {
|
||||
getTraceSummary,
|
||||
parseRawTraceBuffer,
|
||||
} from '../../src/processors/PerformanceTrace.js';
|
||||
import {DevTools} from '../../src/third_party/index.js';
|
||||
|
||||
import {loadTraceAsBuffer} from './fixtures/load.js';
|
||||
|
||||
describe('Trace parsing', async () => {
|
||||
it('can parse a Uint8Array from Tracing.stop())', async () => {
|
||||
describe('Trace parsing', () => {
|
||||
afterEach(() => {
|
||||
sinon.restore();
|
||||
});
|
||||
|
||||
it('creates an isolated model instance for each parsed trace', async () => {
|
||||
const createModelSpy = sinon.spy(
|
||||
DevTools.TraceEngine.TraceModel.Model,
|
||||
'createWithAllHandlers',
|
||||
);
|
||||
const rawData = loadTraceAsBuffer('basic-trace.json.gz');
|
||||
|
||||
const result1 = await parseRawTraceBuffer(rawData);
|
||||
const result2 = await parseRawTraceBuffer(rawData);
|
||||
|
||||
if ('error' in result1) {
|
||||
assert.fail(`Unexpected parse failure on first trace: ${result1.error}`);
|
||||
}
|
||||
if ('error' in result2) {
|
||||
assert.fail(`Unexpected parse failure on second trace: ${result2.error}`);
|
||||
}
|
||||
|
||||
sinon.assert.calledTwice(createModelSpy);
|
||||
const firstModel = createModelSpy.firstCall.returnValue;
|
||||
const secondModel = createModelSpy.secondCall.returnValue;
|
||||
assert.notStrictEqual(firstModel, secondModel);
|
||||
// Verify that each model instance retains only its own trace. If the model
|
||||
// were shared, subsequent parses would accumulate in `#traces` and increase size.
|
||||
assert.strictEqual(firstModel.size(), 1);
|
||||
assert.strictEqual(secondModel.size(), 1);
|
||||
});
|
||||
|
||||
it('can parse a Uint8Array from Tracing.stop()', async () => {
|
||||
const rawData = loadTraceAsBuffer('basic-trace.json.gz');
|
||||
const result = await parseRawTraceBuffer(rawData);
|
||||
if ('error' in result) {
|
||||
assert.fail(`Unexpected parse failure: ${result.error}`);
|
||||
}
|
||||
assert.ok(result?.parsedTrace);
|
||||
assert.ok(result?.insights);
|
||||
assert.ok(result.parsedTrace);
|
||||
assert.ok(result.insights);
|
||||
});
|
||||
|
||||
it('can format results of a trace', async t => {
|
||||
@@ -31,8 +65,8 @@ describe('Trace parsing', async () => {
|
||||
if ('error' in result) {
|
||||
assert.fail(`Unexpected parse failure: ${result.error}`);
|
||||
}
|
||||
assert.ok(result?.parsedTrace);
|
||||
assert.ok(result?.insights);
|
||||
assert.ok(result.parsedTrace);
|
||||
assert.ok(result.insights);
|
||||
|
||||
const output = getTraceSummary(result);
|
||||
t.assert.snapshot(output);
|
||||
|
||||
Reference in New Issue
Block a user