trusty-mpm 1.8.2

trusty-mpm: unified multi-agent orchestration platform (core, daemon, CLI, TUI, Telegram)
//! Tests for the shared kill-on-timeout subprocess runner (#7965).
//!
//! What is NEW at this layer, and covered nowhere else: the return shape the
//! `gh` adapter throws away — a non-zero exit is `Ok` here, carrying both
//! streams — and the typed timeout the hygiene sweep branches on. The
//! process-group kill and the pipe drain are covered here directly rather than
//! only through `run_with_timeout_kills_the_whole_process_group` in
//! `worktree_reclaim_tests.rs`, because this module now owns them and the `gh`
//! adapter could be retired without taking the guarantee with it.

use std::process::Command;
use std::time::{Duration, Instant};

use super::{BoundedError, clamp_to_deadline, run_bounded, run_bounded_with_input, with_deadline};

/// A command that exits non-zero is `Ok` here, with both streams captured.
///
/// Why this is the assertion: the `gh` adapter treats a non-zero exit as a
/// failure, but the hygiene sweep must tell "git refused the fast-forward" (a
/// decision, logged and survivable) from "git never answered" (the #7965 hang).
/// Folding both into `Err` at this layer would erase that distinction.
#[test]
fn run_bounded_captures_stdout_and_status() {
    let mut cmd = Command::new("sh");
    cmd.args(["-c", "echo out; echo err >&2; exit 3"]);
    let out = run_bounded(cmd, Duration::from_secs(5)).expect("a non-zero exit is not an error");
    assert_eq!(out.status.code(), Some(3));
    assert_eq!(out.stdout.trim(), "out");
    assert_eq!(out.stderr.trim(), "err");
}

/// Output larger than one pipe buffer still comes back, and does not deadlock.
///
/// Why: polling `try_wait` without draining the pipes wedges on any command
/// whose output exceeds the buffer — `git status --porcelain` in a dirty
/// checkout, `gh pr list` with 400 pull requests. This is the drain threads'
/// regression test.
#[test]
fn run_bounded_drains_a_pipe_larger_than_its_buffer() {
    let mut cmd = Command::new("sh");
    cmd.args([
        "-c",
        "i=0; while [ $i -lt 20000 ]; do echo 0123456789; i=$((i+1)); done",
    ]);
    let out = run_bounded(cmd, Duration::from_secs(20)).expect("a chatty child must not deadlock");
    assert!(out.status.success(), "{:?}", out.status);
    assert_eq!(out.stdout.lines().count(), 20_000);
}

/// A child that outlives its budget is killed and reported as a TIMEOUT.
///
/// 🔴 #7965 REGRESSION: the variant matters, not just the failure. The hygiene
/// sweep logs a timeout as "this base was abandoned" and keeps going; a spawn
/// error means git is missing. Collapsing them would hide a wedged fetch behind
/// a misleading message.
#[test]
fn run_bounded_kills_a_hung_child() {
    let mut cmd = Command::new("sleep");
    cmd.arg("30");
    let started = Instant::now();
    let err = run_bounded(cmd, Duration::from_millis(300)).expect_err("a hung child must fail");
    assert!(matches!(err, BoundedError::TimedOut), "{err}");
    assert!(
        started.elapsed() < Duration::from_secs(10),
        "the timeout must actually fire; took {:?}",
        started.elapsed()
    );
}

