monochromatic-git-policy-cli 0.5.19

Repository-owned Git policy wrapper: forwards Git commands and checks commits against configured policies.
//! Controls for the watcher of waits that block on a child process:
//!  the limit from its variable, the notices at the cadence, the child stopped at the bound,
//!  and the one-shot run that captures a command's output under a watch.
#![cfg(unix)]

use super::*;
use crate::test_support::{ActionSink, SharedSink};
use std::os::unix::process::ExitStatusExt;
use std::path::PathBuf;

/// A cadence of a 60 ms grace and a 60 ms interval, so a notice comes fast.
const QUICK: Cadence = Cadence {
    grace: Duration::from_millis(60),
    interval: Duration::from_millis(60),
};

/// Terms of a limit, the quick cadence and a shared sink the test reads.
fn terms(limit: Duration, sink: &SharedSink) -> WatchTerms {
    return WatchTerms {
        limit,
        cadence: QUICK,
        notices: Box::new(sink.clone()),
    };
}

/// The sentence for a stopped wait names it, the bound, the variable and what to do next.
#[test]
fn the_timeout_sentence_names_the_wait_the_bound_and_the_next_step() {
    let limit: Duration = ChildClass::Object.default_limit();
    let subject: String = format!("git add -- a.md, {}", ChildClass::Object.label());
    let text: String = watched_timeout_text(subject.as_str(), limit);
    assert!(text.starts_with(format!("{subject} did not finish within ").as_str()), "{text}");
    assert!(text.contains(format!("within {} seconds and was stopped", limit.as_secs()).as_str()), "{text}");
    assert!(text.contains(format!("{CHILD_WAIT_VARIABLE}=").as_str()), "the raise hint spells the variable: {text}");
    assert!(text.contains("run the command again"), "{text}");
    assert!(text.contains("`ps`"), "{text}");
}

/// The object reader names itself with its class, like every other step.
#[test]
fn the_reader_subject_names_the_command_and_its_class() {
    let subject: String = reader_subject();
    assert!(subject.starts_with("git cat-file --batch"), "{subject}");
    assert!(subject.ends_with(ChildClass::Object.label()), "{subject}");
}

/// The terms of a class carry its bound, and a grace that never passes the interval or the bound.
#[test]
fn the_terms_of_a_class_carry_its_bound_and_a_grace_inside_it() {
    for class in [ChildClass::Plumbing, ChildClass::Object] {
        let terms: WatchTerms = WatchTerms::for_class(class);
        assert_eq!(terms.limit, class.limit(), "{class:?}");
        assert!(terms.cadence.grace <= class.notice_grace(), "{class:?}");
        assert!(terms.cadence.grace <= terms.cadence.interval, "{class:?}");
        assert!(terms.cadence.grace < terms.limit, "{class:?}");
    }
}

/// A bound set below a class's grace still gets its first notice before the step is stopped,
///  and a bound above the grace leaves the grace alone.
#[test]
fn a_short_bound_pulls_the_first_notice_inside_it() {
    for class in [ChildClass::Plumbing, ChildClass::Object] {
        let interval: Duration = class.notice_grace() * 2;
        let short: Duration = class.notice_grace() / 2;
        assert!(grace_for(class, interval, short) < short, "{class:?}");
        assert_eq!(grace_for(class, interval, class.default_limit()), class.notice_grace(), "{class:?}");
    }
}

/// A sink that tells a channel each time a line is finished, and keeps the text for the test.
struct LineSignal {
    /// The text written so far.
    shared: SharedSink,
    /// Told once per finished line.
    lines: std::sync::mpsc::Sender<()>,
}

/// Writes are copied and each finished line is signalled.
impl Write for LineSignal {
    /// Copy the bytes and signal the lines they finish.
    fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
        Write::write(&mut self.shared, bytes)?;
        for _line in bytes.iter().filter(|byte| { return **byte == b'\n'; }) {
            self.lines.send(()).ok();
        }
        return Ok(bytes.len());
    }

    /// Nothing is buffered.
    fn flush(&mut self) -> std::io::Result<()> {
        return Ok(());
    }
}

