Skip to content

[Bug]: Test file contexts retained across files with --runInBand (V8 compilation cache + profile-guided tiering of jest-circus dispatch) #16523

Open

Description

@Maxberman3

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.js

Expected 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:

  1. The first heavy file gets dispatch Maglev- and then Turbofan-compiled. V8 records cached_tiering_decision = kEarlyTurbofan on that shared SFI (compiler.cc, --profile-guided-optimization).
  2. In every later file, TieringManager::ShouldOptimize sees that decision on the shared SFI and Turbofan-compiles that context's dispatch after only a few calls (tiering-manager.cc).
  3. 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's retained_maps list (OptimizedCompilationJob::RegisterWeakObjectsInOptimizedCode).
  4. MarkCompactCollector::RetainMaps keeps a listed map while its constructor is live and does not age it while its prototype is live. For this map both are null, 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-cache or a unique source per context stops SFI sharing.
  • --no-profile-guided-optimization stops the tiering decision being cached.
  • --no-turbofan stops the map from being embedded.
  • --retain-maps-for-n-gc=0 turns 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions