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 crate::{
7    MultiContractRunnerBuilder,
8    cmd::test::{FilterArgs, RerunFailure},
9    mutation::{
10        SurvivedSpans,
11        mutant::{Mutant, MutationResult},
12        progress::MutationProgress,
13    },
14    result::SuiteResult,
15    workspace,
16};
17use eyre::Result;
18use foundry_common::{compile::ProjectCompiler, sh_eprintln, sh_println};
19use foundry_compilers::compilers::multi::MultiCompiler;
20use foundry_config::{Config, InlineConfig};
21use foundry_evm::{
22    core::evm::{
23        BlockEnvFor, EthEvmNetwork, FoundryEvmNetwork, SpecFor, TempoEvmNetwork, TxEnvFor,
24    },
25    executors::ExecutorBuilder,
26    fork::ResolvedFork,
27    opts::EvmOpts,
28};
29use rayon::prelude::*;
30use std::{
31    collections::BTreeMap,
32    fs,
33    panic::{self, AssertUnwindSafe},
34    path::{Path, PathBuf},
35    sync::{
36        Arc, Mutex,
37        atomic::{AtomicBool, AtomicUsize, Ordering},
38        mpsc,
39    },
40    thread::JoinHandle,
41    time::Duration,
42};
43use tempfile::TempDir;
44
45#[cfg(feature = "base")]
46use foundry_evm::core::evm::BaseEvmNetwork;
47
48#[cfg(feature = "monad")]
49use foundry_evm::core::evm::MonadEvmNetwork;
50
51#[cfg(feature = "optimism")]
52use foundry_evm::core::evm::OpEvmNetwork;
53
54const MUTATION_STACK_SIZE: usize = 16 * 1024 * 1024;
55
56#[cfg(test)]
57const MUTATION_STACK_PROBE_ENV: &str = "FOUNDRY_MUTATION_STACK_PROBE";
58#[cfg(test)]
59const MUTATION_STACK_PROBE_MARKER_ENV: &str = "FOUNDRY_MUTATION_STACK_PROBE_MARKER";
60#[cfg(test)]
61static MUTATION_STACK_PROBE_RAN: AtomicBool = AtomicBool::new(false);
62
63/// Result of testing a single mutant.
64#[derive(Debug, Clone)]
65pub struct MutantTestResult {
66    pub mutant: Mutant,
67    pub result: MutationResult,
68}
69
70/// Result of a parallel mutation batch.
71#[derive(Debug, Clone)]
72pub struct MutationBatchResult {
73    pub results: Vec<MutantTestResult>,
74    pub cancelled: bool,
75}
76
77/// Immutable EVM inputs shared by the baseline and every mutation worker.
78#[derive(Clone)]
79pub struct MutationEvmConfig {
80    pub opts: EvmOpts,
81    pub resolved_fork: Option<ResolvedFork>,
82    pub create2_deployer_available: bool,
83}
84
85/// Tracks progress and adaptive span skipping across parallel workers.
86pub struct SharedMutationState {
87    /// Spans where mutations have survived - shared across workers for adaptive skipping.
88    pub survived_spans: Mutex<SurvivedSpans>,
89    /// Progress counter.
90    pub completed: AtomicUsize,
91    pub total: AtomicUsize,
92    /// Cancellation flag (Ctrl+C)
93    pub cancelled: Arc<AtomicBool>,
94    /// Optional progress display
95    pub progress: Option<MutationProgress>,
96    /// Whether to suppress all output (for JSON mode)
97    pub silent: bool,
98    /// Worker threads spawned for timed-out mutants. We keep these handles
99    /// alive (and the `TempDir` they own) so that:
100    ///   1. The `TempDir` is *not* dropped while the worker is still touching it.
101    ///   2. We can join the threads at the end of the run and surface leaks.
102    pub pending_workers: Mutex<Vec<JoinHandle<()>>>,
103    /// Maximum number of timed-out worker handles to keep pending at once.
104    /// Older handles are joined before parking more, bounding cleanup backlog.
105    max_pending_workers: AtomicUsize,
106}
107
108impl SharedMutationState {
109    pub fn new(
110        cancelled: Arc<AtomicBool>,
111        silent: bool,
112        progress: Option<MutationProgress>,
113    ) -> Self {
114        Self {
115            survived_spans: Mutex::new(SurvivedSpans::new()),
116            completed: AtomicUsize::new(0),
117            total: AtomicUsize::new(0),
118            cancelled,
119            progress,
120            silent,
121            pending_workers: Mutex::new(Vec::new()),
122            max_pending_workers: AtomicUsize::new(usize::MAX),
123        }
124    }
125
126    pub fn is_cancelled(&self) -> bool {
127        self.cancelled.load(Ordering::SeqCst)
128    }
129
130    pub fn cancel(&self) {
131        self.cancelled.store(true, Ordering::SeqCst);
132        if let Some(ref progress) = self.progress {
133            progress.cancel();
134        }
135    }
136
137    pub fn should_skip_span(&self, span: solar::ast::Span) -> bool {
138        // Handle mutex poisoning gracefully - don't skip if we can't check
139        self.survived_spans.lock().map(|guard| guard.should_skip_in_live_run(span)).unwrap_or(false)
140    }
141
142    pub fn mark_span_survived(&self, span: solar::ast::Span) {
143        // Handle mutex poisoning gracefully - just skip marking if poisoned
144        if let Ok(mut guard) = self.survived_spans.lock() {
145            guard.mark_survived(span);
146        }
147    }
148
149    pub fn increment_completed(&self) -> usize {
150        self.completed.fetch_add(1, Ordering::SeqCst) + 1
151    }
152
153    pub fn set_max_pending_workers(&self, max: usize) {
154        self.max_pending_workers.store(max.max(1), Ordering::SeqCst);
155    }
156
157    fn park_timed_out_worker(&self, handle: JoinHandle<()>) {
158        let mut pending = match self.pending_workers.lock() {
159            Ok(pending) => pending,
160            Err(_) => {
161                let _ = handle.join();
162                return;
163            }
164        };
165
166        let max_pending = self.max_pending_workers.load(Ordering::SeqCst).max(1);
167        while pending.len() >= max_pending {
168            let old_handle = pending.remove(0);
169            drop(pending);
170            let _ = old_handle.join();
171            pending = match self.pending_workers.lock() {
172                Ok(pending) => pending,
173                Err(_) => {
174                    let _ = handle.join();
175                    return;
176                }
177            };
178        }
179
180        pending.push(handle);
181    }
182}
183
184impl Default for SharedMutationState {
185    fn default() -> Self {
186        Self::new(Arc::new(AtomicBool::new(false)), false, None)
187    }
188}
189
190/// Run mutation tests in parallel with optional progress display.
191#[allow(clippy::too_many_arguments)]
192pub fn run_mutations_parallel_with_progress(
193    mutants: Vec<Mutant>,
194    source_path: PathBuf,
195    original_source: Arc<String>,
196    config: Arc<Config>,
197    evm: MutationEvmConfig,
198    num_workers: usize,
199    progress: Option<MutationProgress>,
200    silent: bool,
201    filter_args: FilterArgs,
202    rerun_failures: Option<Vec<RerunFailure>>,
203    selected_sources_relative: Arc<Vec<PathBuf>>,
204    isolate: bool,
205    cancellation_requested: Arc<AtomicBool>,
206) -> Result<MutationBatchResult> {
207    let total = mutants.len();
208    if total == 0 {
209        return Ok(MutationBatchResult { results: vec![], cancelled: false });
210    }
211
212    // Default to available parallelism if num_workers is 0
213    let num_workers = if num_workers == 0 {
214        std::thread::available_parallelism().map(|p| p.get()).unwrap_or(1)
215    } else {
216        num_workers
217    };
218
219    let shared_state = Arc::new(SharedMutationState::new(cancellation_requested, silent, progress));
220    shared_state.total.store(total, Ordering::SeqCst);
221    shared_state.set_max_pending_workers(num_workers);
222
223    // Only print if no progress bar and not silent
224    if shared_state.progress.is_none() && !shared_state.silent {
225        let _ = sh_println!("Running {} mutants in parallel with {} workers", total, num_workers);
226    }
227
228    // Get relative path of source within project - MUST be relative for safety
229    // Canonicalize paths to handle relative vs absolute path comparisons
230    let source_abs =
231        if source_path.is_absolute() { source_path } else { config.root.join(&source_path) };
232
233    let root_abs = config.root.canonicalize().unwrap_or_else(|_| config.root.clone());
234    let source_abs = source_abs.canonicalize().unwrap_or(source_abs);
235
236    let source_relative = source_abs
237        .strip_prefix(&root_abs)
238        .map_err(|_| {
239            eyre::eyre!(
240                "Source path {} is not under project root {}",
241                source_abs.display(),
242                root_abs.display()
243            )
244        })?
245        .to_path_buf();
246
247    workspace::ensure_safe_relative_path(&source_relative, "source", &source_abs)?;
248
249    // `ProjectPathsConfig` canonicalizes its root. Create mutant workspaces beneath the canonical
250    // temp root as well so explicit compiler inputs, project-local remappings, and the project
251    // root all use the same path spelling (notably `/private/var` rather than `/var` on macOS).
252    let temp_root = std::env::temp_dir();
253    let temp_root = dunce::canonicalize(&temp_root).map_err(|err| {
254        eyre::eyre!("failed to canonicalize mutation temp root {}: {err}", temp_root.display())
255    })?;
256
257    // Configure rayon thread pool
258    let pool = rayon::ThreadPoolBuilder::new()
259        .num_threads(num_workers)
260        .stack_size(MUTATION_STACK_SIZE)
261        .build()
262        .map_err(|e| eyre::eyre!("Failed to create thread pool: {}", e))?;
263
264    // Use a thread-safe collection to store results as they complete
265    let completed_results: Arc<Mutex<Vec<MutantTestResult>>> =
266        Arc::new(Mutex::new(Vec::with_capacity(total)));
267
268    let filter_args = Arc::new(filter_args);
269    let rerun_failures = Arc::new(rerun_failures);
270
271    pool.install(|| {
272        mutants.into_par_iter().for_each(|mutant| {
273            // Skip if cancelled
274            if shared_state.is_cancelled() {
275                return;
276            }
277
278            // Wrap in catch_unwind to prevent one panic from aborting the entire run
279            let mutant_clone = mutant.clone();
280            let result = panic::catch_unwind(AssertUnwindSafe(|| {
281                test_single_mutant_isolated(
282                    mutant,
283                    &source_relative,
284                    &original_source,
285                    &config,
286                    &evm,
287                    &shared_state,
288                    &temp_root,
289                    &filter_args,
290                    &rerun_failures,
291                    &selected_sources_relative,
292                    isolate,
293                )
294            }));
295
296            let test_result = match result {
297                Ok(r) => r,
298                Err(_) => {
299                    if shared_state.progress.is_none() {
300                        let _ = sh_eprintln!("Panic while testing mutant: {}", mutant_clone);
301                    }
302                    MutantTestResult { mutant: mutant_clone, result: MutationResult::Invalid }
303                }
304            };
305
306            // Store result immediately
307            if let Ok(mut results) = completed_results.lock() {
308                results.push(test_result);
309            }
310        });
311    });
312
313    // Extract results
314    let results = Arc::try_unwrap(completed_results)
315        .map(|m| m.into_inner().unwrap_or_default())
316        .unwrap_or_default();
317
318    // Drain and join any worker threads that were left running by a
319    // wall-clock `TimedOut`. Each worker owns its own `TempDir`, so joining
320    // here is what actually deletes the per-mutant workspace from disk. This
321    // is the difference between a clean shutdown and stale `forge_mutation_*`
322    // directories piling up under `$TMPDIR`.
323    //
324    // We intentionally block: by this point all rayon work is done, the
325    // wall-clock budget has already been spent, and the only thing left to do
326    // is reclaim cleanup. The inner `fuzz.timeout` / `invariant.timeout`
327    // values we propagated earlier bound how long any individual worker can
328    // actually run.
329    let pending = shared_state
330        .pending_workers
331        .lock()
332        .map(|mut g| std::mem::take(&mut *g))
333        .unwrap_or_default();
334    let pending_count = pending.len();
335    if pending_count > 0 && !shared_state.silent && shared_state.progress.is_none() {
336        let _ = sh_println!("Waiting for {pending_count} timed-out worker(s) to finish cleanup...");
337    }
338    for handle in pending {
339        let _ = handle.join();
340    }
341
342    let cancelled = shared_state.is_cancelled();
343
344    // Clear progress and handle cancellation
345    if let Some(ref progress) = shared_state.progress {
346        progress.clear();
347    }
348    if cancelled && !shared_state.silent {
349        let _ = sh_println!(
350            "\nMutation testing cancelled. Showing results for {} completed mutants.\n",
351            results.len()
352        );
353    }
354
355    Ok(MutationBatchResult { results, cancelled })
356}
357
358/// Test a single mutant in an isolated temporary workspace.
359#[allow(clippy::too_many_arguments)]
360fn test_single_mutant_isolated(
361    mutant: Mutant,
362    source_relative: &PathBuf,
363    original_source: &Arc<String>,
364    config: &Arc<Config>,
365    evm: &MutationEvmConfig,
366    shared_state: &Arc<SharedMutationState>,
367    temp_root: &Path,
368    filter_args: &Arc<FilterArgs>,
369    rerun_failures: &Arc<Option<Vec<RerunFailure>>>,
370    selected_sources_relative: &Arc<Vec<PathBuf>>,
371    isolate: bool,
372) -> MutantTestResult {
373    // Check if we should skip this mutant based on adaptive span tracking
374    if shared_state.should_skip_span(mutant.span) {
375        if let Some(ref progress) = shared_state.progress {
376            progress.complete_mutant(&mutant, &MutationResult::Skipped);
377        } else if !shared_state.silent {
378            let completed = shared_state.increment_completed();
379            let total = shared_state.total.load(Ordering::SeqCst);
380            let _ = sh_println!(
381                "[{}/{}] Skipping mutant (adaptive: span already has surviving mutation)",
382                completed,
383                total
384            );
385        }
386        return MutantTestResult { mutant, result: MutationResult::Skipped };
387    }
388
389    // Show progress or log
390    if let Some(ref progress) = shared_state.progress {
391        progress.start_mutant(&mutant);
392    } else if !shared_state.silent {
393        let completed = shared_state.increment_completed();
394        let total = shared_state.total.load(Ordering::SeqCst);
395        let _ = sh_println!("[{}/{}] Testing mutant: {}", completed, total, mutant);
396    }
397
398    // Create isolated workspace using TempDir for automatic cleanup on drop
399    let temp_dir = match TempDir::with_prefix_in("forge_mutation_", temp_root) {
400        Ok(dir) => dir,
401        Err(e) => {
402            let _ =
403                sh_eprintln!("Failed to create temp directory in {}: {}", temp_root.display(), e);
404            return MutantTestResult { mutant, result: MutationResult::Invalid };
405        }
406    };
407
408    // Copy project to temp directory
409    if let Err(e) = workspace::copy_project(config, temp_dir.path()) {
410        let _ = sh_eprintln!("Failed to copy project: {}", e);
411        return MutantTestResult { mutant, result: MutationResult::Invalid };
412    }
413
414    // Apply mutation - source_relative is guaranteed to be relative at this point
415    let mutated_source_path = temp_dir.path().join(source_relative);
416    if let Err(e) = apply_mutation(&mutant, original_source, &mutated_source_path) {
417        let _ = sh_eprintln!("Failed to apply mutation: {}", e);
418        return MutantTestResult { mutant, result: MutationResult::Invalid };
419    }
420
421    let temp_path = temp_dir.path().to_path_buf();
422    let temp_config = temp_config_for_mutation(config, &temp_path);
423    let temp_config = Arc::new(temp_config);
424
425    // Compile and test, optionally bounded by a wall-clock timeout.
426    //
427    // Lifetime contract: `temp_dir` (the `TempDir`) must live *at least* as
428    // long as the worker thread that reads from `temp_path`. Dropping the
429    // `TempDir` early would delete the workspace while a worker still touches
430    // it, which is a real correctness bug (random compile/test failures and
431    // dangling fs handles on Windows).
432    //
433    // To satisfy that contract we move `temp_dir` ownership into the worker
434    // thread. If the wall-clock budget fires the outer call returns
435    // `TimedOut`, but the `TempDir` only drops when the worker thread itself
436    // exits. The `JoinHandle` is stored in `shared_state.pending_workers` and
437    // joined at the end of the parallel run.
438    let timeout = config.mutation.timeout.map(|s| Duration::from_secs(s as u64));
439
440    let result = match timeout {
441        Some(budget) => run_compile_and_test_with_timeout(
442            temp_config,
443            evm,
444            budget,
445            temp_dir,
446            shared_state,
447            filter_args.clone(),
448            rerun_failures.clone(),
449            selected_sources_relative.clone(),
450            isolate,
451        ),
452        None => {
453            let res = match compile_and_test(
454                &temp_config,
455                evm,
456                filter_args,
457                rerun_failures.as_ref().as_deref(),
458                selected_sources_relative,
459                isolate,
460            ) {
461                Ok(true) => MutationResult::Dead,
462                Ok(false) => MutationResult::Alive,
463                Err(_) => MutationResult::Invalid,
464            };
465            drop(temp_dir); // explicit: workspace is only safe to remove now
466            res
467        }
468    };
469
470    // Track adaptive survived spans only for genuinely Alive mutants; TimedOut
471    // is unresolved and must not mask other mutations on the same span.
472    if matches!(result, MutationResult::Alive) {
473        shared_state.mark_span_survived(mutant.span);
474    }
475
476    // Update progress
477    if let Some(ref progress) = shared_state.progress {
478        progress.complete_mutant(&mutant, &result);
479    }
480
481    MutantTestResult { mutant, result }
482}
483
484/// Run `compile_and_test` on a worker thread and wait at most `budget` for it
485/// to complete. Returns `TimedOut` on overrun and `Invalid` on infrastructure
486/// errors / panics.
487///
488/// The worker takes ownership of `temp_dir` so the underlying workspace
489/// directory is only dropped when the worker thread actually exits. On
490/// timeout the `JoinHandle` is parked in `shared_state.pending_workers`
491/// and joined at the end of the parallel run.
492#[allow(clippy::too_many_arguments)]
493fn run_compile_and_test_with_timeout(
494    config: Arc<Config>,
495    evm: &MutationEvmConfig,
496    budget: Duration,
497    temp_dir: TempDir,
498    shared_state: &Arc<SharedMutationState>,
499    filter_args: Arc<FilterArgs>,
500    rerun_failures: Arc<Option<Vec<RerunFailure>>>,
501    selected_sources_relative: Arc<Vec<PathBuf>>,
502    isolate: bool,
503) -> MutationResult {
504    let (tx, rx) = mpsc::channel::<Result<bool>>();
505    let evm = evm.clone();
506    // Move `temp_dir` into the worker so its `Drop` only runs after the worker
507    // thread exits. Do NOT capture by reference — the worker may outlive this
508    // function on timeout.
509    let cfg = Arc::clone(&config);
510    let filter_for_worker = Arc::clone(&filter_args);
511    let rerun_for_worker = Arc::clone(&rerun_failures);
512    let selected_sources_for_worker = Arc::clone(&selected_sources_relative);
513
514    let spawn_result = std::thread::Builder::new()
515        .stack_size(MUTATION_STACK_SIZE)
516        .name("mutation-worker".to_string())
517        .spawn(move || {
518            // `test_collect` uses Rayon internally. Because this timeout worker
519            // is not itself a Rayon worker, nested parallel iterators would
520            // otherwise escape to the global pool and its default-sized stacks.
521            let res = panic::catch_unwind(AssertUnwindSafe(|| {
522                with_mutation_test_pool(|| {
523                    compile_and_test(
524                        &cfg,
525                        &evm,
526                        &filter_for_worker,
527                        rerun_for_worker.as_ref().as_deref(),
528                        &selected_sources_for_worker,
529                        isolate,
530                    )
531                })
532            }))
533            .unwrap_or_else(|_| Err(eyre::eyre!("worker panicked")));
534            let _ = tx.send(res);
535            // Keep `temp_dir` alive until *after* the worker is done with the
536            // workspace. Dropping here (vs at function entry on timeout)
537            // guarantees no use-after-free of the filesystem.
538            drop(temp_dir);
539        });
540
541    let handle = match spawn_result {
542        Ok(h) => h,
543        Err(_) => return MutationResult::Invalid,
544    };
545
546    match rx.recv_timeout(budget) {
547        Ok(Ok(true)) => {
548            // Worker finished and sent a result; join briefly so the TempDir
549            // is actually cleaned up before we return.
550            let _ = handle.join();
551            MutationResult::Dead
552        }
553        Ok(Ok(false)) => {
554            let _ = handle.join();
555            MutationResult::Alive
556        }
557        Ok(Err(_)) => {
558            let _ = handle.join();
559            MutationResult::Invalid
560        }
561        Err(_) => {
562            // Timeout fired. The worker is still running and still owns the
563            // TempDir; park the handle so we can join (and reclaim cleanup)
564            // at the end of the parallel run instead of leaking it.
565            shared_state.park_timed_out_worker(handle);
566            MutationResult::TimedOut
567        }
568    }
569}
570
571fn with_mutation_test_pool<T: Send>(op: impl FnOnce() -> Result<T> + Send) -> Result<T> {
572    rayon::ThreadPoolBuilder::new()
573        .num_threads(1)
574        .stack_size(MUTATION_STACK_SIZE)
575        .thread_name(|_| "mutation-test".to_string())
576        .build()
577        .map_err(|err| eyre::eyre!("failed to create mutation test pool: {err}"))?
578        .install(|| {
579            #[cfg(test)]
580            if std::env::var_os(MUTATION_STACK_PROBE_ENV).is_some() {
581                std::hint::black_box(mutation_stack_probe(1024));
582                MUTATION_STACK_PROBE_RAN.store(true, Ordering::Release);
583            }
584            op()
585        })
586}
587
588#[cfg(test)]
589#[inline(never)]
590fn mutation_stack_probe(depth: usize) -> usize {
591    let frame = [depth as u8; 8 * 1024];
592    std::hint::black_box(&frame);
593    if depth == 0 {
594        frame[0] as usize
595    } else {
596        let result = mutation_stack_probe(depth - 1).wrapping_add(frame[depth % frame.len()] as _);
597        std::hint::black_box(&frame);
598        result
599    }
600}
601
602/// Apply a mutation to a source file.
603fn apply_mutation(mutant: &Mutant, original_source: &str, dest_path: &Path) -> Result<()> {
604    let span = mutant.span;
605    let replacement = mutant.mutation.to_string();
606    let start_pos = span.lo().0 as usize;
607    let end_pos = span.hi().0 as usize;
608
609    // Use checked slicing to avoid panics on invalid spans or non-UTF8 boundaries
610    let before = original_source.get(..start_pos).ok_or_else(|| {
611        eyre::eyre!(
612            "Invalid mutation span: start {} is out of bounds for source length {}",
613            start_pos,
614            original_source.len()
615        )
616    })?;
617
618    let after = original_source.get(end_pos..).ok_or_else(|| {
619        eyre::eyre!(
620            "Invalid mutation span: end {} is out of bounds for source length {}",
621            end_pos,
622            original_source.len()
623        )
624    })?;
625
626    let mut new_content = String::with_capacity(before.len() + replacement.len() + after.len());
627    new_content.push_str(before);
628    new_content.push_str(&replacement);
629    new_content.push_str(after);
630
631    // Ensure parent directory exists
632    if let Some(parent) = dest_path.parent() {
633        fs::create_dir_all(parent)?;
634    }
635
636    fs::write(dest_path, new_content)?;
637    Ok(())
638}
639
640/// Build the config used inside a per-mutant temp workspace.
641///
642/// Start from the already materialized baseline config instead of reloading
643/// `foundry.toml`, so CLI overrides and runtime normalization stay identical
644/// between the baseline run and every mutant run.
645fn temp_config_for_mutation(config: &Config, temp_path: &Path) -> Config {
646    let mut temp_config = workspace::rebase_config_paths(config, temp_path);
647
648    // Propagate the per-mutant timeout into the inner fuzz/invariant harness
649    // so the hot test loop itself bails out at the deadline. Without this the
650    // outer `recv_timeout` would only stop *waiting* — the leaked worker
651    // thread would keep running expensive fuzz/invariant runs and starve the
652    // pool. We never raise an existing user-configured value.
653    if let Some(mutation_timeout) = config.mutation.timeout {
654        temp_config.fuzz.timeout = Some(match temp_config.fuzz.timeout {
655            Some(existing) => existing.min(mutation_timeout),
656            None => mutation_timeout,
657        });
658        temp_config.invariant.timeout = Some(match temp_config.invariant.timeout {
659            Some(existing) => existing.min(mutation_timeout),
660            None => mutation_timeout,
661        });
662    }
663
664    temp_config
665}
666
667/// Compile the project and run tests, returning true if any test failed (mutant killed).
668///
669/// Dispatches to the correct network type based on `evm_opts.networks`.
670fn compile_and_test(
671    config: &Arc<Config>,
672    evm: &MutationEvmConfig,
673    filter_args: &FilterArgs,
674    rerun_failures: Option<&[RerunFailure]>,
675    selected_sources_relative: &[PathBuf],
676    isolate: bool,
677) -> Result<bool> {
678    if evm.opts.networks.is_tempo() {
679        compile_and_test_inner::<TempoEvmNetwork>(
680            config,
681            evm,
682            filter_args,
683            rerun_failures,
684            selected_sources_relative,
685            isolate,
686            ExecutorBuilder::<TempoEvmNetwork>::new(),
687        )
688    } else {
689        #[cfg(feature = "base")]
690        if evm.opts.networks.is_base() {
691            return compile_and_test_inner::<BaseEvmNetwork>(
692                config,
693                evm,
694                filter_args,
695                rerun_failures,
696                selected_sources_relative,
697                isolate,
698                ExecutorBuilder::<BaseEvmNetwork>::new(),
699            );
700        }
701        #[cfg(feature = "monad")]
702        if evm.opts.networks.is_monad() {
703            return compile_and_test_inner::<MonadEvmNetwork>(
704                config,
705                evm,
706                filter_args,
707                rerun_failures,
708                selected_sources_relative,
709                isolate,
710                ExecutorBuilder::<MonadEvmNetwork>::new(),
711            );
712        }
713        #[cfg(feature = "optimism")]
714        if evm.opts.networks.is_optimism() {
715            return compile_and_test_inner::<OpEvmNetwork>(
716                config,
717                evm,
718                filter_args,
719                rerun_failures,
720                selected_sources_relative,
721                isolate,
722                ExecutorBuilder::<OpEvmNetwork>::new(),
723            );
724        }
725        compile_and_test_inner::<EthEvmNetwork>(
726            config,
727            evm,
728            filter_args,
729            rerun_failures,
730            selected_sources_relative,
731            isolate,
732            ExecutorBuilder::<EthEvmNetwork>::new(),
733        )
734    }
735}
736
737fn compile_and_test_inner<FEN: FoundryEvmNetwork>(
738    config: &Arc<Config>,
739    evm: &MutationEvmConfig,
740    filter_args: &FilterArgs,
741    rerun_failures: Option<&[RerunFailure]>,
742    selected_sources_relative: &[PathBuf],
743    isolate: bool,
744    executor_builder: ExecutorBuilder<FEN>,
745) -> Result<bool> {
746    let evm_opts = &evm.opts;
747    let resolved_fork = evm.resolved_fork.as_ref();
748    // Compile
749    let files = selected_sources_relative
750        .iter()
751        .map(|path| config.root.join(path))
752        .filter(|path| path.exists())
753        .collect::<Vec<_>>();
754    let compiler = ProjectCompiler::new()
755        .dynamic_test_linking(config.dynamic_test_linking)
756        .quiet(true)
757        .files(files);
758
759    let compile_output = compiler.compile(&config.project()?)?;
760    let inline_config = Arc::new(InlineConfig::new_parsed(&compile_output, config)?);
761
762    // Rebuild the per-mutant test filter so `--match-test`, `--match-contract`,
763    // `--match-path`, ... are honored against the temp workspace's paths
764    // (not the original project root). Without this the mutant runs would
765    // ignore user filters and execute a different test set than the baseline.
766    let mut filter = filter_args.clone().merge_with_config(config);
767    if let Some(rerun_failures) = rerun_failures {
768        filter.set_rerun_failures(rerun_failures.to_vec());
769    }
770
771    // Run tests - need a multi-threaded Tokio runtime since test() uses rayon internally
772    // with par_iter, and rayon workers need tokio handle access
773    let rt = tokio::runtime::Builder::new_multi_thread()
774        .worker_threads(1) // Minimize overhead, tests use rayon for parallelism
775        .enable_all()
776        .build()
777        .map_err(|e| eyre::eyre!("Failed to create tokio runtime: {}", e))?;
778
779    // Use block_on to run within the runtime context
780    let results: BTreeMap<String, SuiteResult> = rt.block_on(async {
781        let (evm_env, tx_env) = evm_opts
782            .env_with_resolved_fork::<SpecFor<FEN>, BlockEnvFor<FEN>, TxEnvFor<FEN>>(resolved_fork)
783            .await?;
784        let fork_context = resolved_fork.map(ResolvedFork::context);
785        let fork_chain_id = fork_context.map(|context| context.source_chain_id);
786        let fork_hardfork = fork_context.and_then(|context| context.hardfork);
787
788        // Build test runner mirroring the canonical `forge test` runner: same
789        // isolation flag, same fail-fast semantics for mutation, and same
790        // filter so kept/skipped tests stay consistent across baseline and
791        // mutant runs.
792        let mut runner = MultiContractRunnerBuilder::new(config.clone(), inline_config)
793            .set_debug(false)
794            .initial_balance(evm_opts.initial_balance)
795            .sender(evm_opts.sender)
796            .with_fork(evm_opts.get_fork_resolved(config, evm_env.cfg_env.chain_id, resolved_fork))
797            .with_fork_chain_id(fork_chain_id)
798            .with_fork_hardfork(fork_hardfork)
799            .enable_isolation(isolate)
800            .fail_fast(true)
801            .with_create2_deployer_available(evm.create2_deployer_available)
802            .build::<FEN, MultiCompiler>(
803                &compile_output,
804                evm_env,
805                tx_env,
806                evm_opts.clone(),
807                executor_builder,
808            )?;
809
810        runner.test_collect(&filter)
811    })?;
812
813    // Check if any test failed (mutant killed)
814    let killed = results.values().any(|suite| suite.failed() > 0);
815
816    Ok(killed)
817}
818
819#[cfg(test)]
820mod tests {
821    use super::*;
822    use alloy_primitives::U256;
823    use std::process::Command;
824
825    #[test]
826    fn timeout_path_uses_enlarged_test_worker_stack() {
827        if std::env::var_os(MUTATION_STACK_PROBE_ENV).is_some() {
828            let workspace = TempDir::new().unwrap();
829            let source = PathBuf::from("src/StackProbe.t.sol");
830            fs::create_dir_all(workspace.path().join("src")).unwrap();
831            fs::write(
832                workspace.path().join(&source),
833                "contract StackProbeTest { function test_stackProbe() public {} }",
834            )
835            .unwrap();
836            let mut config = Config::with_root(workspace.path());
837            config.out = workspace.path().join("out");
838            config.cache_path = workspace.path().join("cache/solidity-files-cache.json");
839            let config = Arc::new(config);
840            let evm = MutationEvmConfig {
841                opts: EvmOpts::default(),
842                resolved_fork: None,
843                create2_deployer_available: false,
844            };
845            let shared_state = Arc::new(SharedMutationState::default());
846
847            let result = run_compile_and_test_with_timeout(
848                config,
849                &evm,
850                Duration::from_secs(60),
851                workspace,
852                &shared_state,
853                Arc::new(FilterArgs::default()),
854                Arc::new(None),
855                Arc::new(vec![source]),
856                false,
857            );
858
859            assert!(!matches!(result, MutationResult::TimedOut), "unexpected result: {result:?}");
860            assert!(MUTATION_STACK_PROBE_RAN.load(Ordering::Acquire));
861            fs::write(std::env::var_os(MUTATION_STACK_PROBE_MARKER_ENV).unwrap(), b"ok").unwrap();
862            return;
863        }
864
865        let marker_dir = TempDir::new().unwrap();
866        let marker = marker_dir.path().join("completed");
867        let output = Command::new(std::env::current_exe().unwrap())
868            .args([
869                "--exact",
870                "mutation::runner::tests::timeout_path_uses_enlarged_test_worker_stack",
871            ])
872            .env(MUTATION_STACK_PROBE_ENV, "1")
873            .env(MUTATION_STACK_PROBE_MARKER_ENV, &marker)
874            .output()
875            .unwrap();
876        let stdout = String::from_utf8_lossy(&output.stdout);
877        let stderr = String::from_utf8_lossy(&output.stderr);
878
879        assert!(
880            output.status.success(),
881            "stack-pressure child failed:\nstdout:\n{stdout}\nstderr:\n{stderr}"
882        );
883        assert!(marker.exists(), "stack-pressure child did not complete the production path");
884    }
885
886    #[test]
887    fn park_timed_out_worker_bounds_pending_handles() {
888        let state = SharedMutationState::default();
889        state.set_max_pending_workers(1);
890
891        state.park_timed_out_worker(std::thread::spawn(|| {}));
892        assert_eq!(state.pending_workers.lock().unwrap().len(), 1);
893
894        state.park_timed_out_worker(std::thread::spawn(|| {}));
895        assert_eq!(state.pending_workers.lock().unwrap().len(), 1);
896
897        let pending = std::mem::take(&mut *state.pending_workers.lock().unwrap());
898        for handle in pending {
899            handle.join().unwrap();
900        }
901    }
902
903    #[test]
904    fn temp_config_preserves_materialized_overrides_and_rebases_paths() {
905        let project = TempDir::new().unwrap();
906        let temp = TempDir::new().unwrap();
907        let root = project.path();
908
909        let mut config = Config {
910            root: root.to_path_buf(),
911            src: root.join("contracts"),
912            test: root.join("checks"),
913            script: root.join("deploy"),
914            out: root.join("custom-out"),
915            cache_path: root.join("custom-cache"),
916            snapshots: root.join("custom-snapshots"),
917            broadcast: root.join("custom-broadcast"),
918            mutation_dir: root.join("custom-cache/mutation"),
919            libs: vec![root.join("vendor")],
920            include_paths: vec![root.join("shared")],
921            allow_paths: vec![root.join("fixtures")],
922            dynamic_test_linking: true,
923            cache: true,
924            ..Default::default()
925        };
926        config.fuzz.seed = Some(U256::from(42));
927        config.fuzz.timeout = Some(90);
928        config.invariant.timeout = Some(80);
929        config.fuzz.failure_persist_dir = Some(root.join("custom-cache/fuzz"));
930        config.invariant.failure_persist_dir = Some(root.join("custom-cache/invariant"));
931        config.mutation.timeout = Some(5);
932
933        let temp_config = temp_config_for_mutation(&config, temp.path());
934        let expected_root = dunce::canonicalize(temp.path()).unwrap();
935
936        assert_eq!(temp_config.root, expected_root);
937        assert_eq!(temp_config.src, expected_root.join("contracts"));
938        assert_eq!(temp_config.test, expected_root.join("checks"));
939        assert_eq!(temp_config.script, expected_root.join("deploy"));
940        assert_eq!(temp_config.out, expected_root.join("custom-out"));
941        assert_eq!(temp_config.cache_path, expected_root.join("custom-cache"));
942        assert_eq!(temp_config.snapshots, expected_root.join("custom-snapshots"));
943        assert_eq!(temp_config.broadcast, expected_root.join("custom-broadcast"));
944        assert_eq!(temp_config.mutation_dir, expected_root.join("custom-cache/mutation"));
945        assert_eq!(temp_config.libs, vec![expected_root.join("vendor")]);
946        assert_eq!(temp_config.include_paths, vec![expected_root.join("shared")]);
947        assert_eq!(temp_config.allow_paths, vec![expected_root.join("fixtures")]);
948        assert_eq!(
949            temp_config.fuzz.failure_persist_dir,
950            Some(expected_root.join("custom-cache/fuzz"))
951        );
952        assert_eq!(
953            temp_config.invariant.failure_persist_dir,
954            Some(expected_root.join("custom-cache/invariant"))
955        );
956        assert!(temp_config.dynamic_test_linking);
957        assert!(temp_config.cache);
958        assert_eq!(temp_config.fuzz.seed, Some(U256::from(42)));
959        assert_eq!(temp_config.fuzz.timeout, Some(5));
960        assert_eq!(temp_config.invariant.timeout, Some(5));
961    }
962}