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