/// A wait that crosses the bound is announced, then the child is stopped, which ends the blocked wait.
///  The notice that is due goes out before the bound is checked, so at least one notice is out
///  however slowly the host runs the watcher.
#[test]
fn a_wait_past_the_bound_is_announced_then_the_child_is_stopped() {
    let sink: SharedSink = SharedSink::default();
    let mut child: Child = Command::new("sleep").arg("30").spawn().expect("sleeper");
    let watch: ChildWatch = ChildWatch::start("the sleeper", child.id(), terms(Duration::from_millis(450), &sink));
    watch.begin();
    let status = child.wait().expect("the stopped child is collected");
    watch.end();
    assert!(watch.expired());
    assert_eq!(status.signal(), Some(9), "{status:?}");
    let text: String = sink.text();
    let lines: Vec<&str> = text.lines().collect();
    assert!(!lines.is_empty(), "{text}");
    for line in &lines {
        assert!(line.starts_with("cli-git: still waiting for the sleeper (process "), "{text}");
        assert!(line.contains("(CLI_GIT_CHILD_WAIT_SECONDS changes this)"), "{text}");
    }
    assert_eq!(text.matches("Hint:").count(), 1, "{text}");
}

/// A wait that ends inside the grace prints nothing and does not stop the child; an idle watch prints nothing either.
///  The grace is an hour, so no host is slow enough to make the wait outlast it.
#[test]
fn a_wait_that_ends_inside_the_grace_prints_nothing() {
    let sink: SharedSink = SharedSink::default();
    let mut child: Child = Command::new("sleep").arg("30").spawn().expect("sleeper");
    let watch: ChildWatch = ChildWatch::start(
        "the sleeper",
        child.id(),
        WatchTerms {
            limit: Duration::from_secs(7200),
            cadence: Cadence { grace: Duration::from_secs(3600), interval: Duration::from_secs(3600) },
            notices: Box::new(sink.clone()),
        },
    );
    std::thread::sleep(Duration::from_millis(50));
    watch.begin();
    std::thread::sleep(Duration::from_millis(50));
    watch.end();
    assert!(!watch.expired());
    assert_eq!(sink.text(), "");
    drop(watch);
    child.kill().expect("kill");
    child.wait().expect("collect");
}

/// A wait that has ended is not announced any more: the watcher goes back to sleeping until the next wait begins.
///  The wait ends on its first notice. Then a settling pause lets a line that was being written finish, and the
///  notices must stay at that count over many more intervals. The check cannot fail on a slow host, which only
///  prints nothing, while a watcher that goes on counting a finished wait prints at every interval.
#[test]
fn a_wait_that_has_ended_is_announced_no_more() {
    let shared: SharedSink = SharedSink::default();
    let (sender, receiver) = std::sync::mpsc::channel::<()>();
    let mut child: Child = Command::new("sleep").arg("30").spawn().expect("sleeper");
    let watch: ChildWatch = ChildWatch::start(
        "the sleeper",
        child.id(),
        WatchTerms {
            limit: Duration::from_secs(3600),
            cadence: QUICK,
            notices: Box::new(LineSignal { shared: shared.clone(), lines: sender }),
        },
    );
    watch.begin();
    receiver.recv().expect("a notice announces the wait");
    watch.end();
    std::thread::sleep(QUICK.interval * 3);
    let settled: usize = shared.text().lines().count();
    std::thread::sleep(QUICK.interval * 6);
    let later: String = shared.text();
    let expired: bool = watch.expired();
    drop(watch);
    // Collected before the checks, so a failing check does not leave the sleeper running.
    child.kill().expect("kill");
    child.wait().expect("collect");
    assert_eq!(later.lines().count(), settled, "a finished wait went on being announced: {later}");
    assert!(!expired);
}

/// Each wait gets its own clock: a second wait on the same child is announced from its own start, with its own hint.
///  Each wait ends on its first notice, so every wait has at least one notice, however slowly the host runs the watcher.
#[test]
fn each_wait_is_announced_from_its_own_start() {
    let shared: SharedSink = SharedSink::default();
    let (sender, receiver) = std::sync::mpsc::channel::<()>();
    let mut child: Child = Command::new("sleep").arg("30").spawn().expect("sleeper");
    let watch: ChildWatch = ChildWatch::start(
        "the sleeper",
        child.id(),
        WatchTerms {
            limit: Duration::from_secs(3600),
            cadence: QUICK,
            notices: Box::new(LineSignal { shared: shared.clone(), lines: sender }),
        },
    );
    for _wait in 0..2 {
        watch.begin();
        receiver.recv().expect("a notice announces the wait");
        watch.end();
    }
    let text: String = shared.text();
    assert_eq!(text.matches("Hint:").count(), 2, "the hint opens each wait: {text}");
    assert!(!watch.expired());
    drop(watch);
    child.kill().expect("kill");
    child.wait().expect("collect");
}

