diff --git a/src/console.rs b/src/console.rs index c8db13a5..0226f892 100644 --- a/src/console.rs +++ b/src/console.rs @@ -1,4 +1,4 @@ -// Copyright 2021-2023 Martin Pool +// Copyright 2021-2024 Martin Pool //! Print messages and progress bars on the terminal. @@ -6,6 +6,7 @@ use std::borrow::Cow; use std::fmt::Write; use std::fs::File; use std::io; +use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; @@ -70,9 +71,14 @@ impl Console { } /// Update that a cargo task is starting. - pub fn scenario_started(&self, scenario: &Scenario, log_file: &Utf8Path) -> Result<()> { + pub fn scenario_started( + &self, + dir: &Path, + scenario: &Scenario, + log_file: &Utf8Path, + ) -> Result<()> { let start = Instant::now(); - let scenario_model = ScenarioModel::new(scenario, start, log_file)?; + let scenario_model = ScenarioModel::new(dir, scenario, start, log_file)?; self.view.update(|model| { model.scenario_models.push(scenario_model); }); @@ -82,6 +88,7 @@ impl Console { /// Update that cargo finished. pub fn scenario_finished( &self, + dir: &Path, scenario: &Scenario, outcome: &ScenarioOutcome, options: &Options, @@ -96,7 +103,7 @@ impl Console { SummaryOutcome::Success => model.successes += 1, SummaryOutcome::Failure => model.failures += 1, } - model.remove_scenario(scenario); + model.remove_scenario(dir); }); if (outcome.mutant_caught() && !options.print_caught) @@ -142,24 +149,29 @@ impl Console { self.message(&s); } - pub fn start_copy(&self) { + pub fn start_copy(&self, dir: &Path) { self.view.update(|model| { - assert!(model.copy_model.is_none()); - model.copy_model = Some(CopyModel::new()); + model.copy_models.push(CopyModel::new(dir.to_owned())); }); } - pub fn finish_copy(&self) { + pub fn finish_copy(&self, dir: &Path) { self.view.update(|model| { - model.copy_model = None; + let idx = model + .copy_models + .iter() + .position(|m| m.dest == dir) + .expect("copy model not found"); + model.copy_models.swap_remove(idx); }); } - pub fn copy_progress(&self, total_bytes: u64) { + pub fn copy_progress(&self, dest: &Path, total_bytes: u64) { self.view.update(|model| { model - .copy_model - .as_mut() + .copy_models + .iter_mut() + .find(|m| m.dest == dest) .expect("copy in progress") .bytes_copied(total_bytes) }); @@ -185,15 +197,15 @@ impl Console { } /// A new phase of this scenario started. - pub fn scenario_phase_started(&self, scenario: &Scenario, phase: Phase) { + pub fn scenario_phase_started(&self, dir: &Path, phase: Phase) { self.view.update(|model| { - model.find_scenario_mut(scenario).phase_started(phase); + model.find_scenario_mut(dir).phase_started(phase); }) } - pub fn scenario_phase_finished(&self, scenario: &Scenario, phase: Phase) { + pub fn scenario_phase_finished(&self, dir: &Path, phase: Phase) { self.view.update(|model| { - model.find_scenario_mut(scenario).phase_finished(phase); + model.find_scenario_mut(dir).phase_finished(phase); }) } @@ -342,10 +354,11 @@ impl io::Write for DebugLogWriter { #[derive(Default)] struct LabModel { walk_tree: Option, - copy_model: Option, + /// Copy jobs in progress + copy_models: Vec, scenario_models: Vec, lab_start_time: Option, - // The instant when we started trying mutation scenarios, after running the baseline. + /// The instant when we started trying mutation scenarios, after running the baseline. mutants_start_time: Option, mutants_done: usize, n_mutants: usize, @@ -363,11 +376,11 @@ impl nutmeg::Model for LabModel { if let Some(walk_tree) = &mut self.walk_tree { s += &walk_tree.render(width); } - if let Some(copy) = self.copy_model.as_mut() { - s.push_str(©.render(width)); - } - if !s.is_empty() { - s.push('\n') + for copy_model in self.copy_models.iter_mut() { + if !s.is_empty() { + s.push('\n') + } + s.push_str(©_model.render(width)); } for sm in self.scenario_models.iter_mut() { s.push_str(&sm.render(width)); @@ -438,15 +451,15 @@ impl nutmeg::Model for LabModel { } impl LabModel { - fn find_scenario_mut(&mut self, scenario: &Scenario) -> &mut ScenarioModel { + fn find_scenario_mut(&mut self, dir: &Path) -> &mut ScenarioModel { self.scenario_models .iter_mut() - .find(|sm| sm.scenario == *scenario) - .expect("scenario is in progress") + .find(|sm| sm.dir == *dir) + .expect("scenario directory not found") } - fn remove_scenario(&mut self, scenario: &Scenario) { - self.scenario_models.retain(|sm| sm.scenario != *scenario); + fn remove_scenario(&mut self, dir: &Path) { + self.scenario_models.retain(|sm| sm.dir != *dir); } } @@ -474,7 +487,8 @@ impl nutmeg::Model for WalkModel { /// /// It draws the command and some description of what scenario is being tested. struct ScenarioModel { - scenario: Scenario, + /// The directory where this is being built: unique across all models. + dir: PathBuf, name: Cow<'static, str>, phase_start: Instant, phase: Option, @@ -484,10 +498,15 @@ struct ScenarioModel { } impl ScenarioModel { - fn new(scenario: &Scenario, start: Instant, log_file: &Utf8Path) -> Result { + fn new( + dir: &Path, + scenario: &Scenario, + start: Instant, + log_file: &Utf8Path, + ) -> Result { let log_tail = TailFile::new(log_file).context("Failed to open log file")?; Ok(ScenarioModel { - scenario: scenario.clone(), + dir: dir.to_owned(), name: style_scenario(scenario, true), phase: None, phase_start: start, @@ -537,14 +556,15 @@ impl nutmeg::Model for ScenarioModel { /// A Nutmeg model for progress in copying a tree. struct CopyModel { + dest: PathBuf, bytes_copied: u64, start: Instant, } impl CopyModel { - #[allow(dead_code)] - fn new() -> CopyModel { + fn new(dest: PathBuf) -> CopyModel { CopyModel { + dest, start: Instant::now(), bytes_copied: 0, } diff --git a/src/copy_tree.rs b/src/copy_tree.rs index 3a8c9a5f..e1e7c29c 100644 --- a/src/copy_tree.rs +++ b/src/copy_tree.rs @@ -1,4 +1,4 @@ -// Copyright 2023 Martin Pool +// Copyright 2023 - 2024 Martin Pool //! Copy a source tree, with some exclusions, to a new temporary directory. @@ -39,7 +39,6 @@ pub fn copy_tree( gitignore: bool, console: &Console, ) -> Result { - console.start_copy(); let mut total_bytes = 0; let mut total_files = 0; let temp_dir = tempfile::Builder::new() @@ -47,6 +46,8 @@ pub fn copy_tree( .suffix(".tmp") .tempdir() .context("create temp dir")?; + let dest = temp_dir.path(); + console.start_copy(dest); for entry in WalkBuilder::new(from_path) .standard_filters(gitignore) .hidden(false) @@ -80,7 +81,7 @@ pub fn copy_tree( })?; total_bytes += bytes_copied; total_files += 1; - console.copy_progress(total_bytes); + console.copy_progress(dest, total_bytes); } else if ft.is_dir() { std::fs::create_dir_all(&dest_path) .with_context(|| format!("Failed to create directory {dest_path:?}"))?; @@ -97,8 +98,8 @@ pub fn copy_tree( warn!("Unexpected file type: {:?}", entry.path()); } } - console.finish_copy(); - debug!(?total_bytes, ?total_files, "Copied source tree"); + console.finish_copy(dest); + debug!(?total_bytes, ?total_files, temp_dir = ?temp_dir.path(), "Copied source tree"); Ok(temp_dir) } diff --git a/src/lab.rs b/src/lab.rs index d42046dd..959cdb23 100644 --- a/src/lab.rs +++ b/src/lab.rs @@ -1,9 +1,10 @@ -// Copyright 2021-2023 Martin Pool +// Copyright 2021-2024 Martin Pool //! Successively apply mutations to the source code and run cargo to check, build, and test them. use std::cmp::{max, min}; use std::panic::resume_unwind; +use std::path::Path; use std::sync::Mutex; use std::thread; use std::time::Instant; @@ -34,11 +35,12 @@ pub fn test_mutants( console: &Console, ) -> Result { let start_time = Instant::now(); - let output_in_dir: &Utf8Path = options - .output_in_dir - .as_ref() - .map_or(workspace_dir, |p| p.as_path()); - let output_dir = OutputDir::new(output_in_dir)?; + let output_dir = OutputDir::new( + options + .output_in_dir + .as_ref() + .map_or(workspace_dir, |p| p.as_path()), + )?; console.set_debug_log(output_dir.open_debug_log()?); if options.shuffle { @@ -50,21 +52,22 @@ pub fn test_mutants( warn!("No mutants found under the active filters"); return Ok(LabOutcome::default()); } - let all_packages = mutants.iter().map(|m| m.package()).unique().collect_vec(); - debug!(?all_packages); + let mutant_packages = mutants.iter().map(|m| m.package()).unique().collect_vec(); // hold + debug!(?mutant_packages); let output_mutex = Mutex::new(output_dir); let build_dir = match options.in_place { true => BuildDir::in_place(workspace_dir)?, false => BuildDir::copy_from(workspace_dir, options.gitignore, options.leak_dirs, console)?, }; + let timeouts = match options.baseline { BaselineStrategy::Run => { let outcome = test_scenario( &build_dir, &output_mutex, &Scenario::Baseline, - &all_packages, + &mutant_packages, Timeouts::for_baseline(&options), &options, console, @@ -86,34 +89,38 @@ pub fn test_mutants( BaselineStrategy::Skip => Timeouts::without_baseline(&options), }; debug!(?timeouts); - let mut build_dirs = vec![build_dir]; - let jobs = max(1, min(options.jobs.unwrap_or(1), mutants.len())); - for i in 1..jobs { - debug!("copy build dir {i}"); - build_dirs.push(BuildDir::copy_from( - workspace_dir, - options.gitignore, - options.leak_dirs, - console, - )?); - } - debug!(build_dirs = ?build_dirs); + let build_dir_0 = Mutex::new(Some(build_dir)); // Create n threads, each dedicated to one build directory. Each of them tries to take a // scenario to test off the queue, and then exits when there are no more left. console.start_testing_mutants(mutants.len()); + let n_threads = max(1, min(options.jobs.unwrap_or(1), mutants.len())); let pending = Mutex::new(mutants.into_iter()); thread::scope(|scope| -> crate::Result<()> { let mut threads = Vec::new(); - // TODO: Maybe, make the copies in parallel on each thread, rather than up front? - for build_dir in build_dirs { + for _i_thread in 0..n_threads { threads.push(scope.spawn(|| -> crate::Result<()> { - let build_dir = build_dir; // move it into this thread - trace!(thread_id = ?thread::current().id(), ?build_dir, "start thread"); + trace!(thread_id = ?thread::current().id(), "start thread"); + // First thread to start can use the initial build dir; others need to copy a new one + let build_dir_0 = build_dir_0.lock().expect("lock build dir 0").take(); // separate for lock + let build_dir = match build_dir_0 { + Some(d) => d, + None => { + debug!("copy build dir"); + BuildDir::copy_from( + workspace_dir, + options.gitignore, + options.leak_dirs, + console, + )? + } + }; + let _thread_span = + debug_span!("worker thread", build_dir = ?build_dir.path()).entered(); loop { // Extract the mutant in a separate statement so that we don't hold the // lock while testing it. - let next = pending.lock().map(|mut s| s.next()); + let next = pending.lock().map(|mut s| s.next()); // separate for lock match next { Err(err) => { // PoisonError is not Send so we can't pass it directly. @@ -122,7 +129,7 @@ pub fn test_mutants( Ok(Some(mutant)) => { let _span = debug_span!("mutant", name = mutant.name(false, false)).entered(); - let package = mutant.package().clone(); + let package = mutant.package().clone(); // hold test_scenario( &build_dir, &output_mutex, @@ -134,8 +141,7 @@ pub fn test_mutants( )?; } Ok(None) => { - trace!("no more work"); - return Ok(()); + return Ok(()); // no more work for this thread } } } @@ -202,6 +208,11 @@ fn test_scenario( .expect("lock output_dir to create log") .create_log(scenario)?; log_file.message(&scenario.to_string()); + let phases: &[Phase] = if options.check_only { + &[Phase::Check] + } else { + &[Phase::Build, Phase::Test] + }; let applied = scenario .mutant() .map(|mutant| { @@ -211,16 +222,12 @@ fn test_scenario( mutant.apply(build_dir) }) .transpose()?; - console.scenario_started(scenario, log_file.path())?; + let dir: &Path = build_dir.path().as_ref(); + console.scenario_started(dir, scenario, log_file.path())?; let mut outcome = ScenarioOutcome::new(&log_file, scenario.clone()); - let phases: &[Phase] = if options.check_only { - &[Phase::Check] - } else { - &[Phase::Build, Phase::Test] - }; for &phase in phases { - console.scenario_phase_started(scenario, phase); + console.scenario_phase_started(dir, phase); let timeout = match phase { Phase::Test => timeouts.test, Phase::Build | Phase::Check => timeouts.build, @@ -236,7 +243,7 @@ fn test_scenario( )?; let success = phase_result.is_success(); // so we can move it away outcome.add_phase_result(phase_result); - console.scenario_phase_finished(scenario, phase); + console.scenario_phase_finished(dir, phase); if !success { break; } @@ -247,7 +254,7 @@ fn test_scenario( .expect("lock output dir to add outcome") .add_scenario_outcome(&outcome)?; debug!(outcome = ?outcome.summary()); - console.scenario_finished(scenario, &outcome, options); + console.scenario_finished(dir, scenario, &outcome, options); Ok(outcome) }