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