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:
+25
-16
@@ -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[]> {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user