Skip to main content

forge/mutation/
runner.rs

1//! Parallel mutation testing runner.
2//!
3//! This module provides high-performance parallel execution of mutation tests.
4//! Each mutant is tested in an isolated temporary workspace to enable concurrent execution.
5
6use 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/// Result of testing a single mutant.
48#[derive(Debug, Clone)]
49pub struct MutantTestResult {
50    pub mutant: Mutant,
51    pub result: MutationResult,
52}
53
54/// Result of a parallel mutation batch.
55#[derive(Debug, Clone)]
56pub struct MutationBatchResult {
57    pub results: Vec<MutantTestResult>,
58    pub cancelled: bool,
59}
60
61/// Tracks progress and adaptive span skipping across parallel workers.
62pub struct SharedMutationState {
63    /// Spans where mutations have survived - shared across workers for adaptive skipping.
64    pub survived_spans: Mutex<SurvivedSpans>,
65    /// Progress counter.
66    pub completed: AtomicUsize,
67    pub total: AtomicUsize,
68    /// Cancellation flag (Ctrl+C)
69    pub cancelled: Arc<AtomicBool>,
70    /// Optional progress display
71    pub progress: Option<MutationProgress>,
72    /// Whether to suppress all output (for JSON mode)
73    pub silent: bool,
74    /// Worker threads spawned for timed-out mutants. We keep these handles
75    /// alive (and the `TempDir` they own) so that:
76    ///   1. The `TempDir` is *not* dropped while the worker is still touching it.
77    ///   2. We can join the threads at the end of the run and surface leaks.
78    pub pending_workers: Mutex<Vec<JoinHandle<()>>>,
79    /// Maximum number of timed-out worker handles to keep pending at once.
80    /// Older handles are joined before parking more, bounding cleanup backlog.
81    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        // Handle mutex poisoning gracefully - don't skip if we can't check
115        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        // Handle mutex poisoning gracefully - just skip marking if poisoned
120        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/// Run mutation tests in parallel with optional progress display.
167#[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    // Default to available parallelism if num_workers is 0
188    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    // Only print if no progress bar and not silent
199    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    // Get relative path of source within project - MUST be relative for safety
204    // Canonicalize paths to handle relative vs absolute path comparisons
205    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    // Configure rayon thread pool
225    let pool = rayon::ThreadPoolBuilder::new()
226        .num_threads(num_workers)
227        .stack_size(16 * 1024 * 1024) // 16MB stack to avoid overflow in deep call chains
228        .build()
229        .map_err(|e| eyre::eyre!("Failed to create thread pool: {}", e))?;
230
231    // Use a thread-safe collection to store results as they complete
232    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            // Skip if cancelled
240            if shared_state.is_cancelled() {
241                return;
242            }
243
244            // Wrap in catch_unwind to prevent one panic from aborting the entire run
245            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            // Store result immediately
271            if let Ok(mut results) = completed_results.lock() {
272                results.push(test_result);
273            }
274        });
275    });
276
277    // Extract results
278    let results = Arc::try_unwrap(completed_results)
279        .map(|m| m.into_inner().unwrap_or_default())
280        .unwrap_or_default();
281
282    // Drain and join any worker threads that were left running by a
283    // wall-clock `TimedOut`. Each worker owns its own `TempDir`, so joining
284    // here is what actually deletes the per-mutant workspace from disk. This
285    // is the difference between a clean shutdown and stale `forge_mutation_*`
286    // directories piling up under `$TMPDIR`.
287    //
288    // We intentionally block: by this point all rayon work is done, the
289    // wall-clock budget has already been spent, and the only thing left to do
290    // is reclaim cleanup. The inner `fuzz.timeout` / `invariant.timeout`
291    // values we propagated earlier bound how long any individual worker can
292    // actually run.
293    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    // Clear progress and handle cancellation
309    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/// Test a single mutant in an isolated temporary workspace.
323#[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    // Check if we should skip this mutant based on adaptive span tracking
336    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    // Show progress or log
352    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    // Create isolated workspace using TempDir for automatic cleanup on drop
361    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    // Copy project to temp directory
370    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    // Apply mutation - source_relative is guaranteed to be relative at this point
376    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    // Compile and test, optionally bounded by a wall-clock timeout.
387    //
388    // Lifetime contract: `temp_dir` (the `TempDir`) must live *at least* as
389    // long as the worker thread that reads from `temp_path`. Dropping the
390    // `TempDir` early would delete the workspace while a worker still touches
391    // it, which is a real correctness bug (random compile/test failures and
392    // dangling fs handles on Windows).
393    //
394    // To satisfy that contract we move `temp_dir` ownership into the worker
395    // thread. If the wall-clock budget fires the outer call returns
396    // `TimedOut`, but the `TempDir` only drops when the worker thread itself
397    // exits. The `JoinHandle` is stored in `shared_state.pending_workers` and
398    // joined at the end of the parallel run.
399    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); // explicit: workspace is only safe to remove now
425            res
426        }
427    };
428
429    // Track adaptive survived spans only for genuinely Alive mutants; TimedOut
430    // is unresolved and must not mask other mutations on the same span.
431    if matches!(result, MutationResult::Alive) {
432        shared_state.mark_span_survived(mutant.span);
433    }
434
435    // Update progress
436    if let Some(ref progress) = shared_state.progress {
437        progress.complete_mutant(&mutant, &result);
438    }
439
440    MutantTestResult { mutant, result }
441}
442
443/// Run `compile_and_test` on a worker thread and wait at most `budget` for it
444/// to complete. Returns `TimedOut` on overrun and `Invalid` on infrastructure
445/// errors / panics.
446///
447/// The worker takes ownership of `temp_dir` so the underlying workspace
448/// directory is only dropped when the worker thread actually exits. On
449/// timeout the `JoinHandle` is parked in `shared_state.pending_workers`
450/// and joined at the end of the parallel run.
451#[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    // Move `temp_dir` into the worker so its `Drop` only runs after the worker
465    // thread exits. Do NOT capture by reference — the worker may outlive this
466    // function on timeout.
467    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            // Keep `temp_dir` alive until *after* the worker is done with the
487            // workspace. Dropping here (vs at function entry on timeout)
488            // guarantees no use-after-free of the filesystem.
489            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            // Worker finished and sent a result; join briefly so the TempDir
500            // is actually cleaned up before we return.
501            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            // Timeout fired. The worker is still running and still owns the
514            // TempDir; park the handle so we can join (and reclaim cleanup)
515            // at the end of the parallel run instead of leaking it.
516            shared_state.park_timed_out_worker(handle);
517            MutationResult::TimedOut
518        }
519    }
520}
521
522/// Apply a mutation to a source file.
523fn 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    // Use checked slicing to avoid panics on invalid spans or non-UTF8 boundaries
530    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    // Ensure parent directory exists
552    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
560/// Build the config used inside a per-mutant temp workspace.
561///
562/// Start from the already materialized baseline config instead of reloading
563/// `foundry.toml`, so CLI overrides and runtime normalization stay identical
564/// between the baseline run and every mutant run.
565fn 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    // Propagate the per-mutant timeout into the inner fuzz/invariant harness
599    // so the hot test loop itself bails out at the deadline. Without this the
600    // outer `recv_timeout` would only stop *waiting* — the leaked worker
601    // thread would keep running expensive fuzz/invariant runs and starve the
602    // pool. We never raise an existing user-configured value.
603    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
622/// Compile the project and run tests, returning true if any test failed (mutant killed).
623///
624/// Dispatches to the correct network type based on `evm_opts.networks`.
625fn 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    // Compile
669    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    // Rebuild the per-mutant test filter so `--match-test`, `--match-contract`,
682    // `--match-path`, ... are honored against the temp workspace's paths
683    // (not the original project root). Without this the mutant runs would
684    // ignore user filters and execute a different test set than the baseline.
685    let filter = filter_args.clone().merge_with_config(config);
686
687    // Run tests - need a multi-threaded Tokio runtime since test() uses rayon internally
688    // with par_iter, and rayon workers need tokio handle access
689    let rt = tokio::runtime::Builder::new_multi_thread()
690        .worker_threads(1) // Minimize overhead, tests use rayon for parallelism
691        .enable_all()
692        .build()
693        .map_err(|e| eyre::eyre!("Failed to create tokio runtime: {}", e))?;
694
695    // Use block_on to run within the runtime context
696    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        // Build test runner mirroring the canonical `forge test` runner: same
701        // isolation flag, same fail-fast semantics for mutation, and same
702        // filter so kept/skipped tests stay consistent across baseline and
703        // mutant runs.
704        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    // Check if any test failed (mutant killed)
717    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}