use std::collections::{BTreeMap, BTreeSet, VecDeque};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use fallow_output::MutantStatus;
use rustc_hash::{FxHashMap, FxHashSet};
use super::manifest::RunnerRange;
use super::protocol::{
Capabilities, DryRunResult, InitRequest, LinkFault, Ready, ReloadMethod, Request, Response,
RunRequest, RunResult, RunStatus, TestStatus,
};
use super::transport::{CompanionLink, CompanionSpawner, SpawnFailure};
const WATCHDOG_GRACE_MS: u64 = 2000;
const TIMEOUT_BASE_MS: u64 = 5000;
const TIMEOUT_FACTOR: f64 = 1.5;
const RESPAWN_DRY_RUN_FACTOR: u32 = 2;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ScheduledMutant {
pub id: String,
pub file: String,
}
#[derive(Debug, Clone)]
pub struct SchedulePlan {
pub mutants: Vec<ScheduledMutant>,
pub unreached: Vec<ScheduledMutant>,
pub pending: Vec<ScheduledMutant>,
pub known_ids: FxHashSet<String>,
pub test_files: Vec<String>,
pub file_test_files: BTreeMap<String, Vec<String>>,
}
#[derive(Debug, Clone)]
pub struct ScheduleConfig {
pub init: InitRequest,
pub runner: RunnerRange,
pub concurrency: usize,
pub runner_timeout: Duration,
pub deadline: Option<Instant>,
pub cache: Option<Arc<dyn ResultLookup>>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CachedResult {
pub status: MutantStatus,
pub killed_by: Option<String>,
pub duration_ms: Option<u64>,
}
pub trait ResultLookup: std::fmt::Debug + Send + Sync {
fn key(
&self,
ready: &Ready,
mutant: &ScheduledMutant,
tests: &[String],
test_files: &[String],
) -> String;
fn get(&self, mutant_id: &str, key: &str) -> Option<CachedResult>;
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MutantOutcome {
pub status: MutantStatus,
pub killed_by: Option<String>,
pub covering_tests: Vec<String>,
pub static_mutant: bool,
pub duration_ms: Option<u64>,
pub retried: bool,
pub cached: bool,
}
impl MutantOutcome {
fn no_coverage() -> Self {
Self {
status: MutantStatus::NoCoverage,
killed_by: None,
covering_tests: Vec::new(),
static_mutant: false,
duration_ms: None,
retried: false,
cached: false,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct BaselineFailure {
pub file: Option<String>,
pub test_id: Option<String>,
pub message: Option<String>,
pub mutant_ids: Vec<String>,
}
const RESTARTED_DRY_RUN_FAILURE: &str =
"the test did not pass in the dry run of a restarted companion";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ScheduleIssue {
pub code: &'static str,
pub message: String,
pub mutant_id: Option<String>,
pub stderr_tail: Option<String>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct ScheduleTimings {
pub setup: Duration,
pub dry_run: Duration,
pub mutants: Duration,
pub retry: Duration,
}
#[derive(Debug, Clone)]
pub struct ScheduleOutcome {
pub ready: Option<Ready>,
pub results: FxHashMap<String, MutantOutcome>,
pub baseline_failures: Vec<BaselineFailure>,
pub unloaded_paths: Vec<String>,
pub issues: Vec<ScheduleIssue>,
pub unretried: Vec<String>,
pub timings: ScheduleTimings,
pub workers: usize,
pub cache_keys: FxHashMap<String, String>,
pub promoted: Vec<String>,
}
impl ScheduleOutcome {
fn empty() -> Self {
Self {
ready: None,
results: FxHashMap::default(),
baseline_failures: Vec::new(),
unloaded_paths: Vec::new(),
issues: Vec::new(),
unretried: Vec::new(),
timings: ScheduleTimings::default(),
workers: 0,
cache_keys: FxHashMap::default(),
promoted: Vec::new(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ScheduleError {
Integrity(String),
Setup {
message: String,
stderr_tail: Option<String>,
timed_out: bool,
},
RunnerVersion(String),
UnknownHit(String),
Baseline(Vec<String>),
RunPath { message: String, help: String },
}
impl std::fmt::Display for ScheduleError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Integrity(message)
| Self::RunnerVersion(message)
| Self::Setup { message, .. }
| Self::RunPath { message, .. } => f.write_str(message),
Self::UnknownHit(id) => write!(
f,
"the runner reported a hit for mutant `{id}`, which is not in the plan; the instrumented files do not match the plan"
),
Self::Baseline(messages) => write!(
f,
"the test suite fails with no active mutant: {}",
messages.join("; ")
),
}
}
}
#[derive(Debug, Clone)]
struct Job {
id: String,
tests: Vec<String>,
timeout_ms: u64,
setup_ms: u64,
static_mutant: bool,
cache_key: Option<String>,
}
impl Job {
fn watchdog_ms(&self) -> u64 {
self.timeout_ms + self.setup_ms + WATCHDOG_GRACE_MS
}
fn with_doubled_timeout(mut self) -> Self {
self.timeout_ms = self.timeout_ms.saturating_mul(2);
self.setup_ms = self.setup_ms.saturating_mul(2);
self
}
}
#[derive(Debug, Clone)]
struct Attempt {
status: MutantStatus,
killed_by: Option<String>,
duration_ms: Option<u64>,
issue: Option<ScheduleIssue>,
dropped_tests: Vec<String>,
settled: bool,
}
impl Attempt {
fn runtime_error(job: &Job, message: String, stderr_tail: Option<String>) -> Self {
Self {
status: MutantStatus::RuntimeError,
killed_by: None,
duration_ms: None,
issue: Some(ScheduleIssue {
code: "runtime-error",
message,
mutant_id: Some(job.id.clone()),
stderr_tail,
}),
dropped_tests: Vec::new(),
settled: false,
}
}
const fn uncovered(dropped_tests: Vec<String>) -> Self {
Self {
status: MutantStatus::NoCoverage,
killed_by: None,
duration_ms: None,
issue: None,
dropped_tests,
settled: true,
}
}
const fn decided(
status: MutantStatus,
killed_by: Option<String>,
duration_ms: Option<u64>,
) -> Self {
Self {
status,
killed_by,
duration_ms,
issue: None,
dropped_tests: Vec::new(),
settled: false,
}
}
}
#[expect(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "the duration sum is finite and non-negative"
)]
fn scaled_ms(duration_ms: f64) -> u64 {
let scaled = (duration_ms.max(0.0) * TIMEOUT_FACTOR).ceil();
if scaled.is_finite() { scaled as u64 } else { 0 }
}
fn timeout_ms(total_duration_ms: f64) -> u64 {
scaled_ms(total_duration_ms) + TIMEOUT_BASE_MS
}
fn classify(
job: &Job,
tests: &[String],
result: &RunResult,
) -> Result<(MutantStatus, Option<String>), String> {
if result.mutant_id.as_deref() != Some(job.id.as_str()) {
return Err(format!(
"the run result names mutant `{}`, expected `{}`",
result.mutant_id.as_deref().unwrap_or("null"),
job.id
));
}
let requested: FxHashSet<&str> = tests.iter().map(String::as_str).collect();
match result.status {
RunStatus::Killed => match &result.killed_by {
Some(test) if requested.contains(test.as_str()) => {
Ok((MutantStatus::Killed, Some(test.clone())))
}
Some(test) => Err(format!(
"the run result names killer `{test}`, which was not requested"
)),
None if !result.unhandled_errors.is_empty() => Ok((MutantStatus::Killed, None)),
None => Err("the run result is `killed` with no failed test and no error".to_owned()),
},
RunStatus::Timeout => Ok((MutantStatus::Timeout, None)),
RunStatus::RuntimeError => Err("the companion reported a runtime error".to_owned()),
RunStatus::Survived => {
if result.tests_executed == 0 {
return Err("zero tests ran".to_owned());
}
if result.tests_executed != tests.len() {
return Err(format!(
"{} of {} requested tests ran",
result.tests_executed,
tests.len()
));
}
if !result.unhandled_errors.is_empty() {
return Err("the run result is `survived` with an unhandled error".to_owned());
}
let passed: FxHashSet<&str> = result
.tests
.iter()
.filter(|test| test.status == TestStatus::Pass)
.map(|test| test.test_id.as_str())
.collect();
let all_pass = result.tests.iter().all(|test| {
test.status == TestStatus::Pass && requested.contains(test.test_id.as_str())
});
if !all_pass || requested.iter().any(|test| !passed.contains(test)) {
return Err(
"the run result is `survived` but a requested test did not pass".to_owned(),
);
}
Ok((MutantStatus::Survived, None))
}
}
}
fn is_load_kill(result: &RunResult, status: MutantStatus, killed_by: Option<&String>) -> bool {
status == MutantStatus::Killed && killed_by.is_none() && result.tests_executed == 0
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ControlFault {
no_tests: bool,
failed_tests: Vec<String>,
detail: String,
}
impl ControlFault {
fn untrusted(detail: String) -> Self {
Self {
no_tests: false,
failed_tests: Vec::new(),
detail,
}
}
}
fn control_fault(tests: &[String], result: &RunResult) -> Option<ControlFault> {
let first_error = result
.unhandled_errors
.first()
.map(|error| format!("; first error: {}", first_line(&error.message)));
if result.tests_executed == 0 {
return Some(ControlFault {
no_tests: true,
failed_tests: Vec::new(),
detail: format!(
"0 of {} requested tests ran{}",
tests.len(),
first_error.unwrap_or_default()
),
});
}
if let Some(id) = &result.mutant_id {
return Some(ControlFault::untrusted(format!(
"the run result names mutant `{id}`, expected none"
)));
}
let requested: FxHashSet<&str> = tests.iter().map(String::as_str).collect();
let failed_tests: Vec<String> = result
.tests
.iter()
.filter(|test| test.status == TestStatus::Fail && requested.contains(test.test_id.as_str()))
.map(|test| test.test_id.clone())
.collect();
if let Some(first) = failed_tests.first() {
return Some(ControlFault {
no_tests: false,
detail: format!("the test `{first}` failed"),
failed_tests,
});
}
if let Some(first_error) = first_error {
return Some(ControlFault::untrusted(format!(
"the run reported an error{first_error}"
)));
}
let passed: FxHashSet<&str> = result
.tests
.iter()
.filter(|test| test.status == TestStatus::Pass)
.map(|test| test.test_id.as_str())
.collect();
if result.status != RunStatus::Survived
|| result.tests_executed != tests.len()
|| tests.iter().any(|test| !passed.contains(test.as_str()))
{
return Some(ControlFault::untrusted(format!(
"{} of {} requested tests ran and passed",
passed.len(),
tests.len()
)));
}
None
}
const CONTROL_RUN_FAILURE: &str = "the test fails in a mutant run with no active mutant, but passed in the dry run of its whole file; it can depend on another test of its file";
fn first_line(message: &str) -> &str {
message.lines().next().unwrap_or_default()
}
enum ControlOutcome {
Passed,
Failed(ControlFault),
Fault(String),
TimedOut(String),
}
fn sorted_tests(tests: &[String]) -> Vec<&str> {
let mut sorted: Vec<&str> = tests.iter().map(String::as_str).collect();
sorted.sort_unstable();
sorted
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum ControlVerdict {
Passed,
Failed,
TimedOut(String),
}
type ControlCell = Arc<Mutex<Option<ControlVerdict>>>;
#[derive(Default)]
struct ControlMemo {
entries: Mutex<FxHashMap<Vec<String>, ControlCell>>,
failed_tests: Mutex<BTreeSet<String>>,
}
impl ControlMemo {
fn cell(&self, tests: &[String]) -> ControlCell {
let mut key = tests.to_vec();
key.sort_unstable();
let Ok(mut entries) = self.entries.lock() else {
return Arc::new(Mutex::new(None));
};
Arc::clone(entries.entry(key).or_default())
}
fn store(&self, tests: &[String], verdict: ControlVerdict) {
if let Ok(mut cell) = self.cell(tests).lock() {
*cell = Some(verdict);
}
}
fn mark_failed(&self, tests: &[String]) {
if let Ok(mut failed) = self.failed_tests.lock() {
failed.extend(tests.iter().cloned());
}
}
fn is_failed(&self, test: &str) -> bool {
self.failed_tests
.lock()
.is_ok_and(|failed| failed.contains(test))
}
fn failed(&self) -> BTreeSet<String> {
self.failed_tests
.lock()
.map(|failed| failed.clone())
.unwrap_or_default()
}
}
#[expect(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "durations are finite and non-negative milliseconds"
)]
fn round_ms(value: Option<f64>) -> Option<u64> {
value
.filter(|value| value.is_finite() && *value >= 0.0)
.map(|value| value.round() as u64)
}
struct Worker<'a> {
spawner: &'a dyn CompanionSpawner,
config: &'a ScheduleConfig,
dry_files: &'a [String],
dry_run_timeout: Duration,
link: Option<Box<dyn CompanionLink>>,
known_tests: FxHashSet<String>,
memo: &'a ControlMemo,
}
enum EnsureError {
Fatal(ScheduleError),
Fault(String, Option<String>),
}
impl<'a> Worker<'a> {
fn new(
spawner: &'a dyn CompanionSpawner,
config: &'a ScheduleConfig,
dry_files: &'a [String],
dry_run_timeout: Duration,
memo: &'a ControlMemo,
) -> Self {
Self {
spawner,
config,
dry_files,
dry_run_timeout,
link: None,
known_tests: FxHashSet::default(),
memo,
}
}
fn reset(&mut self) {
if let Some(mut link) = self.link.take() {
link.shutdown();
}
self.known_tests.clear();
}
fn ensure(&mut self) -> Result<&mut Box<dyn CompanionLink>, EnsureError> {
if self.link.is_none() {
let mut link = self.spawner.spawn().map_err(|failure| match failure {
SpawnFailure::Integrity(message) => {
EnsureError::Fatal(ScheduleError::Integrity(message))
}
SpawnFailure::Fault(fault) => EnsureError::Fault(
format!(
"the companion did not start: {fault}{}",
setup_timeout_hint(&fault)
),
fault.stderr_tail().map(str::to_owned),
),
})?;
let fault = |stage: &str, fault: &LinkFault| {
EnsureError::Fault(
format!(
"the companion {stage} failed: {fault}{}",
setup_timeout_hint(fault)
),
fault.stderr_tail().map(str::to_owned),
)
};
match link.request(
&Request::Init(self.config.init.clone()),
self.config.runner_timeout,
) {
Ok(Response::Ready(ready)) => {
if !self.config.runner.contains(&ready.runner_version) {
link.shutdown();
return Err(EnsureError::Fatal(version_error(
&self.config.runner,
&ready.runner_version,
)));
}
}
Ok(_) => return Err(EnsureError::Fault("init gave no ready".to_owned(), None)),
Err(error) => return Err(fault("init", &error)),
}
let request = Request::DryRun {
test_files: self.dry_files.to_vec(),
};
match link.request(&request, self.dry_run_timeout) {
Ok(Response::DryRun(result)) => {
self.known_tests = result
.tests
.into_iter()
.filter(|test| test.status == TestStatus::Pass)
.map(|test| test.test_id)
.collect();
}
Ok(_) => {
return Err(EnsureError::Fault(
"dry_run gave no dry_run_result".to_owned(),
None,
));
}
Err(error) => return Err(fault("dry run", &error)),
}
self.link = Some(link);
}
self.link
.as_mut()
.ok_or_else(|| EnsureError::Fault("the companion is gone".to_owned(), None))
}
fn attempt(&mut self, job: &Job, caps: Capabilities) -> Result<Attempt, ScheduleError> {
let needs_reload = !caps.hot_switch || (job.static_mutant && !caps.fresh_modules_per_run);
if needs_reload && self.link.is_some() {
self.reload();
}
match self.ensure() {
Ok(_) => {}
Err(EnsureError::Fatal(error)) => return Err(error),
Err(EnsureError::Fault(message, tail)) => {
self.reset();
return Ok(Attempt::runtime_error(job, message, tail));
}
}
let (tests, dropped_tests): (Vec<String>, Vec<String>) = job
.tests
.iter()
.cloned()
.partition(|test| self.known_tests.contains(test) && !self.memo.is_failed(test));
if tests.is_empty() {
if dropped_tests.iter().all(|test| self.memo.is_failed(test)) {
return Ok(Attempt::uncovered(dropped_tests));
}
let mut attempt =
Attempt::runtime_error(job, no_covering_test_message(&dropped_tests), None);
attempt.dropped_tests = dropped_tests;
return Ok(attempt);
}
let request = Request::Run(RunRequest {
mutant_id: Some(job.id.clone()),
test_ids: tests.clone(),
timeout_ms: job.timeout_ms,
bail: true,
});
let watchdog = Duration::from_millis(job.watchdog_ms());
let Some(link) = self.link.as_mut() else {
return Ok(Attempt::runtime_error(
job,
"the companion is gone".to_owned(),
None,
));
};
let mut load_kill = false;
let mut attempt = match link.request(&request, watchdog) {
Ok(Response::Run(result)) => match classify(job, &tests, &result) {
Ok((status, killed_by)) => {
load_kill = is_load_kill(&result, status, killed_by.as_ref());
Attempt::decided(status, killed_by, round_ms(result.duration_ms))
}
Err(reason) => {
self.reset();
Attempt::runtime_error(job, reason, None)
}
},
Ok(_) => {
self.reset();
Attempt::runtime_error(job, "run gave no run_result".to_owned(), None)
}
Err(LinkFault::Timeout) => {
self.reset();
Attempt::decided(MutantStatus::Timeout, None, Some(job.watchdog_ms()))
}
Err(fault) => {
let tail = fault.stderr_tail().map(str::to_owned);
self.reset();
Attempt::runtime_error(job, fault.to_string(), tail)
}
};
attempt.dropped_tests = dropped_tests;
if job.static_mutant && !caps.fresh_modules_per_run && self.link.is_some() {
self.reload();
}
if load_kill {
return self.confirm_load_kill(job, &tests, caps, attempt);
}
Ok(attempt)
}
fn confirm_load_kill(
&mut self,
job: &Job,
tests: &[String],
caps: Capabilities,
attempt: Attempt,
) -> Result<Attempt, ScheduleError> {
let tests: Vec<String> = tests
.iter()
.filter(|test| !self.memo.is_failed(test))
.cloned()
.collect();
if tests.is_empty() {
let mut dropped = attempt.dropped_tests;
dropped.extend(
job.tests
.iter()
.filter(|test| self.memo.is_failed(test))
.cloned(),
);
dropped.sort();
dropped.dedup();
return Ok(Attempt::uncovered(dropped));
}
let memo = self.memo;
let cell = memo.cell(&tests);
let Ok(mut known) = cell.lock() else {
return Ok(attempt);
};
let dropped_tests = attempt.dropped_tests.clone();
let failed = |detail: &str| {
let mut failed = Attempt::runtime_error(
job,
format!(
"the run executed no tests and reported an error, and the same tests do not pass in a control run with no active mutant: {detail}"
),
None,
);
failed.dropped_tests.clone_from(&dropped_tests);
failed.settled = true;
failed
};
match &*known {
Some(ControlVerdict::Passed) => return Ok(attempt),
Some(ControlVerdict::Failed) => {
return Ok(failed("an earlier control run of these tests failed"));
}
Some(ControlVerdict::TimedOut(reason)) => return Ok(failed(reason)),
None => {}
}
if self
.config
.deadline
.is_some_and(|deadline| Instant::now() >= deadline)
{
return Ok(Attempt::decided(MutantStatus::Pending, None, None));
}
let (outcome, ran) = self.control_run(&tests, job, caps)?;
let mut remember = |verdict: ControlVerdict| {
if sorted_tests(&ran) == sorted_tests(&tests) {
*known = Some(verdict);
} else if !ran.is_empty() {
memo.store(&ran, verdict);
}
};
match outcome {
ControlOutcome::Passed => {
remember(ControlVerdict::Passed);
Ok(attempt)
}
ControlOutcome::Failed(fault) => {
remember(ControlVerdict::Failed);
Ok(failed(&fault.detail))
}
ControlOutcome::TimedOut(detail) => {
let reason = format!("the control run timed out ({detail})");
remember(ControlVerdict::TimedOut(reason.clone()));
Ok(failed(&reason))
}
ControlOutcome::Fault(message) => Ok(Attempt {
settled: false,
..failed(&message)
}),
}
}
fn control_run(
&mut self,
tests: &[String],
job: &Job,
caps: Capabilities,
) -> Result<(ControlOutcome, Vec<String>), ScheduleError> {
if !caps.hot_switch && self.link.is_some() {
self.reload();
}
match self.ensure() {
Ok(_) => {}
Err(EnsureError::Fatal(error)) => return Err(error),
Err(EnsureError::Fault(message, _)) => {
self.reset();
return Ok((ControlOutcome::Fault(message), Vec::new()));
}
}
let tests: Vec<String> = tests
.iter()
.filter(|test| self.known_tests.contains(*test) && !self.memo.is_failed(test))
.cloned()
.collect();
if tests.is_empty() {
return Ok((
ControlOutcome::Fault(
"no covering test passed in the dry run of the companion".to_owned(),
),
tests,
));
}
let setup_ms = u64::try_from(self.config.runner_timeout.as_millis()).unwrap_or(u64::MAX);
let timeout_ms = job.timeout_ms.max(setup_ms);
let request = Request::Run(RunRequest {
mutant_id: None,
test_ids: tests.clone(),
timeout_ms,
bail: false,
});
let watchdog = Duration::from_millis(timeout_ms + job.setup_ms + WATCHDOG_GRACE_MS);
let Some(link) = self.link.as_mut() else {
return Ok((
ControlOutcome::Fault("the companion is gone".to_owned()),
tests,
));
};
let outcome = match link.request(&request, watchdog) {
Ok(Response::Run(result)) if result.status == RunStatus::Timeout => {
ControlOutcome::TimedOut(format!("a test ran longer than {timeout_ms} ms"))
}
Ok(Response::Run(result)) => control_fault(&tests, &result)
.map_or(ControlOutcome::Passed, ControlOutcome::Failed),
Ok(_) => ControlOutcome::Fault("run gave no run_result".to_owned()),
Err(LinkFault::Timeout) => ControlOutcome::TimedOut(format!(
"the companion gave no result within {} ms",
watchdog.as_millis()
)),
Err(fault) => ControlOutcome::Fault(fault.to_string()),
};
match &outcome {
ControlOutcome::Passed => {}
ControlOutcome::Failed(fault) if !fault.failed_tests.is_empty() => {
self.memo.mark_failed(&fault.failed_tests);
}
ControlOutcome::Failed(_) | ControlOutcome::Fault(_) | ControlOutcome::TimedOut(_) => {
self.reset();
}
}
Ok((outcome, tests))
}
fn reload(&mut self) {
let Some(link) = self.link.as_mut() else {
return;
};
match link.request(&Request::Reload, self.config.runner_timeout) {
Ok(Response::Reloaded(reloaded))
if reloaded.method != ReloadMethod::RestartRequired => {}
_ => self.reset(),
}
}
fn run_with_retry(&mut self, job: &Job, caps: Capabilities) -> Result<Attempt, ScheduleError> {
let first = self.attempt(job, caps)?;
if first.status != MutantStatus::RuntimeError || first.settled {
return Ok(first);
}
self.reset();
let second = self.attempt(job, caps)?;
Ok(second)
}
}
fn respawn_dry_run_timeout(
runner_timeout: Duration,
first_dry_run: Duration,
workers: usize,
) -> Duration {
let workers = u32::try_from(workers).unwrap_or(u32::MAX);
let scaled = first_dry_run.saturating_mul(RESPAWN_DRY_RUN_FACTOR.saturating_mul(workers));
runner_timeout.max(scaled)
}
fn version_error(range: &RunnerRange, version: &str) -> ScheduleError {
ScheduleError::RunnerVersion(format!(
"{} {version} is not supported; the supported range is >={} <{}",
range.package, range.min_version, range.max_version_exclusive
))
}
struct DryRunFacts {
passing: BTreeMap<String, (String, f64)>,
covering: FxHashMap<String, BTreeSet<String>>,
static_ids: FxHashSet<String>,
baseline: Vec<BaselineFailure>,
file_setup: BTreeMap<String, f64>,
}
impl DryRunFacts {
fn setup_of(&self, tests: &[(f64, String)]) -> f64 {
let files: BTreeSet<&str> = tests
.iter()
.filter_map(|(_, test)| self.passing.get(test).map(|(file, _)| file.as_str()))
.collect();
files
.into_iter()
.filter_map(|file| self.file_setup.get(file))
.sum()
}
}
fn read_dry_run(
plan: &SchedulePlan,
caps: Capabilities,
result: &DryRunResult,
) -> Result<DryRunFacts, ScheduleError> {
let reported = result
.hits
.values()
.flatten()
.chain(&result.static_hits)
.chain(
result
.file_hits
.iter()
.flat_map(|hits| hits.values().flatten()),
);
for id in reported {
if !plan.known_ids.contains(id) {
return Err(ScheduleError::UnknownHit(id.clone()));
}
}
let mut baseline = Vec::new();
let mut excluded_files = FxHashSet::default();
let mut global_errors = Vec::new();
for error in &result.unhandled_errors {
match &error.file {
Some(file) => {
excluded_files.insert(file.clone());
baseline.push(BaselineFailure {
file: Some(file.clone()),
test_id: None,
message: Some(error.message.clone()),
mutant_ids: Vec::new(),
});
}
None => global_errors.push(error.message.clone()),
}
}
if !global_errors.is_empty() {
return Err(ScheduleError::Baseline(global_errors));
}
let mut passing = BTreeMap::new();
for test in &result.tests {
match test.status {
TestStatus::Fail => baseline.push(BaselineFailure {
file: Some(test.file.clone()),
test_id: Some(test.test_id.clone()),
message: None,
mutant_ids: Vec::new(),
}),
TestStatus::Pass if !excluded_files.contains(&test.file) => {
passing.insert(
test.test_id.clone(),
(test.file.clone(), test.duration_ms.unwrap_or(0.0)),
);
}
_ => {}
}
}
baseline.sort();
let mut covering: FxHashMap<String, BTreeSet<String>> = FxHashMap::default();
if caps.per_test_attribution {
for (test, ids) in &result.hits {
if !passing.contains_key(test) {
continue;
}
for id in ids {
covering.entry(id.clone()).or_default().insert(test.clone());
}
}
} else if let Some(file_hits) = &result.file_hits {
for (file, ids) in file_hits {
let tests: Vec<&String> = passing
.iter()
.filter(|(_, (test_file, _))| test_file == file)
.map(|(test, _)| test)
.collect();
for id in ids {
covering
.entry(id.clone())
.or_default()
.extend(tests.iter().map(|test| (*test).clone()));
}
}
}
let test_hit: FxHashSet<&String> = result.hits.values().flatten().collect();
let static_ids = result
.static_hits
.iter()
.filter(|id| !test_hit.contains(id))
.cloned()
.collect();
Ok(DryRunFacts {
passing,
covering,
static_ids,
baseline,
file_setup: result.file_setup_ms.clone(),
})
}
fn covering_tests(
plan: &SchedulePlan,
facts: &DryRunFacts,
dry_files: &[String],
mutant: &ScheduledMutant,
static_mutant: bool,
map_miss: bool,
) -> BTreeSet<String> {
if !static_mutant {
return facts.covering.get(&mutant.id).cloned().unwrap_or_default();
}
let files: FxHashSet<&String> = plan
.file_test_files
.get(&mutant.file)
.into_iter()
.flatten()
.filter(|file| dry_files.contains(file))
.collect();
facts
.passing
.iter()
.filter(|(_, (file, _))| map_miss || files.contains(file))
.map(|(test, _)| test.clone())
.collect()
}
fn whole_files(facts: &DryRunFacts, tests: &BTreeSet<String>) -> BTreeSet<String> {
let files: FxHashSet<&String> = tests
.iter()
.filter_map(|test| facts.passing.get(test).map(|(file, _)| file))
.collect();
facts
.passing
.iter()
.filter(|(_, (file, _))| files.contains(file))
.map(|(test, _)| test.clone())
.collect()
}
struct JobContext<'a> {
dry_files: &'a [String],
ready: &'a Ready,
cache: Option<&'a dyn ResultLookup>,
}
fn static_map_miss_issue(mutant: &ScheduledMutant) -> ScheduleIssue {
ScheduleIssue {
code: "static-map-miss",
message: format!(
"the dry run hit this mutant in `{}`, but the static map reaches no test of that file; the module graph misses a test that loads it",
mutant.file
),
mutant_id: Some(mutant.id.clone()),
stderr_tail: None,
}
}
fn cached_outcome(
cache: Option<&dyn ResultLookup>,
key: Option<&String>,
mutant: &ScheduledMutant,
tests: &[String],
static_mutant: bool,
) -> Option<MutantOutcome> {
let hit = cache?.get(&mutant.id, key?)?;
let mut covering_tests = tests.to_vec();
covering_tests.sort_unstable();
Some(MutantOutcome {
status: hit.status,
killed_by: hit.killed_by,
covering_tests,
static_mutant,
duration_ms: hit.duration_ms,
retried: hit.status == MutantStatus::Survived,
cached: true,
})
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum JobSource {
Planned,
Pending,
Unreached,
}
fn build_jobs(
plan: &SchedulePlan,
facts: &DryRunFacts,
context: &JobContext<'_>,
outcome: &mut ScheduleOutcome,
) -> Vec<Job> {
let JobContext {
dry_files,
ready,
cache,
} = *context;
let caps = ready.capabilities;
let mut regular = Vec::new();
let mut statics = Vec::new();
let limit = plan.mutants.len();
let mut covered = 0;
let planned = plan
.mutants
.iter()
.map(|mutant| (mutant, JobSource::Planned));
let pending = plan
.pending
.iter()
.map(|mutant| (mutant, JobSource::Pending));
let unreached = plan
.unreached
.iter()
.map(|mutant| (mutant, JobSource::Unreached));
for (mutant, source) in planned.chain(pending).chain(unreached) {
if source == JobSource::Pending && covered >= limit {
continue;
}
let map_miss = source == JobSource::Unreached;
let static_mutant = facts.static_ids.contains(&mutant.id);
let hit = static_mutant || facts.covering.contains_key(&mutant.id);
if map_miss && !hit {
continue;
}
let mut tests = covering_tests(plan, facts, dry_files, mutant, static_mutant, map_miss);
if !caps.test_filter {
tests = whole_files(facts, &tests);
}
if tests.is_empty() {
outcome.results.insert(
mutant.id.clone(),
MutantOutcome {
static_mutant,
..MutantOutcome::no_coverage()
},
);
continue;
}
match source {
JobSource::Planned => covered += 1,
JobSource::Pending => {
covered += 1;
outcome.promoted.push(mutant.id.clone());
}
JobSource::Unreached => {}
}
if map_miss {
outcome.issues.push(static_map_miss_issue(mutant));
}
let mut tests: Vec<(f64, String)> = tests
.into_iter()
.map(|test| (facts.passing.get(&test).map_or(0.0, |entry| entry.1), test))
.collect();
tests.sort_by(|left, right| {
left.0
.total_cmp(&right.0)
.then_with(|| left.1.cmp(&right.1))
});
let total: f64 = tests.iter().map(|(duration, _)| duration).sum();
let setup_ms = scaled_ms(facts.setup_of(&tests));
let tests: Vec<String> = tests.into_iter().map(|(_, test)| test).collect();
let cache_key = cache.map(|cache| {
let mut test_files: Vec<String> = tests
.iter()
.filter_map(|test| facts.passing.get(test).map(|(file, _)| file.clone()))
.collect();
test_files.sort_unstable();
test_files.dedup();
cache.key(ready, mutant, &tests, &test_files)
});
if let Some(cached) =
cached_outcome(cache, cache_key.as_ref(), mutant, &tests, static_mutant)
{
outcome.results.insert(mutant.id.clone(), cached);
continue;
}
let job = Job {
id: mutant.id.clone(),
tests,
timeout_ms: timeout_ms(total),
setup_ms,
static_mutant,
cache_key,
};
if static_mutant {
statics.push(job);
} else {
regular.push(job);
}
}
regular.extend(statics);
regular
}
struct Phase<'a> {
jobs: Mutex<VecDeque<Job>>,
attempts: Mutex<Vec<(Job, Attempt)>>,
fatal: Mutex<Option<ScheduleError>>,
stop: AtomicBool,
deadline: Option<Instant>,
caps: Capabilities,
spawner: &'a dyn CompanionSpawner,
config: &'a ScheduleConfig,
dry_files: &'a [String],
dry_run_timeout: Duration,
memo: &'a ControlMemo,
}
impl Phase<'_> {
fn work(&self, primary: Option<Box<dyn CompanionLink>>, known: FxHashSet<String>) {
let mut worker = Worker::new(
self.spawner,
self.config,
self.dry_files,
self.dry_run_timeout,
self.memo,
);
if let Some(link) = primary {
worker.link = Some(link);
worker.known_tests = known;
}
loop {
if self.stop.load(Ordering::SeqCst) {
break;
}
let Some(job) = self.jobs.lock().ok().and_then(|mut jobs| jobs.pop_front()) else {
break;
};
if self
.deadline
.is_some_and(|deadline| Instant::now() >= deadline)
{
let attempt = Attempt::decided(MutantStatus::Pending, None, None);
if let Ok(mut attempts) = self.attempts.lock() {
attempts.push((job, attempt));
}
continue;
}
match worker.run_with_retry(&job, self.caps) {
Ok(attempt) => {
if let Ok(mut attempts) = self.attempts.lock() {
attempts.push((job, attempt));
}
}
Err(error) => {
self.stop.store(true, Ordering::SeqCst);
if let Ok(mut fatal) = self.fatal.lock() {
fatal.get_or_insert(error);
}
break;
}
}
}
worker.reset();
}
}
#[derive(Clone, Copy)]
struct PhaseRunner<'a> {
spawner: &'a dyn CompanionSpawner,
config: &'a ScheduleConfig,
caps: Capabilities,
first_dry_run: Duration,
memo: &'a ControlMemo,
}
type WarmLink = (Box<dyn CompanionLink>, FxHashSet<String>);
fn run_phase(
runner: PhaseRunner<'_>,
dry_files: &[String],
jobs: Vec<Job>,
deadline: Option<Instant>,
primary: Option<WarmLink>,
) -> Result<(Vec<(Job, Attempt)>, usize), ScheduleError> {
let workers = runner.config.concurrency.max(1).min(jobs.len().max(1));
let phase = Phase {
jobs: Mutex::new(jobs.into()),
attempts: Mutex::new(Vec::new()),
fatal: Mutex::new(None),
stop: AtomicBool::new(false),
deadline,
caps: runner.caps,
spawner: runner.spawner,
config: runner.config,
dry_files,
dry_run_timeout: respawn_dry_run_timeout(
runner.config.runner_timeout,
runner.first_dry_run,
workers,
),
memo: runner.memo,
};
let mut primary = primary;
std::thread::scope(|scope| {
for _ in 0..workers {
let (link, known) = primary
.take()
.map_or((None, FxHashSet::default()), |(link, known)| {
(Some(link), known)
});
let phase = &phase;
scope.spawn(move || phase.work(link, known));
}
});
if let Some((mut link, _)) = primary {
link.shutdown();
}
if let Some(error) = phase.fatal.into_inner().ok().flatten() {
return Err(error);
}
Ok((phase.attempts.into_inner().unwrap_or_default(), workers))
}
fn record(outcome: &mut ScheduleOutcome, facts: &DryRunFacts, job: Job, attempt: Attempt) {
if let Some(issue) = attempt.issue {
outcome.issues.push(issue);
}
let partial_survivor =
attempt.status == MutantStatus::Survived && !attempt.dropped_tests.is_empty();
for test in attempt.dropped_tests {
note_restarted_baseline_failure(outcome, facts, test, &job.id);
}
let mut covering_tests = if attempt.status == MutantStatus::NoCoverage {
Vec::new()
} else {
job.tests
};
covering_tests.sort_unstable();
match job.cache_key {
Some(key) if !partial_survivor => {
outcome.cache_keys.insert(job.id.clone(), key);
}
_ => {
outcome.cache_keys.remove(&job.id);
}
}
outcome.results.insert(
job.id,
MutantOutcome {
status: attempt.status,
killed_by: attempt.killed_by,
covering_tests,
static_mutant: job.static_mutant,
duration_ms: attempt.duration_ms,
retried: false,
cached: false,
},
);
}
fn note_restarted_baseline_failure(
outcome: &mut ScheduleOutcome,
facts: &DryRunFacts,
test: String,
mutant_id: &str,
) {
if let Some(failure) = outcome
.baseline_failures
.iter_mut()
.find(|failure| failure.test_id.as_deref() == Some(test.as_str()))
{
failure.mutant_ids.push(mutant_id.to_owned());
return;
}
outcome.baseline_failures.push(BaselineFailure {
file: facts.passing.get(&test).map(|(file, _)| file.clone()),
test_id: Some(test),
message: Some(RESTARTED_DRY_RUN_FAILURE.to_owned()),
mutant_ids: vec![mutant_id.to_owned()],
});
}
fn setup_timeout_hint(fault: &LinkFault) -> &'static str {
if matches!(fault, LinkFault::Timeout) {
"; on a slow or busy host, pass --setup-timeout with a longer duration"
} else {
""
}
}
const RUNNER_VERSION_CODE: &str = "runner-version";
fn setup_error(stage: &str, fault: &LinkFault) -> ScheduleError {
if let LinkFault::Remote(error) = fault
&& error.code == RUNNER_VERSION_CODE
{
return ScheduleError::RunnerVersion(error.message.clone());
}
ScheduleError::Setup {
message: format!("the mutation companion {stage} failed: {fault}"),
stderr_tail: fault.stderr_tail().map(str::to_owned),
timed_out: matches!(fault, LinkFault::Timeout),
}
}
fn setup_request(
link: &mut Box<dyn CompanionLink>,
request: &Request,
timeout: Duration,
) -> Result<Response, ScheduleError> {
let stage = request.type_name();
match link.request(request, timeout) {
Ok(response) => Ok(response),
Err(fault) => {
link.shutdown();
Err(setup_error(stage, &fault))
}
}
}
struct Primary {
link: Box<dyn CompanionLink>,
ready: Ready,
dry_files: Vec<String>,
undiscovered: Vec<String>,
}
fn start_primary(
spawner: &dyn CompanionSpawner,
plan: &SchedulePlan,
config: &ScheduleConfig,
) -> Result<Primary, ScheduleError> {
let mut link = spawner.spawn().map_err(|failure| match failure {
SpawnFailure::Integrity(message) => ScheduleError::Integrity(message),
SpawnFailure::Fault(fault) => setup_error("start", &fault),
})?;
let request = Request::Init(config.init.clone());
let Response::Ready(ready) = setup_request(&mut link, &request, config.runner_timeout)? else {
link.shutdown();
return Err(setup_error(
"init",
&LinkFault::Protocol("no ready".to_owned()),
));
};
if !config.runner.contains(&ready.runner_version) {
link.shutdown();
return Err(version_error(&config.runner, &ready.runner_version));
}
let runner_files: FxHashSet<&String> = ready.test_files.iter().collect();
let (dry_files, undiscovered) = plan
.test_files
.iter()
.cloned()
.partition(|file| runner_files.contains(file));
Ok(Primary {
link,
ready,
dry_files,
undiscovered,
})
}
const MAX_TEST_NAME_CHARS: usize = 60;
const ELLIPSIS: &str = "...";
fn no_covering_test_message(dropped: &[String]) -> String {
let first = dropped.first().map_or_else(String::new, |name| {
if name.chars().count() <= MAX_TEST_NAME_CHARS {
return name.clone();
}
let kept: String = name
.chars()
.take(MAX_TEST_NAME_CHARS - ELLIPSIS.len())
.collect();
format!("{kept}{ELLIPSIS}")
});
let tests = if dropped.len() == 1 {
format!("1 test: {first}")
} else {
format!("{} tests, first: {first}", dropped.len())
};
format!("no covering test passed in the dry run of the restarted companion ({tests})")
}
fn undiscovered_issue(files: &[String]) -> Option<ScheduleIssue> {
(!files.is_empty()).then(|| ScheduleIssue {
code: "test-files-not-discovered",
message: {
let (noun, pronoun) = if files.len() == 1 {
("file", "it")
} else {
("files", "they")
};
format!(
"the runner does not list {} static test {noun}, so {pronoun} did not run: {}",
files.len(),
files.join(", ")
)
},
mutant_id: None,
stderr_tail: None,
})
}
fn mark_unreached_pending(
plan: &SchedulePlan,
facts: &DryRunFacts,
dry_files: &[String],
outcome: &mut ScheduleOutcome,
) {
for mutant in &plan.pending {
let static_mutant = facts.static_ids.contains(&mutant.id);
if covering_tests(plan, facts, dry_files, mutant, static_mutant, false).is_empty() {
outcome.results.insert(
mutant.id.clone(),
MutantOutcome {
static_mutant,
..MutantOutcome::no_coverage()
},
);
}
}
}
const fn retried_status(first: MutantStatus, retry: MutantStatus) -> MutantStatus {
match (first, retry) {
(_, MutantStatus::RuntimeError) => MutantStatus::RuntimeError,
(_, MutantStatus::NoCoverage) => MutantStatus::NoCoverage,
(MutantStatus::Survived, MutantStatus::Survived | MutantStatus::Timeout) => {
MutantStatus::Survived
}
(MutantStatus::Timeout, MutantStatus::Timeout) => MutantStatus::Timeout,
(MutantStatus::Timeout, MutantStatus::Killed) => MutantStatus::Killed,
_ => MutantStatus::Flaky,
}
}
fn retry_suspects(
runner: PhaseRunner<'_>,
facts: &DryRunFacts,
suspects: Vec<Job>,
outcome: &mut ScheduleOutcome,
) -> Result<(), ScheduleError> {
let mut retry_files: Vec<String> = suspects
.iter()
.flat_map(|job| &job.tests)
.filter_map(|test| facts.passing.get(test).map(|(file, _)| file.clone()))
.collect();
retry_files.sort();
retry_files.dedup();
let (retries, _) = run_phase(runner, &retry_files, suspects, runner.config.deadline, None)?;
note_control_failures(outcome, facts, runner.memo);
for (job, attempt) in retries {
if attempt.status == MutantStatus::Pending {
outcome.unretried.push(job.id);
continue;
}
let first = outcome.results.get(&job.id);
let first_status = first.map_or(MutantStatus::Survived, |first| first.status);
let status = retried_status(first_status, attempt.status);
let duration_ms = if status == MutantStatus::Killed {
attempt.duration_ms
} else {
first.and_then(|first| first.duration_ms)
};
let id = job.id.clone();
record(
outcome,
facts,
job,
Attempt {
status,
duration_ms,
..attempt
},
);
if let Some(result) = outcome.results.get_mut(&id) {
result.retried = true;
}
}
Ok(())
}
fn first_dry_run(
link: &mut Box<dyn CompanionLink>,
dry_files: &[String],
timeout: Duration,
) -> Result<DryRunResult, ScheduleError> {
let request = Request::DryRun {
test_files: dry_files.to_vec(),
};
let Response::DryRun(dry) = setup_request(link, &request, timeout)? else {
link.shutdown();
return Err(setup_error(
"dry_run",
&LinkFault::Protocol("no dry_run_result".to_owned()),
));
};
Ok(dry)
}
fn drop_failed_tests(facts: &mut DryRunFacts, failed: &[String]) {
for test in failed {
let file = facts.passing.remove(test).map(|(file, _)| file);
for tests in facts.covering.values_mut() {
tests.remove(test);
}
facts.baseline.push(BaselineFailure {
file,
test_id: Some(test.clone()),
message: Some(CONTROL_RUN_FAILURE.to_owned()),
mutant_ids: Vec::new(),
});
}
facts.covering.retain(|_, tests| !tests.is_empty());
facts.baseline.sort();
}
fn note_control_failures(outcome: &mut ScheduleOutcome, facts: &DryRunFacts, memo: &ControlMemo) {
for test in memo.failed() {
if outcome
.baseline_failures
.iter()
.any(|failure| failure.test_id.as_deref() == Some(test.as_str()))
{
continue;
}
outcome.baseline_failures.push(BaselineFailure {
file: facts.passing.get(&test).map(|(file, _)| file.clone()),
test_id: Some(test),
message: Some(CONTROL_RUN_FAILURE.to_owned()),
mutant_ids: Vec::new(),
});
}
}
const ISSUE_TRACKER: &str = "https://github.com/fallow-rs/fallow/issues";
fn self_check(
runner: PhaseRunner<'_>,
dry_files: &[String],
ready: &Ready,
jobs: &[Job],
link: Box<dyn CompanionLink>,
known: &FxHashSet<String>,
) -> Result<(Box<dyn CompanionLink>, Vec<String>), ScheduleError> {
let expired = runner
.config
.deadline
.is_some_and(|deadline| Instant::now() >= deadline);
let Some(job) = jobs.first().filter(|_| !expired) else {
return Ok((link, Vec::new()));
};
let mut worker = Worker::new(
runner.spawner,
runner.config,
dry_files,
runner.config.runner_timeout,
runner.memo,
);
worker.link = Some(link);
worker.known_tests.clone_from(known);
let outcome = worker
.control_run(&job.tests, job, runner.caps)
.map(|(outcome, _)| outcome);
let runner_name = format!("{} {}", runner.config.runner.package, ready.runner_version);
let report =
format!("report this at {ISSUE_TRACKER} with the runner name and version ({runner_name})");
let stopped = ScheduleError::RunPath {
message: format!("the companion of {runner_name} stopped after the self-check"),
help: report.clone(),
};
let error = match outcome {
Err(error) => error,
Ok(ControlOutcome::Passed) => {
runner.memo.store(&job.tests, ControlVerdict::Passed);
if let Some(passed) = worker.link.take() {
return Ok((passed, Vec::new()));
}
stopped
}
Ok(ControlOutcome::Failed(fault)) if !fault.failed_tests.is_empty() => {
runner.memo.store(&job.tests, ControlVerdict::Failed);
if let Some(kept) = worker.link.take() {
return Ok((kept, fault.failed_tests));
}
stopped
}
Ok(ControlOutcome::Failed(fault)) if fault.no_tests => ScheduleError::RunPath {
message: format!(
"{runner_name} executes no tests in a mutant run: a control run of the tests of a covered mutant, with no active mutant, gave {}; each mutant would look killed",
fault.detail
),
help: report,
},
Ok(ControlOutcome::Failed(fault)) => ScheduleError::RunPath {
message: format!(
"{runner_name} gives no trustworthy result in a mutant run: a control run of the tests of a covered mutant, with no active mutant, gave {}, but no test failed",
fault.detail
),
help: report,
},
Ok(ControlOutcome::TimedOut(detail)) => ScheduleError::RunPath {
message: format!(
"the self-check of {runner_name} timed out: a control run of the tests of a covered mutant, with no active mutant, did not finish ({detail})"
),
help: "on a slow or busy host, pass --setup-timeout with a longer duration, for example 5m, or run on a less loaded host".to_owned(),
},
Ok(ControlOutcome::Fault(message)) => ScheduleError::RunPath {
message: format!(
"the self-check of {runner_name} failed: a control run with no active mutant gave no result: {message}"
),
help: report,
},
};
worker.reset();
Err(error)
}
fn checked_jobs(
runner: PhaseRunner<'_>,
plan: &SchedulePlan,
facts: &mut DryRunFacts,
context: &JobContext<'_>,
link: Box<dyn CompanionLink>,
outcome: &mut ScheduleOutcome,
) -> Result<(Vec<Job>, WarmLink), ScheduleError> {
let mut first_jobs = ScheduleOutcome::empty();
let jobs = build_jobs(plan, facts, context, &mut first_jobs);
let known: FxHashSet<String> = facts.passing.keys().cloned().collect();
let (link, failed) = self_check(
runner,
context.dry_files,
context.ready,
&jobs,
link,
&known,
)?;
let (jobs, known) = if failed.is_empty() {
outcome.results.extend(first_jobs.results);
outcome.issues.extend(first_jobs.issues);
outcome.promoted = first_jobs.promoted;
(jobs, known)
} else {
drop_failed_tests(facts, &failed);
let jobs = build_jobs(plan, facts, context, outcome);
(jobs, facts.passing.keys().cloned().collect())
};
outcome.baseline_failures.clone_from(&facts.baseline);
mark_unreached_pending(plan, facts, context.dry_files, outcome);
Ok((jobs, (link, known)))
}
pub fn run_schedule(
spawner: &dyn CompanionSpawner,
plan: &SchedulePlan,
config: &ScheduleConfig,
) -> Result<ScheduleOutcome, ScheduleError> {
let mut outcome = ScheduleOutcome::empty();
let no_coverage_for_all = |outcome: &mut ScheduleOutcome| {
for mutant in plan.mutants.iter().chain(&plan.pending) {
outcome
.results
.insert(mutant.id.clone(), MutantOutcome::no_coverage());
}
};
if (plan.mutants.is_empty() && plan.unreached.is_empty()) || plan.test_files.is_empty() {
no_coverage_for_all(&mut outcome);
return Ok(outcome);
}
let started = Instant::now();
let Primary {
mut link,
ready,
dry_files,
undiscovered,
} = start_primary(spawner, plan, config)?;
outcome.issues.extend(undiscovered_issue(&undiscovered));
let memo = ControlMemo::default();
let mut runner = PhaseRunner {
spawner,
config,
caps: ready.capabilities,
first_dry_run: Duration::ZERO,
memo: &memo,
};
outcome.ready = Some(ready.clone());
outcome.timings.setup = started.elapsed();
if dry_files.is_empty() {
link.shutdown();
no_coverage_for_all(&mut outcome);
return Ok(outcome);
}
let dry_started = Instant::now();
let dry = first_dry_run(&mut link, &dry_files, config.runner_timeout)?;
outcome.timings.dry_run = dry_started.elapsed();
runner.first_dry_run = outcome.timings.dry_run;
let mut facts = match read_dry_run(plan, runner.caps, &dry) {
Ok(facts) => facts,
Err(error) => {
link.shutdown();
return Err(error);
}
};
outcome.unloaded_paths = dry.unloaded_paths;
let context = JobContext {
dry_files: &dry_files,
ready: &ready,
cache: config.cache.as_deref(),
};
let mutants_started = Instant::now();
let (jobs, (link, known)) =
checked_jobs(runner, plan, &mut facts, &context, link, &mut outcome)?;
let (attempts, workers) = run_phase(
runner,
&dry_files,
jobs,
config.deadline,
Some((link, known)),
)?;
outcome.workers = workers;
outcome.timings.mutants = mutants_started.elapsed();
note_control_failures(&mut outcome, &facts, &memo);
let mut suspects = Vec::new();
for (job, attempt) in attempts {
match attempt.status {
MutantStatus::Survived => suspects.push(job.clone()),
MutantStatus::Timeout => suspects.push(job.clone().with_doubled_timeout()),
_ => {}
}
record(&mut outcome, &facts, job, attempt);
}
if !suspects.is_empty() {
let retry_started = Instant::now();
retry_suspects(runner, &facts, suspects, &mut outcome)?;
outcome.timings.retry = retry_started.elapsed();
}
outcome
.issues
.sort_by(|left, right| left.mutant_id.cmp(&right.mutant_id));
for failure in &mut outcome.baseline_failures {
failure.mutant_ids.sort();
failure.mutant_ids.dedup();
}
outcome.baseline_failures.sort();
outcome.unretried.sort();
Ok(outcome)
}
#[cfg(test)]
#[expect(clippy::unwrap_used, reason = "tests unwrap to keep assertions short")]
mod tests;