Fix solidity test step trace memory - related to verbosity bug - #1560
Fix solidity test step trace memory - related to verbosity bug#1560ChristopherDedominici wants to merge 3 commits into
Conversation
|
Codecov Report✅ All modified and coverable lines are covered by tests. 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. 🚀 New features to boost your workflow:
|
…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>
hardhat test solidity -vvvballoons RAM — mimalloc page retentionHandoff 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.
mi_collectis global and thread-safe, but is there a cleaner spot (or should EDR purge on a cadence instead of per suite)?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.)libmimalloc-syswithextendedas a direct dep — acceptable, or is there an existing wrapper you'd rather route through?Alwaysrecording step traces for passing tests something you'd want to avoid at the source regardless?-vvvkeeps 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)Always⇒ step recording for every test.crates/edr_solidity_tests/src/multi_runner.rs:328WithStepsrecords oneCallTraceStepper opcode into the per-runSparsedTraceArena(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.The arenas are freed per suite, not leaked. There are two
SuiteResultobjects 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 JSSuiteResultand the RustSuiteResultis consumed and dropped. For a passing test atincludeTraces = Failingthe step arenas are dropped outright (include_trace = false). So there is no live-object retention — a probe on the napi handles showedcall_trace_arenas = 0at-vvv(i.e. no arenas remained attached to the objects handed to JS).mimalloc retains the freed pages.
crates/edr_napi/src/lib.rs:5mimalloc 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
maina forced JS GC doesn't reclaim it either.Net: the ballooned memory is freed-but-not-returned allocator pages, driven by
Alwaysstep 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(afterSuiteResult::new+ the progress callback):Plus the dependency (
crates/edr_napi/Cargo.toml):Why this shape:
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.extendedfeature is required —mi_collectlives in libmimalloc-sys'sextendedmodule, andmain's dependency graph doesn't otherwise enable it (this differs from the older 0.13-line branch, where it happened to be enabled transitively).mi_collectonly 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)
Responsedeallocation to a background thread (thefix/call-trace-memory-old-edrbranch): that's the provider JSON-RPCResponsepath. The solidity-test runner never produces aResponse, so it has no effect onhardhat test solidity. Measured: no change.dispose()onSuiteResult/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.WithoutStepsunconditionally 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 reasonAlwaysrecords up front.WithoutSteps+ re-run failures,WithStepsonly 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)
Build the branch:
pnpm installthen incrates/edr_napi:pnpm build:dev.Add a heavy fuzz fixture to
js/integration-tests/solidity-tests:contracts/heavy/Heavy.sol:test-contracts/heavy/Heavy{00..15}.t.sol(16 contracts), each:Zero-code confirmation of the mechanism (no rebuild): run the heavy suite at
-vvvwithMIMALLOC_PURGE_DELAY=0in 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.In-process A/B (build
edr-helpersfirst:js/helpers→pnpm build). Measure sustained RSS withTestContext+runAllSolidityTests(..., { collectStackTraces: CollectStackTraces.Always, includeTraces: IncludeTraces.Failing }), samplingprocess.memoryUsage().rssafter the run, undernode --expose-gc:-vvv(16 fuzz suites)main-vvbaseline~340 MB / 62% reduction, held with no GC.
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.