Skip to content

Conversation

@subwaycookiecrunch
Copy link
Contributor

Description

Fixes #12397

This PR addresses the issue of unbounded memory usage during long-running invariant tests. Previously, the InvariantExecutor retained the full calldata for every successful fuzz case. In testing sessions with millions of runs (e.g., overnight fuzzing), this caused memory usage to grow indefinitely, eventually leading to OOM crashes.

Changes

  • crates/evm/fuzz: Added prune_calldata() methods to FuzzCase and FuzzedCases to allow clearing the stored calldata while preserving metrics (gas, stipend).
  • crates/evm/evm: Updated InvariantExecutor to enforce a rolling window of stored calldata. It now keeps full traces only for the last 4,096 runs (for debugging purposes) and prunes the calldata from all older runs.

Verification

  • Verified manually with a reproduction case where memory usage remains stable.
  • Added extensive logic to ensure that while calldata is pruned, the total count of runs and other metrics remain accurate for reporting.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right , this file isn't required for the invariant memory fix.
Its presence here isn't intentional for this change, and I’ll remove it
from this PR so the scope stays minimal.

@subwaycookiecrunch subwaycookiecrunch force-pushed the fix/invariant-memory-usage branch from 278ae4d to 68b258a Compare December 21, 2025 20:49
Comment on lines 242 to 252
self.test_data.fuzz_cases.push(FuzzedCases::new(run.fuzz_runs));

// Prune older cases if we have too many.
// We keep a rolling window of full traces for the last 4096 runs to aid debugging,
// but prune older ones to avoid unbounded memory usage.
const MAX_KEPT_CALLDATA: usize = 4096;
if self.test_data.fuzz_cases.len() > MAX_KEPT_CALLDATA {
let prune_index = self.test_data.fuzz_cases.len() - MAX_KEPT_CALLDATA - 1;
self.test_data.fuzz_cases[prune_index].prune_calldata();
}

Copy link
Contributor

@0xalpharush 0xalpharush Dec 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't actually shrink the fuzz_cases vec. Maybe we should use a ring buffer or altogether disable this for long running campaigns

cc @grandizzy

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or vec deque

use std::collections::VecDeque;

if self.test_data.fuzz_cases.len() >= 4096 {
    self.test_data.fuzz_cases.pop_front();
}
self.test_data.fuzz_cases.push_back(new_case);

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point. I realized that while pruning calldata removes the bulk of the payload, we're still effectively leaking the FuzzedCases structs and the inner Vec headers (which adds up to ~4KB per run at depth 100). I'll proceed with your suggestion to use a ring buffer (or simply drop the oldest cases) to strictly bound the vector to MAX_KEPT_CALLDATA entries. This means we'll lose the gas history for pruned runs, but that's a necessary trade-off for unbounded campaigns.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@subwaycookiecrunch @0xalpharush I don't think the fuzz cases are shared between runs so we can just prune after each run? (We can have a bool config to preserve backwards compatibility)

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

actually at a closer look that one is not even used, great catch @subwaycookiecrunch I updated pr to just get rid of the calldata field #12893 (comment)

gakonst added a commit to tempoxyz/tempo-foundry that referenced this pull request Jan 31, 2026
Aggressively prunes calldata from completed invariant runs to bound
memory usage during long-running fuzzing campaigns.

Unlike foundry-rs/foundry#12893 which keeps a rolling window of 4096
runs, this implementation prunes immediately after each run completes.
This is safe because:

- Calldata is not shared between runs
- Shrinking/replay uses last_run_inputs, not historical fuzz_cases
- Gas/stipend metrics are preserved for reporting
- Coverage is collected during execution, not reconstructed

Ref: foundry-rs/foundry#12397
Amp-Thread-ID: https://ampcode.com/threads/T-019c1560-4151-77ce-ba30-f46f9fb80353
Co-authored-by: Amp <amp@ampcode.com>
@gakonst
Copy link
Member

gakonst commented Feb 1, 2026

Based on @grandizzy's observation that fuzz cases aren't shared between runs, I investigated further and found that FuzzCase.calldata is stored but never read after construction.

The simplest fix is to remove the field entirely:

pub struct FuzzCase {
    pub gas: u64,
    pub stipend: u64,
}

This eliminates memory accumulation during long invariant runs without needing pruning logic, ring buffers, or config flags.

I've pushed a clean implementation to foundry-rs/foundry:fix/invariant-memory-usage that:

  • Removes the unused calldata field from FuzzCase
  • Updates construction sites to omit calldata
  • Does NOT delete the unrelated vyper test files

Net: -4 lines, simpler struct, no memory accumulation.

The calldata field in FuzzCase was stored but never read after construction.
Removing it entirely eliminates memory accumulation during long invariant runs.

Changes:
- Remove FuzzCase.calldata field (unused after construction)
- Remove prune_calldata() methods (no longer needed)
- Restore vyper test files that were incorrectly deleted

Fixes foundry-rs#12397

Amp-Thread-ID: https://ampcode.com/threads/T-019c17c9-d969-7370-bf0d-495e473e8e30
Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019c17c9-d969-7370-bf0d-495e473e8e30
Co-authored-by: Amp <amp@ampcode.com>
@gakonst gakonst force-pushed the fix/invariant-memory-usage branch from c70ed2e to d581a6b Compare February 1, 2026 06:50
Copy link
Collaborator

@grandizzy grandizzy left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lgtm, thank you, great finding. Waiting on one more reviewer before merge cc @DaniPopes @0xalpharush

@grandizzy grandizzy changed the title fix(invariant): prune calldata to bound memory usage in long runs fix(invariant): remove unused cloned calldata Feb 1, 2026
@DaniPopes DaniPopes added this pull request to the merge queue Feb 1, 2026
Merged via the queue into foundry-rs:master with commit 50dbffb Feb 1, 2026
16 checks passed
@github-project-automation github-project-automation bot moved this to Done in Foundry Feb 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

bug(invariant): unbounded memory usage in long invariant testing runs

5 participants