Skip to content

Commit 5e3e557

Browse files
authored
fix(forge): enable fail fast flag (#11328)
* fix(forge): enable fail fast flag * Cleanup after review
1 parent f543e6e commit 5e3e557

File tree

7 files changed

+125
-5
lines changed

7 files changed

+125
-5
lines changed

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
use crate::executors::{DURATION_BETWEEN_METRICS_REPORT, Executor, FuzzTestTimer, RawCallResult};
1+
use crate::executors::{
2+
DURATION_BETWEEN_METRICS_REPORT, Executor, FailFast, FuzzTestTimer, RawCallResult,
3+
};
24
use alloy_dyn_abi::JsonAbiExt;
35
use alloy_json_abi::Function;
46
use alloy_primitives::{Address, Bytes, Log, U256, map::HashMap};
@@ -90,6 +92,7 @@ impl FuzzedExecutor {
9092
/// test case.
9193
///
9294
/// Returns a list of all the consumed gas and calldata of every fuzz case.
95+
#[allow(clippy::too_many_arguments)]
9396
pub fn fuzz(
9497
&mut self,
9598
func: &Function,
@@ -98,6 +101,7 @@ impl FuzzedExecutor {
98101
address: Address,
99102
rd: &RevertDecoder,
100103
progress: Option<&ProgressBar>,
104+
fail_fast: &FailFast,
101105
) -> Result<FuzzTestResult> {
102106
// Stores the fuzz test execution data.
103107
let mut test_data = FuzzTestData::default();
@@ -128,6 +132,10 @@ impl FuzzedExecutor {
128132
let mut last_metrics_report = Instant::now();
129133
let max_runs = self.config.runs;
130134
let continue_campaign = |runs: u32| {
135+
if fail_fast.should_stop() {
136+
return false;
137+
}
138+
131139
if timer.is_enabled() { !timer.is_timed_out() } else { runs < max_runs }
132140
};
133141

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ use serde_json::json;
4949

5050
mod shrink;
5151
use crate::executors::{
52-
DURATION_BETWEEN_METRICS_REPORT, EvmError, FuzzTestTimer, corpus::CorpusManager,
52+
DURATION_BETWEEN_METRICS_REPORT, EvmError, FailFast, FuzzTestTimer, corpus::CorpusManager,
5353
};
5454
pub use shrink::check_sequence;
5555

@@ -327,6 +327,7 @@ impl<'a> InvariantExecutor<'a> {
327327
fuzz_fixtures: &FuzzFixtures,
328328
deployed_libs: &[Address],
329329
progress: Option<&ProgressBar>,
330+
fail_fast: &FailFast,
330331
) -> Result<InvariantFuzzTestResult> {
331332
// Throw an error to abort test run if the invariant function accepts input params
332333
if !invariant_contract.invariant_function.inputs.is_empty() {
@@ -341,6 +342,10 @@ impl<'a> InvariantExecutor<'a> {
341342
let timer = FuzzTestTimer::new(self.config.timeout);
342343
let mut last_metrics_report = Instant::now();
343344
let continue_campaign = |runs: u32| {
345+
if fail_fast.should_stop() {
346+
return false;
347+
}
348+
344349
if timer.is_enabled() { !timer.is_timed_out() } else { runs < self.config.runs }
345350
};
346351

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

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@ use revm::{
4444
};
4545
use std::{
4646
borrow::Cow,
47+
sync::{
48+
Arc,
49+
atomic::{AtomicBool, Ordering},
50+
},
4751
time::{Duration, Instant},
4852
};
4953

@@ -1109,3 +1113,34 @@ impl FuzzTestTimer {
11091113
self.inner.is_some_and(|(start, duration)| start.elapsed() > duration)
11101114
}
11111115
}
1116+
1117+
/// Helper struct to enable fail fast behavior: when one test fails, all other tests stop early.
1118+
#[derive(Clone)]
1119+
pub struct FailFast {
1120+
/// Shared atomic flag set to `true` when a failure occurs.
1121+
/// None if fail-fast is disabled.
1122+
inner: Option<Arc<AtomicBool>>,
1123+
}
1124+
1125+
impl FailFast {
1126+
pub fn new(fail_fast: bool) -> Self {
1127+
Self { inner: fail_fast.then_some(Arc::new(AtomicBool::new(false))) }
1128+
}
1129+
1130+
/// Returns `true` if fail-fast is enabled.
1131+
pub fn is_enabled(&self) -> bool {
1132+
self.inner.is_some()
1133+
}
1134+
1135+
/// Sets the failure flag. Used by other tests to stop early.
1136+
pub fn record_fail(&self) {
1137+
if let Some(fail_fast) = &self.inner {
1138+
fail_fast.store(true, Ordering::Relaxed);
1139+
}
1140+
}
1141+
1142+
/// Whether a failure has been recorded and test should stop.
1143+
pub fn should_stop(&self) -> bool {
1144+
self.inner.as_ref().map(|flag| flag.load(Ordering::Relaxed)).unwrap_or(false)
1145+
}
1146+
}

crates/forge/src/cmd/test/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,7 @@ impl TestArgs {
355355
.sender(evm_opts.sender)
356356
.with_fork(evm_opts.get_fork(&config, env.clone()))
357357
.enable_isolation(evm_opts.isolate)
358+
.fail_fast(self.fail_fast)
358359
.odyssey(evm_opts.odyssey)
359360
.build::<MultiCompiler>(project_root, &output, env, evm_opts)?;
360361

crates/forge/src/multi_runner.rs

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ use foundry_evm::{
1818
Env,
1919
backend::Backend,
2020
decode::RevertDecoder,
21-
executors::{Executor, ExecutorBuilder},
21+
executors::{Executor, ExecutorBuilder, FailFast},
2222
fork::CreateFork,
2323
inspectors::CheatsConfig,
2424
opts::EvmOpts,
@@ -296,6 +296,8 @@ pub struct TestRunnerConfig {
296296
pub isolation: bool,
297297
/// Whether to enable Odyssey features.
298298
pub odyssey: bool,
299+
/// Whether to exit early on test failure.
300+
pub fail_fast: FailFast,
299301
}
300302

301303
impl TestRunnerConfig {
@@ -404,6 +406,8 @@ pub struct MultiContractRunnerBuilder {
404406
pub isolation: bool,
405407
/// Whether to enable Odyssey features.
406408
pub odyssey: bool,
409+
/// Whether to exit early on test failure.
410+
pub fail_fast: bool,
407411
}
408412

409413
impl MultiContractRunnerBuilder {
@@ -419,6 +423,7 @@ impl MultiContractRunnerBuilder {
419423
isolation: Default::default(),
420424
decode_internal: Default::default(),
421425
odyssey: Default::default(),
426+
fail_fast: false,
422427
}
423428
}
424429

@@ -457,6 +462,11 @@ impl MultiContractRunnerBuilder {
457462
self
458463
}
459464

465+
pub fn fail_fast(mut self, fail_fast: bool) -> Self {
466+
self.fail_fast = fail_fast;
467+
self
468+
}
469+
460470
pub fn enable_isolation(mut self, enable: bool) -> Self {
461471
self.isolation = enable;
462472
self
@@ -538,15 +548,14 @@ impl MultiContractRunnerBuilder {
538548
env,
539549
spec_id: self.evm_spec.unwrap_or_else(|| self.config.evm_spec_id()),
540550
sender: self.sender.unwrap_or(self.config.sender),
541-
542551
line_coverage: self.line_coverage,
543552
debug: self.debug,
544553
decode_internal: self.decode_internal,
545554
inline_config: Arc::new(InlineConfig::new_parsed(output, &self.config)?),
546555
isolation: self.isolation,
547556
odyssey: self.odyssey,
548-
549557
config: self.config,
558+
fail_fast: FailFast::new(self.fail_fast),
550559
},
551560
})
552561
}

crates/forge/src/runner.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -401,9 +401,16 @@ impl<'a> ContractRunner<'a> {
401401
return SuiteResult::new(start.elapsed(), [(instances, fail)].into(), warnings);
402402
}
403403

404+
let fail_fast = &self.tcfg.fail_fast;
405+
404406
let test_results = functions
405407
.par_iter()
406408
.map(|&func| {
409+
// Early exit if we're running with fail-fast and a test already failed.
410+
if fail_fast.should_stop() {
411+
return (func.signature(), TestResult::setup_result(setup.clone()));
412+
}
413+
407414
let start = Instant::now();
408415

409416
let _guard = self.tokio_handle.enter();
@@ -432,6 +439,11 @@ impl<'a> ContractRunner<'a> {
432439
);
433440
res.duration = start.elapsed();
434441

442+
// Set fail fast flag if current test failed.
443+
if res.status.is_failure() {
444+
fail_fast.record_fail();
445+
}
446+
435447
(sig, res)
436448
})
437449
.collect::<BTreeMap<_, _>>();
@@ -629,6 +641,10 @@ impl<'a> FunctionRunner<'a> {
629641
);
630642

631643
for i in 0..fixtures_len {
644+
if self.tcfg.fail_fast.should_stop() {
645+
return self.result;
646+
}
647+
632648
// Increment progress bar.
633649
if let Some(progress) = progress.as_ref() {
634650
progress.inc(1);
@@ -800,6 +816,7 @@ impl<'a> FunctionRunner<'a> {
800816
&self.setup.fuzz_fixtures,
801817
&self.setup.deployed_libs,
802818
progress.as_ref(),
819+
&self.tcfg.fail_fast,
803820
) {
804821
Ok(x) => x,
805822
Err(e) => {
@@ -950,6 +967,7 @@ impl<'a> FunctionRunner<'a> {
950967
self.address,
951968
&self.cr.mcr.revert_decoder,
952969
progress.as_ref(),
970+
&self.tcfg.fail_fast,
953971
) {
954972
Ok(x) => x,
955973
Err(e) => {

crates/forge/tests/cli/config.rs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1878,3 +1878,47 @@ forgetest_init!(test_exclude_lints_config, |prj, cmd| {
18781878
});
18791879
cmd.args(["lint"]).assert_success().stdout_eq(str![""]);
18801880
});
1881+
1882+
// <https://github.com/foundry-rs/foundry/issues/6529>
1883+
forgetest_init!(test_fail_fast_config, |prj, cmd| {
1884+
prj.update_config(|config| {
1885+
// Set large timeout for fuzzed tests so test campaign won't stop if fail fast not passed.
1886+
config.fuzz.timeout = Some(3600);
1887+
config.invariant.timeout = Some(3600);
1888+
});
1889+
prj.add_test(
1890+
"AnotherCounterTest.sol",
1891+
r#"
1892+
import {Test} from "forge-std/Test.sol";
1893+
1894+
contract InvariantHandler is Test {
1895+
function fuzz_selector(uint256 x) public {
1896+
}
1897+
}
1898+
1899+
contract AnotherCounterTest is Test {
1900+
uint256[] public fixtureAmount = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
1901+
1902+
function setUp() public {
1903+
new InvariantHandler();
1904+
}
1905+
// This failure should stop all other tests.
1906+
function test_Failure() public pure {
1907+
require(false);
1908+
}
1909+
1910+
function testFuzz_SetNumber(uint256 x) public {
1911+
}
1912+
1913+
function invariant_SetNumber() public {
1914+
}
1915+
1916+
function table_SetNumber(uint256 amount) public {
1917+
require(amount < 100);
1918+
}
1919+
}
1920+
"#,
1921+
)
1922+
.unwrap();
1923+
cmd.args(["test", "--fail-fast"]).assert_failure();
1924+
});

0 commit comments

Comments
 (0)