Skip to content

Fix solidity test step trace memory - related to verbosity bug - #1560

Draft
ChristopherDedominici wants to merge 3 commits into
mainfrom
fix/solidity-test-step-trace-memory
Draft

Fix solidity test step trace memory - related to verbosity bug#1560
ChristopherDedominici wants to merge 3 commits into
mainfrom
fix/solidity-test-step-trace-memory

Conversation

@ChristopherDedominici

@ChristopherDedominici ChristopherDedominici commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

hardhat test solidity -vvv balloons RAM — mimalloc page retention

Handoff for the EDR team: what the bug is, why the fix takes the shape it does, why other approaches were rejected, and how to reproduce/verify it yourself.


Questions for EDR team

Check this section after reading the whole description. I moved the questions to the top so they are not missed.

  1. Is the run callback the right place / granularity? It runs per suite on the runner's worker; mi_collect is global and thread-safe, but is there a cleaner spot (or should EDR purge on a cadence instead of per suite)?
  2. Is mi_collect(true) the intended call, or would you prefer configuring the purge delay / mi_option_* at init? (Per-suite avoids the per-free cost of a zero global delay.)
  3. libmimalloc-sys with extended as a direct dep — acceptable, or is there an existing wrapper you'd rather route through?
  4. Peak vs. sustained. This bounds the sustained RSS (the multi-GB accumulation). The momentary peak while many suites record step traces concurrently is reduced less. Do you consider the peak worth a follow-up (cap concurrency, or record steps only for non-replayable tests)? Is Always recording step traces for passing tests something you'd want to avoid at the source regardless?
  5. No Hardhat change is needed-vvv keeps all its output. Do you agree this belongs entirely in EDR?

Summary

