Skip to content

Commit c5ba719

Browse files
smartcontractsgrandizzy
authored andcommitted
feat: add timeouts to fuzz testing (foundry-rs#9394)
* feat: add timeouts to fuzz testing Adds --fuzz-timeout-secs to fuzz tests which will cause a property test to timeout after a certain number of seconds. Also adds --fuzz-allow-timeouts so that timeouts are optionally not considered to be failures. * simplify timeout implementation * use u32 for timeout * switch back to failing for timeouts * clippy * Nits: - move logic to interrupt invariant test in depth loop - add and reuse start_timer fn and TEST_TIMEOUT constant - add fuzz and invariant tests - fix failing test * Fix fmt * Changes after review: introduce FuzzTestTimer --------- Co-authored-by: grandizzy <[email protected]>
1 parent ded0545 commit c5ba719

File tree

10 files changed

+162
-13
lines changed

10 files changed

+162
-13
lines changed

crates/config/src/fuzz.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ pub struct FuzzConfig {
2828
pub failure_persist_file: Option<String>,
2929
/// show `console.log` in fuzz test, defaults to `false`
3030
pub show_logs: bool,
31+
/// Optional timeout (in seconds) for each property test
32+
pub timeout: Option<u32>,
3133
}
3234

3335
impl Default for FuzzConfig {
@@ -41,6 +43,7 @@ impl Default for FuzzConfig {
4143
failure_persist_dir: None,
4244
failure_persist_file: None,
4345
show_logs: false,
46+
timeout: None,
4447
}
4548
}
4649
}

crates/config/src/invariant.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ pub struct InvariantConfig {
3030
pub failure_persist_dir: Option<PathBuf>,
3131
/// Whether to collect and display fuzzed selectors metrics.
3232
pub show_metrics: bool,
33+
/// Optional timeout (in seconds) for each invariant test.
34+
pub timeout: Option<u32>,
3335
}
3436

3537
impl Default for InvariantConfig {
@@ -45,6 +47,7 @@ impl Default for InvariantConfig {
4547
gas_report_samples: 256,
4648
failure_persist_dir: None,
4749
show_metrics: false,
50+
timeout: None,
4851
}
4952
}
5053
}
@@ -63,6 +66,7 @@ impl InvariantConfig {
6366
gas_report_samples: 256,
6467
failure_persist_dir: Some(cache_dir),
6568
show_metrics: false,
69+
timeout: None,
6670
}
6771
}
6872

crates/evm/core/src/constants.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ pub const MAGIC_ASSUME: &[u8] = b"FOUNDRY::ASSUME";
3737
/// Magic return value returned by the `skip` cheatcode. Optionally appended with a reason.
3838
pub const MAGIC_SKIP: &[u8] = b"FOUNDRY::SKIP";
3939

