From 6255572772ba26a6fc636403ea9c1dc589f6dd4e Mon Sep 17 00:00:00 2001 From: Vishal Ranaut Date: Sun, 14 Jun 2026 17:33:26 +0530 Subject: [PATCH] vm: fix SourceTextModule memory leak This commit fixes a memory leak in vm.SourceTextModule where the module namespace and all objects reachable from it were kept alive after evaluation. The issue was caused by an unconditional registration in the \moduleRegistries\ WeakMap, which was kept alive by the module's \idSymbol\ as long as the V8 Module was retained (e.g. by an AsyncContextFrame in the microtask queue). The fix explicitly unregisters the module from the registry after instantiation, unless \importModuleDynamically\ is provided (which inherently requires keeping the referrer alive to support dynamic imports at any time). Fixes: https://github.com/nodejs/node/issues/63186 --- lib/internal/vm/module.js | 8 +++ test/parallel/test-vm-module-memory-leak.js | 54 +++++++++++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 test/parallel/test-vm-module-memory-leak.js diff --git a/lib/internal/vm/module.js b/lib/internal/vm/module.js index ce2350f6cd16cb..30cb236e9b6da6 100644 --- a/lib/internal/vm/module.js +++ b/lib/internal/vm/module.js @@ -405,6 +405,14 @@ class SourceTextModule extends Module { throw new ERR_VM_MODULE_STATUS('must be unlinked'); } this[kWrap].instantiate(); + + if (this.#importModuleDynamically === undefined) { + const { moduleRegistries } = require('internal/modules/esm/utils'); + const idSymbol = this[kWrap][host_defined_option_symbol]; + if (idSymbol) { + moduleRegistries.delete(idSymbol); + } + } } get dependencySpecifiers() { diff --git a/test/parallel/test-vm-module-memory-leak.js b/test/parallel/test-vm-module-memory-leak.js new file mode 100644 index 00000000000000..0de57b5b6066f3 --- /dev/null +++ b/test/parallel/test-vm-module-memory-leak.js @@ -0,0 +1,54 @@ +'use strict'; +// Flags: --experimental-vm-modules --expose-gc + +require('../common'); +const assert = require('assert'); +const { SourceTextModule } = require('vm'); +const v8 = require('v8'); + +async function run() { + let initialMemory = 0; + + // Run a few times to warm up the VM + for (let i = 0; i < 5; i++) { + const m = new SourceTextModule('import.meta.url; const x = new Array(1000).fill(1);', { identifier: `file://warmup${i}.js` }); + await m.link(() => {}); + await m.evaluate(); + } + global.gc(); + initialMemory = process.memoryUsage().heapUsed; + + process.on('unhandledRejection', () => {}); + const ctx = require('vm').createContext({}); + for (let i = 0; i < 1000; i++) { + const m = new SourceTextModule(` + import.meta.url; + const x = new Array(1024 * 1024).fill(1); // some memory + await new Promise(r => setTimeout(r, 0)); + throw new Error('foo'); + `, { + context: ctx, + identifier: `file://test${i}.js`, + initializeImportMeta(meta) { meta.url = `file://test${i}.js`; } + }); + await m.link(() => {}); + m.evaluate(); // Don't catch! + } + + // Wait for all microtasks to run + await new Promise(r => setImmediate(r)); + + delete globalThis.importMetaUrl; + global.gc(); + + const finalMemory = process.memoryUsage().heapUsed; + const diffMB = (finalMemory - initialMemory) / 1024 / 1024; + + // The leak was ~80MB for 1000 iterations. + // We expect the diff to be small (e.g. < 5MB). + assert(diffMB < 10, `Memory leaked: ${diffMB.toFixed(2)}MB`); +} + +run().then(() => { + console.log('Test passed'); +});