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, 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/// 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    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    // Default to available parallelism if num_workers is 0
189    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    // Only print if no progress bar and not silent
200    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    // Get relative path of source within project - MUST be relative for safety
205    // Canonicalize paths to handle relative vs absolute path comparisons
206    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    // `ProjectPathsConfig` canonicalizes its root. Create mutant workspaces beneath the canonical
226    // temp root as well so explicit compiler inputs, project-local remappings, and the project
227    // root all use the same path spelling (notably `/private/var` rather than `/var` on macOS).
228    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    // Configure rayon thread pool
234    let pool = rayon::ThreadPoolBuilder::new()
235        .num_threads(num_workers)
236        .stack_size(16 * 1024 * 1024) // 16MB stack to avoid overflow in deep call chains
237        .build()
238        .map_err(|e| eyre::eyre!("Failed to create thread pool: {}", e))?;
239
240    // Use a thread-safe collection to store results as they complete
241    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            // Skip if cancelled
250            if shared_state.is_cancelled() {
251                return;
252            }
253
254            // Wrap in catch_unwind to prevent one panic from aborting the entire run
255            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            // Store result immediately
283            if let Ok(mut results) = completed_results.lock() {
284                results.push(test_result);
285            }
286        });
287    });
288
289    // Extract results
290    let results = Arc::try_unwrap(completed_results)
291        .map(|m| m.into_inner().unwrap_or_default())
292        .unwrap_or_default();
293
294    // Drain and join any worker threads that were left running by a
295    // wall-clock `TimedOut`. Each worker owns its own `TempDir`, so joining
296    // here is what actually deletes the per-mutant workspace from disk. This
297    // is the difference between a clean shutdown and stale `forge_mutation_*`
298    // directories piling up under `$TMPDIR`.
299    //
300    // We intentionally block: by this point all rayon work is done, the
301    // wall-clock budget has already been spent, and the only thing left to do
302    // is reclaim cleanup. The inner `fuzz.timeout` / `invariant.timeout`
303    // values we propagated earlier bound how long any individual worker can
304    // actually run.
305    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    // Clear progress and handle cancellation
321    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/// Test a single mutant in an isolated temporary workspace.
335#[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    // Check if we should skip this mutant based on adaptive span tracking
350    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    // Show progress or log
366    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    // Create isolated workspace using TempDir for automatic cleanup on drop
375    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    // Copy project to temp directory
385    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    // Apply mutation - source_relative is guaranteed to be relative at this point
391    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    // Compile and test, optionally bounded by a wall-clock timeout.
402    //
403    // Lifetime contract: `temp_dir` (the `TempDir`) must live *at least* as
404    // long as the worker thread that reads from `temp_path`. Dropping the
405    // `TempDir` early would delete the workspace while a worker still touches
406    // it, which is a real correctness bug (random compile/test failures and
407    // dangling fs handles on Windows).
408    //
409    // To satisfy that contract we move `temp_dir` ownership into the worker
410    // thread. If the wall-clock budget fires the outer call returns
411    // `TimedOut`, but the `TempDir` only drops when the worker thread itself
412    // exits. The `JoinHandle` is stored in `shared_state.pending_workers` and
413    // joined at the end of the parallel run.
414    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); // explicit: workspace is only safe to remove now
442            res
443        }
444    };
445
446    // Track adaptive survived spans only for genuinely Alive mutants; TimedOut
447    // is unresolved and must not mask other mutations on the same span.
448    if matches!(result, MutationResult::Alive) {
449        shared_state.mark_span_survived(mutant.span);
450    }
451
452    // Update progress
453    if let Some(ref progress) = shared_state.progress {
454        progress.complete_mutant(&mutant, &result);
455    }
456
457    MutantTestResult { mutant, result }
458}
459
460/// Run `compile_and_test` on a worker thread and wait at most `budget` for it
461/// to complete. Returns `TimedOut` on overrun and `Invalid` on infrastructure
462/// errors / panics.
463///
464/// The worker takes ownership of `temp_dir` so the underlying workspace
465/// directory is only dropped when the worker thread actually exits. On
466/// timeout the `JoinHandle` is parked in `shared_state.pending_workers`
467/// and joined at the end of the parallel run.
468#[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    // Move `temp_dir` into the worker so its `Drop` only runs after the worker
483    // thread exits. Do NOT capture by reference — the worker may outlive this
484    // function on timeout.
485    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            // Keep `temp_dir` alive until *after* the worker is done with the
507            // workspace. Dropping here (vs at function entry on timeout)
508            // guarantees no use-after-free of the filesystem.
509            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            // Worker finished and sent a result; join briefly so the TempDir
520            // is actually cleaned up before we return.
521            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            // Timeout fired. The worker is still running and still owns the
534            // TempDir; park the handle so we can join (and reclaim cleanup)
535            // at the end of the parallel run instead of leaking it.
536            shared_state.park_timed_out_worker(handle);
537            MutationResult::TimedOut
538        }
539    }
540}
541
542/// Apply a mutation to a source file.
543fn 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    // Use checked slicing to avoid panics on invalid spans or non-UTF8 boundaries
550    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    // Ensure parent directory exists
572    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
580/// Build the config used inside a per-mutant temp workspace.
581///
582/// Start from the already materialized baseline config instead of reloading
583/// `foundry.toml`, so CLI overrides and runtime normalization stay identical
584/// between the baseline run and every mutant run.
585fn temp_config_for_mutation(config: &Config, temp_path: &Path) -> Config {
586    let mut temp_config = workspace::rebase_config_paths(config, temp_path);
587
588    // Propagate the per-mutant timeout into the inner fuzz/invariant harness
589    // so the hot test loop itself bails out at the deadline. Without this the
590    // outer `recv_timeout` would only stop *waiting* — the leaked worker
591    // thread would keep running expensive fuzz/invariant runs and starve the
592    // pool. We never raise an existing user-configured value.
593    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
607/// Compile the project and run tests, returning true if any test failed (mutant killed).
608///
609/// Dispatches to the correct network type based on `evm_opts.networks`.
610fn 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    // Compile
659    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    // Rebuild the per-mutant test filter so `--match-test`, `--match-contract`,
673    // `--match-path`, ... are honored against the temp workspace's paths
674    // (not the original project root). Without this the mutant runs would
675    // ignore user filters and execute a different test set than the baseline.
676    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    // Run tests - need a multi-threaded Tokio runtime since test() uses rayon internally
682    // with par_iter, and rayon workers need tokio handle access
683    let rt = tokio::runtime::Builder::new_multi_thread()
684        .worker_threads(1) // Minimize overhead, tests use rayon for parallelism
685        .enable_all()
686        .build()
687        .map_err(|e| eyre::eyre!("Failed to create tokio runtime: {}", e))?;
688
689    // Use block_on to run within the runtime context
690    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        // Build test runner mirroring the canonical `forge test` runner: same
695        // isolation flag, same fail-fast semantics for mutation, and same
696        // filter so kept/skipped tests stay consistent across baseline and
697        // mutant runs.
698        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    // Check if any test failed (mutant killed)
711    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}