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