/// 🔴 #6867 REGRESSION, restated at this layer: killing the child must reap its
/// GRANDCHILDREN too.
///
/// Why here as well as through the `gh` adapter: the hygiene sweep's wedge is a
/// `git fetch` whose transport helper (`ssh`, a credential helper) is a
/// grandchild holding the same pipes. `child.kill()` signals one pid and leaves
/// it running, reparented to launchd — which is how #6867 accumulated ~200 orphan
/// pairs in a couple of hours. The stand-in is a shell that backgrounds a long
/// `sleep` and then blocks: one process the runner knows about, one it does not.
#[test]
fn run_bounded_kills_the_whole_process_group() {
    let tmp = tempfile::tempdir().expect("tempdir");
    let pidfile = tmp.path().join("grandchild.pid");
    // The grandchild's fds are redirected so it does not hold the runner's
    // stdout pipe open after its parent dies.
    let script = format!(
        "sleep 300 >/dev/null 2>&1 & echo $! > {}; sleep 300",
        pidfile.display()
    );
    let mut cmd = Command::new("sh");
    cmd.args(["-c", &script]);
    let err = run_bounded(cmd, Duration::from_millis(500)).expect_err("a hung child must fail");
    assert!(matches!(err, BoundedError::TimedOut), "{err}");

    let pid: i32 = std::fs::read_to_string(&pidfile)
        .expect("the shell must have recorded its background child's pid")
        .trim()
        .parse()
        .expect("a pid");

    // The grandchild is reparented on its parent's death, so a killed one is
    // reaped promptly and `kill(pid, 0)` starts reporting ESRCH.
    let deadline = Instant::now() + Duration::from_secs(5);
    while alive(pid) && Instant::now() < deadline {
        std::thread::sleep(Duration::from_millis(50));
    }
    let still_there = alive(pid);
    if still_there {
        // Never leave a 300-second sleep behind, whatever the verdict.
        // SAFETY: `pid` was read from a child this test itself started.
        unsafe { libc::kill(pid, libc::SIGKILL) };
    }
    assert!(
        !still_there,
        "the grandchild (pid {pid}) survived its parent's timeout — the process group \
         was not killed (#6867)"
    );
}

/// Is `pid` still a live process?
///
/// SAFETY: `kill(pid, 0)` sends no signal; it only probes for the process's
/// existence and permission to signal it.
#[cfg(unix)]
fn alive(pid: i32) -> bool {
    unsafe { libc::kill(pid, 0) == 0 }
}

/// A binary that is not on PATH is a spawn failure, not a timeout.
#[test]
fn run_bounded_reports_a_spawn_failure() {
    let cmd = Command::new("definitely-not-a-real-binary-7965");
    let err = run_bounded(cmd, Duration::from_secs(5)).expect_err("an unspawnable command fails");
    assert!(matches!(err, BoundedError::Spawn(_)), "{err}");
    assert!(err.to_string().contains("could not be run"), "{err}");
}

/// Input reaches the child's stdin and the child sees EOF after it (#8306).
#[test]
fn run_bounded_with_input_feeds_stdin() {
    let out = run_bounded_with_input(
        Command::new("cat"),
        Some(b"patch\n".to_vec()),
        Duration::from_secs(10),
    )
    .expect("cat echoes its input and exits at EOF");
    assert!(out.status.success(), "{:?}", out.status);
    assert_eq!(out.stdout, "patch\n");
}

/// 🔴 #8306 REGRESSION: a child that exits while something it started still
/// holds stdout open is an ERROR, never an empty `Ok`.
///
/// Why: before #8306 the drain wait's expiry became `unwrap_or_default()`, an
/// empty stdout — which `git status --porcelain` callers read as "clean". The
/// stand-in backgrounds a `cat` that blocks opening a FIFO while holding the
/// inherited stdout, then exits; the test releases the FIFO afterwards.
#[cfg(unix)]
#[test]
fn run_bounded_reports_a_pipe_held_open_after_exit() {
    use std::os::unix::fs::OpenOptionsExt;
    let tmp = tempfile::tempdir().expect("tempdir");
    let fifo = tmp.path().join("gate");
    let made = Command::new("mkfifo").arg(&fifo).status();
    assert!(made.is_ok_and(|s| s.success()), "mkfifo");
    let script = format!("(exec cat \"{}\") & echo partial", fifo.display());
    let mut cmd = Command::new("sh");
    cmd.args(["-c", &script]);

    let result = run_bounded(cmd, Duration::from_secs(10));

    // Release the backgrounded `cat` whatever the verdict: a write-open lets its
    // blocked open complete and the close hands it EOF.
    let _ = std::fs::OpenOptions::new()
        .write(true)
        .custom_flags(libc::O_NONBLOCK)
        .open(&fifo);
    let err = result.expect_err("a held-open stdout must not read as complete output");
    assert!(matches!(err, BoundedError::PipeHeldOpen("stdout")), "{err}");
}