At verbosity ≥ 3, Hardhat sets collectStackTraces = Always, which makes the runner record per-opcode step traces (TracingMode::WithSteps) for every test. Those step arenas are freed per suite — but mimalloc (EDR's global allocator) keeps the freed pages instead of returning them to the OS, so RSS (resident set size — the physical RAM the process holds) stays at the high-water mark for the whole run (multiple GB on a large suite). The fix returns each suite's freed pages after it completes (mi_collect(true)), which is lossless and cheap.


Root cause (with code references, main)

  1. Always ⇒ step recording for every test. crates/edr_solidity_tests/src/multi_runner.rs:328

    let tracing_mode = match self.collect_stack_traces {
        CollectStackTraces::Always => TracingMode::WithSteps,   // verbosity 3+
        CollectStackTraces::OnFailure => match self.include_traces { ... }
    };

    WithSteps records one CallTraceStep per opcode into the per-run SparsedTraceArena (an in-memory buffer holding the recorded step records). It does not record memory/stack snapshots, so each step is small — but a fuzz suite executes a huge number of opcodes (256 runs/test — the fuzz-runner default — × loop-heavy bodies), so the arenas are large in aggregate.

  2. The arenas are freed per suite, not leaked. There are two SuiteResult objects per suite: a Rust one that owns the trace arenas (execution_traces / setup_traces), and a JS one exposed to JavaScript across napi. When a suite completes, the napi callback builds the JS SuiteResult and the Rust SuiteResult is consumed and dropped. For a passing test at includeTraces = Failing the step arenas are dropped outright (include_trace = false). So there is no live-object retention — a probe on the napi handles showed call_trace_arenas = 0 at -vvv (i.e. no arenas remained attached to the objects handed to JS).

  3. mimalloc retains the freed pages. crates/edr_napi/src/lib.rs:5

    #[global_allocator]
    static ALLOC: mimalloc::MiMalloc = mimalloc::MiMalloc;

    mimalloc purges freed pages lazily (on later allocator activity / after a delay). Across a run the process just sits at the high-water mark; it doesn't shrink. On main a forced JS GC doesn't reclaim it either.

Net: the ballooned memory is freed-but-not-returned allocator pages, driven by Always step recording. Not a leak, not a live holder — an allocator-return problem.


The fix

Return each suite's freed pages right after it's handed off, in the run callback — crates/edr_napi/src/context.rs (after SuiteResult::new + the progress callback):

assert_eq!(status, napi::Status::Ok, "...");

// Return this suite's freed memory to the OS. Under CollectStackTraces::Always
// (verbosity 3+) every test records per-opcode step traces; those arenas are
// dropped above when the Rust SuiteResult is consumed, but mimalloc retains the
// pages by default, so RSS grows for the whole run. Purging per suite keeps it
// flat without the per-free overhead of a zero purge delay. Safe: mi_collect
// only reclaims already-freed memory.
unsafe { libmimalloc_sys::mi_collect(true); }

Plus the dependency (crates/edr_napi/Cargo.toml):

libmimalloc-sys = { version = "0.1", features = ["extended"] }

Why this shape:

  • Per suite, not a global MIMALLOC_PURGE_DELAY=0. A zero purge delay reproduces the same RSS but purges on every free (syscall overhead). Purging once per suite gets the memory back at a natural boundary with negligible overhead.
  • extended feature is requiredmi_collect lives in libmimalloc-sys's extended module, and main's dependency graph doesn't otherwise enable it (this differs from the older 0.13-line branch, where it happened to be enabled transitively).
  • Lossless by constructionmi_collect only reclaims memory that's already been freed; it never touches live data. Failure stack traces, gas, coverage, call traces are unaffected (they read separate structures; see "Why not…" below).

Why not the other approaches (rejected, with reasons)

  • Off-loading Response deallocation to a background thread (the fix/call-trace-memory-old-edr branch): that's the provider JSON-RPC Response path. The solidity-test runner never produces a Response, so it has no effect on hardhat test solidity. Measured: no change.
  • A dispose() on SuiteResult/TestResult: the step arenas are not on the napi handles at -vvv (call_trace_arenas = 0 — they're dropped during the Rust→napi conversion). Disposing the handles frees nothing. Measured: no change.
  • WithoutSteps unconditionally at -vvv: eliminates the memory but regresses failure stack traces for tests that can't be safely re-run because their result depends on external or one-time mutable state a replay may not reproduce (fork-latest, impure cheatcodes) — the whole reason Always records up front.
  • Selective recording (WithoutSteps + re-run failures, WithSteps only for non-replayable): correct for reducing the peak, but a runner-logic change and a real design decision. Not needed for the reported bug (sustained multi-GB), so deferred.

Reproduce & verify (in the EDR repo)

  1. Build the branch: pnpm install then in crates/edr_napi: pnpm build:dev.

  2. Add a heavy fuzz fixture to js/integration-tests/solidity-tests:

    • contracts/heavy/Heavy.sol:
      // SPDX-License-Identifier: MIT
      pragma solidity ^0.8.0;
      contract Heavy {
        uint256 public acc;
        mapping(uint256 => uint256) public store;
        function work(uint256 seed, uint256 iters) public returns (uint256) {
          uint256 a = seed;
          unchecked { for (uint256 i = 0; i < iters; i++) { a = a * 1103515245 + 12345; store[i % 64] = a; acc = a ^ acc; } }
          return a;
        }
      }
    • test-contracts/heavy/Heavy{00..15}.t.sol (16 contracts), each:
      // SPDX-License-Identifier: MIT
      pragma solidity ^0.8.0;
      import "../../contracts/heavy/Heavy.sol";
      contract HeavyNNTest {
        Heavy h;
        function setUp() public { h = new Heavy(); }
        function testFuzzXNN(uint256 seed) public { h.work(seed, 250); }
        function testFuzzYNN(uint256 seed) public { h.work(seed, 250); }
      }
  3. Zero-code confirmation of the mechanism (no rebuild): run the heavy suite at -vvv with MIMALLOC_PURGE_DELAY=0 in the env vs. without. RSS drops from ~500 MB to ~125 MB purely from the allocator returning pages — confirming it's page retention, not a leak.

  4. In-process A/B (build edr-helpers first: js/helperspnpm build). Measure sustained RSS with TestContext + runAllSolidityTests(..., { collectStackTraces: CollectStackTraces.Always, includeTraces: IncludeTraces.Failing }), sampling process.memoryUsage().rss after the run, under node --expose-gc:

    -vvv (16 fuzz suites) after run after forced GC
    stock main 543 MB 543 MB
    with fix 205 MB 205 MB
    -vv baseline ~140 MB

    ~340 MB / 62% reduction, held with no GC.

  5. Lossless checks (all pass with the fix): node --import tsx/esm --test test/call_traces.ts (call-trace generation), the 16-suite run reports all tests passing at -vvv, and a failing test still emits its full source-mapped stack trace. Gas stats / snapshots / coverage use separate data paths and are unaffected.

@changeset-bot

changeset-bot Bot commented Jul 15, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: a988f26

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@ChristopherDedominici
ChristopherDedominici temporarily deployed to github-action-benchmark July 15, 2026 12:18 — with GitHub Actions Inactive
@ChristopherDedominici
ChristopherDedominici temporarily deployed to github-action-benchmark July 15, 2026 12:20 — with GitHub Actions Inactive
@ChristopherDedominici
ChristopherDedominici temporarily deployed to github-action-benchmark July 15, 2026 12:20 — with GitHub Actions Inactive
@codecov

codecov Bot commented Jul 15, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 79.71%. Comparing base (329ac70) to head (a988f26).
⚠️ Report is 2 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1560      +/-   ##
==========================================
+ Coverage   79.70%   79.71%   +0.01%     
==========================================
  Files         446      446              
  Lines       76792    76849      +57     
  Branches    76792    76849      +57     
==========================================
+ Hits        61206    61261      +55     
  Misses      13470    13470              
- Partials     2116     2118       +2     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@ChristopherDedominici
ChristopherDedominici temporarily deployed to github-action-benchmark July 16, 2026 09:37 — with GitHub Actions Inactive
@ChristopherDedominici
ChristopherDedominici temporarily deployed to github-action-benchmark July 16, 2026 09:38 — with GitHub Actions Inactive
@ChristopherDedominici
ChristopherDedominici temporarily deployed to github-action-benchmark July 16, 2026 09:38 — with GitHub Actions Inactive
…ipts

Adds the reproduction workload for the step-trace memory fix:
- contracts/heavy/Heavy.sol + test-contracts/heavy/Heavy00..15.t.sol: a
  16-suite fuzz fixture that exercises per-opcode step tracing at -vvv/-vvvv.
- measure-mem.mts: single-run sustained/peak RSS A/B harness.
- workload.mts: looping workload that emits an RSS-over-time trajectory.

Run from js/integration-tests/solidity-tests after building edr_napi + edr-helpers:
  node --expose-gc --import tsx/esm measure-mem.mts --verbosity 3 --mode hold
  node --expose-gc --import tsx/esm workload.mts --verbosity 3 --loops 4 --out traj.json

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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