40+
/// Test timeout return value.
41+
pub const TEST_TIMEOUT: &str = "FOUNDRY::TEST_TIMEOUT";
42+
4043
/// The address that deploys the default CREATE2 deployer contract.
4144
pub const DEFAULT_CREATE2_DEPLOYER_DEPLOYER: Address =
4245
address!("3fAB184622Dc19b6109349B94811493BF2a45362");

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

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
1-
use crate::executors::{Executor, RawCallResult};
1+
use crate::executors::{Executor, FuzzTestTimer, RawCallResult};
22
use alloy_dyn_abi::JsonAbiExt;
33
use alloy_json_abi::Function;
44
use alloy_primitives::{map::HashMap, Address, Bytes, Log, U256};
55
use eyre::Result;
66
use foundry_common::evm::Breakpoints;
77
use foundry_config::FuzzConfig;
88
use foundry_evm_core::{
9-
constants::MAGIC_ASSUME,
9+
constants::{MAGIC_ASSUME, TEST_TIMEOUT},
1010
decode::{RevertDecoder, SkipReason},
1111
};
1212
use foundry_evm_coverage::HitMaps;
@@ -98,7 +98,15 @@ impl FuzzedExecutor {
9898
let max_traces_to_collect = std::cmp::max(1, self.config.gas_report_samples) as usize;
9999
let show_logs = self.config.show_logs;
100100

101+
// Start timer for this fuzz test.
102+
let timer = FuzzTestTimer::new(self.config.timeout);
103+
101104
let run_result = self.runner.clone().run(&strategy, |calldata| {
105+
// Check if the timeout has been reached.
106+
if timer.is_timed_out() {
107+
return Err(TestCaseError::fail(TEST_TIMEOUT));
108+
}
109+
102110
let fuzz_res = self.single_fuzz(address, should_fail, calldata)?;
103111

104112
// If running with progress then increment current run.
@@ -193,17 +201,21 @@ impl FuzzedExecutor {
193201
}
194202
Err(TestError::Fail(reason, _)) => {
195203
let reason = reason.to_string();
196-
result.reason = (!reason.is_empty()).then_some(reason);
197-
198-
let args = if let Some(data) = calldata.get(4..) {
199-
func.abi_decode_input(data, false).unwrap_or_default()
204+
if reason == TEST_TIMEOUT {
205+
// If the reason is a timeout, we consider the fuzz test successful.
206+
result.success = true;
200207
} else {
201-
vec![]
202-
};
208+
result.reason = (!reason.is_empty()).then_some(reason);
209+
let args = if let Some(data) = calldata.get(4..) {
210+
func.abi_decode_input(data, false).unwrap_or_default()
211+
} else {
212+
vec![]
213+
};
203214

204-
result.counterexample = Some(CounterExample::Single(
205-
BaseCounterExample::from_fuzz_call(calldata, args, call.traces),
206-
));
215+
result.counterexample = Some(CounterExample::Single(
216+
BaseCounterExample::from_fuzz_call(calldata, args, call.traces),
217+
));
218+
}
207219
}
208220
}
209221

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

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ use foundry_config::InvariantConfig;
1010
use foundry_evm_core::{
1111
constants::{
1212
CALLER, CHEATCODE_ADDRESS, DEFAULT_CREATE2_DEPLOYER, HARDHAT_CONSOLE_ADDRESS, MAGIC_ASSUME,
13+
TEST_TIMEOUT,
1314
},
1415
precompiles::PRECOMPILES,
1516
};
@@ -49,7 +50,7 @@ pub use result::InvariantFuzzTestResult;
4950
use serde::{Deserialize, Serialize};
5051

5152
mod shrink;
52-
use crate::executors::EvmError;
53+
use crate::executors::{EvmError, FuzzTestTimer};
5354
pub use shrink::check_sequence;
5455

5556
sol! {
@@ -332,6 +333,9 @@ impl<'a> InvariantExecutor<'a> {
332333
let (invariant_test, invariant_strategy) =
333334
self.prepare_test(&invariant_contract, fuzz_fixtures)?;
334335

336+
// Start timer for this invariant test.
337+
let timer = FuzzTestTimer::new(self.config.timeout);
338+
335339
let _ = self.runner.run(&invariant_strategy, |first_input| {
336340
// Create current invariant run data.
337341
let mut current_run = InvariantTestRun::new(
@@ -347,6 +351,15 @@ impl<'a> InvariantExecutor<'a> {
347351
}
348352

349353
while current_run.depth < self.config.depth {
354+
// Check if the timeout has been reached.
355+
if timer.is_timed_out() {
356+
// Since we never record a revert here the test is still considered
357+
// successful even though it timed out. We *want*
358+
// this behavior for now, so that's ok, but
359+
// future developers should be aware of this.
360+
return Err(TestCaseError::fail(TEST_TIMEOUT));
361+
}
362+
350363
let tx = current_run.inputs.last().ok_or_else(|| {
351364
TestCaseError::fail("No input generated to call fuzzed target.")
352365
})?;

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

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,10 @@ use revm::{
3535
ResultAndState, SignedAuthorization, SpecId, TxEnv, TxKind,
3636
},
3737
};
38-
use std::borrow::Cow;
38+
use std::{
39+
borrow::Cow,
40+
time::{Duration, Instant},
41+
};
3942

4043
mod builder;
4144
pub use builder::ExecutorBuilder;
@@ -952,3 +955,20 @@ fn convert_executed_result(
952955
chisel_state,
953956
})
954957
}
958+
959+
/// Timer for a fuzz test.
960+
pub struct FuzzTestTimer {
961+
/// Inner fuzz test timer - (test start time, test duration).
962+
inner: Option<(Instant, Duration)>,
963+
}
964+
965+
impl FuzzTestTimer {
966+
pub fn new(timeout: Option<u32>) -> Self {
967+
Self { inner: timeout.map(|timeout| (Instant::now(), Duration::from_secs(timeout.into()))) }
968+
}
969+
970+
/// Whether the current fuzz test timed out and should be stopped.
971+
pub fn is_timed_out(&self) -> bool {
972+
self.inner.is_some_and(|(start, duration)| start.elapsed() > duration)
973+
}
974+
}

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,10 @@ pub struct TestArgs {
145145
#[arg(long, env = "FOUNDRY_FUZZ_RUNS", value_name = "RUNS")]
146146
pub fuzz_runs: Option<u64>,
147147

148+
/// Timeout for each fuzz run in seconds.
149+
#[arg(long, env = "FOUNDRY_FUZZ_TIMEOUT", value_name = "TIMEOUT")]
150+
pub fuzz_timeout: Option<u64>,
151+
148152
/// File to rerun fuzz failures from.
149153
#[arg(long)]
150154
pub fuzz_input_file: Option<String>,
@@ -864,6 +868,9 @@ impl Provider for TestArgs {
864868
if let Some(fuzz_runs) = self.fuzz_runs {
865869
fuzz_dict.insert("runs".to_string(), fuzz_runs.into());
866870
}
871+
if let Some(fuzz_timeout) = self.fuzz_timeout {
872+
fuzz_dict.insert("timeout".to_string(), fuzz_timeout.into());
873+
}
867874
if let Some(fuzz_input_file) = self.fuzz_input_file.clone() {
868875
fuzz_dict.insert("failure_persist_file".to_string(), fuzz_input_file.into());
869876
}

crates/forge/tests/it/fuzz.rs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,3 +240,38 @@ contract InlineMaxRejectsTest is Test {
240240
...
241241
"#]]);
242242
});
243+
244+
// Tests that test timeout config is properly applied.
245+
// If test doesn't timeout after one second, then test will fail with `rejected too many inputs`.
246+
forgetest_init!(test_fuzz_timeout, |prj, cmd| {
247+
prj.wipe_contracts();
248+
249+
prj.add_test(
250+
"Contract.t.sol",
251+
r#"
252+
import {Test} from "forge-std/Test.sol";
253+
254+
contract FuzzTimeoutTest is Test {
255+
/// forge-config: default.fuzz.max-test-rejects = 10000
256+
/// forge-config: default.fuzz.timeout = 1
257+
function test_fuzz_bound(uint256 a) public pure {
258+
vm.assume(a == 0);
259+
}
260+
}
261+
"#,
262+
)
263+
.unwrap();
264+
265+
cmd.args(["test"]).assert_success().stdout_eq(str![[r#"
266+
[COMPILING_FILES] with [SOLC_VERSION]
267+
[SOLC_VERSION] [ELAPSED]
268+
Compiler run successful!
269+
270+
Ran 1 test for test/Contract.t.sol:FuzzTimeoutTest
271+
[PASS] test_fuzz_bound(uint256) (runs: [..], [AVG_GAS])
272+
Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED]
273+
274+
Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests)
275+
276+
"#]]);
277+
});

crates/forge/tests/it/invariant.rs

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -949,3 +949,53 @@ Ran 2 tests for test/SelectorMetricsTest.t.sol:CounterTest
949949
...
950950
"#]]);
951951
});
952+
953+
// Tests that invariant exists with success after configured timeout.
954+
forgetest_init!(should_apply_configured_timeout, |prj, cmd| {
955+
// Add initial test that breaks invariant.
956+
prj.add_test(
957+
"TimeoutTest.t.sol",
958+
r#"
959+
import {Test} from "forge-std/Test.sol";
960+
961+
contract TimeoutHandler is Test {
962+
uint256 public count;
963+
964+
function increment() public {
965+
count++;
966+
}
967+
}
968+
969+
contract TimeoutTest is Test {
970+
TimeoutHandler handler;
971+
972+
function setUp() public {
973+
handler = new TimeoutHandler();
974+
}
975+
976+
/// forge-config: default.invariant.runs = 10000
977+
/// forge-config: default.invariant.depth = 20000
978+
/// forge-config: default.invariant.timeout = 1
979+
function invariant_counter_timeout() public view {
980+
// Invariant will fail if more than 10000 increments.
981+
// Make sure test timeouts after one second and remaining runs are canceled.
982+
require(handler.count() < 10000);
983+
}
984+
}
985+
"#,
986+
)
987+
.unwrap();
988+
989+
cmd.args(["test", "--mt", "invariant_counter_timeout"]).assert_success().stdout_eq(str![[r#"
990+
[COMPILING_FILES] with [SOLC_VERSION]
991+
[SOLC_VERSION] [ELAPSED]
992+
Compiler run successful!
993+
994+
Ran 1 test for test/TimeoutTest.t.sol:TimeoutTest
995+
[PASS] invariant_counter_timeout() (runs: 0, calls: 0, reverts: 0)
996+
Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED]
997+
998+
Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests)
999+
1000+
"#]]);
1001+
});

crates/forge/tests/it/test_helpers.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ impl ForgeTestProfile {
121121
failure_persist_dir: Some(tempfile::tempdir().unwrap().into_path()),
122122
failure_persist_file: Some("testfailure".to_string()),
123123
show_logs: false,
124+
timeout: None,
124125
};
125126
config.invariant = InvariantConfig {
126127
runs: 256,
@@ -145,6 +146,7 @@ impl ForgeTestProfile {
145146
.into_path(),
146147
),
147148
show_metrics: false,
149+
timeout: None,
148150
};
149151

150152
config.sanitized()

0 commit comments

Comments
 (0)