/// A command that runs long has its output collected untouched while the wait is announced.
///  The command waits for a release file that the second notice creates, so the wait ends on that event and at least
///  two notices are out, however slowly the host runs the watcher.
#[test]
fn a_command_that_runs_long_is_announced_and_its_output_is_kept() {
    let shared: SharedSink = SharedSink::default();
    let root: PathBuf = crate::test_support::fixture("child-watch-long");
    let release: PathBuf = root.join("release");
    let mut command: Command = Command::new("sh");
    command
        .arg("-c")
        .arg("while [ ! -e \"$1\" ]; do sleep 0.02; done; printf out; printf err >&2")
        .arg("sh")
        .arg(&release)
        .stdin(Stdio::null());
    let release_file: PathBuf = release.clone();
    let counted: ActionSink = ActionSink::new(&shared, 2, move || {
        std::fs::write(&release_file, b"").expect("release the command");
    });
    let output: Output = output_watched(
        &mut command,
        "the slow command",
        WatchTerms { limit: Duration::from_secs(3600), cadence: QUICK, notices: Box::new(counted) },
    )
    .expect("finished");
    assert_eq!((output.stdout, output.stderr), (b"out".to_vec(), b"err".to_vec()));
    assert!(output.status.success());
    let text: String = shared.text();
    assert!(text.lines().count() >= 2, "{text}");
    assert!(text.starts_with("cli-git: still waiting for the slow command (process "), "{text}");
    crate::test_support::remove(root.as_path());
}

/// A command that outlasts the bound is stopped and reported as a timeout that names it, after at least one notice.
#[test]
fn a_command_past_the_bound_is_stopped_and_reported() {
    let sink: SharedSink = SharedSink::default();
    let mut command: Command = Command::new("sleep");
    command.arg("30").stdin(Stdio::null());
    let failure: std::io::Error = output_watched(&mut command, "git rev-parse HEAD", terms(Duration::from_millis(400), &sink))
        .expect_err("stopped at the bound");
    assert_eq!(failure.kind(), std::io::ErrorKind::TimedOut);
    assert!(failure.to_string().starts_with("git rev-parse HEAD did not finish within 0 seconds and was stopped"), "{failure}");
    assert!(sink.text().lines().count() >= 1, "{}", sink.text());
}

/// Whether `pid` is still the `sleep` of `seconds` that a control started: the number may have been given to another process
/// since, and only the helper is signalled.
fn is_the_helper(pid: i32, seconds: u64) -> bool {
    let Ok(line) = std::fs::read(format!("/proc/{pid}/cmdline")) else { return false };
    let words: Vec<&[u8]> = line.split(|byte| { return *byte == 0; }).filter(|word| { return !word.is_empty(); }).collect();
    return words == [b"sleep".as_slice(), seconds.to_string().as_bytes()];
}

/// Whether the process `pid` still runs.
/// A process that was killed and is waiting to be reaped is a zombie,
/// and `kill` with signal 0 succeeds for it.
/// On a host where the process that adopts an orphan is the user's service manager,
/// which reaps a moment after the signal landed,
/// the helper of the control below was a zombie when it was looked at.
/// The process table says what the process is:
/// a zombie has exited (`process_birth_identity` answers nothing for one),
/// so the observation does not depend on how fast the reaper is.
fn helper_is_running(pid: i32) -> bool {
    return !matches!(crate::process_identity::process_birth_identity(i64::from(pid)), Ok(None));
}

