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, InlineConfig};
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, RerunFailure},
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 rerun_failures: Option<Vec<RerunFailure>>,
179 selected_sources_relative: Arc<Vec<PathBuf>>,
180 isolate: bool,
181 cancellation_requested: Arc<AtomicBool>,
182) -> Result<MutationBatchResult> {
183 let total = mutants.len();
184 if total == 0 {
185 return Ok(MutationBatchResult { results: vec![], cancelled: false });
186 }
187
188 let num_workers = if num_workers == 0 {
190 std::thread::available_parallelism().map(|p| p.get()).unwrap_or(1)
191 } else {
192 num_workers
193 };
194
195 let shared_state = Arc::new(SharedMutationState::new(cancellation_requested, silent, progress));
196 shared_state.total.store(total, Ordering::SeqCst);
197 shared_state.set_max_pending_workers(num_workers);
198
199 if shared_state.progress.is_none() && !shared_state.silent {
201 let _ = sh_println!("Running {} mutants in parallel with {} workers", total, num_workers);
202 }
203
204 let source_abs =
207 if source_path.is_absolute() { source_path } else { config.root.join(&source_path) };
208
209 let root_abs = config.root.canonicalize().unwrap_or_else(|_| config.root.clone());
210 let source_abs = source_abs.canonicalize().unwrap_or(source_abs);
211
212 let source_relative = source_abs
213 .strip_prefix(&root_abs)
214 .map_err(|_| {
215 eyre::eyre!(
216 "Source path {} is not under project root {}",
217 source_abs.display(),
218 root_abs.display()
219 )
220 })?
221 .to_path_buf();
222
223 workspace::ensure_safe_relative_path(&source_relative, "source", &source_abs)?;
224
225 let temp_root = std::env::temp_dir();
229 let temp_root = dunce::canonicalize(&temp_root).map_err(|err| {
230 eyre::eyre!("failed to canonicalize mutation temp root {}: {err}", temp_root.display())
231 })?;
232
233 let pool = rayon::ThreadPoolBuilder::new()
235 .num_threads(num_workers)
236 .stack_size(16 * 1024 * 1024) .build()
238 .map_err(|e| eyre::eyre!("Failed to create thread pool: {}", e))?;
239
240 let completed_results: Arc<Mutex<Vec<MutantTestResult>>> =
242 Arc::new(Mutex::new(Vec::with_capacity(total)));
243
244 let filter_args = Arc::new(filter_args);
245 let rerun_failures = Arc::new(rerun_failures);
246
247 pool.install(|| {
248 mutants.into_par_iter().for_each(|mutant| {
249 if shared_state.is_cancelled() {
251 return;
252 }
253
254 let mutant_clone = mutant.clone();
256 let result = panic::catch_unwind(AssertUnwindSafe(|| {
257 test_single_mutant_isolated(
258 mutant,
259 &source_relative,
260 &original_source,
261 &config,
262 &evm_opts,
263 &shared_state,
264 &temp_root,
265 &filter_args,
266 &rerun_failures,
267 &selected_sources_relative,
268 isolate,
269 )
270 }));
271
272 let test_result = match result {
273 Ok(r) => r,
274 Err(_) => {
275 if shared_state.progress.is_none() {
276 let _ = sh_eprintln!("Panic while testing mutant: {}", mutant_clone);
277 }
278 MutantTestResult { mutant: mutant_clone, result: MutationResult::Invalid }
279 }
280 };
281
282 if let Ok(mut results) = completed_results.lock() {
284 results.push(test_result);
285 }
286 });
287 });
288
289 let results = Arc::try_unwrap(completed_results)
291 .map(|m| m.into_inner().unwrap_or_default())
292 .unwrap_or_default();
293
294 let pending = shared_state
306 .pending_workers
307 .lock()
308 .map(|mut g| std::mem::take(&mut *g))
309 .unwrap_or_default();
310 let pending_count = pending.len();
311 if pending_count > 0 && !shared_state.silent && shared_state.progress.is_none() {
312 let _ = sh_println!("Waiting for {pending_count} timed-out worker(s) to finish cleanup...");
313 }
314 for handle in pending {
315 let _ = handle.join();
316 }
317
318 let cancelled = shared_state.is_cancelled();
319
320 if let Some(ref progress) = shared_state.progress {
322 progress.clear();
323 }
324 if cancelled && !shared_state.silent {
325 let _ = sh_println!(
326 "\nMutation testing cancelled. Showing results for {} completed mutants.\n",
327 results.len()
328 );
329 }
330
331 Ok(MutationBatchResult { results, cancelled })
332}
333
334#[allow(clippy::too_many_arguments)]
336fn test_single_mutant_isolated(
337 mutant: Mutant,
338 source_relative: &PathBuf,
339 original_source: &Arc<String>,
340 config: &Arc<Config>,
341 evm_opts: &EvmOpts,
342 shared_state: &Arc<SharedMutationState>,
343 temp_root: &Path,
344 filter_args: &Arc<FilterArgs>,
345 rerun_failures: &Arc<Option<Vec<RerunFailure>>>,
346 selected_sources_relative: &Arc<Vec<PathBuf>>,
347 isolate: bool,
348) -> MutantTestResult {
349 if shared_state.should_skip_span(mutant.span) {
351 if let Some(ref progress) = shared_state.progress {
352 progress.complete_mutant(&mutant, &MutationResult::Skipped);
353 } else if !shared_state.silent {
354 let completed = shared_state.increment_completed();
355 let total = shared_state.total.load(Ordering::SeqCst);
356 let _ = sh_println!(
357 "[{}/{}] Skipping mutant (adaptive: span already has surviving mutation)",
358 completed,
359 total
360 );
361 }
362 return MutantTestResult { mutant, result: MutationResult::Skipped };
363 }
364
365 if let Some(ref progress) = shared_state.progress {
367 progress.start_mutant(&mutant);
368 } else if !shared_state.silent {
369 let completed = shared_state.increment_completed();
370 let total = shared_state.total.load(Ordering::SeqCst);
371 let _ = sh_println!("[{}/{}] Testing mutant: {}", completed, total, mutant);
372 }
373
374 let temp_dir = match TempDir::with_prefix_in("forge_mutation_", temp_root) {
376 Ok(dir) => dir,
377 Err(e) => {
378 let _ =
379 sh_eprintln!("Failed to create temp directory in {}: {}", temp_root.display(), e);
380 return MutantTestResult { mutant, result: MutationResult::Invalid };
381 }
382 };
383
384 if let Err(e) = workspace::copy_project(config, temp_dir.path()) {
386 let _ = sh_eprintln!("Failed to copy project: {}", e);
387 return MutantTestResult { mutant, result: MutationResult::Invalid };
388 }
389
390 let mutated_source_path = temp_dir.path().join(source_relative);
392 if let Err(e) = apply_mutation(&mutant, original_source, &mutated_source_path) {
393 let _ = sh_eprintln!("Failed to apply mutation: {}", e);
394 return MutantTestResult { mutant, result: MutationResult::Invalid };
395 }
396
397 let temp_path = temp_dir.path().to_path_buf();
398 let temp_config = temp_config_for_mutation(config, &temp_path);
399 let temp_config = Arc::new(temp_config);
400
401 let timeout = config.mutation.timeout.map(|s| Duration::from_secs(s as u64));
415
416 let result = match timeout {
417 Some(budget) => run_compile_and_test_with_timeout(
418 temp_config,
419 evm_opts,
420 budget,
421 temp_dir,
422 shared_state,
423 filter_args.clone(),
424 rerun_failures.clone(),
425 selected_sources_relative.clone(),
426 isolate,
427 ),
428 None => {
429 let res = match compile_and_test(
430 &temp_config,
431 evm_opts,
432 filter_args,
433 rerun_failures.as_ref().as_deref(),
434 selected_sources_relative,
435 isolate,
436 ) {
437 Ok(true) => MutationResult::Dead,
438 Ok(false) => MutationResult::Alive,
439 Err(_) => MutationResult::Invalid,
440 };
441 drop(temp_dir); res
443 }
444 };
445
446 if matches!(result, MutationResult::Alive) {
449 shared_state.mark_span_survived(mutant.span);
450 }
451
452 if let Some(ref progress) = shared_state.progress {
454 progress.complete_mutant(&mutant, &result);
455 }
456
457 MutantTestResult { mutant, result }
458}
459
460#[allow(clippy::too_many_arguments)]
469fn run_compile_and_test_with_timeout(
470 config: Arc<Config>,
471 evm_opts: &EvmOpts,
472 budget: Duration,
473 temp_dir: TempDir,
474 shared_state: &Arc<SharedMutationState>,
475 filter_args: Arc<FilterArgs>,
476 rerun_failures: Arc<Option<Vec<RerunFailure>>>,
477 selected_sources_relative: Arc<Vec<PathBuf>>,
478 isolate: bool,
479) -> MutationResult {
480 let (tx, rx) = mpsc::channel::<Result<bool>>();
481 let opts = evm_opts.clone();
482 let cfg = Arc::clone(&config);
486 let filter_for_worker = Arc::clone(&filter_args);
487 let rerun_for_worker = Arc::clone(&rerun_failures);
488 let selected_sources_for_worker = Arc::clone(&selected_sources_relative);
489
490 let spawn_result = std::thread::Builder::new()
491 .stack_size(16 * 1024 * 1024)
492 .name("mutation-worker".to_string())
493 .spawn(move || {
494 let res = panic::catch_unwind(AssertUnwindSafe(|| {
495 compile_and_test(
496 &cfg,
497 &opts,
498 &filter_for_worker,
499 rerun_for_worker.as_ref().as_deref(),
500 &selected_sources_for_worker,
501 isolate,
502 )
503 }))
504 .unwrap_or_else(|_| Err(eyre::eyre!("worker panicked")));
505 let _ = tx.send(res);
506 drop(temp_dir);
510 });
511
512 let handle = match spawn_result {
513 Ok(h) => h,
514 Err(_) => return MutationResult::Invalid,
515 };
516
517 match rx.recv_timeout(budget) {
518 Ok(Ok(true)) => {
519 let _ = handle.join();
522 MutationResult::Dead
523 }
524 Ok(Ok(false)) => {
525 let _ = handle.join();
526 MutationResult::Alive
527 }
528 Ok(Err(_)) => {
529 let _ = handle.join();
530 MutationResult::Invalid
531 }
532 Err(_) => {
533 shared_state.park_timed_out_worker(handle);
537 MutationResult::TimedOut
538 }
539 }
540}
541
542fn apply_mutation(mutant: &Mutant, original_source: &str, dest_path: &Path) -> Result<()> {
544 let span = mutant.span;
545 let replacement = mutant.mutation.to_string();
546 let start_pos = span.lo().0 as usize;
547 let end_pos = span.hi().0 as usize;
548
549 let before = original_source.get(..start_pos).ok_or_else(|| {
551 eyre::eyre!(
552 "Invalid mutation span: start {} is out of bounds for source length {}",
553 start_pos,
554 original_source.len()
555 )
556 })?;
557
558 let after = original_source.get(end_pos..).ok_or_else(|| {
559 eyre::eyre!(
560 "Invalid mutation span: end {} is out of bounds for source length {}",
561 end_pos,
562 original_source.len()
563 )
564 })?;
565
566 let mut new_content = String::with_capacity(before.len() + replacement.len() + after.len());
567 new_content.push_str(before);
568 new_content.push_str(&replacement);
569 new_content.push_str(after);
570
571 if let Some(parent) = dest_path.parent() {
573 fs::create_dir_all(parent)?;
574 }
575
576 fs::write(dest_path, new_content)?;
577 Ok(())
578}
579
580fn temp_config_for_mutation(config: &Config, temp_path: &Path) -> Config {
586 let mut temp_config = workspace::rebase_config_paths(config, temp_path);
587
588 if let Some(mutation_timeout) = config.mutation.timeout {
594 temp_config.fuzz.timeout = Some(match temp_config.fuzz.timeout {
595 Some(existing) => existing.min(mutation_timeout),
596 None => mutation_timeout,
597 });
598 temp_config.invariant.timeout = Some(match temp_config.invariant.timeout {
599 Some(existing) => existing.min(mutation_timeout),
600 None => mutation_timeout,
601 });
602 }
603
604 temp_config
605}
606
607fn compile_and_test(
611 config: &Arc<Config>,
612 evm_opts: &EvmOpts,
613 filter_args: &FilterArgs,
614 rerun_failures: Option<&[RerunFailure]>,
615 selected_sources_relative: &[PathBuf],
616 isolate: bool,
617) -> Result<bool> {
618 if evm_opts.networks.is_tempo() {
619 compile_and_test_inner::<TempoEvmNetwork>(
620 config,
621 evm_opts,
622 filter_args,
623 rerun_failures,
624 selected_sources_relative,
625 isolate,
626 )
627 } else {
628 #[cfg(feature = "optimism")]
629 if evm_opts.networks.is_optimism() {
630 return compile_and_test_inner::<OpEvmNetwork>(
631 config,
632 evm_opts,
633 filter_args,
634 rerun_failures,
635 selected_sources_relative,
636 isolate,
637 );
638 }
639 compile_and_test_inner::<EthEvmNetwork>(
640 config,
641 evm_opts,
642 filter_args,
643 rerun_failures,
644 selected_sources_relative,
645 isolate,
646 )
647 }
648}
649
650fn compile_and_test_inner<FEN: FoundryEvmNetwork>(
651 config: &Arc<Config>,
652 evm_opts: &EvmOpts,
653 filter_args: &FilterArgs,
654 rerun_failures: Option<&[RerunFailure]>,
655 selected_sources_relative: &[PathBuf],
656 isolate: bool,
657) -> Result<bool> {
658 let files = selected_sources_relative
660 .iter()
661 .map(|path| config.root.join(path))
662 .filter(|path| path.exists())
663 .collect::<Vec<_>>();
664 let compiler = ProjectCompiler::new()
665 .dynamic_test_linking(config.dynamic_test_linking)
666 .quiet(true)
667 .files(files);
668
669 let compile_output = compiler.compile(&config.project()?)?;
670 let inline_config = Arc::new(InlineConfig::new_parsed(&compile_output, config)?);
671
672 let mut filter = filter_args.clone().merge_with_config(config);
677 if let Some(rerun_failures) = rerun_failures {
678 filter.set_rerun_failures(rerun_failures.to_vec());
679 }
680
681 let rt = tokio::runtime::Builder::new_multi_thread()
684 .worker_threads(1) .enable_all()
686 .build()
687 .map_err(|e| eyre::eyre!("Failed to create tokio runtime: {}", e))?;
688
689 let results: BTreeMap<String, SuiteResult> = rt.block_on(async {
691 let (evm_env, tx_env, fork_block) =
692 evm_opts.env::<SpecFor<FEN>, BlockEnvFor<FEN>, TxEnvFor<FEN>>().await?;
693
694 let mut runner = MultiContractRunnerBuilder::new(config.clone(), inline_config)
699 .set_debug(false)
700 .initial_balance(evm_opts.initial_balance)
701 .sender(evm_opts.sender)
702 .with_fork(evm_opts.get_fork(config, evm_env.cfg_env.chain_id, fork_block))
703 .enable_isolation(isolate)
704 .fail_fast(true)
705 .build::<FEN, MultiCompiler>(&compile_output, evm_env, tx_env, evm_opts.clone())?;
706
707 runner.test_collect(&filter)
708 })?;
709
710 let killed = results.values().any(|suite| suite.failed() > 0);
712
713 Ok(killed)
714}
715
716#[cfg(test)]
717mod tests {
718 use super::*;
719 use alloy_primitives::U256;
720
721 #[test]
722 fn park_timed_out_worker_bounds_pending_handles() {
723 let state = SharedMutationState::default();
724 state.set_max_pending_workers(1);
725
726 state.park_timed_out_worker(std::thread::spawn(|| {}));
727 assert_eq!(state.pending_workers.lock().unwrap().len(), 1);
728
729 state.park_timed_out_worker(std::thread::spawn(|| {}));
730 assert_eq!(state.pending_workers.lock().unwrap().len(), 1);
731
732 let pending = std::mem::take(&mut *state.pending_workers.lock().unwrap());
733 for handle in pending {
734 handle.join().unwrap();
735 }
736 }
737
738 #[test]
739 fn temp_config_preserves_materialized_overrides_and_rebases_paths() {
740 let project = TempDir::new().unwrap();
741 let temp = TempDir::new().unwrap();
742 let root = project.path();
743
744 let mut config = Config {
745 root: root.to_path_buf(),
746 src: root.join("contracts"),
747 test: root.join("checks"),
748 script: root.join("deploy"),
749 out: root.join("custom-out"),
750 cache_path: root.join("custom-cache"),
751 snapshots: root.join("custom-snapshots"),
752 broadcast: root.join("custom-broadcast"),
753 mutation_dir: root.join("custom-cache/mutation"),
754 libs: vec![root.join("vendor")],
755 include_paths: vec![root.join("shared")],
756 allow_paths: vec![root.join("fixtures")],
757 dynamic_test_linking: true,
758 cache: true,
759 ..Default::default()
760 };
761 config.fuzz.seed = Some(U256::from(42));
762 config.fuzz.timeout = Some(90);
763 config.invariant.timeout = Some(80);
764 config.fuzz.failure_persist_dir = Some(root.join("custom-cache/fuzz"));
765 config.invariant.failure_persist_dir = Some(root.join("custom-cache/invariant"));
766 config.mutation.timeout = Some(5);
767
768 let temp_config = temp_config_for_mutation(&config, temp.path());
769
770 assert_eq!(temp_config.root, temp.path());
771 assert_eq!(temp_config.src, temp.path().join("contracts"));
772 assert_eq!(temp_config.test, temp.path().join("checks"));
773 assert_eq!(temp_config.script, temp.path().join("deploy"));
774 assert_eq!(temp_config.out, temp.path().join("custom-out"));
775 assert_eq!(temp_config.cache_path, temp.path().join("custom-cache"));
776 assert_eq!(temp_config.snapshots, temp.path().join("custom-snapshots"));
777 assert_eq!(temp_config.broadcast, temp.path().join("custom-broadcast"));
778 assert_eq!(temp_config.mutation_dir, temp.path().join("custom-cache/mutation"));
779 assert_eq!(temp_config.libs, vec![temp.path().join("vendor")]);
780 assert_eq!(temp_config.include_paths, vec![temp.path().join("shared")]);
781 assert_eq!(temp_config.allow_paths, vec![temp.path().join("fixtures")]);
782 assert_eq!(
783 temp_config.fuzz.failure_persist_dir,
784 Some(temp.path().join("custom-cache/fuzz"))
785 );
786 assert_eq!(
787 temp_config.invariant.failure_persist_dir,
788 Some(temp.path().join("custom-cache/invariant"))
789 );
790 assert!(temp_config.dynamic_test_linking);
791 assert!(temp_config.cache);
792 assert_eq!(temp_config.fuzz.seed, Some(U256::from(42)));
793 assert_eq!(temp_config.fuzz.timeout, Some(5));
794 assert_eq!(temp_config.invariant.timeout, Some(5));
795 }
796}