/// 🔴 #8306 REGRESSION: the grandchild holding the pipe is KILLED before
/// `PipeHeldOpen` is returned, not left running.
///
/// Why: the pipe holder is a grandchild in the child's process group — the git
/// transport or credential-helper shape #6867 names. Returning the error without
/// killing the group leaked it forever on every destructive call site. The
/// stand-in is the same FIFO-blocked `cat`, whose pid the shell records; the
/// test never opens the FIFO before the verdict, so only the runner's kill can
/// end it. The closing write-open must then find no reader (`ENXIO`).
#[cfg(unix)]
#[test]
fn run_bounded_kills_the_pipe_holder_it_reports_held_open() {
    use std::os::unix::fs::OpenOptionsExt;
    let tmp = tempfile::tempdir().expect("tempdir");
    let fifo = tmp.path().join("gate");
    let pidfile = tmp.path().join("holder.pid");
    let made = Command::new("mkfifo").arg(&fifo).status();
    assert!(made.is_ok_and(|s| s.success()), "mkfifo");
    let script = format!(
        "(exec cat \"{}\") & echo $! > \"{}\"; echo partial",
        fifo.display(),
        pidfile.display()
    );
    let mut cmd = Command::new("sh");
    cmd.args(["-c", &script]);

    let err = run_bounded(cmd, Duration::from_secs(10)).expect_err("the pipe is held open");
    assert!(matches!(err, BoundedError::PipeHeldOpen("stdout")), "{err}");

    let pid: i32 = std::fs::read_to_string(&pidfile)
        .expect("the shell must have recorded the pipe holder's pid")
        .trim()
        .parse()
        .expect("a pid");
    // Reparented on its parent's exit, a killed holder is reaped promptly.
    let deadline = Instant::now() + Duration::from_secs(5);
    while alive(pid) && Instant::now() < deadline {
        std::thread::sleep(Duration::from_millis(50));
    }
    let still_there = alive(pid);
    if still_there {
        // SAFETY: `pid` was read from a process this test itself started.
        unsafe { libc::kill(pid, libc::SIGKILL) };
    }
    assert!(
        !still_there,
        "the pipe holder (pid {pid}) outlived the PipeHeldOpen return — the process \
         group was not killed (#8306)"
    );
    let probe = std::fs::OpenOptions::new()
        .write(true)
        .custom_flags(libc::O_NONBLOCK)
        .open(&fifo);
    assert_eq!(
        probe.err().and_then(|e| e.raw_os_error()),
        Some(libc::ENXIO),
        "the FIFO still has a reader after the runner returned"
    );
}

/// 🔴 #8301: a thread deadline caps a child's own budget, and once it has
/// passed nothing more is spawned.
///
/// Why: the merged-PR survey ran 78 minutes as a loop of children that each
/// honoured its own budget; only a shared deadline bounds the loop. The second
/// call must fail without spawning, as a timeout, so every caller keeps the
/// worktree.
#[test]
fn a_deadline_cuts_a_child_short_and_refuses_the_next_one() {
    let started = Instant::now();
    let deadline = started + Duration::from_millis(300);
    let (first, second) = with_deadline(deadline, || {
        let mut cmd = Command::new("sleep");
        cmd.arg("30");
        let first = run_bounded(cmd, Duration::from_secs(30));
        let second = run_bounded(Command::new("true"), Duration::from_secs(30));
        (first, second)
    });
    assert!(matches!(first, Err(BoundedError::TimedOut)), "{first:?}");
    assert!(matches!(second, Err(BoundedError::TimedOut)), "{second:?}");
    assert!(
        started.elapsed() < Duration::from_secs(10),
        "the deadline, not the 30 s budget, must end the child; took {:?}",
        started.elapsed()
    );
    // The guard restored the thread: no deadline is left in force.
    assert_eq!(
        clamp_to_deadline(Duration::from_secs(5)),
        Some(Duration::from_secs(5))
    );
}