Repository navigation
Description
Version
29.7.0. Not verified on 30.x, but dispatch in packages/jest-circus/src/state.ts is unchanged on main.
Steps to reproduce
Node 24.19.0 (V8 13.6.233.17), macOS. --runInBand, default testEnvironment: 'node'.
One test file with many tests runs first, then several light files. Each file keeps about 40 MB on its own global:
// __tests__/a-heavy.test.js
globalThis.ballast = new Array(5e6).fill(1.5); // ~40 MB held by this file's global
for (let i = 0; i < 3000; i++) test(`t${i}`, () => {});// __tests__/b-light-01.test.js … b-light-15.test.js (identical)
globalThis.ballast = new Array(5e6).fill(1.5); // ~40 MB held by this file's global
test('a', () => {}); test('b', () => {}); test('c', () => {});// alpha-sequencer.js: keeps the heavy file first
const Sequencer = require('@jest/test-sequencer').default;
module.exports = class AlphaSequencer extends Sequencer {
sort(tests) { return [...tests].sort((a, b) => a.path.localeCompare(b.path)); }
};// gc-reporter.js: 12 full GCs after each file, then print heapUsed
const tick = () => new Promise((r) => setImmediate(r));
module.exports = class GcReporter {
async onTestFileResult() {
for (let i = 0; i < 12; i++) { global.gc(); await tick(); }
console.log(`GCHEAP ${Math.round(process.memoryUsage().heapUsed / 1e6)}`);
}
};// jest.config.js
module.exports = { testSequencer: './alpha-sequencer.js', testEnvironment: 'node' };node --expose-gc [flag] node_modules/jest/bin/jest.js --runInBand --reporters=default --reporters=./gc-reporter.jsExpected behavior
Each finished test file's context is collected, so heap stays flat after the first file.
Actual behavior
Every test file's context stays alive, so heap grows by the ballast size per file:
| flag | heapUsed (MB) after each of the 16 files |
|---|---|
| none | 68 109 150 191 232 272 313 354 395 436 476 517 558 598 639 680 |
--no-compilation-cache |
66 66 66 66 66 66 66 67 67 67 67 67 67 67 67 67 |
--no-profile-guided-optimization |
flat at ~106 ¹ |
--retain-maps-for-n-gc=0 |
24 24 24 24 24 25 25 25 25 25 25 25 25 25 25 25 |
¹ Measured with --logHeapUsage (one GC per file) rather than the reporter above.
In a large real suite (513 files in band), the heap grew from 271 MB to 4.6 GB. With --no-compilation-cache it stayed around 570 MB, and the run took about a third of the time. This matches the many --no-compilation-cache reports in #11956 and #7874. Those threads never found the cause, and I think this is one.
Additional context
Retention path. I confirmed it with an ordinary-GC heap snapshot (--heap-snapshot-on-gc), by replaying V8's marking on that snapshot, and with a jest-free vm repro:
Heap::FindAllRetainedMaps() (walks every native context, reachable or not)
└─ test context N: NativeContext.retained_maps
└─ weak → NativeContext(N).async_function_object_map
constructor null, prototype null: RetainMaps keeps it and never ages it
└─ meta map → native_context → test context N → its global, module registry, …
How the map gets there. jest-circus's dispatch is an async function (state.ts). jest loads it into every test context through requireInternalModule, and the source and filename are identical each time. So V8's isolate compilation cache gives every context the same SharedFunctionInfo. In V8:
- The first heavy file gets
dispatchMaglev- and then Turbofan-compiled. V8 recordscached_tiering_decision = kEarlyTurbofanon that shared SFI (compiler.cc,--profile-guided-optimization). - In every later file,
TieringManager::ShouldOptimizesees that decision on the shared SFI and Turbofan-compiles that context'sdispatchafter only a few calls (tiering-manager.cc). - Turbofan inlines the async-function-object allocation and embeds
native_context().async_function_object_map()(js-create-lowering.cc). Embedded maps are registered in the context'sretained_mapslist (OptimizedCompilationJob::RegisterWeakObjectsInOptimizedCode). MarkCompactCollector::RetainMapskeeps a listed map while its constructor is live and does not age it while its prototype is live. For this map both arenull, which is read-only and counts as always live. So the map is kept forever, and through its meta map so is the whole context.
RetainMaps is skipped in memory-reducing GCs. That is why a heap snapshot, which runs CollectAllAvailableGarbage, frees all of it and the snapshot shows nothing.
Each link can be removed independently:
--no-compilation-cacheor a unique source per context stops SFI sharing.--no-profile-guided-optimizationstops the tiering decision being cached.--no-turbofanstops the map from being embedded.--retain-maps-for-n-gc=0turns map retention off.
In my real suite, --turbo-filter=dispatch (Turbofan may compile only jest-circus's dispatch) reproduced the whole leak.
Upstream status. This is V8 bug https://issues.chromium.org/issues/571157135 (regressed between V8 12.9 and 13.6, so Node 24+), also tracked as nodejs/node#66490. V8 fixed it on main in "[heap] Always decrement age in RetainMaps()" (https://chromium-review.googlesource.com/8557820). A follow-up, "[heap] Clear DescriptorArray when trimming is not possible" (https://chromium-review.googlesource.com/8572378), covers the remaining single-context hold through the module.parent getter's AccessorPair. Until Node ships the fix, node --no-compilation-cache (or --retain-maps-for-n-gc=0) is the workaround for large in-band suites, and documenting it may be the useful Jest-side action.
Related but different mechanisms (ESM only): nodejs/node#65866 and nodejs/node#63186.
Environment
System: macOS 26 (Darwin 25.6.0), Apple Silicon
Binaries: Node 24.19.0 (V8 13.6.233.17)
npmPackages: jest 29.7.0, jest-circus 29.7.0
Activity