1use std::{
7 collections::BTreeMap,
8 fs,
9 panic::{self, AssertUnwindSafe},
10 path::{Path, PathBuf},
11 sync::{
12 Arc, Mutex,
13 atomic::{AtomicBool, AtomicUsize, Ordering},
14 mpsc,
15 },
16 thread::JoinHandle,
17 time::Duration,
18};
19
20use eyre::Result;
21use foundry_common::{compile::ProjectCompiler, sh_eprintln, sh_println};
22use foundry_compilers::compilers::multi::MultiCompiler;
23use foundry_config::Config;
24#[cfg(feature = "optimism")]
25use foundry_evm::core::evm::OpEvmNetwork;
26use foundry_evm::{
27 core::evm::{
28 BlockEnvFor, EthEvmNetwork, FoundryEvmNetwork, SpecFor, TempoEvmNetwork, TxEnvFor,
29 },
30 opts::EvmOpts,
31};
32use rayon::prelude::*;
33use tempfile::TempDir;
34
35use crate::{
36 MultiContractRunnerBuilder,
37 cmd::test::FilterArgs,
38 mutation::{
39 SurvivedSpans,
40 mutant::{Mutant, MutationResult},
41 progress::MutationProgress,
42 },
43 result::SuiteResult,
44 workspace,
45};
46
47#[derive(Debug, Clone)]
49pub struct MutantTestResult {
50 pub mutant: Mutant,
51 pub result: MutationResult,
52}
53
54#[derive(Debug, Clone)]
56pub struct MutationBatchResult {
57 pub results: Vec<MutantTestResult>,
58 pub cancelled: bool,
59}
60
61pub struct SharedMutationState {
63 pub survived_spans: Mutex<SurvivedSpans>,
65 pub completed: AtomicUsize,
67 pub total: AtomicUsize,
68 pub cancelled: Arc<AtomicBool>,
70 pub progress: Option<MutationProgress>,
72 pub silent: bool,
74 pub pending_workers: Mutex<Vec<JoinHandle<()>>>,
79 max_pending_workers: AtomicUsize,
82}
83
84impl SharedMutationState {
85 pub fn new(
86 cancelled: Arc<AtomicBool>,
87 silent: bool,
88 progress: Option<MutationProgress>,
89 ) -> Self {
90 Self {
91 survived_spans: Mutex::new(SurvivedSpans::new()),
92 completed: AtomicUsize::new(0),
93 total: AtomicUsize::new(0),
94 cancelled,
95 progress,
96 silent,
97 pending_workers: Mutex::new(Vec::new()),
98 max_pending_workers: AtomicUsize::new(usize::MAX),
99 }
100 }
101
102 pub fn is_cancelled(&self) -> bool {
103 self.cancelled.load(Ordering::SeqCst)
104 }
105
106 pub fn cancel(&self) {
107 self.cancelled.store(true, Ordering::SeqCst);
108 if let Some(ref progress) = self.progress {
109 progress.cancel();
110 }
111 }
112
113 pub fn should_skip_span(&self, span: solar::ast::Span) -> bool {
114 self.survived_spans.lock().map(|guard| guard.should_skip_in_live_run(span)).unwrap_or(false)
116 }
117
118 pub fn mark_span_survived(&self, span: solar::ast::Span) {
119 if let Ok(mut guard) = self.survived_spans.lock() {
121 guard.mark_survived(span);
122 }
123 }
124
125 pub fn increment_completed(&self) -> usize {
126 self.completed.fetch_add(1, Ordering::SeqCst) + 1
127 }
128
129 pub fn set_max_pending_workers(&self, max: usize) {
130 self.max_pending_workers.store(max.max(1), Ordering::SeqCst);
131 }
132
133 fn park_timed_out_worker(&self, handle: JoinHandle<()>) {
134 let mut pending = match self.pending_workers.lock() {
135 Ok(pending) => pending,
136 Err(_) => {
137 let _ = handle.join();
138 return;
139 }
140 };
141
142 let max_pending = self.max_pending_workers.load(Ordering::SeqCst).max(1);
143 while pending.len() >= max_pending {
144 let old_handle = pending.remove(0);
145 drop(pending);
146 let _ = old_handle.join();
147 pending = match self.pending_workers.lock() {
148 Ok(pending) => pending,
149 Err(_) => {
150 let _ = handle.join();
151 return;
152 }
153 };
154 }
155
156 pending.push(handle);
157 }
158}
159
160impl Default for SharedMutationState {
161 fn default() -> Self {
162 Self::new(Arc::new(AtomicBool::new(false)), false, None)
163 }
164}
165
166#[allow(clippy::too_many_arguments)]
168pub fn run_mutations_parallel_with_progress(
169 mutants: Vec<Mutant>,
170 source_path: PathBuf,
171 original_source: Arc<String>,
172 config: Arc<Config>,
173 evm_opts: EvmOpts,
174 num_workers: usize,
175 progress: Option<MutationProgress>,
176 silent: bool,
177 filter_args: FilterArgs,
178 selected_sources_relative: Arc<Vec<PathBuf>>,
179 isolate: bool,
180 cancellation_requested: Arc<AtomicBool>,
181) -> Result<MutationBatchResult> {
182 let total = mutants.len();
183 if total == 0 {
184 return Ok(MutationBatchResult { results: vec![], cancelled: false });
185 }
186
187 let num_workers = if num_workers == 0 {
189 std::thread::available_parallelism().map(|p| p.get()).unwrap_or(1)
190 } else {
191 num_workers
192 };
193
194 let shared_state = Arc::new(SharedMutationState::new(cancellation_requested, silent, progress));
195 shared_state.total.store(total, Ordering::SeqCst);
196 shared_state.set_max_pending_workers(num_workers);
197
198 if shared_state.progress.is_none() && !shared_state.silent {
200 let _ = sh_println!("Running {} mutants in parallel with {} workers", total, num_workers);
201 }
202
203 let source_abs =
206 if source_path.is_absolute() { source_path } else { config.root.join(&source_path) };
207
208 let root_abs = config.root.canonicalize().unwrap_or_else(|_| config.root.clone());
209 let source_abs = source_abs.canonicalize().unwrap_or(source_abs);
210
211 let source_relative = source_abs
212 .strip_prefix(&root_abs)
213 .map_err(|_| {
214 eyre::eyre!(
215 "Source path {} is not under project root {}",
216 source_abs.display(),
217 root_abs.display()
218 )
219 })?
220 .to_path_buf();
221
222 workspace::ensure_safe_relative_path(&source_relative, "source", &source_abs)?;
223
224 let pool = rayon::ThreadPoolBuilder::new()
226 .num_threads(num_workers)
227 .stack_size(16 * 1024 * 1024) .build()
229 .map_err(|e| eyre::eyre!("Failed to create thread pool: {}", e))?;
230
231 let completed_results: Arc<Mutex<Vec<MutantTestResult>>> =
233 Arc::new(Mutex::new(Vec::with_capacity(total)));
234
235 let filter_args = Arc::new(filter_args);
236
237 pool.install(|| {
238 mutants.into_par_iter().for_each(|mutant| {
239 if shared_state.is_cancelled() {
241 return;
242 }
243
244 let mutant_clone = mutant.clone();
246 let result = panic::catch_unwind(AssertUnwindSafe(|| {
247 test_single_mutant_isolated(
248 mutant,
249 &source_relative,
250 &original_source,
251 &config,
252 &evm_opts,
253 &shared_state,
254 &filter_args,
255 &selected_sources_relative,
256 isolate,
257 )
258 }));
259
260 let test_result = match result {
261 Ok(r) => r,
262 Err(_) => {
263 if shared_state.progress.is_none() {
264 let _ = sh_eprintln!("Panic while testing mutant: {}", mutant_clone);
265 }
266 MutantTestResult { mutant: mutant_clone, result: MutationResult::Invalid }
267 }
268 };
269
270 if let Ok(mut results) = completed_results.lock() {
272 results.push(test_result);
273 }
274 });
275 });
276
277 let results = Arc::try_unwrap(completed_results)
279 .map(|m| m.into_inner().unwrap_or_default())
280 .unwrap_or_default();
281
282 let pending = shared_state
294 .pending_workers
295 .lock()
296 .map(|mut g| std::mem::take(&mut *g))
297 .unwrap_or_default();
298 let pending_count = pending.len();
299 if pending_count > 0 && !shared_state.silent && shared_state.progress.is_none() {
300 let _ = sh_println!("Waiting for {pending_count} timed-out worker(s) to finish cleanup...");
301 }
302 for handle in pending {
303 let _ = handle.join();
304 }
305
306 let cancelled = shared_state.is_cancelled();
307
308 if let Some(ref progress) = shared_state.progress {
310 progress.clear();
311 }
312 if cancelled && !shared_state.silent {
313 let _ = sh_println!(
314 "\nMutation testing cancelled. Showing results for {} completed mutants.\n",
315 results.len()
316 );
317 }
318
319 Ok(MutationBatchResult { results, cancelled })
320}
321
322#[allow(clippy::too_many_arguments)]
324fn test_single_mutant_isolated(
325 mutant: Mutant,
326 source_relative: &PathBuf,
327 original_source: &Arc<String>,
328 config: &Arc<Config>,
329 evm_opts: &EvmOpts,
330 shared_state: &Arc<SharedMutationState>,
331 filter_args: &Arc<FilterArgs>,
332 selected_sources_relative: &Arc<Vec<PathBuf>>,
333 isolate: bool,
334) -> MutantTestResult {
335 if shared_state.should_skip_span(mutant.span) {
337 if let Some(ref progress) = shared_state.progress {
338 progress.complete_mutant(&mutant, &MutationResult::Skipped);
339 } else if !shared_state.silent {
340 let completed = shared_state.increment_completed();
341 let total = shared_state.total.load(Ordering::SeqCst);
342 let _ = sh_println!(
343 "[{}/{}] Skipping mutant (adaptive: span already has surviving mutation)",
344 completed,
345 total
346 );
347 }
348 return MutantTestResult { mutant, result: MutationResult::Skipped };
349 }
350
351 if let Some(ref progress) = shared_state.progress {
353 progress.start_mutant(&mutant);
354 } else if !shared_state.silent {
355 let completed = shared_state.increment_completed();
356 let total = shared_state.total.load(Ordering::SeqCst);
357 let _ = sh_println!("[{}/{}] Testing mutant: {}", completed, total, mutant);
358 }
359
360 let temp_dir = match TempDir::with_prefix("forge_mutation_") {
362 Ok(dir) => dir,
363 Err(e) => {
364 let _ = sh_eprintln!("Failed to create temp directory: {}", e);
365 return MutantTestResult { mutant, result: MutationResult::Invalid };
366 }
367 };
368
369 if let Err(e) = workspace::copy_project(config, temp_dir.path()) {
371 let _ = sh_eprintln!("Failed to copy project: {}", e);
372 return MutantTestResult { mutant, result: MutationResult::Invalid };
373 }
374
375 let mutated_source_path = temp_dir.path().join(source_relative);
377 if let Err(e) = apply_mutation(&mutant, original_source, &mutated_source_path) {
378 let _ = sh_eprintln!("Failed to apply mutation: {}", e);
379 return MutantTestResult { mutant, result: MutationResult::Invalid };
380 }
381
382 let temp_path = temp_dir.path().to_path_buf();
383 let temp_config = temp_config_for_mutation(config, &temp_path);
384 let temp_config = Arc::new(temp_config);
385
386 let timeout = config.mutation.timeout.map(|s| Duration::from_secs(s as u64));
400
401 let result = match timeout {
402 Some(budget) => run_compile_and_test_with_timeout(
403 temp_config,
404 evm_opts,
405 budget,
406 temp_dir,
407 shared_state,
408 filter_args.clone(),
409 selected_sources_relative.clone(),
410 isolate,
411 ),
412 None => {
413 let res = match compile_and_test(
414 &temp_config,
415 evm_opts,
416 filter_args,
417 selected_sources_relative,
418 isolate,
419 ) {
420 Ok(true) => MutationResult::Dead,
421 Ok(false) => MutationResult::Alive,
422 Err(_) => MutationResult::Invalid,
423 };
424 drop(temp_dir); res
426 }
427 };
428
429 if matches!(result, MutationResult::Alive) {
432 shared_state.mark_span_survived(mutant.span);
433 }
434
435 if let Some(ref progress) = shared_state.progress {
437 progress.complete_mutant(&mutant, &result);
438 }
439
440 MutantTestResult { mutant, result }
441}
442
443#[allow(clippy::too_many_arguments)]
452fn run_compile_and_test_with_timeout(
453 config: Arc<Config>,
454 evm_opts: &EvmOpts,
455 budget: Duration,
456 temp_dir: TempDir,
457 shared_state: &Arc<SharedMutationState>,
458 filter_args: Arc<FilterArgs>,
459 selected_sources_relative: Arc<Vec<PathBuf>>,
460 isolate: bool,
461) -> MutationResult {
462 let (tx, rx) = mpsc::channel::<Result<bool>>();
463 let opts = evm_opts.clone();
464 let cfg = Arc::clone(&config);
468 let filter_for_worker = Arc::clone(&filter_args);
469 let selected_sources_for_worker = Arc::clone(&selected_sources_relative);
470
471 let spawn_result = std::thread::Builder::new()
472 .stack_size(16 * 1024 * 1024)
473 .name("mutation-worker".to_string())
474 .spawn(move || {
475 let res = panic::catch_unwind(AssertUnwindSafe(|| {
476 compile_and_test(
477 &cfg,
478 &opts,
479 &filter_for_worker,
480 &selected_sources_for_worker,
481 isolate,
482 )
483 }))
484 .unwrap_or_else(|_| Err(eyre::eyre!("worker panicked")));
485 let _ = tx.send(res);
486 drop(temp_dir);
490 });
491
492 let handle = match spawn_result {
493 Ok(h) => h,
494 Err(_) => return MutationResult::Invalid,
495 };
496
497 match rx.recv_timeout(budget) {
498 Ok(Ok(true)) => {
499 let _ = handle.join();
502 MutationResult::Dead
503 }
504 Ok(Ok(false)) => {
505 let _ = handle.join();
506 MutationResult::Alive
507 }
508 Ok(Err(_)) => {
509 let _ = handle.join();
510 MutationResult::Invalid
511 }
512 Err(_) => {
513 shared_state.park_timed_out_worker(handle);
517 MutationResult::TimedOut
518 }
519 }
520}
521
522fn apply_mutation(mutant: &Mutant, original_source: &str, dest_path: &Path) -> Result<()> {
524 let span = mutant.span;
525 let replacement = mutant.mutation.to_string();
526 let start_pos = span.lo().0 as usize;
527 let end_pos = span.hi().0 as usize;
528
529 let before = original_source.get(..start_pos).ok_or_else(|| {
531 eyre::eyre!(
532 "Invalid mutation span: start {} is out of bounds for source length {}",
533 start_pos,
534 original_source.len()
535 )
536 })?;
537
538 let after = original_source.get(end_pos..).ok_or_else(|| {
539 eyre::eyre!(
540 "Invalid mutation span: end {} is out of bounds for source length {}",
541 end_pos,
542 original_source.len()
543 )
544 })?;
545
546 let mut new_content = String::with_capacity(before.len() + replacement.len() + after.len());
547 new_content.push_str(before);
548 new_content.push_str(&replacement);
549 new_content.push_str(after);
550
551 if let Some(parent) = dest_path.parent() {
553 fs::create_dir_all(parent)?;
554 }
555
556 fs::write(dest_path, new_content)?;
557 Ok(())
558}
559
560fn temp_config_for_mutation(config: &Config, temp_path: &Path) -> Config {
566 let mut temp_config = config.clone();
567 temp_config.root = temp_path.to_path_buf();
568 temp_config.src = rebase_project_path(&config.root, temp_path, &config.src);
569 temp_config.test = rebase_project_path(&config.root, temp_path, &config.test);
570 temp_config.script = rebase_project_path(&config.root, temp_path, &config.script);
571 temp_config.out = rebase_project_path(&config.root, temp_path, &config.out);
572 temp_config.cache_path = rebase_project_path(&config.root, temp_path, &config.cache_path);
573 temp_config.snapshots = rebase_project_path(&config.root, temp_path, &config.snapshots);
574 temp_config.broadcast = rebase_project_path(&config.root, temp_path, &config.broadcast);
575 temp_config.mutation_dir = rebase_project_path(&config.root, temp_path, &config.mutation_dir);
576 temp_config.libs =
577 config.libs.iter().map(|lib| rebase_project_path(&config.root, temp_path, lib)).collect();
578 temp_config.include_paths = config
579 .include_paths
580 .iter()
581 .map(|path| rebase_project_path(&config.root, temp_path, path))
582 .collect();
583 temp_config.allow_paths = config
584 .allow_paths
585 .iter()
586 .map(|path| rebase_project_path(&config.root, temp_path, path))
587 .collect();
588
589 if let Some(path) = &config.fuzz.failure_persist_dir {
590 temp_config.fuzz.failure_persist_dir =
591 Some(rebase_project_path(&config.root, temp_path, path));
592 }
593 if let Some(path) = &config.invariant.failure_persist_dir {
594 temp_config.invariant.failure_persist_dir =
595 Some(rebase_project_path(&config.root, temp_path, path));
596 }
597
598 if let Some(mutation_timeout) = config.mutation.timeout {
604 temp_config.fuzz.timeout = Some(match temp_config.fuzz.timeout {
605 Some(existing) => existing.min(mutation_timeout),
606 None => mutation_timeout,
607 });
608 temp_config.invariant.timeout = Some(match temp_config.invariant.timeout {
609 Some(existing) => existing.min(mutation_timeout),
610 None => mutation_timeout,
611 });
612 }
613
614 temp_config
615}
616
617fn rebase_project_path(root: &Path, temp_path: &Path, path: &Path) -> PathBuf {
618 let rel = workspace::relative_to_root(root, path);
619 if rel.is_absolute() { path.to_path_buf() } else { temp_path.join(rel) }
620}
621
622fn compile_and_test(
626 config: &Arc<Config>,
627 evm_opts: &EvmOpts,
628 filter_args: &FilterArgs,
629 selected_sources_relative: &[PathBuf],
630 isolate: bool,
631) -> Result<bool> {
632 if evm_opts.networks.is_tempo() {
633 compile_and_test_inner::<TempoEvmNetwork>(
634 config,
635 evm_opts,
636 filter_args,
637 selected_sources_relative,
638 isolate,
639 )
640 } else {
641 #[cfg(feature = "optimism")]
642 if evm_opts.networks.is_optimism() {
643 return compile_and_test_inner::<OpEvmNetwork>(
644 config,
645 evm_opts,
646 filter_args,
647 selected_sources_relative,
648 isolate,
649 );
650 }
651 compile_and_test_inner::<EthEvmNetwork>(
652 config,
653 evm_opts,
654 filter_args,
655 selected_sources_relative,
656 isolate,
657 )
658 }
659}
660
661fn compile_and_test_inner<FEN: FoundryEvmNetwork>(
662 config: &Arc<Config>,
663 evm_opts: &EvmOpts,
664 filter_args: &FilterArgs,
665 selected_sources_relative: &[PathBuf],
666 isolate: bool,
667) -> Result<bool> {
668 let files = selected_sources_relative
670 .iter()
671 .map(|path| config.root.join(path))
672 .filter(|path| path.exists())
673 .collect::<Vec<_>>();
674 let compiler = ProjectCompiler::new()
675 .dynamic_test_linking(config.dynamic_test_linking)
676 .quiet(true)
677 .files(files);
678
679 let compile_output = compiler.compile(&config.project()?)?;
680
681 let filter = filter_args.clone().merge_with_config(config);
686
687 let rt = tokio::runtime::Builder::new_multi_thread()
690 .worker_threads(1) .enable_all()
692 .build()
693 .map_err(|e| eyre::eyre!("Failed to create tokio runtime: {}", e))?;
694
695 let results: BTreeMap<String, SuiteResult> = rt.block_on(async {
697 let (evm_env, tx_env, fork_block) =
698 evm_opts.env::<SpecFor<FEN>, BlockEnvFor<FEN>, TxEnvFor<FEN>>().await?;
699
700 let mut runner = MultiContractRunnerBuilder::new(config.clone())
705 .set_debug(false)
706 .initial_balance(evm_opts.initial_balance)
707 .sender(evm_opts.sender)
708 .with_fork(evm_opts.get_fork(config, evm_env.cfg_env.chain_id, fork_block))
709 .enable_isolation(isolate)
710 .fail_fast(true)
711 .build::<FEN, MultiCompiler>(&compile_output, evm_env, tx_env, evm_opts.clone())?;
712
713 runner.test_collect(&filter)
714 })?;
715
716 let killed = results.values().any(|suite| suite.failed() > 0);
718
719 Ok(killed)
720}
721
722#[cfg(test)]
723mod tests {
724 use super::*;
725 use alloy_primitives::U256;
726
727 #[test]
728 fn park_timed_out_worker_bounds_pending_handles() {
729 let state = SharedMutationState::default();
730 state.set_max_pending_workers(1);
731
732 state.park_timed_out_worker(std::thread::spawn(|| {}));
733 assert_eq!(state.pending_workers.lock().unwrap().len(), 1);
734
735 state.park_timed_out_worker(std::thread::spawn(|| {}));
736 assert_eq!(state.pending_workers.lock().unwrap().len(), 1);
737
738 let pending = std::mem::take(&mut *state.pending_workers.lock().unwrap());
739 for handle in pending {
740 handle.join().unwrap();
741 }
742 }
743
744 #[test]
745 fn temp_config_preserves_materialized_overrides_and_rebases_paths() {
746 let project = TempDir::new().unwrap();
747 let temp = TempDir::new().unwrap();
748 let root = project.path();
749
750 let mut config = Config {
751 root: root.to_path_buf(),
752 src: root.join("contracts"),
753 test: root.join("checks"),
754 script: root.join("deploy"),
755 out: root.join("custom-out"),
756 cache_path: root.join("custom-cache"),
757 snapshots: root.join("custom-snapshots"),
758 broadcast: root.join("custom-broadcast"),
759 mutation_dir: root.join("custom-cache/mutation"),
760 libs: vec![root.join("vendor")],
761 include_paths: vec![root.join("shared")],
762 allow_paths: vec![root.join("fixtures")],
763 dynamic_test_linking: true,
764 cache: true,
765 ..Default::default()
766 };
767 config.fuzz.seed = Some(U256::from(42));
768 config.fuzz.timeout = Some(90);
769 config.invariant.timeout = Some(80);
770 config.fuzz.failure_persist_dir = Some(root.join("custom-cache/fuzz"));
771 config.invariant.failure_persist_dir = Some(root.join("custom-cache/invariant"));
772 config.mutation.timeout = Some(5);
773
774 let temp_config = temp_config_for_mutation(&config, temp.path());
775
776 assert_eq!(temp_config.root, temp.path());
777 assert_eq!(temp_config.src, temp.path().join("contracts"));
778 assert_eq!(temp_config.test, temp.path().join("checks"));
779 assert_eq!(temp_config.script, temp.path().join("deploy"));
780 assert_eq!(temp_config.out, temp.path().join("custom-out"));
781 assert_eq!(temp_config.cache_path, temp.path().join("custom-cache"));
782 assert_eq!(temp_config.snapshots, temp.path().join("custom-snapshots"));
783 assert_eq!(temp_config.broadcast, temp.path().join("custom-broadcast"));
784 assert_eq!(temp_config.mutation_dir, temp.path().join("custom-cache/mutation"));
785 assert_eq!(temp_config.libs, vec![temp.path().join("vendor")]);
786 assert_eq!(temp_config.include_paths, vec![temp.path().join("shared")]);
787 assert_eq!(temp_config.allow_paths, vec![temp.path().join("fixtures")]);
788 assert_eq!(
789 temp_config.fuzz.failure_persist_dir,
790 Some(temp.path().join("custom-cache/fuzz"))
791 );
792 assert_eq!(
793 temp_config.invariant.failure_persist_dir,
794 Some(temp.path().join("custom-cache/invariant"))
795 );
796 assert!(temp_config.dynamic_test_linking);
797 assert!(temp_config.cache);
798 assert_eq!(temp_config.fuzz.seed, Some(U256::from(42)));
799 assert_eq!(temp_config.fuzz.timeout, Some(5));
800 assert_eq!(temp_config.invariant.timeout, Some(5));
801 }
802}