Skip to content

Per-test-file memory leak: Metro dependency graph retained per file → runner OOM #144

Description

@mfazekas

Describe the bug

A test suite with enough test files OOMs the Harness node runner (FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory). It is per-test-FILE, not per-render-time / per-bridge-traffic / Rive-specific / a single heavy test — 40 files that each only render(<View/>) + cleanup() reproduce it, while 20+ its in a single file stay bounded.

Root cause: each test file is fetched from Metro as its own bundle entry point (<file>.bundle?modulesOnly=true, see packages/runtime/src/bundler/bundle.ts). Metro keeps a full dependency graph in memory per distinct entry (in IncrementalBundler's revision cache, for delta updates) and only frees it when the server shuts down — so the run retains one graph (~tens of MB) per test file, climbing ~linearly with file count until the runner OOMs. Disabling HMR does not help (the caching is in the delta/revision cache, independent of watch/HMR).

Distinguishing experiments (iOS runner, --max-old-space-size=2048, sampling runner RSS):

Test shape Files Result Peak RSS
autoPlay RiveView + dataBind, 20 its 1 ✅ pass ~600–850 MB
Animated loop (JS-driven), 30 its 1 ✅ pass bounded
Animated loop, 1 it each 40 ✋ OOM 800 → 2103 MB
render(<View/>) only, 1 it each 40 ✋ OOM 865 → 2195 MB

Heap snapshot at ~1 GB (streamed aggregate): 326 MB / 5.6M array, 180 MB / 5.8M object, 103 MB / 1.25M string; top containers are multiple Arrays of ~88K elements — consistent with a retained Metro module graph per processed file.

System Info

node 20 (CI) / 24 (local). React Native 0.79–0.80, Metro 0.82/0.83. iOS simulator, macOS.
Not version-specific to a recent change — reproduces on 1.3.0 and 1.4.0-rc.1.

React Native Harness Version

1.3.0 (npm latest) and 1.4.0-rc.1 (npm rc) — both reproduce.

Reproduction

Rive-free, self-contained repro (generator + jest config + expected RSS curve) on a branch:
https://github.com/mfazekas/react-native-harness/tree/repro/per-file-metro-graph-leak (apps/playground/LEAK_REPRO.md).

cd apps/playground
LEAK_FILES=40 node scripts/generate-leak-repro.mjs
NODE_OPTIONS="--max-old-space-size=2048" \
  jest -c jest.harness.leak.config.mjs --selectProjects react-native-harness --harnessRunner ios

Steps to reproduce

  1. Generate N empty render(<View/>) harness test files (above).
  2. Run them on the iOS runner under a heap cap; sample the runner node process RSS.
  3. RSS climbs ~30 MB/file and never releases → Reached heap limit ... JavaScript heap out of memory. A correctly-bounded runner keeps RSS flat regardless of file count.

Real occurrence (CI)

Originally hit in rive-app/rive-nitro-react-native · run 28585932939 · job test-harness-ios (~50-file iOS Harness suite). The node runner (node/20.19.0) aborts ~256s in:

<--- Last few GCs --->
[8372:0x158008000]   256128 ms: Mark-Compact (reduce) 2054.2 (2093.3) -> 2044.2 (2087.5) MB ...
FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory
 1: 0x104e75334 node::OOMErrorHandler(char const*, v8::OOMDetails const&) [.../node/20.19.0/arm64/bin/node]
 2: 0x105031e84 v8::internal::V8::FatalProcessOutOfMemory(...)
10: 0x105a0ddf0 Builtins_NewPromiseCapability
11: 0x105a0ea34 Builtins_PromiseAll
13: 0x1059dbee4 Builtins_ArrayMap
15: 0x1059b5844 Builtins_AsyncGeneratorResumeNext
20: 0x105956b94 Builtins_RunMicrotasks

(The PromiseAll/microtask frames are just the runner orchestrating files while the heap is already exhausted — the allocation that finally fails is incidental, not the leak site.)

Minimal reproduction in CI (chromium, no macOS/Xcode)

Reproduced on the cheap chromium runner (the leak is node-side, so it's platform-agnostic): generate 300 empty render(<View/>) files, disable per-file environment reset, and run under --max-old-space-size=1536.

🔴 Failing run: https://github.com/mfazekas/react-native-harness/actions/runs/28651060708
(fork mfazekas/react-native-harness, branch repro/per-file-metro-graph-leak, workflow .github/workflows/leak-repro.yml)

It processed 26 distinct empty test files, the runner heap climbed to the cap, then aborted:

<--- Last few GCs --->
[3651:0x3fe19000]    42976 ms: Mark-Compact (reduce) 1534.3 (1538.9) -> 1534.3 (1537.9) MB ...
FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory
 3: 0x13bf47f v8::internal::V8::FatalProcessOutOfMemory(...) [node]
Aborted (core dumped)   # exit code 134

~26 files × ~40 MB retained graph/file ≈ 1 GB on top of baseline ≈ the 1536 MB cap — one Metro graph retained per file, confirming the root cause. Proposed fix: #145.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions