use super::commit::{assert_nothing_left, git_text, keep_maintenance_in_the_foreground, remove_settled, run_commit,
wrapped_commit};
use super::support::{Fixture, fixture, git, repository};
use std::ffi::OsStr;
use std::os::unix::process::ExitStatusExt;
use std::path::PathBuf;
use std::process::{Command, Output};
use std::sync::atomic::{AtomicUsize, Ordering};
const SWEEP_CASES: usize = 60;
const SWEEP_WIDTH: usize = 6;
const BURST_LENGTH: usize = 3;
pub(super) fn strace_program() -> Option<PathBuf> {
let search = std::env::var_os("PATH")?;
return std::env::split_paths(&search).map(|directory| { return directory.join("strace"); })
.find(|candidate| { return candidate.is_file(); });
}
pub(super) fn strace_available() -> bool {
return strace_program().is_some_and(|program| {
return Command::new(program).arg("--version").output().is_ok();
});
}
fn staged_repository(fixture: &Fixture, name: &str) -> PathBuf {
let repo: PathBuf = repository(fixture, OsStr::new(name));
std::fs::write(repo.join("a.txt"), b"one\n").expect("a");
git(fixture, repo.as_path(), &["add", "--", "a.txt"]);
keep_maintenance_in_the_foreground(fixture, repo.as_path());
return repo;
}
fn failing_clone3(inner: &Command, first: usize, length: usize, trace: &std::path::Path) -> Command {
let when: String = if length == 1 { format!("{first}") } else { format!("{first}..{}", first + length - 1) };
let mut outer: Command = Command::new(strace_program().expect("strace is installed"));
outer
.arg("--follow-forks")
.arg("--quiet")
.arg("--quiet")
.arg(format!("--output={}", trace.display()))
.arg("--trace=clone3")
.arg(format!("--inject=clone3:error=EAGAIN:when={when}"))
.arg(inner.get_program())
.args(inner.get_args());
outer.env_clear();
for (name, setting) in inner.get_envs() {
if let Some(text) = setting {
outer.env(name, text);
}
}
if let Some(directory) = inner.get_current_dir() {
outer.current_dir(directory);
}
return outer;
}
fn check_case(fixture: &Fixture, first: usize, length: usize) -> bool {
let repo: PathBuf = staged_repository(fixture, &format!("case-{length}-{first}"));
let trace: PathBuf = fixture.root.join(format!("trace-{length}-{first}.txt"));
let inner: Command = wrapped_commit(fixture, repo.as_path(), &["commit", "--quiet", "--message=x", "--", "a.txt"]);
let failed: Output = failing_clone3(&inner, first, length, trace.as_path()).output().expect("strace starts");
let injected: bool = std::fs::read_to_string(&trace).is_ok_and(|text| { return text.contains("(INJECTED)"); });
assert_ne!(failed.status.signal(), Some(9), "case {first}: the commit hit its time bound");
let code: i32 = failed.status.code().unwrap_or(-1);
assert!(matches!(code, 0 | 1 | 2), "case {first}: unexpected exit {code}");
let text: String = String::from_utf8_lossy(&[failed.stdout.as_slice(), failed.stderr.as_slice()].concat()).into_owned();
if code != 0 {
assert!(!text.trim().is_empty(), "case {first}: a failed commit must say why");
}
assert!(
!text.contains("could not start"),
"case {first}: a refused start of the wrapper's own escaped the retry: {text}"
);
let next = run_commit(fixture, repo.as_path(), &["status", "--porcelain"]);
assert_eq!(next.code, Some(0), "case {first}: the next command must succeed: {}", String::from_utf8_lossy(&next.stderr));
let commits: String = git_text(fixture, repo.as_path(), &["rev-list", "--count", "HEAD"]);
let status: String = String::from_utf8_lossy(next.stdout.as_slice()).into_owned();
match commits.trim() {
"1" => assert_eq!(status, "A a.txt\n", "case {first}: the commit was not made, so the staged file stays"),
"2" => assert_eq!(status, "", "case {first}: the commit landed, so the index must be clean"),
other => panic!("case {first}: {other} commits"),
}
let outcome: String = format!(
"case {first}: injected run exit {code}: {}; commits {}; status {status:?}",
text.chars().take(600).collect::<String>(),
commits.trim(),
);
let leftovers = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { assert_nothing_left(fixture, repo.as_path()); }));
if leftovers.is_err() {
panic!("{outcome}");
}
return injected;
}
fn sweep(label: &str, length: usize) {
if !strace_available() {
eprintln!("skipped: strace is not installed, so no process start was failed");
return;
}
let fixture: Fixture = fixture(label);
let cases: Vec<usize> = (1..=SWEEP_CASES).collect();
let injected_cases: AtomicUsize = AtomicUsize::new(0);
for batch in cases.chunks(SWEEP_WIDTH) {
std::thread::scope(|scope| {
for first in batch {
let shared_fixture: &Fixture = &fixture;
let counter: &AtomicUsize = &injected_cases;
scope.spawn(move || {
if check_case(shared_fixture, *first, length) {
counter.fetch_add(1, Ordering::SeqCst);
}
});
}
});
}
assert!(injected_cases.load(Ordering::SeqCst) > 0, "no clone3 was refused, so nothing was tested");
remove_settled(&fixture);
}
#[test]
fn a_failed_process_start_never_strands_a_commit() {
sweep("fork-failures", 1);
}
#[test]
fn a_short_burst_of_refused_starts_is_ridden_out() {
sweep("fork-failure-bursts", BURST_LENGTH);
}
#[test]
fn the_uninjected_commit_lands_and_leaves_nothing() {
let fixture: Fixture = fixture("fork-failures-control");
let repo: PathBuf = staged_repository(&fixture, "control");
let landed = run_commit(&fixture, repo.as_path(), &["commit", "--quiet", "--message=x", "--", "a.txt"]);
assert_eq!(landed.code, Some(0), "{}", String::from_utf8_lossy(&landed.stderr));
assert_eq!(git_text(&fixture, repo.as_path(), &["rev-list", "--count", "HEAD"]).trim(), "2");
assert_nothing_left(&fixture, repo.as_path());
remove_settled(&fixture);
}