From 35f46b067e8c0bf4c3f5475b54e5af5e6cec0a6a Mon Sep 17 00:00:00 2001 From: Yash Atreya <44857776+yash-atreya@users.noreply.github.com> Date: Fri, 26 Sep 2025 16:37:40 +0530 Subject: [PATCH 01/28] feat(types): SharedFuzzState and FuzzWorker --- Cargo.lock | 1 + crates/evm/evm/Cargo.toml | 1 + crates/evm/evm/src/executors/fuzz/types.rs | 118 ++++++++++++++++++++- 3 files changed, 119 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 3a4618c18ca93..68928835af894 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4690,6 +4690,7 @@ dependencies = [ "indicatif 0.18.0", "parking_lot", "proptest", + "rayon", "revm", "revm-inspectors", "serde", diff --git a/crates/evm/evm/Cargo.toml b/crates/evm/evm/Cargo.toml index b3e9553f40304..ea247c5dcbba7 100644 --- a/crates/evm/evm/Cargo.toml +++ b/crates/evm/evm/Cargo.toml @@ -56,3 +56,4 @@ indicatif.workspace = true serde_json.workspace = true serde.workspace = true uuid.workspace = true +rayon.workspace = true diff --git a/crates/evm/evm/src/executors/fuzz/types.rs b/crates/evm/evm/src/executors/fuzz/types.rs index 11f658f4bed8b..512b2c02113c9 100644 --- a/crates/evm/evm/src/executors/fuzz/types.rs +++ b/crates/evm/evm/src/executors/fuzz/types.rs @@ -1,9 +1,15 @@ -use crate::executors::RawCallResult; +use std::sync::{ + Arc, + atomic::{AtomicBool, AtomicU32, Ordering}, +}; + +use crate::executors::{FailFast, FuzzTestTimer, RawCallResult, corpus::WorkerCorpus}; use alloy_primitives::{Bytes, Log, map::HashMap}; use foundry_common::evm::Breakpoints; use foundry_evm_coverage::HitMaps; use foundry_evm_fuzz::FuzzCase; use foundry_evm_traces::SparsedTraceArena; +use proptest::prelude::TestCaseError; use revm::interpreter::InstructionResult; /// Returned by a single fuzz in the case of a successful run @@ -41,3 +47,113 @@ pub enum FuzzOutcome { Case(CaseOutcome), CounterExample(CounterExampleOutcome), } + +/// Shared state for coordinating parallel fuzz workers +pub struct SharedFuzzState { + /// Total runs across workers + total_runs: Arc, + /// Whether a counterexample has been found (use example from first failure) + found_counterexample: Arc, + /// Maximum number of runs + max_runs: u32, + /// Fuzz timer + timer: FuzzTestTimer, + /// Fail Fast coordinator + fail_fast: FailFast, +} + +impl SharedFuzzState { + pub fn new(max_runs: u32, timeout: Option, fail_fast: FailFast) -> Self { + Self { + total_runs: Arc::new(AtomicU32::new(0)), + found_counterexample: Arc::new(AtomicBool::new(false)), + max_runs, + timer: FuzzTestTimer::new(timeout), + fail_fast, + } + } + + pub fn increment_runs(&self) -> u32 { + self.total_runs.fetch_add(1, Ordering::Relaxed) + } + + pub fn should_continue(&self) -> bool { + // Check fail-fast + if self.fail_fast.should_stop() { + return false; + } + + if self.timer.is_enabled() { + // Check timer + !self.timer.is_timed_out() + } else { + // Check runs + let total_runs = self.total_runs.load(Ordering::Relaxed); + total_runs < self.max_runs + } + } + + pub fn try_claim_failure(&self) -> bool { + let claimed = self + .found_counterexample + .compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed) + .is_ok(); + + if claimed { + // Record failure + self.fail_fast.record_fail(); + } + + claimed + } +} + +pub struct FuzzWorker { + /// Worker identifier + pub worker_id: u32, + /// First fuzz case this worker encountered (with global run number) + /// The global run number is used to compare which worker found the failure first. + pub first_case: Option<(u32, FuzzCase)>, + /// Gas usage for all cases this worker ran + pub gas_by_case: Vec<(u64, u64)>, + /// Counterexample if this worker found one + pub counterexample: Option<(Bytes, RawCallResult)>, + /// Traces collected by this worker (up to limit) + pub traces: Vec, + /// Last breakpoints from this worker + pub breakpoints: Option, + /// Coverage collected by this worker + pub coverage: Option, + /// Logs from all cases this worker ran + pub logs: Vec, + /// Deprecated cheatcodes seen by this worker + pub deprecated_cheatcodes: HashMap<&'static str, Option<&'static str>>, + /// Number of runs this worker completed + pub runs: u32, + /// Number of rejects this worker encountered + pub rejects: u32, + /// Failure reason if this worker failed + pub failure: Option, + /// Worker's corpus manager + pub corpus: WorkerCorpus, +} + +impl FuzzWorker { + pub fn new(worker_id: u32, corpus: WorkerCorpus) -> Self { + Self { + worker_id, + corpus, + first_case: Default::default(), + gas_by_case: Default::default(), + counterexample: Default::default(), + traces: Default::default(), + breakpoints: Default::default(), + coverage: Default::default(), + logs: Default::default(), + deprecated_cheatcodes: Default::default(), + runs: 0, + rejects: 0, + failure: Default::default(), + } + } +} From 431cc0605616671278ce5eb85c78d87b72b273c9 Mon Sep 17 00:00:00 2001 From: Yash Atreya <44857776+yash-atreya@users.noreply.github.com> Date: Fri, 26 Sep 2025 17:37:33 +0530 Subject: [PATCH 02/28] basic run_worker in FuzzExecutor --- crates/evm/evm/src/executors/fuzz/mod.rs | 137 ++++++++++++++++++++- crates/evm/evm/src/executors/fuzz/types.rs | 26 ++-- 2 files changed, 143 insertions(+), 20 deletions(-) diff --git a/crates/evm/evm/src/executors/fuzz/mod.rs b/crates/evm/evm/src/executors/fuzz/mod.rs index 2541b9fd4c4f8..6cc990b23d989 100644 --- a/crates/evm/evm/src/executors/fuzz/mod.rs +++ b/crates/evm/evm/src/executors/fuzz/mod.rs @@ -1,6 +1,7 @@ use crate::executors::{ DURATION_BETWEEN_METRICS_REPORT, Executor, FailFast, FuzzTestTimer, RawCallResult, corpus::WorkerCorpus, + fuzz::types::{FuzzWorker, SharedFuzzState}, }; use alloy_dyn_abi::JsonAbiExt; use alloy_json_abi::Function; @@ -25,7 +26,10 @@ use proptest::{ test_runner::{TestCaseError, TestRunner}, }; use serde_json::json; -use std::time::{Instant, SystemTime, UNIX_EPOCH}; +use std::{ + sync::Arc, + time::{Instant, SystemTime, UNIX_EPOCH}, +}; mod types; pub use types::{CaseOutcome, CounterExampleOutcome, FuzzOutcome}; @@ -356,6 +360,137 @@ impl FuzzedExecutor { } } + fn run_worker( + &mut self, + worker_id: u32, + num_workers: u32, + func: &Function, + fuzz_fixtures: &FuzzFixtures, + deployed_libs: &[Address], + address: Address, + rd: &RevertDecoder, + shared_state: Arc, + progress: Option<&ProgressBar>, + ) -> Result { + // Prepare + let state = self.build_fuzz_state(deployed_libs); + let dictionary_weight = self.config.dictionary.dictionary_weight.min(100); + let strategy = proptest::prop_oneof![ + 100 - dictionary_weight => fuzz_calldata(func.clone(), fuzz_fixtures), + dictionary_weight => fuzz_calldata_from_state(func.clone(), &state), + ] + .prop_map(move |calldata| BasicTxDetails { + sender: Default::default(), + call_details: CallDetails { target: Default::default(), calldata }, + }); + + let mut corpus = WorkerCorpus::new( + worker_id, + self.config.corpus.clone(), + strategy.boxed(), + if worker_id == 0 { Some(&self.executor) } else { None }, + if worker_id == 0 { Some(func) } else { None }, + None, // fuzzed_contracts for invariant tests + )?; + + let mut worker = FuzzWorker::new(worker_id); + let max_traces_to_collect = std::cmp::max(1, self.config.gas_report_samples) as usize; + + let mut runner = self.runner.clone(); + // TODO: Add sync interval parameters for corpus sync + 'stop: while shared_state.should_continue() { + let input = if let Some(failure) = self.persisted_failure.take() { + failure.calldata + } else { + if let Some(progress) = progress { + progress.inc(1); + + if self.config.corpus.collect_edge_coverage() { + // TODO: Display Global Corpus Metrics + } + } else if self.config.corpus.collect_edge_coverage() { + // TODO: Display global corpus metrics since DURATION_BETWEEN_METRICS_REPORT + } + + worker.runs += 1; + + match corpus.new_input(&mut runner, &state, func) { + Ok(input) => input, + Err(err) => { + worker.failure = Some(TestCaseError::fail(format!( + "failed to generate fuzzed input in worker {}: {err}", + worker.worker_id + ))); + // TODO: Send signal to stop all workers via SharedFuzzState + break 'stop; + } + } + }; + + match self.single_fuzz(address, input, &mut corpus) { + Ok(fuzz_outcome) => match fuzz_outcome { + FuzzOutcome::Case(case) => { + worker.gas_by_case.push((case.case.gas, case.case.stipend)); + + if worker.first_case.is_none() { + let total_runs = shared_state.total_runs(); + worker.first_case.replace((total_runs, case.case)); + } + + if let Some(call_traces) = case.traces { + if worker.traces.len() == max_traces_to_collect { + worker.traces.pop(); + } + worker.traces.push(call_traces); + worker.breakpoints.replace(case.breakpoints); + } + + if self.config.show_logs { + worker.logs.extend(case.logs); + } + + HitMaps::merge_opt(&mut worker.coverage, case.coverage); + worker.deprecated_cheatcodes = case.deprecated_cheatcodes; + } + FuzzOutcome::CounterExample(CounterExampleOutcome { + exit_reason: status, + counterexample: outcome, + .. + }) => { + let reason = rd.maybe_decode(&outcome.1.result, status); + worker.logs.extend(outcome.1.logs.clone()); + // TODO: Send signal for failure via SharedFuzzState + worker.counterexample = Some(outcome); + worker.failure = Some(TestCaseError::fail(reason.unwrap_or_default())); + break 'stop; + } + }, + Err(err) => { + match err { + TestCaseError::Fail(_) => { + // TODO: Send signal for failure via SharedFuzzState + worker.failure = Some(err); + break 'stop; + } + TestCaseError::Reject(_) => { + // Apply max rejects only if configured, otherwise silently discard run. + // TODO: Add max_rejects to SharedFuzzState and track rejects across + // workers. + if self.config.max_test_rejects > 0 { + worker.rejects += 1; + if worker.rejects >= self.config.max_test_rejects { + worker.failure = Some(err); + break 'stop; + } + } + } + } + } + } + } + + Ok(worker) + } /// Stores fuzz state for use with [fuzz_calldata_from_state] pub fn build_fuzz_state(&self, deployed_libs: &[Address]) -> EvmFuzzState { if let Some(fork_db) = self.executor.backend().active_fork_db() { diff --git a/crates/evm/evm/src/executors/fuzz/types.rs b/crates/evm/evm/src/executors/fuzz/types.rs index 512b2c02113c9..4d3f7604f7cb0 100644 --- a/crates/evm/evm/src/executors/fuzz/types.rs +++ b/crates/evm/evm/src/executors/fuzz/types.rs @@ -106,13 +106,17 @@ impl SharedFuzzState { claimed } + + pub fn total_runs(&self) -> u32 { + self.total_runs.load(Ordering::Relaxed) + } } +#[derive(Default)] pub struct FuzzWorker { /// Worker identifier pub worker_id: u32, /// First fuzz case this worker encountered (with global run number) - /// The global run number is used to compare which worker found the failure first. pub first_case: Option<(u32, FuzzCase)>, /// Gas usage for all cases this worker ran pub gas_by_case: Vec<(u64, u64)>, @@ -134,26 +138,10 @@ pub struct FuzzWorker { pub rejects: u32, /// Failure reason if this worker failed pub failure: Option, - /// Worker's corpus manager - pub corpus: WorkerCorpus, } impl FuzzWorker { - pub fn new(worker_id: u32, corpus: WorkerCorpus) -> Self { - Self { - worker_id, - corpus, - first_case: Default::default(), - gas_by_case: Default::default(), - counterexample: Default::default(), - traces: Default::default(), - breakpoints: Default::default(), - coverage: Default::default(), - logs: Default::default(), - deprecated_cheatcodes: Default::default(), - runs: 0, - rejects: 0, - failure: Default::default(), - } + pub fn new(worker_id: u32) -> Self { + Self { worker_id, ..Default::default() } } } From e4c60608aa4f172a84900065303b6aef75ae43df Mon Sep 17 00:00:00 2001 From: Yash Atreya <44857776+yash-atreya@users.noreply.github.com> Date: Fri, 26 Sep 2025 18:42:56 +0530 Subject: [PATCH 03/28] rename found_counterexample to found_failure in SharedFuzzState and store the workers id that found the failure --- crates/evm/evm/src/executors/fuzz/mod.rs | 6 ++--- crates/evm/evm/src/executors/fuzz/types.rs | 26 +++++++++++++--------- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/crates/evm/evm/src/executors/fuzz/mod.rs b/crates/evm/evm/src/executors/fuzz/mod.rs index 6cc990b23d989..c542ffd2831b7 100644 --- a/crates/evm/evm/src/executors/fuzz/mod.rs +++ b/crates/evm/evm/src/executors/fuzz/mod.rs @@ -421,7 +421,7 @@ impl FuzzedExecutor { "failed to generate fuzzed input in worker {}: {err}", worker.worker_id ))); - // TODO: Send signal to stop all workers via SharedFuzzState + shared_state.try_claim_failure(worker_id); break 'stop; } } @@ -459,17 +459,17 @@ impl FuzzedExecutor { }) => { let reason = rd.maybe_decode(&outcome.1.result, status); worker.logs.extend(outcome.1.logs.clone()); - // TODO: Send signal for failure via SharedFuzzState worker.counterexample = Some(outcome); worker.failure = Some(TestCaseError::fail(reason.unwrap_or_default())); + shared_state.try_claim_failure(worker_id); break 'stop; } }, Err(err) => { match err { TestCaseError::Fail(_) => { - // TODO: Send signal for failure via SharedFuzzState worker.failure = Some(err); + shared_state.try_claim_failure(worker_id); break 'stop; } TestCaseError::Reject(_) => { diff --git a/crates/evm/evm/src/executors/fuzz/types.rs b/crates/evm/evm/src/executors/fuzz/types.rs index 4d3f7604f7cb0..b5cdeaefa6a58 100644 --- a/crates/evm/evm/src/executors/fuzz/types.rs +++ b/crates/evm/evm/src/executors/fuzz/types.rs @@ -1,5 +1,5 @@ use std::sync::{ - Arc, + Arc, OnceLock, atomic::{AtomicBool, AtomicU32, Ordering}, }; @@ -52,8 +52,12 @@ pub enum FuzzOutcome { pub struct SharedFuzzState { /// Total runs across workers total_runs: Arc, - /// Whether a counterexample has been found (use example from first failure) - found_counterexample: Arc, + /// Found failure + /// + /// The worker that found the failure sets it's ID. + /// + /// This ID is then used to correctly extract the failure reason and counterexample. + found_failure: OnceLock, /// Maximum number of runs max_runs: u32, /// Fuzz timer @@ -66,7 +70,7 @@ impl SharedFuzzState { pub fn new(max_runs: u32, timeout: Option, fail_fast: FailFast) -> Self { Self { total_runs: Arc::new(AtomicU32::new(0)), - found_counterexample: Arc::new(AtomicBool::new(false)), + found_failure: OnceLock::new(), max_runs, timer: FuzzTestTimer::new(timeout), fail_fast, @@ -93,14 +97,16 @@ impl SharedFuzzState { } } - pub fn try_claim_failure(&self) -> bool { - let claimed = self - .found_counterexample - .compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed) - .is_ok(); + /// Returns true if the worker was able to claim the failure, false if failure was set by + /// another worker + pub fn try_claim_failure(&self, worker_id: u32) -> bool { + if self.found_failure.get().is_some() { + return false; + } + let claimed = self.found_failure.set(worker_id).is_ok(); if claimed { - // Record failure + // Record failure in FailFast as well self.fail_fast.record_fail(); } From 40263fd747d26693d903c8c2cbc5563537cfcfb2 Mon Sep 17 00:00:00 2001 From: Yash Atreya <44857776+yash-atreya@users.noreply.github.com> Date: Fri, 26 Sep 2025 18:48:50 +0530 Subject: [PATCH 04/28] feat: track total_rejects in SharedFuzzState --- crates/evm/evm/src/executors/fuzz/mod.rs | 7 ++++--- crates/evm/evm/src/executors/fuzz/types.rs | 11 +++++++++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/crates/evm/evm/src/executors/fuzz/mod.rs b/crates/evm/evm/src/executors/fuzz/mod.rs index c542ffd2831b7..c3a0eba4bf697 100644 --- a/crates/evm/evm/src/executors/fuzz/mod.rs +++ b/crates/evm/evm/src/executors/fuzz/mod.rs @@ -474,12 +474,13 @@ impl FuzzedExecutor { } TestCaseError::Reject(_) => { // Apply max rejects only if configured, otherwise silently discard run. - // TODO: Add max_rejects to SharedFuzzState and track rejects across - // workers. if self.config.max_test_rejects > 0 { worker.rejects += 1; - if worker.rejects >= self.config.max_test_rejects { + shared_state.increment_rejects(); + // Fail only total_rejects across workers exceeds the config value + if shared_state.total_rejects() >= self.config.max_test_rejects { worker.failure = Some(err); + shared_state.try_claim_failure(worker_id); break 'stop; } } diff --git a/crates/evm/evm/src/executors/fuzz/types.rs b/crates/evm/evm/src/executors/fuzz/types.rs index b5cdeaefa6a58..72402317d77c9 100644 --- a/crates/evm/evm/src/executors/fuzz/types.rs +++ b/crates/evm/evm/src/executors/fuzz/types.rs @@ -60,6 +60,8 @@ pub struct SharedFuzzState { found_failure: OnceLock, /// Maximum number of runs max_runs: u32, + /// Total rejects across workers + total_rejects: Arc, /// Fuzz timer timer: FuzzTestTimer, /// Fail Fast coordinator @@ -72,6 +74,7 @@ impl SharedFuzzState { total_runs: Arc::new(AtomicU32::new(0)), found_failure: OnceLock::new(), max_runs, + total_rejects: Arc::new(AtomicU32::new(0)), timer: FuzzTestTimer::new(timeout), fail_fast, } @@ -81,6 +84,10 @@ impl SharedFuzzState { self.total_runs.fetch_add(1, Ordering::Relaxed) } + pub fn increment_rejects(&self) -> u32 { + self.total_rejects.fetch_add(1, Ordering::Relaxed) + } + pub fn should_continue(&self) -> bool { // Check fail-fast if self.fail_fast.should_stop() { @@ -116,6 +123,10 @@ impl SharedFuzzState { pub fn total_runs(&self) -> u32 { self.total_runs.load(Ordering::Relaxed) } + + pub fn total_rejects(&self) -> u32 { + self.total_rejects.load(Ordering::Relaxed) + } } #[derive(Default)] From d1990ec90ce539805cc5e956a49798809c8e02d8 Mon Sep 17 00:00:00 2001 From: Yash Atreya <44857776+yash-atreya@users.noreply.github.com> Date: Mon, 29 Sep 2025 12:23:03 +0530 Subject: [PATCH 05/28] shared_state.increment_runs + only worker0 replays persisted failure and corpus --- crates/evm/evm/src/executors/fuzz/mod.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/evm/evm/src/executors/fuzz/mod.rs b/crates/evm/evm/src/executors/fuzz/mod.rs index c3a0eba4bf697..ecaa1f2520732 100644 --- a/crates/evm/evm/src/executors/fuzz/mod.rs +++ b/crates/evm/evm/src/executors/fuzz/mod.rs @@ -388,8 +388,9 @@ impl FuzzedExecutor { worker_id, self.config.corpus.clone(), strategy.boxed(), + // Master worker replays the persisted corpus using the executor if worker_id == 0 { Some(&self.executor) } else { None }, - if worker_id == 0 { Some(func) } else { None }, + Some(func), None, // fuzzed_contracts for invariant tests )?; @@ -399,7 +400,10 @@ impl FuzzedExecutor { let mut runner = self.runner.clone(); // TODO: Add sync interval parameters for corpus sync 'stop: while shared_state.should_continue() { - let input = if let Some(failure) = self.persisted_failure.take() { + // Only the master worker replays the persisted failure, if any. + let input = if worker_id == 0 + && let Some(failure) = self.persisted_failure.take() + { failure.calldata } else { if let Some(progress) = progress { @@ -413,6 +417,7 @@ impl FuzzedExecutor { } worker.runs += 1; + shared_state.increment_runs(); match corpus.new_input(&mut runner, &state, func) { Ok(input) => input, From ed234d8347dd0255c9a6d08b8683af7f88a830b1 Mon Sep 17 00:00:00 2001 From: Yash Atreya <44857776+yash-atreya@users.noreply.github.com> Date: Mon, 29 Sep 2025 12:44:45 +0530 Subject: [PATCH 06/28] feat: basic staggered corpus sync in run_worker --- crates/evm/evm/src/executors/fuzz/mod.rs | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/crates/evm/evm/src/executors/fuzz/mod.rs b/crates/evm/evm/src/executors/fuzz/mod.rs index ecaa1f2520732..1875701dfb543 100644 --- a/crates/evm/evm/src/executors/fuzz/mod.rs +++ b/crates/evm/evm/src/executors/fuzz/mod.rs @@ -33,6 +33,8 @@ use std::{ mod types; pub use types::{CaseOutcome, CounterExampleOutcome, FuzzOutcome}; +/// Corpus syncs across workers every `SYNC_INTERVAL` runs. +const SYNC_INTERVAL: u32 = 1000; /// Contains data collected during fuzz test runs. #[derive(Default)] @@ -363,7 +365,7 @@ impl FuzzedExecutor { fn run_worker( &mut self, worker_id: u32, - num_workers: u32, + num_workers: usize, func: &Function, fuzz_fixtures: &FuzzFixtures, deployed_libs: &[Address], @@ -398,7 +400,12 @@ impl FuzzedExecutor { let max_traces_to_collect = std::cmp::max(1, self.config.gas_report_samples) as usize; let mut runner = self.runner.clone(); - // TODO: Add sync interval parameters for corpus sync + + // Offset to stagger corpus syncs across workers; so that workers don't sync at the same + // time. + let sync_offset = worker_id * 100; + let mut runs_since_sync = 0; + let sync_threshold = SYNC_INTERVAL + sync_offset; 'stop: while shared_state.should_continue() { // Only the master worker replays the persisted failure, if any. let input = if worker_id == 0 @@ -419,6 +426,14 @@ impl FuzzedExecutor { worker.runs += 1; shared_state.increment_runs(); + runs_since_sync += 1; + if runs_since_sync >= sync_threshold { + let instance = Instant::now(); + corpus.sync(num_workers, &self.executor, Some(func), None)?; + trace!("Worker {worker_id} finished corpus sync in {:?}", instance.elapsed()); + runs_since_sync = 0; + } + match corpus.new_input(&mut runner, &state, func) { Ok(input) => input, Err(err) => { From 67c96a7792b5416eaff8c955a97756dfd209456a Mon Sep 17 00:00:00 2001 From: Yash Atreya <44857776+yash-atreya@users.noreply.github.com> Date: Mon, 29 Sep 2025 17:02:43 +0530 Subject: [PATCH 07/28] feat: parallelize fuzz runs + aggregate results --- crates/evm/evm/src/executors/corpus.rs | 1 - crates/evm/evm/src/executors/fuzz/mod.rs | 321 ++++++++------------- crates/evm/evm/src/executors/fuzz/types.rs | 18 +- 3 files changed, 139 insertions(+), 201 deletions(-) diff --git a/crates/evm/evm/src/executors/corpus.rs b/crates/evm/evm/src/executors/corpus.rs index f84190c138d5c..445714c557af5 100644 --- a/crates/evm/evm/src/executors/corpus.rs +++ b/crates/evm/evm/src/executors/corpus.rs @@ -770,7 +770,6 @@ impl WorkerCorpus { }; if timestamp <= self.last_sync_timestamp { - // TODO: Delete synced file continue; } diff --git a/crates/evm/evm/src/executors/fuzz/mod.rs b/crates/evm/evm/src/executors/fuzz/mod.rs index 1875701dfb543..750a05a287fef 100644 --- a/crates/evm/evm/src/executors/fuzz/mod.rs +++ b/crates/evm/evm/src/executors/fuzz/mod.rs @@ -1,5 +1,5 @@ use crate::executors::{ - DURATION_BETWEEN_METRICS_REPORT, Executor, FailFast, FuzzTestTimer, RawCallResult, + Executor, FailFast, RawCallResult, corpus::WorkerCorpus, fuzz::types::{FuzzWorker, SharedFuzzState}, }; @@ -7,7 +7,7 @@ use alloy_dyn_abi::JsonAbiExt; use alloy_json_abi::Function; use alloy_primitives::{Address, Bytes, Log, U256, map::HashMap}; use eyre::Result; -use foundry_common::{evm::Breakpoints, sh_println}; +use foundry_common::evm::Breakpoints; use foundry_config::FuzzConfig; use foundry_evm_core::{ constants::{CHEATCODE_ADDRESS, MAGIC_ASSUME}, @@ -25,7 +25,7 @@ use proptest::{ strategy::Strategy, test_runner::{TestCaseError, TestRunner}, }; -use serde_json::json; +use rayon::iter::{IntoParallelIterator, ParallelIterator}; use std::{ sync::Arc, time::{Instant, SystemTime, UNIX_EPOCH}, @@ -110,199 +110,40 @@ impl FuzzedExecutor { fail_fast: &FailFast, ) -> Result { // Stores the fuzz test execution data. - let mut test_data = FuzzTestData::default(); - let state = self.build_fuzz_state(deployed_libs); - let dictionary_weight = self.config.dictionary.dictionary_weight.min(100); - let strategy = proptest::prop_oneof![ - 100 - dictionary_weight => fuzz_calldata(func.clone(), fuzz_fixtures), - dictionary_weight => fuzz_calldata_from_state(func.clone(), &state), - ] - .prop_map(move |calldata| BasicTxDetails { - sender: Default::default(), - call_details: CallDetails { target: Default::default(), calldata }, - }); - // We want to collect at least one trace which will be displayed to user. - let max_traces_to_collect = std::cmp::max(1, self.config.gas_report_samples) as usize; - - let mut corpus_manager = WorkerCorpus::new( - 0, // Id of the Master - self.config.corpus.clone(), - strategy.boxed(), - Some(&self.executor), - Some(func), - None, - )?; - - // Start timer for this fuzz test. - let timer = FuzzTestTimer::new(self.config.timeout); - let mut last_metrics_report = Instant::now(); - let max_runs = self.config.runs; - let continue_campaign = |runs: u32| { - if fail_fast.should_stop() { - return false; - } - - if timer.is_enabled() { !timer.is_timed_out() } else { runs < max_runs } - }; - - 'stop: while continue_campaign(test_data.runs) { - // If counterexample recorded, replay it first, without incrementing runs. - let input = if let Some(failure) = self.persisted_failure.take() { - failure.calldata - } else { - // If running with progress, then increment current run. - if let Some(progress) = progress { - progress.inc(1); - // Display metrics in progress bar. - if self.config.corpus.collect_edge_coverage() { - progress.set_message(format!("{}", &corpus_manager.metrics)); - } - } else if self.config.corpus.collect_edge_coverage() - && last_metrics_report.elapsed() > DURATION_BETWEEN_METRICS_REPORT - { - // Display metrics inline. - let metrics = json!({ - "timestamp": SystemTime::now() - .duration_since(UNIX_EPOCH)? - .as_secs(), - "test": func.name, - "metrics": &corpus_manager.metrics, - }); - let _ = sh_println!("{}", serde_json::to_string(&metrics)?); - last_metrics_report = Instant::now(); - }; - - test_data.runs += 1; - - match corpus_manager.new_input(&mut self.runner, &state, func) { - Ok(input) => input, - Err(err) => { - test_data.failure = Some(TestCaseError::fail(format!( - "failed to generate fuzzed input: {err}" - ))); - break 'stop; - } - } - }; - - match self.single_fuzz(address, input, &mut corpus_manager) { - Ok(fuzz_outcome) => match fuzz_outcome { - FuzzOutcome::Case(case) => { - test_data.gas_by_case.push((case.case.gas, case.case.stipend)); - - if test_data.first_case.is_none() { - test_data.first_case.replace(case.case); - } - - if let Some(call_traces) = case.traces { - if test_data.traces.len() == max_traces_to_collect { - test_data.traces.pop(); - } - test_data.traces.push(call_traces); - test_data.breakpoints.replace(case.breakpoints); - } - - if self.config.show_logs { - test_data.logs.extend(case.logs); - } - - HitMaps::merge_opt(&mut test_data.coverage, case.coverage); - test_data.deprecated_cheatcodes = case.deprecated_cheatcodes; - } - FuzzOutcome::CounterExample(CounterExampleOutcome { - exit_reason: status, - counterexample: outcome, - .. - }) => { - let reason = rd.maybe_decode(&outcome.1.result, status); - test_data.logs.extend(outcome.1.logs.clone()); - test_data.counterexample = outcome; - test_data.failure = Some(TestCaseError::fail(reason.unwrap_or_default())); - break 'stop; - } - }, - Err(err) => { - match err { - TestCaseError::Fail(_) => { - test_data.failure = Some(err); - break 'stop; - } - TestCaseError::Reject(_) => { - // Apply max rejects only if configured, otherwise silently discard run. - if self.config.max_test_rejects > 0 { - test_data.rejects += 1; - if test_data.rejects >= self.config.max_test_rejects { - test_data.failure = Some(err); - break 'stop; - } - } - } - } - } - } - } - - let (calldata, call) = test_data.counterexample; - let mut traces = test_data.traces; - let (last_run_traces, last_run_breakpoints) = if test_data.failure.is_none() { - (traces.pop(), test_data.breakpoints) - } else { - (call.traces.clone(), call.cheatcodes.map(|c| c.breakpoints)) - }; - - let mut result = FuzzTestResult { - first_case: test_data.first_case.unwrap_or_default(), - gas_by_case: test_data.gas_by_case, - success: test_data.failure.is_none(), - skipped: false, - reason: None, - counterexample: None, - logs: test_data.logs, - labels: call.labels, - traces: last_run_traces, - breakpoints: last_run_breakpoints, - gas_report_traces: traces.into_iter().map(|a| a.arena).collect(), - line_coverage: test_data.coverage, - deprecated_cheatcodes: test_data.deprecated_cheatcodes, - failed_corpus_replays: corpus_manager.failed_replays, - }; - - match test_data.failure { - Some(TestCaseError::Fail(reason)) => { - let reason = reason.to_string(); - result.reason = (!reason.is_empty()).then_some(reason); - let args = if let Some(data) = calldata.get(4..) { - func.abi_decode_input(data).unwrap_or_default() - } else { - vec![] - }; - result.counterexample = Some(CounterExample::Single( - BaseCounterExample::from_fuzz_call(calldata, args, call.traces), - )); - } - Some(TestCaseError::Reject(reason)) => { - let reason = reason.to_string(); - result.reason = (!reason.is_empty()).then_some(reason); - } - None => {} - } - - if let Some(reason) = &result.reason - && let Some(reason) = SkipReason::decode_self(reason) - { - result.skipped = true; - result.reason = reason.0; - } - - state.log_stats(); - - Ok(result) + let shared_state = Arc::new(SharedFuzzState::new( + self.config.runs, + self.config.timeout, + fail_fast.clone(), + )); + + // TODO: Determine the number of workers + let num_workers = rayon::current_num_threads() as u32; + let persisted_failure = self.persisted_failure.take(); + let workers = (0..num_workers) + .into_par_iter() + .map(|worker_id| { + self.run_worker( + worker_id, + num_workers as usize, + func, + fuzz_fixtures, + deployed_libs, + address, + rd, + shared_state.clone(), + progress, + if worker_id == 0 { persisted_failure.clone() } else { None }, + ) + }) + .collect::>>()?; + + Ok(self.aggregate_results(workers, func, shared_state)) } /// Granular and single-step function that runs only one fuzz and returns either a `CaseOutcome` /// or a `CounterExampleOutcome` fn single_fuzz( - &mut self, + &self, address: Address, calldata: Bytes, coverage_metrics: &mut WorkerCorpus, @@ -362,8 +203,95 @@ impl FuzzedExecutor { } } + fn aggregate_results( + &self, + workers: Vec, + func: &Function, + shared_state: Arc, + ) -> FuzzTestResult { + let mut result = FuzzTestResult::default(); + + // Find the worker with the lowest global run in FuzzWorker.first_case.0 + let first_case = workers + .iter() + // Get the worker_id, global_run number, and FuzzCase + .filter_map(|w| w.first_case.as_ref().map(|(run, case)| (w.worker_id, *run, case))) + .min_by_key(|(_, run, _)| *run); + + result.first_case = first_case.map(|(_, _, fc)| fc.clone()).unwrap_or_default(); + // TODO: Fix order of cases when aggregating + result.gas_by_case = workers.iter().flat_map(|w| w.gas_by_case.clone()).collect(); + + result.logs = workers.iter().flat_map(|w| w.logs.clone()).collect(); + result.gas_report_traces = + workers.iter().flat_map(|w| w.traces.iter().map(|a| a.arena.clone())).collect(); + result.line_coverage = workers.iter().fold(None, |mut acc, w| { + HitMaps::merge_opt(&mut acc, w.coverage.clone()); + acc + }); + result.deprecated_cheatcodes = workers.iter().fold(HashMap::default(), |mut acc, w| { + acc.extend(w.deprecated_cheatcodes.clone()); + acc + }); + + let failed_worked_id = shared_state.failed_worked_id(); + result.success = failed_worked_id.is_none(); + if failed_worked_id.is_none() + && let Some(last_run) = workers.iter().max_by_key(|w| w.last_run_timestamp) + { + result.traces = last_run.traces.last().cloned(); + result.breakpoints = last_run.breakpoints.clone(); + } + + let failed_worker = + failed_worked_id.and_then(|id| workers.into_iter().find(|w| w.worker_id == id)); + + if let Some(failed_worker) = failed_worker { + let (calldata, call) = failed_worker.counterexample; + + result.labels = call.labels; + result.traces = call.traces.clone(); + result.breakpoints = call.cheatcodes.map(|c| c.breakpoints); + + match failed_worker.failure { + Some(TestCaseError::Fail(reason)) => { + let reason = reason.to_string(); + result.reason = (!reason.is_empty()).then_some(reason); + let args = if let Some(data) = calldata.get(4..) { + func.abi_decode_input(data).unwrap_or_default() + } else { + vec![] + }; + result.counterexample = Some(CounterExample::Single( + BaseCounterExample::from_fuzz_call(calldata, args, call.traces), + )); + } + Some(TestCaseError::Reject(reason)) => { + let reason = reason.to_string(); + result.reason = (!reason.is_empty()).then_some(reason); + } + None => {} + } + }; + + if let Some(reason) = &result.reason + && let Some(reason) = SkipReason::decode_self(reason) + { + result.skipped = true; + result.reason = reason.0; + } + + // TODO + result.failed_corpus_replays = 0; + + // TODO: Logs stats from EvmFuzzState of all workers + // state.log_stats(); + + result + } + fn run_worker( - &mut self, + &self, worker_id: u32, num_workers: usize, func: &Function, @@ -373,6 +301,7 @@ impl FuzzedExecutor { rd: &RevertDecoder, shared_state: Arc, progress: Option<&ProgressBar>, + mut persisted_failure: Option, ) -> Result { // Prepare let state = self.build_fuzz_state(deployed_libs); @@ -397,7 +326,8 @@ impl FuzzedExecutor { )?; let mut worker = FuzzWorker::new(worker_id); - let max_traces_to_collect = std::cmp::max(1, self.config.gas_report_samples) as usize; + let max_traces_to_collect = + std::cmp::max(1, self.config.gas_report_samples as usize / num_workers) as usize; let mut runner = self.runner.clone(); @@ -409,7 +339,7 @@ impl FuzzedExecutor { 'stop: while shared_state.should_continue() { // Only the master worker replays the persisted failure, if any. let input = if worker_id == 0 - && let Some(failure) = self.persisted_failure.take() + && let Some(failure) = persisted_failure.take() { failure.calldata } else { @@ -447,6 +377,7 @@ impl FuzzedExecutor { } }; + worker.last_run_timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis(); match self.single_fuzz(address, input, &mut corpus) { Ok(fuzz_outcome) => match fuzz_outcome { FuzzOutcome::Case(case) => { @@ -479,7 +410,7 @@ impl FuzzedExecutor { }) => { let reason = rd.maybe_decode(&outcome.1.result, status); worker.logs.extend(outcome.1.logs.clone()); - worker.counterexample = Some(outcome); + worker.counterexample = outcome; worker.failure = Some(TestCaseError::fail(reason.unwrap_or_default())); shared_state.try_claim_failure(worker_id); break 'stop; diff --git a/crates/evm/evm/src/executors/fuzz/types.rs b/crates/evm/evm/src/executors/fuzz/types.rs index 72402317d77c9..6695765e87434 100644 --- a/crates/evm/evm/src/executors/fuzz/types.rs +++ b/crates/evm/evm/src/executors/fuzz/types.rs @@ -1,9 +1,9 @@ use std::sync::{ - Arc, OnceLock, - atomic::{AtomicBool, AtomicU32, Ordering}, -}; + Arc, OnceLock, + atomic::{AtomicU32, Ordering}, + }; -use crate::executors::{FailFast, FuzzTestTimer, RawCallResult, corpus::WorkerCorpus}; +use crate::executors::{FailFast, FuzzTestTimer, RawCallResult}; use alloy_primitives::{Bytes, Log, map::HashMap}; use foundry_common::evm::Breakpoints; use foundry_evm_coverage::HitMaps; @@ -127,6 +127,10 @@ impl SharedFuzzState { pub fn total_rejects(&self) -> u32 { self.total_rejects.load(Ordering::Relaxed) } + + pub fn failed_worked_id(&self) -> Option { + self.found_failure.get().copied() + } } #[derive(Default)] @@ -138,7 +142,7 @@ pub struct FuzzWorker { /// Gas usage for all cases this worker ran pub gas_by_case: Vec<(u64, u64)>, /// Counterexample if this worker found one - pub counterexample: Option<(Bytes, RawCallResult)>, + pub counterexample: (Bytes, RawCallResult), /// Traces collected by this worker (up to limit) pub traces: Vec, /// Last breakpoints from this worker @@ -155,6 +159,10 @@ pub struct FuzzWorker { pub rejects: u32, /// Failure reason if this worker failed pub failure: Option, + /// Last run timestamp in milliseconds + /// + /// Used to identify which worker ran last and collect its traces and call breakpoints + pub last_run_timestamp: u128, } impl FuzzWorker { From 0cdccd9428f502e32e77cbd054b2517b8957b5bd Mon Sep 17 00:00:00 2001 From: Yash Atreya <44857776+yash-atreya@users.noreply.github.com> Date: Tue, 30 Sep 2025 13:27:52 +0530 Subject: [PATCH 08/28] fix: derive seeds per worker --- crates/evm/evm/src/executors/fuzz/mod.rs | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/crates/evm/evm/src/executors/fuzz/mod.rs b/crates/evm/evm/src/executors/fuzz/mod.rs index 750a05a287fef..fcae50e97d0c4 100644 --- a/crates/evm/evm/src/executors/fuzz/mod.rs +++ b/crates/evm/evm/src/executors/fuzz/mod.rs @@ -5,7 +5,7 @@ use crate::executors::{ }; use alloy_dyn_abi::JsonAbiExt; use alloy_json_abi::Function; -use alloy_primitives::{Address, Bytes, Log, U256, map::HashMap}; +use alloy_primitives::{Address, Bytes, Log, U256, keccak256, map::HashMap}; use eyre::Result; use foundry_common::evm::Breakpoints; use foundry_config::FuzzConfig; @@ -23,7 +23,7 @@ use foundry_evm_traces::SparsedTraceArena; use indicatif::ProgressBar; use proptest::{ strategy::Strategy, - test_runner::{TestCaseError, TestRunner}, + test_runner::{RngAlgorithm, TestCaseError, TestRng, TestRunner}, }; use rayon::iter::{IntoParallelIterator, ParallelIterator}; use std::{ @@ -329,7 +329,23 @@ impl FuzzedExecutor { let max_traces_to_collect = std::cmp::max(1, self.config.gas_report_samples as usize / num_workers) as usize; - let mut runner = self.runner.clone(); + // For deterministic parallel fuzzing, derive a unique seed for each worker + let mut runner = if worker_id == 0 { + self.runner.clone() + } else if let Some(seed) = self.config.seed { + // Derive a worker-specific seed using keccak256(seed || worker_id) + let mut seed_data = [0u8; 36]; // 32 bytes for seed + 4 bytes for worker_id + seed_data[..32].copy_from_slice(&seed.to_be_bytes::<32>()); + seed_data[32..36].copy_from_slice(&worker_id.to_be_bytes()); + let worker_seed = U256::from_be_bytes(keccak256(&seed_data).0); + + // Create a new TestRunner with the derived seed + trace!(target: "forge::test", ?worker_seed, "deterministic seed for worker {worker_id}"); + let rng = TestRng::from_seed(RngAlgorithm::ChaCha, &worker_seed.to_be_bytes::<32>()); + TestRunner::new_with_rng(self.runner.config().clone(), rng) + } else { + self.runner.clone() + }; // Offset to stagger corpus syncs across workers; so that workers don't sync at the same // time. From f7310d040d763625d13cea6bdf96be57a1d50e90 Mon Sep 17 00:00:00 2001 From: Yash Atreya <44857776+yash-atreya@users.noreply.github.com> Date: Tue, 30 Sep 2025 13:33:27 +0530 Subject: [PATCH 09/28] fix: only increment runs for success cases + try_increment_runs atomically to prevent going over max_runs --- crates/evm/evm/src/executors/fuzz/mod.rs | 39 ++++++++++++++-------- crates/evm/evm/src/executors/fuzz/types.rs | 22 ++++++++++-- 2 files changed, 45 insertions(+), 16 deletions(-) diff --git a/crates/evm/evm/src/executors/fuzz/mod.rs b/crates/evm/evm/src/executors/fuzz/mod.rs index fcae50e97d0c4..3e4b792faec9d 100644 --- a/crates/evm/evm/src/executors/fuzz/mod.rs +++ b/crates/evm/evm/src/executors/fuzz/mod.rs @@ -359,19 +359,6 @@ impl FuzzedExecutor { { failure.calldata } else { - if let Some(progress) = progress { - progress.inc(1); - - if self.config.corpus.collect_edge_coverage() { - // TODO: Display Global Corpus Metrics - } - } else if self.config.corpus.collect_edge_coverage() { - // TODO: Display global corpus metrics since DURATION_BETWEEN_METRICS_REPORT - } - - worker.runs += 1; - shared_state.increment_runs(); - runs_since_sync += 1; if runs_since_sync >= sync_threshold { let instance = Instant::now(); @@ -397,6 +384,24 @@ impl FuzzedExecutor { match self.single_fuzz(address, input, &mut corpus) { Ok(fuzz_outcome) => match fuzz_outcome { FuzzOutcome::Case(case) => { + // Only increment runs for successful non-rejected cases + // Check if we should actually count this run + if shared_state.try_increment_runs().is_none() { + // We've exceeded the run limit, stop + break 'stop; + } + worker.runs += 1; + + if let Some(progress) = progress { + progress.inc(1); + if self.config.corpus.collect_edge_coverage() { + // TODO: Display Global Corpus Metrics + } + } else if self.config.corpus.collect_edge_coverage() { + // TODO: Display global corpus metrics since + // DURATION_BETWEEN_METRICS_REPORT + } + worker.gas_by_case.push((case.case.gas, case.case.stipend)); if worker.first_case.is_none() { @@ -424,6 +429,14 @@ impl FuzzedExecutor { counterexample: outcome, .. }) => { + // Count this as a run since we found a counterexample + // We always count counterexamples regardless of run limit + shared_state.increment_runs(); + worker.runs += 1; + + if let Some(progress) = progress { + progress.inc(1); + } let reason = rd.maybe_decode(&outcome.1.result, status); worker.logs.extend(outcome.1.logs.clone()); worker.counterexample = outcome; diff --git a/crates/evm/evm/src/executors/fuzz/types.rs b/crates/evm/evm/src/executors/fuzz/types.rs index 6695765e87434..25f8a50b798f1 100644 --- a/crates/evm/evm/src/executors/fuzz/types.rs +++ b/crates/evm/evm/src/executors/fuzz/types.rs @@ -1,7 +1,7 @@ use std::sync::{ - Arc, OnceLock, - atomic::{AtomicU32, Ordering}, - }; + Arc, OnceLock, + atomic::{AtomicU32, Ordering}, +}; use crate::executors::{FailFast, FuzzTestTimer, RawCallResult}; use alloy_primitives::{Bytes, Log, map::HashMap}; @@ -80,6 +80,22 @@ impl SharedFuzzState { } } + pub fn try_increment_runs(&self) -> Option { + // If using timer, always increment + if self.timer.is_enabled() { + return Some(self.total_runs.fetch_add(1, Ordering::Relaxed) + 1); + } + + let current = self.total_runs.load(Ordering::Relaxed); + // check run limit + if current + 1 < self.max_runs { + self.total_runs.fetch_add(1, Ordering::Relaxed); + Some(current + 1) + } else { + None + } + } + pub fn increment_runs(&self) -> u32 { self.total_runs.fetch_add(1, Ordering::Relaxed) } From e5b00bc809e88eb1c44d7db0e457365e3922d7e4 Mon Sep 17 00:00:00 2001 From: Yash Atreya <44857776+yash-atreya@users.noreply.github.com> Date: Tue, 30 Sep 2025 15:00:47 +0530 Subject: [PATCH 10/28] fix: run only 1 worker if replaying persisted failure --- crates/evm/evm/src/executors/fuzz/mod.rs | 5 +++-- crates/evm/evm/src/executors/fuzz/types.rs | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/evm/evm/src/executors/fuzz/mod.rs b/crates/evm/evm/src/executors/fuzz/mod.rs index 3e4b792faec9d..91cc15c6667e3 100644 --- a/crates/evm/evm/src/executors/fuzz/mod.rs +++ b/crates/evm/evm/src/executors/fuzz/mod.rs @@ -116,9 +116,10 @@ impl FuzzedExecutor { fail_fast.clone(), )); - // TODO: Determine the number of workers - let num_workers = rayon::current_num_threads() as u32; + // Use single worker for deterministic behavior when replaying persisted failures let persisted_failure = self.persisted_failure.take(); + let num_workers = + if persisted_failure.is_some() { 1 } else { rayon::current_num_threads() as u32 }; let workers = (0..num_workers) .into_par_iter() .map(|worker_id| { diff --git a/crates/evm/evm/src/executors/fuzz/types.rs b/crates/evm/evm/src/executors/fuzz/types.rs index 25f8a50b798f1..02249cf95901d 100644 --- a/crates/evm/evm/src/executors/fuzz/types.rs +++ b/crates/evm/evm/src/executors/fuzz/types.rs @@ -88,7 +88,7 @@ impl SharedFuzzState { let current = self.total_runs.load(Ordering::Relaxed); // check run limit - if current + 1 < self.max_runs { + if current < self.max_runs { self.total_runs.fetch_add(1, Ordering::Relaxed); Some(current + 1) } else { From 1d6363b7d460a42d353299ac9a8a3e3ebbd5011e Mon Sep 17 00:00:00 2001 From: Yash Atreya <44857776+yash-atreya@users.noreply.github.com> Date: Tue, 30 Sep 2025 15:25:46 +0530 Subject: [PATCH 11/28] fix: timed campaigns - introduce per worker timer --- crates/evm/evm/src/executors/fuzz/mod.rs | 40 ++++------------------ crates/evm/evm/src/executors/fuzz/types.rs | 4 ++- 2 files changed, 10 insertions(+), 34 deletions(-) diff --git a/crates/evm/evm/src/executors/fuzz/mod.rs b/crates/evm/evm/src/executors/fuzz/mod.rs index 91cc15c6667e3..db05c96ab750f 100644 --- a/crates/evm/evm/src/executors/fuzz/mod.rs +++ b/crates/evm/evm/src/executors/fuzz/mod.rs @@ -1,13 +1,12 @@ use crate::executors::{ - Executor, FailFast, RawCallResult, + Executor, FailFast, FuzzTestTimer, corpus::WorkerCorpus, fuzz::types::{FuzzWorker, SharedFuzzState}, }; use alloy_dyn_abi::JsonAbiExt; use alloy_json_abi::Function; -use alloy_primitives::{Address, Bytes, Log, U256, keccak256, map::HashMap}; +use alloy_primitives::{Address, Bytes, U256, keccak256, map::HashMap}; use eyre::Result; -use foundry_common::evm::Breakpoints; use foundry_config::FuzzConfig; use foundry_evm_core::{ constants::{CHEATCODE_ADDRESS, MAGIC_ASSUME}, @@ -19,7 +18,6 @@ use foundry_evm_fuzz::{ FuzzFixtures, FuzzTestResult, strategies::{EvmFuzzState, fuzz_calldata, fuzz_calldata_from_state}, }; -use foundry_evm_traces::SparsedTraceArena; use indicatif::ProgressBar; use proptest::{ strategy::Strategy, @@ -36,33 +34,6 @@ pub use types::{CaseOutcome, CounterExampleOutcome, FuzzOutcome}; /// Corpus syncs across workers every `SYNC_INTERVAL` runs. const SYNC_INTERVAL: u32 = 1000; -/// Contains data collected during fuzz test runs. -#[derive(Default)] -struct FuzzTestData { - // Stores the first fuzz case. - first_case: Option, - // Stored gas usage per fuzz case. - gas_by_case: Vec<(u64, u64)>, - // Stores the result and calldata of the last failed call, if any. - counterexample: (Bytes, RawCallResult), - // Stores up to `max_traces_to_collect` traces. - traces: Vec, - // Stores breakpoints for the last fuzz case. - breakpoints: Option, - // Stores coverage information for all fuzz cases. - coverage: Option, - // Stores logs for all fuzz cases - logs: Vec, - // Deprecated cheatcodes mapped to their replacements. - deprecated_cheatcodes: HashMap<&'static str, Option<&'static str>>, - // Runs performed in fuzz test. - runs: u32, - // Current assume rejects of the fuzz run. - rejects: u32, - // Test failure. - failure: Option, -} - /// Wrapper around an [`Executor`] which provides fuzzing support using [`proptest`]. /// /// After instantiation, calling `fuzz` will proceed to hammer the deployed smart contract with @@ -338,7 +309,7 @@ impl FuzzedExecutor { let mut seed_data = [0u8; 36]; // 32 bytes for seed + 4 bytes for worker_id seed_data[..32].copy_from_slice(&seed.to_be_bytes::<32>()); seed_data[32..36].copy_from_slice(&worker_id.to_be_bytes()); - let worker_seed = U256::from_be_bytes(keccak256(&seed_data).0); + let worker_seed = U256::from_be_bytes(keccak256(seed_data).0); // Create a new TestRunner with the derived seed trace!(target: "forge::test", ?worker_seed, "deterministic seed for worker {worker_id}"); @@ -353,7 +324,10 @@ impl FuzzedExecutor { let sync_offset = worker_id * 100; let mut runs_since_sync = 0; let sync_threshold = SYNC_INTERVAL + sync_offset; - 'stop: while shared_state.should_continue() { + // Create per-worker timer that scales down with number of workers + let worker_timeout = self.config.timeout.map(|t| t / num_workers as u32); + let worker_timer = FuzzTestTimer::new(worker_timeout); + 'stop: while shared_state.should_continue() && !worker_timer.is_timed_out() { // Only the master worker replays the persisted failure, if any. let input = if worker_id == 0 && let Some(failure) = persisted_failure.take() diff --git a/crates/evm/evm/src/executors/fuzz/types.rs b/crates/evm/evm/src/executors/fuzz/types.rs index 02249cf95901d..35eabcedc710f 100644 --- a/crates/evm/evm/src/executors/fuzz/types.rs +++ b/crates/evm/evm/src/executors/fuzz/types.rs @@ -159,7 +159,9 @@ pub struct FuzzWorker { pub gas_by_case: Vec<(u64, u64)>, /// Counterexample if this worker found one pub counterexample: (Bytes, RawCallResult), - /// Traces collected by this worker (up to limit) + /// Traces collected by this worker + /// + /// Stores upto `max_traces_to_collect` which is `config.gas_report_samples / num_workers` pub traces: Vec, /// Last breakpoints from this worker pub breakpoints: Option, From dde52ccdbdcd2ff59f0b334d579b7e88ed0b0c32 Mon Sep 17 00:00:00 2001 From: Yash Atreya <44857776+yash-atreya@users.noreply.github.com> Date: Wed, 1 Oct 2025 13:57:53 +0530 Subject: [PATCH 12/28] fix: flaky try_increment_runs --- crates/evm/evm/src/executors/fuzz/types.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/evm/evm/src/executors/fuzz/types.rs b/crates/evm/evm/src/executors/fuzz/types.rs index 35eabcedc710f..5d7401d9beff6 100644 --- a/crates/evm/evm/src/executors/fuzz/types.rs +++ b/crates/evm/evm/src/executors/fuzz/types.rs @@ -86,12 +86,14 @@ impl SharedFuzzState { return Some(self.total_runs.fetch_add(1, Ordering::Relaxed) + 1); } - let current = self.total_runs.load(Ordering::Relaxed); - // check run limit + // Simple atomic increment with check + let current = self.total_runs.fetch_add(1, Ordering::Relaxed); + if current < self.max_runs { - self.total_runs.fetch_add(1, Ordering::Relaxed); Some(current + 1) } else { + // We went over the limit, decrement back + self.total_runs.fetch_sub(1, Ordering::Relaxed); None } } From 63ca2bf13360b4907cf873c8dcf6966687bd7750 Mon Sep 17 00:00:00 2001 From: Yash Atreya <44857776+yash-atreya@users.noreply.github.com> Date: Wed, 1 Oct 2025 14:06:10 +0530 Subject: [PATCH 13/28] fix: expect_emit_test_should_fail - identify events only using cache when not in tokio --- Cargo.lock | 1 + crates/cheatcodes/Cargo.toml | 1 + crates/cheatcodes/src/test/expect.rs | 8 ++++++-- crates/evm/traces/src/identifier/signatures.rs | 7 +++++++ 4 files changed, 15 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 68928835af894..436d383bbf75e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4367,6 +4367,7 @@ dependencies = [ "serde", "serde_json", "thiserror 2.0.16", + "tokio", "toml 0.9.7", "tracing", "walkdir", diff --git a/crates/cheatcodes/Cargo.toml b/crates/cheatcodes/Cargo.toml index caafb92464ce8..f8f9e356513ed 100644 --- a/crates/cheatcodes/Cargo.toml +++ b/crates/cheatcodes/Cargo.toml @@ -60,6 +60,7 @@ revm-inspectors.workspace = true semver.workspace = true serde_json.workspace = true thiserror.workspace = true +tokio.workspace = true toml = { workspace = true, features = ["preserve_order"] } tracing.workspace = true walkdir.workspace = true diff --git a/crates/cheatcodes/src/test/expect.rs b/crates/cheatcodes/src/test/expect.rs index c98ccca56ae98..1cec671faf98c 100644 --- a/crates/cheatcodes/src/test/expect.rs +++ b/crates/cheatcodes/src/test/expect.rs @@ -1094,8 +1094,12 @@ fn decode_event( return None; } let t0 = topics[0]; // event sig - // Try to identify the event - let event = foundry_common::block_on(identifier.identify_event(t0))?; + // Try to identify the event - detect if we're in a Tokio runtime and use appropriate method + let event = if tokio::runtime::Handle::try_current().is_ok() { + foundry_common::block_on(identifier.identify_event(t0))? + } else { + identifier.identify_event_sync(t0)? + }; // Check if event already has indexed information from signatures let has_indexed_info = event.inputs.iter().any(|p| p.indexed); diff --git a/crates/evm/traces/src/identifier/signatures.rs b/crates/evm/traces/src/identifier/signatures.rs index 5c4765a851fc7..0e524f171053b 100644 --- a/crates/evm/traces/src/identifier/signatures.rs +++ b/crates/evm/traces/src/identifier/signatures.rs @@ -223,6 +223,13 @@ impl SignaturesIdentifier { self.identify_events([identifier]).await.pop().unwrap() } + /// Synchronously identifies an `Event` using only cached data. + pub fn identify_event_sync(&self, identifier: B256) -> Option { + let cache = self.0.cache.blocking_read(); + let selector = SelectorKind::Event(identifier); + cache.get(&selector).unwrap_or_default().and_then(|sig| get_event(&sig).ok()) + } + /// Identifies `Error`s. pub async fn identify_errors( &self, From 349b5eebd7a3a6a3279312c9efab568dfdc4fdfc Mon Sep 17 00:00:00 2001 From: Yash Atreya <44857776+yash-atreya@users.noreply.github.com> Date: Wed, 1 Oct 2025 14:44:39 +0530 Subject: [PATCH 14/28] fix: timer should be global to fit as many runs as possible --- crates/evm/evm/src/executors/fuzz/mod.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/crates/evm/evm/src/executors/fuzz/mod.rs b/crates/evm/evm/src/executors/fuzz/mod.rs index db05c96ab750f..a55c5bc7fb651 100644 --- a/crates/evm/evm/src/executors/fuzz/mod.rs +++ b/crates/evm/evm/src/executors/fuzz/mod.rs @@ -324,10 +324,7 @@ impl FuzzedExecutor { let sync_offset = worker_id * 100; let mut runs_since_sync = 0; let sync_threshold = SYNC_INTERVAL + sync_offset; - // Create per-worker timer that scales down with number of workers - let worker_timeout = self.config.timeout.map(|t| t / num_workers as u32); - let worker_timer = FuzzTestTimer::new(worker_timeout); - 'stop: while shared_state.should_continue() && !worker_timer.is_timed_out() { + 'stop: while shared_state.should_continue() { // Only the master worker replays the persisted failure, if any. let input = if worker_id == 0 && let Some(failure) = persisted_failure.take() From 33a294c875a6ed43ff2c308cd87b47c1cb5672a2 Mon Sep 17 00:00:00 2001 From: Yash Atreya <44857776+yash-atreya@users.noreply.github.com> Date: Wed, 1 Oct 2025 16:17:56 +0530 Subject: [PATCH 15/28] fix: worker_runs = config.runs/ num_workers --- crates/evm/evm/src/executors/fuzz/mod.rs | 49 +++++++++++++++------- crates/evm/evm/src/executors/fuzz/types.rs | 2 +- 2 files changed, 35 insertions(+), 16 deletions(-) diff --git a/crates/evm/evm/src/executors/fuzz/mod.rs b/crates/evm/evm/src/executors/fuzz/mod.rs index a55c5bc7fb651..14b3540731647 100644 --- a/crates/evm/evm/src/executors/fuzz/mod.rs +++ b/crates/evm/evm/src/executors/fuzz/mod.rs @@ -1,5 +1,5 @@ use crate::executors::{ - Executor, FailFast, FuzzTestTimer, + Executor, FailFast, corpus::WorkerCorpus, fuzz::types::{FuzzWorker, SharedFuzzState}, }; @@ -301,22 +301,38 @@ impl FuzzedExecutor { let max_traces_to_collect = std::cmp::max(1, self.config.gas_report_samples as usize / num_workers) as usize; - // For deterministic parallel fuzzing, derive a unique seed for each worker - let mut runner = if worker_id == 0 { - self.runner.clone() - } else if let Some(seed) = self.config.seed { - // Derive a worker-specific seed using keccak256(seed || worker_id) - let mut seed_data = [0u8; 36]; // 32 bytes for seed + 4 bytes for worker_id - seed_data[..32].copy_from_slice(&seed.to_be_bytes::<32>()); - seed_data[32..36].copy_from_slice(&worker_id.to_be_bytes()); - let worker_seed = U256::from_be_bytes(keccak256(seed_data).0); - - // Create a new TestRunner with the derived seed + // Calculate worker-specific run limit when not using timer + let worker_runs = if self.config.timeout.is_some() { + // When using timer, workers run as many as possible + u32::MAX + } else { + // Distribute runs evenly across workers, with worker 0 handling any remainder + let base_runs = self.config.runs / num_workers as u32; + let remainder = self.config.runs % num_workers as u32; + if worker_id == 0 { base_runs + remainder } else { base_runs } + }; + + let mut runner_config = self.runner.config().clone(); + // Set the runner cases to worker_runs + runner_config.cases = worker_runs; + + let mut runner = if let Some(seed) = self.config.seed { + // For deterministic parallel fuzzing, derive a unique seed for each worker + let worker_seed = if worker_id == 0 { + // Master worker uses the provided seed as is. + seed + } else { + // Derive a worker-specific seed using keccak256(seed || worker_id) + let mut seed_data = [0u8; 36]; // 32 bytes for seed + 4 bytes for worker_id + seed_data[..32].copy_from_slice(&seed.to_be_bytes::<32>()); + seed_data[32..36].copy_from_slice(&worker_id.to_be_bytes()); + U256::from_be_bytes(keccak256(seed_data).0) + }; trace!(target: "forge::test", ?worker_seed, "deterministic seed for worker {worker_id}"); let rng = TestRng::from_seed(RngAlgorithm::ChaCha, &worker_seed.to_be_bytes::<32>()); - TestRunner::new_with_rng(self.runner.config().clone(), rng) + TestRunner::new_with_rng(runner_config, rng) } else { - self.runner.clone() + TestRunner::new(runner_config) }; // Offset to stagger corpus syncs across workers; so that workers don't sync at the same @@ -324,7 +340,10 @@ impl FuzzedExecutor { let sync_offset = worker_id * 100; let mut runs_since_sync = 0; let sync_threshold = SYNC_INTERVAL + sync_offset; - 'stop: while shared_state.should_continue() { + // Continue while: + // 1. Global state allows (not timed out, not at global limit, no failure found) + // 2. Worker hasn't reached its specific run limit + 'stop: while shared_state.should_continue() && worker.runs < worker_runs { // Only the master worker replays the persisted failure, if any. let input = if worker_id == 0 && let Some(failure) = persisted_failure.take() diff --git a/crates/evm/evm/src/executors/fuzz/types.rs b/crates/evm/evm/src/executors/fuzz/types.rs index 5d7401d9beff6..4e5a9372a429e 100644 --- a/crates/evm/evm/src/executors/fuzz/types.rs +++ b/crates/evm/evm/src/executors/fuzz/types.rs @@ -88,7 +88,7 @@ impl SharedFuzzState { // Simple atomic increment with check let current = self.total_runs.fetch_add(1, Ordering::Relaxed); - + if current < self.max_runs { Some(current + 1) } else { From e8fde6f5ff3b4b1d1244b5a21d923e464b88dacc Mon Sep 17 00:00:00 2001 From: Yash Atreya <44857776+yash-atreya@users.noreply.github.com> Date: Wed, 1 Oct 2025 17:22:47 +0530 Subject: [PATCH 16/28] fix: should_not_shrink_fuzz_failure --- crates/forge/tests/cli/test_cmd.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/forge/tests/cli/test_cmd.rs b/crates/forge/tests/cli/test_cmd.rs index eb85ad5098801..dd9e33bb2e069 100644 --- a/crates/forge/tests/cli/test_cmd.rs +++ b/crates/forge/tests/cli/test_cmd.rs @@ -749,14 +749,14 @@ contract CounterTest is Test { Compiler run successful! Ran 1 test for test/CounterFuzz.t.sol:CounterTest -[FAIL: panic: arithmetic underflow or overflow (0x11); counterexample: calldata=0xa76d58f5fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe args=[115792089237316195423570985008687907853269984665640564039457584007913129639934 [1.157e77]]] testAddOne(uint256) (runs: 27, [AVG_GAS]) +[FAIL: panic: arithmetic underflow or overflow (0x11); counterexample: calldata=0xa76d58f5fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe args=[115792089237316195423570985008687907853269984665640564039457584007913129639934 [1.157e77]]] testAddOne(uint256) (runs: [RUNS], [AVG_GAS]) Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] Ran 1 test suite [ELAPSED]: 0 tests passed, 1 failed, 0 skipped (1 total tests) Failing tests: Encountered 1 failing test in test/CounterFuzz.t.sol:CounterTest -[FAIL: panic: arithmetic underflow or overflow (0x11); counterexample: calldata=0xa76d58f5fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe args=[115792089237316195423570985008687907853269984665640564039457584007913129639934 [1.157e77]]] testAddOne(uint256) (runs: 27, [AVG_GAS]) +[FAIL: panic: arithmetic underflow or overflow (0x11); counterexample: calldata=0xa76d58f5fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe args=[115792089237316195423570985008687907853269984665640564039457584007913129639934 [1.157e77]]] testAddOne(uint256) (runs: [RUNS], [AVG_GAS]) Encountered a total of 1 failing tests, 0 tests succeeded From e991826970dee7c561fc64fed8bc30571ba8febd Mon Sep 17 00:00:00 2001 From: Yash Atreya <44857776+yash-atreya@users.noreply.github.com> Date: Wed, 1 Oct 2025 17:49:49 +0530 Subject: [PATCH 17/28] feat: propagate --jobs / threads to FuzzConfig to determine number of workers --- crates/config/src/fuzz.rs | 5 +++++ crates/evm/evm/src/executors/corpus.rs | 4 ++-- crates/evm/evm/src/executors/fuzz/mod.rs | 28 +++++++++++++++--------- crates/forge/src/cmd/test/mod.rs | 3 +++ 4 files changed, 28 insertions(+), 12 deletions(-) diff --git a/crates/config/src/fuzz.rs b/crates/config/src/fuzz.rs index ccb8cf45b632b..28323ac44ba54 100644 --- a/crates/config/src/fuzz.rs +++ b/crates/config/src/fuzz.rs @@ -34,6 +34,10 @@ pub struct FuzzConfig { pub show_logs: bool, /// Optional timeout (in seconds) for each property test pub timeout: Option, + /// Number of threads to use for parallel fuzz runs + /// + /// This is set by passing `-j` or `--jobs` + pub threads: Option, } impl Default for FuzzConfig { @@ -49,6 +53,7 @@ impl Default for FuzzConfig { failure_persist_dir: None, show_logs: false, timeout: None, + threads: None, } } } diff --git a/crates/evm/evm/src/executors/corpus.rs b/crates/evm/evm/src/executors/corpus.rs index 445714c557af5..42851e355980c 100644 --- a/crates/evm/evm/src/executors/corpus.rs +++ b/crates/evm/evm/src/executors/corpus.rs @@ -890,7 +890,7 @@ impl WorkerCorpus { /// To be run by the master worker (id = 0) to distribute the global corpus to sync/ directories /// of other workers. - fn distribute(&mut self, num_workers: usize) -> eyre::Result<()> { + fn distribute(&mut self, num_workers: u32) -> eyre::Result<()> { if self.id != 0 || self.worker_dir.is_none() { return Ok(()); } @@ -945,7 +945,7 @@ impl WorkerCorpus { /// Syncs the workers in_memory_corpus and history_map with the findings from other workers. pub fn sync( &mut self, - num_workers: usize, + num_workers: u32, executor: &Executor, fuzzed_function: Option<&Function>, fuzzed_contracts: Option<&FuzzRunIdentifiedContracts>, diff --git a/crates/evm/evm/src/executors/fuzz/mod.rs b/crates/evm/evm/src/executors/fuzz/mod.rs index 14b3540731647..c0e3b3865168c 100644 --- a/crates/evm/evm/src/executors/fuzz/mod.rs +++ b/crates/evm/evm/src/executors/fuzz/mod.rs @@ -89,14 +89,12 @@ impl FuzzedExecutor { // Use single worker for deterministic behavior when replaying persisted failures let persisted_failure = self.persisted_failure.take(); - let num_workers = - if persisted_failure.is_some() { 1 } else { rayon::current_num_threads() as u32 }; - let workers = (0..num_workers) + let num_workers = self.num_workers(); + let workers = (0..num_workers as u32) .into_par_iter() .map(|worker_id| { self.run_worker( worker_id, - num_workers as usize, func, fuzz_fixtures, deployed_libs, @@ -265,7 +263,6 @@ impl FuzzedExecutor { fn run_worker( &self, worker_id: u32, - num_workers: usize, func: &Function, fuzz_fixtures: &FuzzFixtures, deployed_libs: &[Address], @@ -298,8 +295,8 @@ impl FuzzedExecutor { )?; let mut worker = FuzzWorker::new(worker_id); - let max_traces_to_collect = - std::cmp::max(1, self.config.gas_report_samples as usize / num_workers) as usize; + let num_workers = self.num_workers(); + let max_traces_to_collect = std::cmp::max(1, self.config.gas_report_samples / num_workers); // Calculate worker-specific run limit when not using timer let worker_runs = if self.config.timeout.is_some() { @@ -307,8 +304,8 @@ impl FuzzedExecutor { u32::MAX } else { // Distribute runs evenly across workers, with worker 0 handling any remainder - let base_runs = self.config.runs / num_workers as u32; - let remainder = self.config.runs % num_workers as u32; + let base_runs = self.config.runs / num_workers; + let remainder = self.config.runs % num_workers; if worker_id == 0 { base_runs + remainder } else { base_runs } }; @@ -401,7 +398,7 @@ impl FuzzedExecutor { } if let Some(call_traces) = case.traces { - if worker.traces.len() == max_traces_to_collect { + if worker.traces.len() == max_traces_to_collect as usize { worker.traces.pop(); } worker.traces.push(call_traces); @@ -475,4 +472,15 @@ impl FuzzedExecutor { ) } } + + /// Determines the number of workers to run + fn num_workers(&self) -> u32 { + if self.persisted_failure.is_some() { + 1 + } else if let Some(threads) = self.config.threads { + threads as u32 + } else { + rayon::current_num_threads() as u32 + } + } } diff --git a/crates/forge/src/cmd/test/mod.rs b/crates/forge/src/cmd/test/mod.rs index 24a7e35b72886..7926bd8dfccaa 100644 --- a/crates/forge/src/cmd/test/mod.rs +++ b/crates/forge/src/cmd/test/mod.rs @@ -258,6 +258,9 @@ impl TestArgs { config.invariant.gas_report_samples = 0; } + // Set the number of threads in fuzz config. + config.fuzz.threads = config.threads; + // Install missing dependencies. if install::install_missing_dependencies(&mut config) && config.auto_detect_remappings { // need to re-configure here to also catch additional remappings From 47bd12d2ee916a084535fca34c2d9398a8cd1763 Mon Sep 17 00:00:00 2001 From: Yash Atreya <44857776+yash-atreya@users.noreply.github.com> Date: Wed, 1 Oct 2025 19:00:41 +0530 Subject: [PATCH 18/28] optimize aggregate results --- crates/evm/evm/src/executors/fuzz/mod.rs | 94 ++++++++++++++---------- 1 file changed, 56 insertions(+), 38 deletions(-) diff --git a/crates/evm/evm/src/executors/fuzz/mod.rs b/crates/evm/evm/src/executors/fuzz/mod.rs index c0e3b3865168c..05f9a8702cd55 100644 --- a/crates/evm/evm/src/executors/fuzz/mod.rs +++ b/crates/evm/evm/src/executors/fuzz/mod.rs @@ -5,7 +5,7 @@ use crate::executors::{ }; use alloy_dyn_abi::JsonAbiExt; use alloy_json_abi::Function; -use alloy_primitives::{Address, Bytes, U256, keccak256, map::HashMap}; +use alloy_primitives::{Address, Bytes, U256, keccak256}; use eyre::Result; use foundry_config::FuzzConfig; use foundry_evm_core::{ @@ -90,7 +90,7 @@ impl FuzzedExecutor { // Use single worker for deterministic behavior when replaying persisted failures let persisted_failure = self.persisted_failure.take(); let num_workers = self.num_workers(); - let workers = (0..num_workers as u32) + let workers = (0..num_workers) .into_par_iter() .map(|worker_id| { self.run_worker( @@ -173,52 +173,24 @@ impl FuzzedExecutor { } } + /// Aggregates the results from all workers fn aggregate_results( &self, - workers: Vec, + mut workers: Vec, func: &Function, shared_state: Arc, ) -> FuzzTestResult { let mut result = FuzzTestResult::default(); - // Find the worker with the lowest global run in FuzzWorker.first_case.0 - let first_case = workers - .iter() - // Get the worker_id, global_run number, and FuzzCase - .filter_map(|w| w.first_case.as_ref().map(|(run, case)| (w.worker_id, *run, case))) - .min_by_key(|(_, run, _)| *run); - - result.first_case = first_case.map(|(_, _, fc)| fc.clone()).unwrap_or_default(); - // TODO: Fix order of cases when aggregating - result.gas_by_case = workers.iter().flat_map(|w| w.gas_by_case.clone()).collect(); - - result.logs = workers.iter().flat_map(|w| w.logs.clone()).collect(); - result.gas_report_traces = - workers.iter().flat_map(|w| w.traces.iter().map(|a| a.arena.clone())).collect(); - result.line_coverage = workers.iter().fold(None, |mut acc, w| { - HitMaps::merge_opt(&mut acc, w.coverage.clone()); - acc + // Extract failed worker first if it exists + let failed_worker = shared_state.failed_worked_id().and_then(|id| { + workers.iter().position(|w| w.worker_id == id).map(|idx| workers.swap_remove(idx)) }); - result.deprecated_cheatcodes = workers.iter().fold(HashMap::default(), |mut acc, w| { - acc.extend(w.deprecated_cheatcodes.clone()); - acc - }); - - let failed_worked_id = shared_state.failed_worked_id(); - result.success = failed_worked_id.is_none(); - if failed_worked_id.is_none() - && let Some(last_run) = workers.iter().max_by_key(|w| w.last_run_timestamp) - { - result.traces = last_run.traces.last().cloned(); - result.breakpoints = last_run.breakpoints.clone(); - } - - let failed_worker = - failed_worked_id.and_then(|id| workers.into_iter().find(|w| w.worker_id == id)); + // Process failure first if exists if let Some(failed_worker) = failed_worker { + result.success = false; let (calldata, call) = failed_worker.counterexample; - result.labels = call.labels; result.traces = call.traces.clone(); result.breakpoints = call.cheatcodes.map(|c| c.breakpoints); @@ -242,8 +214,54 @@ impl FuzzedExecutor { } None => {} } - }; + } else { + result.success = true; + } + + // Single pass aggregation for remaining workers + let mut first_case_candidate: Option<(u32, FuzzCase)> = None; + let mut last_run_worker: Option<&FuzzWorker> = None; + let mut last_run_timestamp = 0u128; + + for worker in &workers { + // Track first case (compare without cloning) + if let Some((run, case)) = &worker.first_case + && first_case_candidate.as_ref().is_none_or(|(r, _)| run < r) + { + first_case_candidate = Some((*run, case.clone())); + } + + // Track last run worker (keep reference, no clone) + if worker.last_run_timestamp > last_run_timestamp { + last_run_timestamp = worker.last_run_timestamp; + last_run_worker = Some(worker); + } + } + + // Set first case + result.first_case = first_case_candidate.map(|(_, case)| case).unwrap_or_default(); + + // If no failure, set traces and breakpoints from last run + if result.success + && let Some(last_worker) = last_run_worker + { + result.traces = last_worker.traces.last().cloned(); + result.breakpoints = last_worker.breakpoints.clone(); + } + + // Now consume workers vector for owned data + for mut worker in workers { + result.gas_by_case.append(&mut worker.gas_by_case); + result.logs.append(&mut worker.logs); + result.gas_report_traces.extend(worker.traces.into_iter().map(|t| t.arena)); + + // Merge coverage + HitMaps::merge_opt(&mut result.line_coverage, worker.coverage); + + result.deprecated_cheatcodes.extend(worker.deprecated_cheatcodes); + } + // Check for skip reason if let Some(reason) = &result.reason && let Some(reason) = SkipReason::decode_self(reason) { From e77fe5bdd4c67320331d984f8b435c6119cc39c4 Mon Sep 17 00:00:00 2001 From: Yash Atreya <44857776+yash-atreya@users.noreply.github.com> Date: Wed, 1 Oct 2025 19:00:50 +0530 Subject: [PATCH 19/28] nit --- crates/evm/evm/src/executors/fuzz/mod.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/evm/evm/src/executors/fuzz/mod.rs b/crates/evm/evm/src/executors/fuzz/mod.rs index 05f9a8702cd55..e1febbfaebdd4 100644 --- a/crates/evm/evm/src/executors/fuzz/mod.rs +++ b/crates/evm/evm/src/executors/fuzz/mod.rs @@ -249,7 +249,6 @@ impl FuzzedExecutor { result.breakpoints = last_worker.breakpoints.clone(); } - // Now consume workers vector for owned data for mut worker in workers { result.gas_by_case.append(&mut worker.gas_by_case); result.logs.append(&mut worker.logs); From 0cca8e16fcb7937948827c858669c19b1fbeea78 Mon Sep 17 00:00:00 2001 From: Yash Atreya <44857776+yash-atreya@users.noreply.github.com> Date: Wed, 1 Oct 2025 19:12:46 +0530 Subject: [PATCH 20/28] fix --- crates/forge/tests/it/test_helpers.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/forge/tests/it/test_helpers.rs b/crates/forge/tests/it/test_helpers.rs index 1fc54b415caed..9c522f7be3dda 100644 --- a/crates/forge/tests/it/test_helpers.rs +++ b/crates/forge/tests/it/test_helpers.rs @@ -130,6 +130,7 @@ impl ForgeTestProfile { failure_persist_dir: Some(tempfile::tempdir().unwrap().keep()), show_logs: false, timeout: None, + threads: None, }; config.invariant = InvariantConfig { runs: 256, From aed7ee0fd0dd23bcd5f1ebc56ddc0112b3306c86 Mon Sep 17 00:00:00 2001 From: Yash Atreya <44857776+yash-atreya@users.noreply.github.com> Date: Thu, 2 Oct 2025 12:56:54 +0530 Subject: [PATCH 21/28] skip fuzzConfig.threads serialization + use placeholder for should_not_shrink_fuzz_failure --- crates/config/src/fuzz.rs | 1 + crates/forge/tests/cli/test_cmd.rs | 4 ++-- crates/test-utils/src/util.rs | 1 + 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/config/src/fuzz.rs b/crates/config/src/fuzz.rs index 28323ac44ba54..1571185821f8b 100644 --- a/crates/config/src/fuzz.rs +++ b/crates/config/src/fuzz.rs @@ -37,6 +37,7 @@ pub struct FuzzConfig { /// Number of threads to use for parallel fuzz runs /// /// This is set by passing `-j` or `--jobs` + #[serde(skip)] pub threads: Option, } diff --git a/crates/forge/tests/cli/test_cmd.rs b/crates/forge/tests/cli/test_cmd.rs index dd9e33bb2e069..998939d99a2b3 100644 --- a/crates/forge/tests/cli/test_cmd.rs +++ b/crates/forge/tests/cli/test_cmd.rs @@ -749,14 +749,14 @@ contract CounterTest is Test { Compiler run successful! Ran 1 test for test/CounterFuzz.t.sol:CounterTest -[FAIL: panic: arithmetic underflow or overflow (0x11); counterexample: calldata=0xa76d58f5fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe args=[115792089237316195423570985008687907853269984665640564039457584007913129639934 [1.157e77]]] testAddOne(uint256) (runs: [RUNS], [AVG_GAS]) +[FAIL: panic: arithmetic underflow or overflow (0x11); counterexample: calldata=[..] args=[..]] testAddOne(uint256) ([RUNS], [AVG_GAS]) Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] Ran 1 test suite [ELAPSED]: 0 tests passed, 1 failed, 0 skipped (1 total tests) Failing tests: Encountered 1 failing test in test/CounterFuzz.t.sol:CounterTest -[FAIL: panic: arithmetic underflow or overflow (0x11); counterexample: calldata=0xa76d58f5fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe args=[115792089237316195423570985008687907853269984665640564039457584007913129639934 [1.157e77]]] testAddOne(uint256) (runs: [RUNS], [AVG_GAS]) +[FAIL: panic: arithmetic underflow or overflow (0x11); counterexample: calldata=[..] args=[..]] testAddOne(uint256) ([RUNS], [AVG_GAS]) Encountered a total of 1 failing tests, 0 tests succeeded diff --git a/crates/test-utils/src/util.rs b/crates/test-utils/src/util.rs index e1e53ba59218f..4f079516b5648 100644 --- a/crates/test-utils/src/util.rs +++ b/crates/test-utils/src/util.rs @@ -1054,6 +1054,7 @@ fn test_redactions() -> snapbox::Redactions { ("[GAS_COST]", r"[Gg]as cost\s*\(\d+\)"), ("[GAS_LIMIT]", r"[Gg]as limit\s*\(\d+\)"), ("[AVG_GAS]", r"μ: \d+, ~: \d+"), + ("[RUNS]", r"runs: \d+"), ("[FILE]", r"-->.*\.sol"), ("[FILE]", r"Location(.|\n)*\.rs(.|\n)*Backtrace"), ("[COMPILING_FILES]", r"Compiling \d+ files?"), From 7df4e0549d9e430734b5d0726509e44c2cc73f46 Mon Sep 17 00:00:00 2001 From: Yash Atreya <44857776+yash-atreya@users.noreply.github.com> Date: Thu, 2 Oct 2025 13:25:39 +0530 Subject: [PATCH 22/28] fix tests --- crates/evm/evm/src/executors/fuzz/mod.rs | 8 +++++++- crates/forge/tests/cli/test_cmd.rs | 4 ++-- crates/forge/tests/it/fuzz.rs | 9 ++++++--- crates/test-utils/src/util.rs | 1 - 4 files changed, 15 insertions(+), 7 deletions(-) diff --git a/crates/evm/evm/src/executors/fuzz/mod.rs b/crates/evm/evm/src/executors/fuzz/mod.rs index e1febbfaebdd4..862358cd71d69 100644 --- a/crates/evm/evm/src/executors/fuzz/mod.rs +++ b/crates/evm/evm/src/executors/fuzz/mod.rs @@ -87,9 +87,10 @@ impl FuzzedExecutor { fail_fast.clone(), )); + // Determine number of workers + let num_workers = self.num_workers(); // Use single worker for deterministic behavior when replaying persisted failures let persisted_failure = self.persisted_failure.take(); - let num_workers = self.num_workers(); let workers = (0..num_workers) .into_par_iter() .map(|worker_id| { @@ -491,6 +492,11 @@ impl FuzzedExecutor { } /// Determines the number of workers to run + /// + /// Depends on: + /// - Whether we're replaying a persisted failure + /// - `--jobs` specified by the user + /// - Available threads fn num_workers(&self) -> u32 { if self.persisted_failure.is_some() { 1 diff --git a/crates/forge/tests/cli/test_cmd.rs b/crates/forge/tests/cli/test_cmd.rs index 998939d99a2b3..a49ad7bdcafe3 100644 --- a/crates/forge/tests/cli/test_cmd.rs +++ b/crates/forge/tests/cli/test_cmd.rs @@ -749,14 +749,14 @@ contract CounterTest is Test { Compiler run successful! Ran 1 test for test/CounterFuzz.t.sol:CounterTest -[FAIL: panic: arithmetic underflow or overflow (0x11); counterexample: calldata=[..] args=[..]] testAddOne(uint256) ([RUNS], [AVG_GAS]) +[FAIL: panic: arithmetic underflow or overflow (0x11); counterexample: calldata=[..] args=[..]] testAddOne(uint256) (runs: [..], [AVG_GAS]) Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] Ran 1 test suite [ELAPSED]: 0 tests passed, 1 failed, 0 skipped (1 total tests) Failing tests: Encountered 1 failing test in test/CounterFuzz.t.sol:CounterTest -[FAIL: panic: arithmetic underflow or overflow (0x11); counterexample: calldata=[..] args=[..]] testAddOne(uint256) ([RUNS], [AVG_GAS]) +[FAIL: panic: arithmetic underflow or overflow (0x11); counterexample: calldata=[..] args=[..]] testAddOne(uint256) (runs: [..], [AVG_GAS]) Encountered a total of 1 failing tests, 0 tests succeeded diff --git a/crates/forge/tests/it/fuzz.rs b/crates/forge/tests/it/fuzz.rs index f4e251fd52a44..2376b91ea32b1 100644 --- a/crates/forge/tests/it/fuzz.rs +++ b/crates/forge/tests/it/fuzz.rs @@ -208,10 +208,12 @@ contract FuzzerDictTest is Test { ); // Test that immutable address is used as fuzzed input, causing test to fail. - cmd.args(["test", "--fuzz-seed", "119", "--mt", "testImmutableOwner"]).assert_failure(); + // Use --jobs 1 to force single worker for deterministic behavior with seed + cmd.args(["test", "--fuzz-seed", "119", "--mt", "testImmutableOwner", "--jobs", "1"]) + .assert_failure(); // Test that storage address is used as fuzzed input, causing test to fail. cmd.forge_fuse() - .args(["test", "--fuzz-seed", "119", "--mt", "testStorageOwner"]) + .args(["test", "--fuzz-seed", "119", "--mt", "testStorageOwner", "--jobs", "1"]) .assert_failure(); }); @@ -260,7 +262,8 @@ contract FuzzTimeoutTest is Test { "#, ); - cmd.args(["test"]).assert_success().stdout_eq(str![[r#" + // Use single worker for deterministic timeout behavior + cmd.args(["test", "--jobs", "1"]).assert_success().stdout_eq(str![[r#" [COMPILING_FILES] with [SOLC_VERSION] [SOLC_VERSION] [ELAPSED] Compiler run successful! diff --git a/crates/test-utils/src/util.rs b/crates/test-utils/src/util.rs index 4f079516b5648..e1e53ba59218f 100644 --- a/crates/test-utils/src/util.rs +++ b/crates/test-utils/src/util.rs @@ -1054,7 +1054,6 @@ fn test_redactions() -> snapbox::Redactions { ("[GAS_COST]", r"[Gg]as cost\s*\(\d+\)"), ("[GAS_LIMIT]", r"[Gg]as limit\s*\(\d+\)"), ("[AVG_GAS]", r"μ: \d+, ~: \d+"), - ("[RUNS]", r"runs: \d+"), ("[FILE]", r"-->.*\.sol"), ("[FILE]", r"Location(.|\n)*\.rs(.|\n)*Backtrace"), ("[COMPILING_FILES]", r"Compiling \d+ files?"), From d973b79ceb6c8ddcf8fddc3e461c041b32a691b6 Mon Sep 17 00:00:00 2001 From: Yash Atreya <44857776+yash-atreya@users.noreply.github.com> Date: Thu, 2 Oct 2025 16:02:09 +0530 Subject: [PATCH 23/28] feat: sync corpus metrics --- crates/evm/evm/src/executors/corpus.rs | 97 +++++++++++++++++++++- crates/evm/evm/src/executors/fuzz/mod.rs | 37 +++++++-- crates/evm/evm/src/executors/fuzz/types.rs | 5 +- 3 files changed, 129 insertions(+), 10 deletions(-) diff --git a/crates/evm/evm/src/executors/corpus.rs b/crates/evm/evm/src/executors/corpus.rs index 42851e355980c..4274ebce72291 100644 --- a/crates/evm/evm/src/executors/corpus.rs +++ b/crates/evm/evm/src/executors/corpus.rs @@ -19,7 +19,10 @@ use serde::Serialize; use std::{ fmt, path::PathBuf, - sync::Arc, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, time::{SystemTime, UNIX_EPOCH}, }; use uuid::Uuid; @@ -101,6 +104,37 @@ impl CorpusEntry { } #[derive(Serialize, Default)] +pub(crate) struct GlobalCorpusMetrics { + // Number of edges seen during the invariant run + cumulative_edges_seen: Arc, + // Number of features (new hitcount bin of previously hit edge) seen during the invariant run + cumulative_features_seen: Arc, + // Number of corpus entries + corpus_count: Arc, + // Number of corpus entries that are favored + favored_items: Arc, +} + +impl fmt::Display for GlobalCorpusMetrics { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + writeln!(f)?; + writeln!( + f, + " - cumulative edges seen: {}", + self.cumulative_edges_seen.load(Ordering::Relaxed) + )?; + writeln!( + f, + " - cumulative features seen: {}", + self.cumulative_features_seen.load(Ordering::Relaxed) + )?; + writeln!(f, " - corpus count: {}", self.corpus_count.load(Ordering::Relaxed))?; + write!(f, " - favored items: {}", self.favored_items.load(Ordering::Relaxed))?; + Ok(()) + } +} + +#[derive(Serialize, Default, Clone)] pub(crate) struct CorpusMetrics { // Number of edges seen during the invariant run. cumulative_edges_seen: usize, @@ -171,6 +205,8 @@ pub struct WorkerCorpus { /// Worker Dir /// corpus_dir/worker1/ worker_dir: Option, + /// Metrics at last sync - used to calculate deltas while syncing with global metrics + last_sync_metrics: CorpusMetrics, } impl WorkerCorpus { @@ -317,6 +353,7 @@ impl WorkerCorpus { new_entry_indices: Default::default(), last_sync_timestamp: 0, worker_dir, + last_sync_metrics: Default::default(), }) } @@ -942,14 +979,70 @@ impl WorkerCorpus { Ok(()) } + /// Syncs local metrics with global corpus metrics by calculating and applying deltas + pub(crate) fn sync_metrics(&mut self, global_corpus_metrics: &GlobalCorpusMetrics) { + // Calculate delta metrics since last sync + let edges_delta = self + .metrics + .cumulative_edges_seen + .saturating_sub(self.last_sync_metrics.cumulative_edges_seen); + let features_delta = self + .metrics + .cumulative_features_seen + .saturating_sub(self.last_sync_metrics.cumulative_features_seen); + // For corpus count and favored items, calculate deltas + let corpus_count_delta = + self.metrics.corpus_count as isize - self.last_sync_metrics.corpus_count as isize; + let favored_delta = + self.metrics.favored_items as isize - self.last_sync_metrics.favored_items as isize; + + // Add delta values to global metrics + + if edges_delta > 0 { + global_corpus_metrics.cumulative_edges_seen.fetch_add(edges_delta, Ordering::Relaxed); + } + if features_delta > 0 { + global_corpus_metrics + .cumulative_features_seen + .fetch_add(features_delta, Ordering::Relaxed); + } + + if corpus_count_delta > 0 { + global_corpus_metrics + .corpus_count + .fetch_add(corpus_count_delta as usize, Ordering::Relaxed); + } else if corpus_count_delta < 0 { + global_corpus_metrics + .corpus_count + .fetch_sub((-corpus_count_delta) as usize, Ordering::Relaxed); + } + + if favored_delta > 0 { + global_corpus_metrics + .favored_items + .fetch_add(favored_delta as usize, Ordering::Relaxed); + } else if favored_delta < 0 { + global_corpus_metrics + .favored_items + .fetch_sub((-favored_delta) as usize, Ordering::Relaxed); + } + + // Store current metrics as last sync metrics for next delta calculation + self.last_sync_metrics = self.metrics.clone(); + } + /// Syncs the workers in_memory_corpus and history_map with the findings from other workers. - pub fn sync( + pub(crate) fn sync( &mut self, num_workers: u32, executor: &Executor, fuzzed_function: Option<&Function>, fuzzed_contracts: Option<&FuzzRunIdentifiedContracts>, + global_corpus_metrics: &GlobalCorpusMetrics, ) -> eyre::Result<()> { + // Sync metrics with global corpus metrics + self.sync_metrics(global_corpus_metrics); + if self.id == 0 { // Master worker self.calibrate(executor, fuzzed_function, fuzzed_contracts)?; diff --git a/crates/evm/evm/src/executors/fuzz/mod.rs b/crates/evm/evm/src/executors/fuzz/mod.rs index 862358cd71d69..924e161da7a2e 100644 --- a/crates/evm/evm/src/executors/fuzz/mod.rs +++ b/crates/evm/evm/src/executors/fuzz/mod.rs @@ -1,5 +1,5 @@ use crate::executors::{ - Executor, FailFast, + DURATION_BETWEEN_METRICS_REPORT, Executor, FailFast, corpus::WorkerCorpus, fuzz::types::{FuzzWorker, SharedFuzzState}, }; @@ -7,6 +7,7 @@ use alloy_dyn_abi::JsonAbiExt; use alloy_json_abi::Function; use alloy_primitives::{Address, Bytes, U256, keccak256}; use eyre::Result; +use foundry_common::sh_println; use foundry_config::FuzzConfig; use foundry_evm_core::{ constants::{CHEATCODE_ADDRESS, MAGIC_ASSUME}, @@ -24,6 +25,7 @@ use proptest::{ test_runner::{RngAlgorithm, TestCaseError, TestRng, TestRunner}, }; use rayon::iter::{IntoParallelIterator, ParallelIterator}; +use serde_json::json; use std::{ sync::Arc, time::{Instant, SystemTime, UNIX_EPOCH}, @@ -355,6 +357,7 @@ impl FuzzedExecutor { let sync_offset = worker_id * 100; let mut runs_since_sync = 0; let sync_threshold = SYNC_INTERVAL + sync_offset; + let mut last_metrics_report = Instant::now(); // Continue while: // 1. Global state allows (not timed out, not at global limit, no failure found) // 2. Worker hasn't reached its specific run limit @@ -368,7 +371,13 @@ impl FuzzedExecutor { runs_since_sync += 1; if runs_since_sync >= sync_threshold { let instance = Instant::now(); - corpus.sync(num_workers, &self.executor, Some(func), None)?; + corpus.sync( + num_workers, + &self.executor, + Some(func), + None, + &shared_state.global_corpus_metrics, + )?; trace!("Worker {worker_id} finished corpus sync in {:?}", instance.elapsed()); runs_since_sync = 0; } @@ -400,12 +409,26 @@ impl FuzzedExecutor { if let Some(progress) = progress { progress.inc(1); - if self.config.corpus.collect_edge_coverage() { - // TODO: Display Global Corpus Metrics + if self.config.corpus.collect_edge_coverage() && worker_id == 0 { + corpus.sync_metrics(&shared_state.global_corpus_metrics); + progress + .set_message(format!("{}", shared_state.global_corpus_metrics)); } - } else if self.config.corpus.collect_edge_coverage() { - // TODO: Display global corpus metrics since - // DURATION_BETWEEN_METRICS_REPORT + } else if self.config.corpus.collect_edge_coverage() + && last_metrics_report.elapsed() > DURATION_BETWEEN_METRICS_REPORT + && worker_id == 0 + { + corpus.sync_metrics(&shared_state.global_corpus_metrics); + // Display metrics inline. + let metrics = json!({ + "timestamp": SystemTime::now() + .duration_since(UNIX_EPOCH)? + .as_secs(), + "test": func.name, + "metrics": &shared_state.global_corpus_metrics, + }); + let _ = sh_println!("{}", serde_json::to_string(&metrics)?); + last_metrics_report = Instant::now(); } worker.gas_by_case.push((case.case.gas, case.case.stipend)); diff --git a/crates/evm/evm/src/executors/fuzz/types.rs b/crates/evm/evm/src/executors/fuzz/types.rs index 4e5a9372a429e..19b4843017d55 100644 --- a/crates/evm/evm/src/executors/fuzz/types.rs +++ b/crates/evm/evm/src/executors/fuzz/types.rs @@ -3,7 +3,7 @@ use std::sync::{ atomic::{AtomicU32, Ordering}, }; -use crate::executors::{FailFast, FuzzTestTimer, RawCallResult}; +use crate::executors::{FailFast, FuzzTestTimer, RawCallResult, corpus::GlobalCorpusMetrics}; use alloy_primitives::{Bytes, Log, map::HashMap}; use foundry_common::evm::Breakpoints; use foundry_evm_coverage::HitMaps; @@ -66,6 +66,8 @@ pub struct SharedFuzzState { timer: FuzzTestTimer, /// Fail Fast coordinator fail_fast: FailFast, + /// Global corpus metrics + pub(crate) global_corpus_metrics: GlobalCorpusMetrics, } impl SharedFuzzState { @@ -77,6 +79,7 @@ impl SharedFuzzState { total_rejects: Arc::new(AtomicU32::new(0)), timer: FuzzTestTimer::new(timeout), fail_fast, + global_corpus_metrics: GlobalCorpusMetrics::default(), } } From af55f7175764bf9770d19bce2844ff4b6e51bb96 Mon Sep 17 00:00:00 2001 From: Yash Atreya <44857776+yash-atreya@users.noreply.github.com> Date: Thu, 2 Oct 2025 16:03:09 +0530 Subject: [PATCH 24/28] nit --- crates/evm/evm/src/executors/fuzz/mod.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/evm/evm/src/executors/fuzz/mod.rs b/crates/evm/evm/src/executors/fuzz/mod.rs index 924e161da7a2e..4deeb9531a22a 100644 --- a/crates/evm/evm/src/executors/fuzz/mod.rs +++ b/crates/evm/evm/src/executors/fuzz/mod.rs @@ -280,6 +280,8 @@ impl FuzzedExecutor { result } + /// Runs a single fuzz worker + #[allow(clippy::too_many_arguments)] fn run_worker( &self, worker_id: u32, From 6e190be95ebad8f30d1311b81b58ff7787b792ab Mon Sep 17 00:00:00 2001 From: Yash Atreya <44857776+yash-atreya@users.noreply.github.com> Date: Thu, 2 Oct 2025 16:12:30 +0530 Subject: [PATCH 25/28] feat: surface failed_corpus_replays from master worker --- crates/evm/evm/src/executors/fuzz/mod.rs | 13 ++++++++++--- crates/evm/evm/src/executors/fuzz/types.rs | 2 ++ crates/evm/fuzz/src/lib.rs | 2 +- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/crates/evm/evm/src/executors/fuzz/mod.rs b/crates/evm/evm/src/executors/fuzz/mod.rs index 4deeb9531a22a..97d4c865fd3b2 100644 --- a/crates/evm/evm/src/executors/fuzz/mod.rs +++ b/crates/evm/evm/src/executors/fuzz/mod.rs @@ -239,6 +239,12 @@ impl FuzzedExecutor { last_run_timestamp = worker.last_run_timestamp; last_run_worker = Some(worker); } + + // Only retrieve from worker 0 i.e master worker which is responsible for replaying + // persisted corpus. + if worker.worker_id == 0 { + result.failed_corpus_replays = worker.failed_corpus_replays; + } } // Set first case @@ -271,9 +277,6 @@ impl FuzzedExecutor { result.reason = reason.0; } - // TODO - result.failed_corpus_replays = 0; - // TODO: Logs stats from EvmFuzzState of all workers // state.log_stats(); @@ -501,6 +504,10 @@ impl FuzzedExecutor { } } + if worker_id == 0 { + worker.failed_corpus_replays = corpus.failed_replays; + } + Ok(worker) } /// Stores fuzz state for use with [fuzz_calldata_from_state] diff --git a/crates/evm/evm/src/executors/fuzz/types.rs b/crates/evm/evm/src/executors/fuzz/types.rs index 19b4843017d55..75929cebbf9a5 100644 --- a/crates/evm/evm/src/executors/fuzz/types.rs +++ b/crates/evm/evm/src/executors/fuzz/types.rs @@ -186,6 +186,8 @@ pub struct FuzzWorker { /// /// Used to identify which worker ran last and collect its traces and call breakpoints pub last_run_timestamp: u128, + /// Failed corpus replays + pub failed_corpus_replays: usize, } impl FuzzWorker { diff --git a/crates/evm/fuzz/src/lib.rs b/crates/evm/fuzz/src/lib.rs index f8e52b2fd17b8..4c505fbd11980 100644 --- a/crates/evm/fuzz/src/lib.rs +++ b/crates/evm/fuzz/src/lib.rs @@ -245,7 +245,7 @@ pub struct FuzzTestResult { // Deprecated cheatcodes mapped to their replacements. pub deprecated_cheatcodes: HashMap<&'static str, Option<&'static str>>, - /// NUmber of failed replays from persisted corpus. + /// Number of failed replays from persisted corpus. pub failed_corpus_replays: usize, } From 8543ab45e5b1e9aefd8e28275cf51a4a0cfadfee Mon Sep 17 00:00:00 2001 From: Yash Atreya <44857776+yash-atreya@users.noreply.github.com> Date: Thu, 2 Oct 2025 16:14:37 +0530 Subject: [PATCH 26/28] feat: log worker fuzz stats --- crates/evm/evm/src/executors/fuzz/mod.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/evm/evm/src/executors/fuzz/mod.rs b/crates/evm/evm/src/executors/fuzz/mod.rs index 97d4c865fd3b2..2fdb28608992d 100644 --- a/crates/evm/evm/src/executors/fuzz/mod.rs +++ b/crates/evm/evm/src/executors/fuzz/mod.rs @@ -277,9 +277,6 @@ impl FuzzedExecutor { result.reason = reason.0; } - // TODO: Logs stats from EvmFuzzState of all workers - // state.log_stats(); - result } @@ -508,6 +505,10 @@ impl FuzzedExecutor { worker.failed_corpus_replays = corpus.failed_replays; } + // Logs stats + trace!("worker {worker_id} fuzz stats"); + state.log_stats(); + Ok(worker) } /// Stores fuzz state for use with [fuzz_calldata_from_state] From 23910e8c03ae03f623dfc6fbabc2cc642884aad4 Mon Sep 17 00:00:00 2001 From: Yash Atreya <44857776+yash-atreya@users.noreply.github.com> Date: Thu, 2 Oct 2025 16:44:25 +0530 Subject: [PATCH 27/28] fix corpus tests --- crates/evm/evm/src/executors/corpus.rs | 28 +++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/crates/evm/evm/src/executors/corpus.rs b/crates/evm/evm/src/executors/corpus.rs index 37d3938092132..488f6e5762a22 100644 --- a/crates/evm/evm/src/executors/corpus.rs +++ b/crates/evm/evm/src/executors/corpus.rs @@ -1101,7 +1101,7 @@ mod tests { dir } - fn new_manager_with_single_corpus() -> (CorpusManager, Uuid) { + fn new_manager_with_single_corpus() -> (WorkerCorpus, Uuid) { let tx_gen = Just(basic_tx()).boxed(); let config = FuzzCorpusConfig { corpus_dir: Some(temp_corpus_dir()), @@ -1115,15 +1115,24 @@ mod tests { let corpus = CorpusEntry::from_tx_seq(&tx_seq); let seed_uuid = corpus.uuid; - let manager = CorpusManager { + // Create worker dir + let worker_dir = temp_corpus_dir().join("worker0"); + let _ = fs::create_dir_all(&worker_dir); + + let manager = WorkerCorpus { + id: 0, tx_generator: tx_gen, mutation_generator: Just(MutationType::Repeat).boxed(), - config, + config: config.into(), in_memory_corpus: vec![corpus], current_mutated: Some(seed_uuid), failed_replays: 0, history_map: vec![0u8; COVERAGE_MAP_SIZE], metrics: CorpusMetrics::default(), + new_entry_indices: Default::default(), + last_sync_timestamp: 0, + worker_dir: Some(temp_corpus_dir().join("worker0")), + last_sync_metrics: CorpusMetrics::default(), }; (manager, seed_uuid) @@ -1213,15 +1222,24 @@ mod tests { non_favored.is_favored = false; let non_favored_uuid = non_favored.uuid; - let mut manager = CorpusManager { + let corpus_root = temp_corpus_dir(); + let worker_subdir = corpus_root.join("worker0"); + fs::create_dir_all(&worker_subdir).unwrap(); + + let mut manager = WorkerCorpus { + id: 0, tx_generator: tx_gen, mutation_generator: Just(MutationType::Repeat).boxed(), - config, + config: config.into(), in_memory_corpus: vec![favored, non_favored], current_mutated: None, failed_replays: 0, history_map: vec![0u8; COVERAGE_MAP_SIZE], metrics: CorpusMetrics::default(), + new_entry_indices: Default::default(), + last_sync_timestamp: 0, + worker_dir: Some(corpus_root), + last_sync_metrics: CorpusMetrics::default(), }; // First eviction should remove the non-favored one From 21257dd29fe2a556d9c836411f70ea66c67dc5ce Mon Sep 17 00:00:00 2001 From: Yash Atreya <44857776+yash-atreya@users.noreply.github.com> Date: Fri, 3 Oct 2025 18:38:51 +0530 Subject: [PATCH 28/28] fix: worker corpus dir --- crates/evm/evm/src/executors/corpus.rs | 13 ++++++------- crates/evm/evm/src/executors/fuzz/mod.rs | 2 +- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/crates/evm/evm/src/executors/corpus.rs b/crates/evm/evm/src/executors/corpus.rs index 488f6e5762a22..4df467b8126cf 100644 --- a/crates/evm/evm/src/executors/corpus.rs +++ b/crates/evm/evm/src/executors/corpus.rs @@ -592,8 +592,7 @@ impl WorkerCorpus { let corpus = &self.in_memory_corpus [test_runner.rng().random_range(0..self.in_memory_corpus.len())]; self.current_mutated = Some(corpus.uuid); - let new_seq = corpus.tx_seq.clone(); - let mut tx = new_seq.first().unwrap().clone(); + let mut tx = corpus.tx_seq.first().unwrap().clone(); self.abi_mutate(&mut tx, function, test_runner, fuzz_state)?; tx } else { @@ -660,7 +659,6 @@ impl WorkerCorpus { self.worker_dir .clone() .unwrap() - .join(format!("{WORKER}{}", self.id)) // Worker dir .join(format!("{uuid}-{eviction_time}-{METADATA_SUFFIX}")) .as_path(), &corpus, @@ -1115,9 +1113,10 @@ mod tests { let corpus = CorpusEntry::from_tx_seq(&tx_seq); let seed_uuid = corpus.uuid; - // Create worker dir - let worker_dir = temp_corpus_dir().join("worker0"); - let _ = fs::create_dir_all(&worker_dir); + // Create corpus root dir and worker subdirectory + let corpus_root = config.corpus_dir.clone().unwrap(); + let worker_subdir = corpus_root.join("worker0"); + let _ = fs::create_dir_all(&worker_subdir); let manager = WorkerCorpus { id: 0, @@ -1131,7 +1130,7 @@ mod tests { metrics: CorpusMetrics::default(), new_entry_indices: Default::default(), last_sync_timestamp: 0, - worker_dir: Some(temp_corpus_dir().join("worker0")), + worker_dir: Some(corpus_root), last_sync_metrics: CorpusMetrics::default(), }; diff --git a/crates/evm/evm/src/executors/fuzz/mod.rs b/crates/evm/evm/src/executors/fuzz/mod.rs index 2fdb28608992d..96b0ae574bb42 100644 --- a/crates/evm/evm/src/executors/fuzz/mod.rs +++ b/crates/evm/evm/src/executors/fuzz/mod.rs @@ -301,7 +301,7 @@ impl FuzzedExecutor { 100 - dictionary_weight => fuzz_calldata(func.clone(), fuzz_fixtures), dictionary_weight => fuzz_calldata_from_state(func.clone(), &state), ] - .prop_map(move |calldata| BasicTxDetails { + .prop_map(|calldata| BasicTxDetails { sender: Default::default(), call_details: CallDetails { target: Default::default(), calldata }, });