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:
Gunjan Jaswal
2026-07-29 16:59:15 +05:30
committed by GitHub
parent 04269f1b31
commit f245c76057
4 changed files with 28 additions and 1 deletions
+7
View File
@@ -403,4 +403,11 @@ export class HeapSnapshotManager {
}
return false;
}
disposeAll(): void {
for (const cached of this.#snapshots.values()) {
cached.worker.dispose();
}
this.#snapshots.clear();
}
}
+1
View File
@@ -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();
}
+1
View File
@@ -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,
+19 -1
View File
@@ -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({