Skip to content

Commit 9f034be

Browse files
committed
Persist and replay fuzz failure
1 parent 4eddd93 commit 9f034be

File tree

6 files changed

+98
-101
lines changed

6 files changed

+98
-101
lines changed

crates/config/src/fuzz.rs

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,6 @@ pub struct FuzzConfig {
2626
pub gas_report_samples: u32,
2727
/// Path where fuzz failures are recorded and replayed.
2828
pub failure_persist_dir: Option<PathBuf>,
29-
/// Name of the file to record fuzz failures, defaults to `failures`.
30-
pub failure_persist_file: Option<String>,
3129
/// show `console.log` in fuzz test, defaults to `false`
3230
pub show_logs: bool,
3331
/// Optional timeout (in seconds) for each property test
@@ -44,7 +42,6 @@ impl Default for FuzzConfig {
4442
dictionary: FuzzDictionaryConfig::default(),
4543
gas_report_samples: 256,
4644
failure_persist_dir: None,
47-
failure_persist_file: None,
4845
show_logs: false,
4946
timeout: None,
5047
}
@@ -54,11 +51,7 @@ impl Default for FuzzConfig {
5451
impl FuzzConfig {
5552
/// Creates fuzz configuration to write failures in `{PROJECT_ROOT}/cache/fuzz` dir.
5653
pub fn new(cache_dir: PathBuf) -> Self {
57-
Self {
58-
failure_persist_dir: Some(cache_dir),
59-
failure_persist_file: Some("failures".to_string()),
60-
..Default::default()
61-
}
54+
Self { failure_persist_dir: Some(cache_dir), ..Default::default() }
6255
}
6356
}
6457

crates/evm/evm/src/executors/fuzz/mod.rs

Lines changed: 56 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -54,14 +54,16 @@ pub struct FuzzTestData {
5454
/// inputs, until it finds a counterexample. The provided [`TestRunner`] contains all the
5555
/// configuration which can be overridden via [environment variables](proptest::test_runner::Config)
5656
pub struct FuzzedExecutor {
57-
/// The EVM executor
57+
/// The EVM executor.
5858
executor: Executor,
5959
/// The fuzzer
6060
runner: TestRunner,
61-
/// The account that calls tests
61+
/// The account that calls tests.
6262
sender: Address,
63-
/// The fuzz configuration
63+
/// The fuzz configuration.
6464
config: FuzzConfig,
65+
/// The persisted counterexample to be replayed, if any.
66+
persisted_failure: Option<BaseCounterExample>,
6567
}
6668

6769
impl FuzzedExecutor {
@@ -71,15 +73,16 @@ impl FuzzedExecutor {
7173
runner: TestRunner,
7274
sender: Address,
7375
config: FuzzConfig,
76+
persisted_failure: Option<BaseCounterExample>,
7477
) -> Self {
75-
Self { executor, runner, sender, config }
78+
Self { executor, runner, sender, config, persisted_failure }
7679
}
7780

7881
/// Fuzzes the provided function, assuming it is available at the contract at `address`
7982
/// If `should_fail` is set to `true`, then it will stop only when there's a success
8083
/// test case.
8184
///
82-
/// Returns a list of all the consumed gas and calldata of every fuzz case
85+
/// Returns a list of all the consumed gas and calldata of every fuzz case.
8386
pub fn fuzz(
8487
&mut self,
8588
func: &Function,
@@ -116,57 +119,64 @@ impl FuzzedExecutor {
116119
let mut runs = 0;
117120
let mut rejects = 0;
118121
let mut run_failure = None;
122+
119123
'stop: while continue_campaign(runs) {
120-
let Ok(strategy) = strategy.new_tree(&mut self.runner) else {
121-
run_failure = Some(TestCaseError::fail("no input generated to call fuzzed target"));
122-
break 'stop;
123-
};
124+
// If counterexample recorded, replay it first, without incrementing runs.
125+
let input = if let Some(failure) = self.persisted_failure.take() {
126+
failure.calldata
127+
} else {
128+
// If running with progress, then increment current run.
129+
if let Some(progress) = progress {
130+
progress.inc(1);
131+
};
124132

125-
// If running with progress then increment current run.
126-
if let Some(progress) = progress {
127-
progress.inc(1);
128-
};
133+
runs += 1;
129134

130-
match self.single_fuzz(address, strategy.current()) {
131-
Ok(fuzz_outcome) => {
132-
match fuzz_outcome {
133-
FuzzOutcome::Case(case) => {
134-
let mut data = execution_data.borrow_mut();
135-
data.gas_by_case.push((case.case.gas, case.case.stipend));
135+
let Ok(strategy) = strategy.new_tree(&mut self.runner) else {
136+
run_failure =
137+
Some(TestCaseError::fail("no input generated to call fuzzed target"));
138+
break 'stop;
139+
};
140+
strategy.current()
141+
};
136142

137-
if data.first_case.is_none() {
138-
data.first_case.replace(case.case);
139-
}
143+
match self.single_fuzz(address, input) {
144+
Ok(fuzz_outcome) => match fuzz_outcome {
145+
FuzzOutcome::Case(case) => {
146+
let mut data = execution_data.borrow_mut();
147+
data.gas_by_case.push((case.case.gas, case.case.stipend));
140148

141-
if let Some(call_traces) = case.traces {
142-
if data.traces.len() == max_traces_to_collect {
143-
data.traces.pop();
144-
}
145-
data.traces.push(call_traces);
146-
data.breakpoints.replace(case.breakpoints);
147-
}
149+
if data.first_case.is_none() {
150+
data.first_case.replace(case.case);
151+
}
148152

149-
if show_logs {
150-
data.logs.extend(case.logs);
153+
if let Some(call_traces) = case.traces {
154+
if data.traces.len() == max_traces_to_collect {
155+
data.traces.pop();
151156
}
152-
153-
HitMaps::merge_opt(&mut data.coverage, case.coverage);
154-
data.deprecated_cheatcodes = case.deprecated_cheatcodes;
157+
data.traces.push(call_traces);
158+
data.breakpoints.replace(case.breakpoints);
155159
}
156-
FuzzOutcome::CounterExample(CounterExampleOutcome {
157-
exit_reason: status,
158-
counterexample: outcome,
159-
..
160-
}) => {
161-
let reason = rd.maybe_decode(&outcome.1.result, status);
162-
execution_data.borrow_mut().logs.extend(outcome.1.logs.clone());
163-
execution_data.borrow_mut().counterexample = outcome;
164-
// HACK: we have to use an empty string here to denote `None`.
165-
run_failure = Some(TestCaseError::fail(reason.unwrap_or_default()));
166-
break 'stop;
160+
161+
if show_logs {
162+
data.logs.extend(case.logs);
167163
}
164+
165+
HitMaps::merge_opt(&mut data.coverage, case.coverage);
166+
data.deprecated_cheatcodes = case.deprecated_cheatcodes;
168167
}
169-
}
168+
FuzzOutcome::CounterExample(CounterExampleOutcome {
169+
exit_reason: status,
170+
counterexample: outcome,
171+
..
172+
}) => {
173+
let reason = rd.maybe_decode(&outcome.1.result, status);
174+
execution_data.borrow_mut().logs.extend(outcome.1.logs.clone());
175+
execution_data.borrow_mut().counterexample = outcome;
176+
run_failure = Some(TestCaseError::fail(reason.unwrap_or_default()));
177+
break 'stop;
178+
}
179+
},
170180
Err(err) => {
171181
match err {
172182
TestCaseError::Fail(_) => {
@@ -188,8 +198,6 @@ impl FuzzedExecutor {
188198
}
189199
}
190200
}
191-
192-
runs += 1;
193201
}
194202

195203
let fuzz_result = execution_data.into_inner();

crates/forge/src/runner.rs

Lines changed: 39 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ use alloy_primitives::{Address, Bytes, U256, address, map::HashMap};
1313
use eyre::Result;
1414
use foundry_common::{TestFunctionExt, TestFunctionKind, contracts::ContractsByAddress};
1515
use foundry_compilers::utils::canonicalized;
16-
use foundry_config::{Config, InvariantConfig};
16+
use foundry_config::Config;
1717
use foundry_evm::{
1818
constants::CALLER,
1919
decode::RevertDecoder,
@@ -31,9 +31,7 @@ use foundry_evm::{
3131
traces::{TraceKind, TraceMode, load_contracts},
3232
};
3333
use itertools::Itertools;
34-
use proptest::test_runner::{
35-
FailurePersistence, FileFailurePersistence, RngAlgorithm, TestError, TestRng, TestRunner,
36-
};
34+
use proptest::test_runner::{RngAlgorithm, TestError, TestRng, TestRunner};
3735
use rayon::prelude::*;
3836
use serde::{Deserialize, Serialize};
3937
use std::{
@@ -726,8 +724,8 @@ impl<'a> FunctionRunner<'a> {
726724
abi: &self.cr.contract.abi,
727725
};
728726

729-
let (failure_dir, failure_file) = invariant_failure_paths(
730-
invariant_config,
727+
let (failure_dir, failure_file) = test_failure_paths(
728+
invariant_config.failure_persist_dir.clone().unwrap(),
731729
self.cr.name,
732730
&invariant_contract.invariant_function.name,
733731
);
@@ -929,9 +927,23 @@ impl<'a> FunctionRunner<'a> {
929927
fuzz_config.runs,
930928
);
931929

930+
let (failure_dir, failure_file) = test_failure_paths(
931+
fuzz_config.failure_persist_dir.clone().unwrap(),
932+
self.cr.name,
933+
&func.name,
934+
);
935+
936+
// Load persisted counterexample, if any.
937+
let persisted_failure =
938+
foundry_common::fs::read_json_file::<BaseCounterExample>(failure_file.as_path()).ok();
932939
// Run fuzz test.
933-
let mut fuzzed_executor =
934-
FuzzedExecutor::new(self.executor.into_owned(), runner, self.tcfg.sender, fuzz_config);
940+
let mut fuzzed_executor = FuzzedExecutor::new(
941+
self.executor.into_owned(),
942+
runner,
943+
self.tcfg.sender,
944+
fuzz_config,
945+
persisted_failure,
946+
);
935947
let result = fuzzed_executor.fuzz(
936948
func,
937949
&self.setup.fuzz_fixtures,
@@ -940,6 +952,18 @@ impl<'a> FunctionRunner<'a> {
940952
&self.cr.mcr.revert_decoder,
941953
progress.as_ref(),
942954
);
955+
956+
// Record counterexample.
957+
if let Some(CounterExample::Single(counterexample)) = &result.counterexample {
958+
if let Err(err) = foundry_common::fs::create_dir_all(failure_dir) {
959+
error!(%err, "Failed to create fuzz failure dir");
960+
} else if let Err(err) =
961+
foundry_common::fs::write_json_file(failure_file.as_path(), counterexample)
962+
{
963+
error!(%err, "Failed to record call sequence");
964+
}
965+
}
966+
943967
self.result.fuzz_result(result);
944968
self.result
945969
}
@@ -995,40 +1019,21 @@ impl<'a> FunctionRunner<'a> {
9951019

9961020
fn fuzz_runner(&self) -> TestRunner {
9971021
let config = &self.config.fuzz;
998-
let failure_persist_path = config
999-
.failure_persist_dir
1000-
.as_ref()
1001-
.unwrap()
1002-
.join(config.failure_persist_file.as_ref().unwrap())
1003-
.into_os_string()
1004-
.into_string()
1005-
.unwrap();
1006-
fuzzer_with_cases(
1007-
config.seed,
1008-
config.runs,
1009-
config.max_test_rejects,
1010-
Some(Box::new(FileFailurePersistence::Direct(failure_persist_path.leak()))),
1011-
)
1022+
fuzzer_with_cases(config.seed, config.runs, config.max_test_rejects)
10121023
}
10131024

10141025
fn invariant_runner(&self) -> TestRunner {
10151026
let config = &self.config.invariant;
1016-
fuzzer_with_cases(self.config.fuzz.seed, config.runs, config.max_assume_rejects, None)
1027+
fuzzer_with_cases(self.config.fuzz.seed, config.runs, config.max_assume_rejects)
10171028
}
10181029

10191030
fn clone_executor(&self) -> Executor {
10201031
self.executor.clone().into_owned()
10211032
}
10221033
}
10231034

1024-
fn fuzzer_with_cases(
1025-
seed: Option<U256>,
1026-
cases: u32,
1027-
max_global_rejects: u32,
1028-
file_failure_persistence: Option<Box<dyn FailurePersistence>>,
1029-
) -> TestRunner {
1035+
fn fuzzer_with_cases(seed: Option<U256>, cases: u32, max_global_rejects: u32) -> TestRunner {
10301036
let config = proptest::test_runner::Config {
1031-
failure_persistence: file_failure_persistence,
10321037
cases,
10331038
max_global_rejects,
10341039
// Disable proptest shrink: for fuzz tests we provide single counterexample,
@@ -1077,18 +1082,13 @@ fn persisted_call_sequence(path: &Path, bytecode: &Bytes) -> Option<Vec<BaseCoun
10771082
)
10781083
}
10791084

1080-
/// Helper functions to return canonicalized invariant failure paths.
1081-
fn invariant_failure_paths(
1082-
config: &InvariantConfig,
1085+
/// Helper functions to return canonicalized test failure paths.
1086+
fn test_failure_paths(
1087+
persist_dir: PathBuf,
10831088
contract_name: &str,
10841089
invariant_name: &str,
10851090
) -> (PathBuf, PathBuf) {
1086-
let dir = config
1087-
.failure_persist_dir
1088-
.clone()
1089-
.unwrap()
1090-
.join("failures")
1091-
.join(contract_name.split(':').next_back().unwrap());
1091+
let dir = persist_dir.join("failures").join(contract_name.split(':').next_back().unwrap());
10921092
let dir = canonicalized(dir);
10931093
let file = canonicalized(dir.join(invariant_name));
10941094
(dir, file)

crates/forge/tests/cli/config.rs

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,6 @@ forgetest!(can_extract_config_values, |prj, cmd| {
8484
max_test_rejects: 100203,
8585
seed: Some(U256::from(1000)),
8686
failure_persist_dir: Some("test-cache/fuzz".into()),
87-
failure_persist_file: Some("failures".to_string()),
8887
show_logs: false,
8988
..Default::default()
9089
},
@@ -1099,7 +1098,6 @@ max_fuzz_dictionary_addresses = 15728640
10991098
max_fuzz_dictionary_values = 6553600
11001099
gas_report_samples = 256
11011100
failure_persist_dir = "cache/fuzz"
1102-
failure_persist_file = "failures"
11031101
show_logs = false
11041102
11051103
[invariant]
@@ -1211,7 +1209,6 @@ exclude = []
12111209
"max_fuzz_dictionary_values": 6553600,
12121210
"gas_report_samples": 256,
12131211
"failure_persist_dir": "cache/fuzz",
1214-
"failure_persist_file": "failures",
12151212
"show_logs": false,
12161213
"timeout": null
12171214
},

crates/forge/tests/it/fuzz.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -150,9 +150,9 @@ async fn test_persist_fuzz_failure() {
150150
assert_eq!(initial_calldata, new_calldata, "run {i}");
151151
}
152152

153-
// write new failure in different file
153+
// write new failure in different dir.
154154
let new_calldata = match run_fail!(|config| {
155-
config.fuzz.failure_persist_file = Some("failure1".to_string());
155+
config.fuzz.failure_persist_dir = Some(tempfile::tempdir().unwrap().keep())
156156
}) {
157157
Some(CounterExample::Single(counterexample)) => counterexample.calldata,
158158
_ => Bytes::new(),

crates/forge/tests/it/test_helpers.rs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,6 @@ impl ForgeTestProfile {
127127
},
128128
gas_report_samples: 256,
129129
failure_persist_dir: Some(tempfile::tempdir().unwrap().keep()),
130-
failure_persist_file: Some("testfailure".to_string()),
131130
show_logs: false,
132131
timeout: None,
133132
};

0 commit comments

Comments
 (0)