fix(memory): dispose the heap-snapshot worker when loading fails (#2449)

Fixes #2448

## Problem

`HeapSnapshotManager.#loadSnapshot` creates a `HeapSnapshotWorkerProxy`
(a live worker) before reading the file, but the manager only disposes
workers tracked in the `#snapshots` map. `getSnapshot` adds the entry
only on success, so a failed load — e.g. a missing or invalid
`.heapsnapshot` path passed to any `get_heapsnapshot_*` tool — throws
before the entry exists, and neither `dispose()` nor `disposeAll()`
(added in #2428) can reach the worker. It leaks for the life of the
process, one per failed load.

## Fix

Wrap the load body in `try/catch` and `workerProxy.dispose()` before
rethrowing, so a failed load cleans up its own worker.

## Testing

- Added `tests/HeapSnapshotManager.test.ts`: spies on
`HeapSnapshotWorkerProxy.prototype.dispose`, forces a load failure via a
nonexistent path, and asserts the worker was disposed. Fails on `main`
(`dispose` called 0 times) and passes with this change.
- `npm run test tests/HeapSnapshotManager.test.ts
tests/tools/memory.test.ts` — all pass.
- ESLint + Prettier clean.
This commit is contained in:
bassem chagra
2026-08-07 18:55:09 +02:00
committed by GitHub
parent ca66d0f321
commit 744738deaa
2 changed files with 64 additions and 16 deletions
+25 -16
View File
@@ -362,26 +362,35 @@ export class HeapSnapshotManager {
import.meta.resolve('./third_party/devtools-heap-snapshot-worker.js'),
);
const {promise: snapshotPromise, resolve: resolveSnapshot} =
Promise.withResolvers<DevTools.HeapSnapshotModel.HeapSnapshotProxy.HeapSnapshotProxy>();
try {
const {promise: snapshotPromise, resolve: resolveSnapshot} =
Promise.withResolvers<DevTools.HeapSnapshotModel.HeapSnapshotProxy.HeapSnapshotProxy>();
const loaderProxy = workerProxy.createLoader(uid, snapshotProxy => {
resolveSnapshot(snapshotProxy);
});
const loaderProxy = workerProxy.createLoader(uid, snapshotProxy => {
resolveSnapshot(snapshotProxy);
});
const fileStream = fsSync.createReadStream(absolutePath, {
encoding: 'utf-8',
highWaterMark: 1024 * 1024,
});
const fileStream = fsSync.createReadStream(absolutePath, {
encoding: 'utf-8',
highWaterMark: 1024 * 1024,
});
for await (const chunk of fileStream) {
await loaderProxy.write(chunk);
for await (const chunk of fileStream) {
await loaderProxy.write(chunk);
}
await loaderProxy.close();
const snapshot = await snapshotPromise;
return {snapshot, worker: workerProxy};
} catch (error) {
// The worker is created before the read, and a failed load never reaches
// the #snapshots map, so dispose()/disposeAll() can never clean it up.
// Dispose it here to avoid leaking a worker on every failed load (e.g. a
// missing or invalid .heapsnapshot path).
workerProxy.dispose();
throw error;
}
await loaderProxy.close();
const snapshot = await snapshotPromise;
return {snapshot, worker: workerProxy};
}
async getDuplicateStrings(filePath: string): Promise<DuplicateStringGroup[]> {
+39
View File
@@ -0,0 +1,39 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import assert from 'node:assert';
import {describe, it, afterEach} from 'node:test';
import sinon from 'sinon';
import {HeapSnapshotManager} from '../src/HeapSnapshotManager.js';
import {DevTools} from '../src/third_party/index.js';
describe('HeapSnapshotManager', () => {
afterEach(() => {
sinon.restore();
});
it('disposes the worker when snapshot loading fails', async () => {
const disposeSpy = sinon.spy(
DevTools.HeapSnapshotModel.HeapSnapshotProxy.HeapSnapshotWorkerProxy
.prototype,
'dispose',
);
const manager = new HeapSnapshotManager();
// A path that passes into #loadSnapshot but fails on read. The worker is
// created before the read, so a failed load must still dispose it,
// otherwise it leaks for the life of the process (it is never added to the
// #snapshots map, so dispose()/disposeAll() cannot reach it).
await assert.rejects(
manager.getSnapshot('/nonexistent/does-not-exist.heapsnapshot'),
);
sinon.assert.calledOnce(disposeSpy);
});
});