fix: dispose heap snapshot workers on context teardown (#2428)
Heap snapshot worker threads (Node worker threads, independent of the browser) and their parsed snapshots are only freed per file, through the `close_heapsnapshot` tool. On context teardown and on browser reconnect they leak. `McpContext.dispose()` cleans up the service-worker console collector and each `McpPage`, but never touches the `HeapSnapshotManager`, so any snapshot loaded during the session keeps its worker alive. And `getContext()` in `index.ts` replaces the previous context on reconnect (`if (context?.browser !== browser)`) without ever calling `dispose()` on the old one, so every reconnect strands the previous context's workers and retained snapshots. Repeated reconnects accumulate the leak. This adds `HeapSnapshotManager.disposeAll()` (terminates every cached worker and clears the map, mirroring the existing per-file `dispose`), calls it from `McpContext.dispose()`, and disposes the outgoing context before it is replaced on reconnect. A regression test in `McpContext.test.ts` loads a fixture snapshot and asserts it is freed after `dispose()`. Verified on Node 24 (the `.nvmrc` version): typecheck, eslint, and the affected test files pass, and `npm run gen` leaves the tree clean, so there is no generated-files drift. Reverting the source makes the new test fail.
This commit is contained in:
@@ -403,4 +403,11 @@ export class HeapSnapshotManager {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
disposeAll(): void {
|
||||
for (const cached of this.#snapshots.values()) {
|
||||
cached.worker.dispose();
|
||||
}
|
||||
this.#snapshots.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,6 +139,7 @@ export class McpContext implements Context {
|
||||
this.browser.off('targetdestroyed', this.#onTargetDestroyed);
|
||||
|
||||
this.#serviceWorkerConsoleCollector.dispose();
|
||||
this.#heapSnapshotManager.disposeAll();
|
||||
for (const mcpPage of this.#mcpPages.values()) {
|
||||
mcpPage.dispose();
|
||||
}
|
||||
|
||||
@@ -147,6 +147,7 @@ export async function createMcpServer(
|
||||
});
|
||||
|
||||
if (context?.browser !== browser) {
|
||||
context?.dispose();
|
||||
context = await McpContext.from(browser, logger, {
|
||||
experimentalDevToolsDebugging: devtools,
|
||||
experimentalIncludeAllPages: serverArgs.experimentalIncludeAllPages,
|
||||
|
||||
@@ -18,7 +18,7 @@ import sinon from 'sinon';
|
||||
import {NetworkFormatter} from '../src/formatters/NetworkFormatter.js';
|
||||
import {McpContext} from '../src/McpContext.js';
|
||||
import {TextSnapshot} from '../src/TextSnapshot.js';
|
||||
import type {HTTPResponse} from '../src/third_party/index.js';
|
||||
import {type HTTPResponse} from '../src/third_party/index.js';
|
||||
import type {TraceResult} from '../src/trace-processing/parse.js';
|
||||
|
||||
import {getMockRequest, html, withBrowser, withMcpContext} from './utils.js';
|
||||
@@ -285,6 +285,24 @@ describe('McpContext', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('disposes loaded heap snapshots on teardown', async () => {
|
||||
await withMcpContext(async (_response, context) => {
|
||||
const filePath = path.join(
|
||||
process.cwd(),
|
||||
'tests/fixtures/example.heapsnapshot',
|
||||
);
|
||||
await context.getHeapSnapshotStats(filePath);
|
||||
assert.ok(context.hasHeapSnapshots(), 'snapshot loaded before teardown');
|
||||
|
||||
context.dispose();
|
||||
|
||||
assert.ok(
|
||||
!context.hasHeapSnapshots(),
|
||||
'heap snapshots freed on teardown',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should include network requests in structured content', async t => {
|
||||
await withMcpContext(async (response, context) => {
|
||||
const mockRequest = getMockRequest({
|
||||
|
||||
Reference in New Issue
Block a user