Skip to content

feat: add --heapProfile flag for per-file heap NDJSON dumps#1292

Closed
pkasarda wants to merge 2 commits into
web-infra-dev:mainfrom
pkasarda:pr/heap-profile
Closed

feat: add --heapProfile flag for per-file heap NDJSON dumps#1292
pkasarda wants to merge 2 commits into
web-infra-dev:mainfrom
pkasarda:pr/heap-profile

Conversation

@pkasarda
Copy link
Copy Markdown
Contributor

Draft — opening for visibility, no CC, no reviewers.

What

Adds --heapProfile [path] (CLI) and heapProfile?: boolean | string (config) — appends one NDJSON line per test file with the worker's full process.memoryUsage() snapshot at file boundary.

rstest run --heapProfile=./heap.jsonl
defineConfig({ heapProfile: './heap.jsonl' });

Why

--logHeapUsage (existing) records per-TEST heapUsed into the test result. Useful for "which test allocated?" Useless for "is the worker leaking heap across FILES?" — the question that matters under isolate: false (or anything reusing workers).

When debugging a long jsdom-heavy suite under reuse, we needed to see the per-worker trajectory: file 1 = 71 MB, file 10 = 745 MB, file 50 = 3.5 GB. The existing per-test heap stats don't aggregate by worker pid or by file sequence — you'd have to scrape the JSON reporter output and reconstruct it.

--heapProfile writes the trajectory directly so jq/pandas can plot it:

jq -s 'group_by(.pid)[] | { pid: .[0].pid, files: length, growth: (max_by(.heapUsed).heapUsed - min_by(.heapUsed).heapUsed) }' heap.jsonl

Schema

{ "pid": 12345, "seq": 1, "test": "src/foo.test.ts",
  "heapUsed": 71000000, "heapTotal": 136000000, "rss": 254000000, "external": 133000000,
  "ts": 1714563210123 }
  • pid — worker pid (use to group lines from the same process)
  • seq — 1-indexed per-worker sequence number (use to detect monotonic growth)
  • test — absolute path of the test file
  • heapUsed, heapTotal, rss, externalprocess.memoryUsage() snapshot at file-boundary entry
  • tsDate.now() of the sample

Mechanism

The snapshot is taken at the top of preparePool — i.e. before the per-file rspack compile + setup-file evaluation — so consecutive lines for the same pid form a clean per-file trajectory.

Multiple workers appending to the same path is safe: each appendFileSync write is below PIPE_BUF (4 KiB on Linux, 512 B on macOS) so writes don't interleave mid-line. Lines from different workers will interleave but each is self-describing via pid/seq.

If the file can't be written (permissions, full disk), the error is swallowed — a profiler bug must never break test runs.

What doesn't change

  • --logHeapUsage — unchanged. Different audience: per-test, in-band, machine-readable via JSON reporter.
  • All other config — unchanged.
  • Worker protocol — unchanged (file is written from the worker directly).

Files

  • packages/core/src/types/config.ts — public heapProfile?: boolean | string
  • packages/core/src/types/worker.tsRuntimeConfig picks heapProfile
  • packages/core/src/config.ts — default heapProfile: false
  • packages/core/src/cli/commands.ts--heapProfile [path] flag def
  • packages/core/src/cli/init.tsCommonOptions + merge key
  • packages/core/src/pool/index.ts — passes through to runtimeConfig
  • packages/core/src/runtime/worker/runInPool.tswriteHeapProfileLine helper, invoked at the top of preparePool
  • e2e/heap-profile/ — fixture passes --heapProfile=<tmpfile> and asserts the NDJSON shape + line count

Testing

  • pnpm --filter @rstest/core test — 242/242 (snapshots updated for the new default heapProfile: false)
  • e2e/heap-profile — 1/1

Status

Draft. Opening for visibility, no CC, no reviewers. Will mark ready when the bigger memory-debugging story is sketched (the pool.memoryLimit PR is the natural companion).

When debugging slow suites under `isolate: false`, the per-test
`--logHeapUsage` flag tells you which TESTS allocated, but not how
the worker's heap grew across FILES — which is the trajectory you
actually care about for monotonic-growth bugs.

`--heapProfile [path]` (or `heapProfile: true | <path>` in config)
appends one NDJSON line per test file with the worker's full
`process.memoryUsage()` snapshot at file boundary. Plot with `jq`
or pandas to see heap-vs-file-sequence.

```bash
rstest run --heapProfile=./heap.jsonl
jq -s 'group_by(.pid) | map({pid: .[0].pid, growth: (max_by(.heapUsed).heapUsed - min_by(.heapUsed).heapUsed)})' heap.jsonl
```

Schema (one JSON object per line):
```json
{ "pid": 12345, "seq": 1, "test": "src/foo.test.ts", "heapUsed": 71000000, "heapTotal": 136000000, "rss": 254000000, "external": 133000000, "ts": 1714563210123 }
```

Multiple workers appending to the same path is safe — each
`appendFileSync` write is below `PIPE_BUF` (4 KiB on Linux, 512 B
on macOS) so writes don't interleave mid-line. Lines from different
workers will interleave but each is self-describing.

Defaults / disabled-paths are no-ops; if the file can't be written
(permissions, full disk), the error is swallowed — a profiler bug
must never break test runs.

Files:
- `packages/core/src/types/config.ts` — public `heapProfile?: boolean | string`
- `packages/core/src/types/worker.ts` — `RuntimeConfig` picks `heapProfile`
- `packages/core/src/config.ts` — default `heapProfile: false`
- `packages/core/src/cli/commands.ts` — `--heapProfile [path]` flag def
- `packages/core/src/cli/init.ts` — `CommonOptions` + merge key
- `packages/core/src/pool/index.ts` — pass through to runtime config
- `packages/core/src/runtime/worker/runInPool.ts` — `writeHeapProfileLine` helper, called at the top of `preparePool`
- `e2e/heap-profile/` — fixture asserts NDJSON shape and per-file count
`heapProfile?: boolean | string` was already optional on the public
`RstestConfig`, but `NormalizedConfig = Required<Omit<RstestConfig,
OptionalKeys | ...>>` re-promoted it to required, which propagated
through `RuntimeConfig` (a `Pick<NormalizedConfig, ...>`).

That broke @rstest/browser's `getRuntimeConfigFromProject`:

  src/hostController.ts:831 - error TS2741: Property 'heapProfile'
  is missing in type '...' but required in type 'RuntimeConfig'.

The browser host doesn't surface heap profiling (no fs in the
browser sandbox), so undefined-means-off is the right semantic.
Add `heapProfile` to `OptionalKeys` so it stays optional after
normalization; downstream readers (`writeHeapProfileLine` in the
worker) already early-return on falsy values.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@pkasarda
Copy link
Copy Markdown
Contributor Author

Closing — on reflection this overlaps with the existing --logHeapUsage flag, which already populates TestResult.heap with process.memoryUsage() data. The only meaningful difference here was the output format (NDJSON file vs interleaved reporter line), and that's properly a reporter concern, not a worker-runtime one. A custom reporter that reads TestResult.heap and writes NDJSON would cover the same diagnostic use case without growing the core surface.

Keeping #1291 (pool.memoryLimit) as the single focused contribution from this work.

@pkasarda pkasarda closed this May 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant