Repository navigation
vm contexts never get freed after Jest runs a big test file (Node 24+, retained maps) #66490
Description
Version
v24.21.0 (also v24.8.0, v24.15.0, v26.10.0; not v22.14.0 or v22.18.0)
Platform
Darwin <host> 25.6.0 Darwin Kernel Version 25.6.0: Fri Jul 31 19:17:26 PDT 2026; root:xnu-12377.161.14~5/RELEASE_ARM64_T6041 arm64
Subsystem
vm
What steps will reproduce the bug?
I put a small repro here: https://github.com/markmssd/nodejs-repro/tree/vm-context-retained-maps
git clone -b vm-context-retained-maps https://github.com/markmssd/nodejs-repro.git
cd nodejs-repro
npm install
npm run repro # "still in memory" goes up to 8
npm run repro:fixed # same thing with --retain-maps-for-n-gc=0, stays at 1Jest runs each test file in its own vm context. many.test.js has 3000 trivial tests, which is enough to get Jest's per-test code optimized, and then there are seven files with a single test each. counting-environment.js is Jest's node environment plus a small counter: after each test file it runs a GC and prints how many test-file globals are still around.
How often does it reproduce? Is there a required condition?
Every time on Node 24 and 26. You just need one test file that does enough per-test work for Jest's code to get optimized. 3000 empty tests does it. In our real project, about 120 tests is enough, because our setup files register around 12 beforeEach hooks.
What is the expected behavior? Why is that the expected behavior?
Once a test file is done, its context should be garbage collected, so only the file that's currently running should still be in memory:
test files finished: 1, still in memory: 1
test files finished: 2, still in memory: 1
...
test files finished: 8, still in memory: 1
That's what I get on Node 22, and on Node 24/26 with --retain-maps-for-n-gc=0 (npm run repro:fixed).
What do you see instead?
On Node 24 and 26, none of the test files after many.test.js ever get freed:
test files finished: 1, still in memory: 1
test files finished: 2, still in memory: 2
...
test files finished: 8, still in memory: 8
This is how I found it: in our real test suite, one Jest worker running 180 test files ended up holding 44 old contexts and about 1.1 GB of heap. With --retain-maps-for-n-gc=0 it's 2 contexts and around 330 MB. In CI our workers peak at 1.4–1.9 GB of retained heap, and 670–820 MB with the flag.
Additional information
- The old contexts aren't strongly reachable. A heap snapshot (
v8.writeHeapSnapshot(), whose GC tries to reduce memory) frees them, and afterwards the snapshot only shows the current one. Plainglobal.gc()calls never do, which is why I think it's the retained maps.--retain-maps-for-n-gc=0makes it go away completely. --no-maglevmostly hides it (2 stay alive instead of all of them), I assume because less code gets optimized.- I did find one strong path, but it only keeps a single previous test file alive: from Jest's runtime, through the
FeedbackVector/AllocationSiteofcreateInitialModule, the boilerplate object'sMap→DescriptorArray→AccessorPair(amodule.parentgetter Jest defines on every module withObject.defineProperty), and from that getter's closure to the test file's environment. - I couldn't reproduce it with a plain
node:vmloop (new context each iteration with a hot function inside), so it probably depends on how Jest's code is shared between the outer realm and the contexts.
Activity
inoway46 commentedon Oct 8, 2026
I reproduced this with the provided repro on official v24.21.0 and v26.10.0, and a local
mainbuild (v27.0.0-pre), on macOS x64.I'm not sure whether this is expected V8 map retention or a memory regression.
@markmssd this may be worth investigating upstream in V8. Would you be willing to open an issue there with your repro and findings, and link it here?
markmssd commentedon Oct 8, 2026
Thanks for reproducing it! I narrowed it down a bit more before going to V8:
--maglev)So it looks like it regressed in V8 between 12.9 and 13.6. Node 23 already has Maglev on by default and is fine, so it's not just Maglev being enabled.
It only happens with optimized code:
--jitless, or--no-maglev --no-turbofan: clean on 24 and 26--no-turbofan: clean on 24, but still leaks on 26--no-maglev: 2 contexts kept instead of all of them--no-sparkplug: no changeI'll open an issue on the V8 tracker with this and link it here.
markmssd commentedon Oct 8, 2026
Opened it on the V8 tracker: https://issues.chromium.org/issues/571157135
inoway46 commentedon Oct 14, 2026
I verified the upstream fix:
https://chromium-review.googlesource.com/c/v8/v8/+/8557820
Context accumulation stopped in all tested cases on Node.js v24.21.0, v26.11.0, and main.