/// A killed process that nobody has reaped yet is not running,
/// though `kill` with signal 0 still reaches it,
/// and a process that runs is running.
/// This is the observation the control below makes of the helper a stopped step started.
/// The host's reaper makes the zombie window wide enough to fall into,
/// and this makes it deterministic.
#[test]
fn a_killed_process_that_is_not_yet_reaped_is_not_running() {
    let mut child: std::process::Child = Command::new("sleep").arg("30").stdin(Stdio::null()).spawn().expect("a process");
    let pid: i32 = i32::try_from(child.id()).expect("a process ID");
    assert!(helper_is_running(pid), "a sleeping process runs");
    // SAFETY: `kill` takes two integers and touches no memory of this process.
    unsafe { libc::kill(pid, libc::SIGKILL) };
    let began: Instant = Instant::now();
    while std::fs::read_to_string(format!("/proc/{pid}/stat")).is_ok_and(|text| { return !text.contains(") Z "); }) {
        assert!(began.elapsed() < Duration::from_secs(10), "the process never became a zombie");
        std::thread::sleep(Duration::from_millis(2));
    }
    // SAFETY: `kill` with signal 0 only checks that the process exists.
    assert_eq!(unsafe { libc::kill(pid, 0) }, 0, "the premise: a zombie still answers signal 0");
    assert!(!helper_is_running(pid), "a zombie has exited");
    child.wait().expect("reaped");
    assert!(!helper_is_running(pid), "a reaped process is gone");
}

/// What a command that started a process of its own must not leave behind when it is stopped at the bound: a step of a commit
///  (`git gc`, a `git push` with its transport) has helpers, and a helper that outlives its step holds the step's output open,
///  so the wait for the output outlasts the bound by as long as the helper lives.
#[test]
fn a_command_past_the_bound_is_stopped_with_the_processes_it_started() {
    let root: PathBuf = crate::test_support::fixture("child-watch-tree");
    let helper_file: PathBuf = root.join("helper-pid");
    let helper_seconds: u64 = 25;
    let sink: SharedSink = SharedSink::default();
    let mut command: Command = Command::new("sh");
    command
        .arg("-c")
        .arg(format!("sleep {helper_seconds} & echo $! > '{}'; wait", helper_file.display()))
        .stdin(Stdio::null());
    let started: Instant = Instant::now();
    let failure: std::io::Error = output_watched(&mut command, "a step with a helper", terms(Duration::from_millis(600), &sink))
        .expect_err("stopped at the bound");
    let waited: Duration = started.elapsed();
    assert_eq!(failure.kind(), std::io::ErrorKind::TimedOut);
    let helper: i32 = std::fs::read_to_string(&helper_file).expect("the helper's process ID").trim().parse().expect("a process ID");
    // A process that was killed stays a zombie until whoever inherited it has waited for it, and a zombie is not running
    //  (`helper_is_running`). The kill lands before the process is scheduled to die, so the control waits for that event, with a
    //  patience that only a helper that was never stopped reaches.
    let running = || -> bool { return helper_is_running(helper) && is_the_helper(helper, helper_seconds); };
    let patience: Instant = Instant::now();
    while running() && patience.elapsed() < Duration::from_secs(60) {
        std::thread::sleep(Duration::from_millis(20));
    }
    let helper_alive: bool = running();
    // The assertions come after the cleanup, so a failure leaves no helper running. Only a process that is still the helper is
    //  signalled: its number may have been given to another process since.
    if helper_alive {
        // SAFETY: `kill` takes two integers and touches no memory of this process.
        unsafe { libc::kill(helper, libc::SIGKILL) };
    }
    assert!(
        waited < Duration::from_secs(helper_seconds / 2),
        "the stop took {waited:?}, as long as the helper that kept the output open lived"
    );
    assert!(!helper_alive, "the helper the step started is still running after the step was stopped");
    crate::test_support::remove(root.as_path());
}

/// A command that finishes at once prints nothing; the grace is an hour, so no host is slow enough to outlast it.
#[test]
fn a_command_that_finishes_at_once_prints_nothing() {
    let sink: SharedSink = SharedSink::default();
    let mut command: Command = Command::new("true");
    command.stdin(Stdio::null());
    let output: Output = output_watched(
        &mut command,
        "true",
        WatchTerms {
            limit: Duration::from_secs(7200),
            cadence: Cadence { grace: Duration::from_secs(3600), interval: Duration::from_secs(3600) },
            notices: Box::new(sink.clone()),
        },
    )
    .expect("finished");
    assert!(output.status.success());
    assert_eq!(sink.text(), "");
}