use repolith_core::types::BuildError;
use std::process::{Output, Stdio};
use tokio::process::Command;
use tokio_util::sync::CancellationToken;
#[cfg(unix)]
const SIGTERM_GRACE: std::time::Duration = std::time::Duration::from_millis(250);
pub(crate) async fn run_with_cancel(
mut cmd: Command,
cancel: &CancellationToken,
) -> Result<Output, BuildError> {
cmd.kill_on_drop(true);
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
#[cfg(unix)]
cmd.process_group(0);
let child = cmd
.spawn()
.map_err(|e| BuildError::Io(format!("spawn: {e}")))?;
#[cfg(unix)]
let pgid = child.id().and_then(|p| i32::try_from(p).ok());
let wait = child.wait_with_output();
tokio::select! {
result = wait => result.map_err(|e| BuildError::Io(format!("subprocess: {e}"))),
() = cancel.cancelled() => {
#[cfg(unix)]
if let Some(pgid) = pgid {
kill_group(pgid).await;
}
Err(BuildError::Cancelled)
}
}
}
#[cfg(unix)]
async fn kill_group(pgid: i32) {
use nix::errno::Errno;
use nix::sys::signal::{Signal, killpg};
use nix::unistd::Pid;
let pg = Pid::from_raw(pgid);
let _ = killpg(pg, Signal::SIGTERM);
tokio::time::sleep(SIGTERM_GRACE).await;
if !matches!(killpg(pg, None), Err(Errno::ESRCH)) {
let _ = killpg(pg, Signal::SIGKILL);
}
}
pub(crate) fn check_status(out: &Output) -> Result<(), BuildError> {
if out.status.success() {
Ok(())
} else {
Err(BuildError::CommandFailed {
exit_code: out.status.code().unwrap_or(-1),
stderr: String::from_utf8_lossy(&out.stderr).trim().to_string(),
})
}
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn cancel_reaps_subprocess_fast() {
let mut cmd = Command::new("sh");
cmd.args(["-c", "sleep 30"]);
let cancel = CancellationToken::new();
let cancel_clone = cancel.clone();
let handle = tokio::spawn(async move { run_with_cancel(cmd, &cancel_clone).await });
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
let start = std::time::Instant::now();
cancel.cancel();
let result = tokio::time::timeout(std::time::Duration::from_secs(3), handle)
.await
.expect("must finish within 3s")
.expect("task panic");
assert!(
start.elapsed() < std::time::Duration::from_secs(2),
"cancel took too long: {:?}",
start.elapsed()
);
assert!(matches!(result, Err(BuildError::Cancelled)));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn cancel_reaps_grandchild() {
use nix::errno::Errno;
use nix::sys::signal::kill;
use nix::unistd::Pid;
let tmp = tempfile::tempdir().expect("tempdir");
let pidfile = tmp.path().join("sleep.pid");
let script = format!("sleep 30 & echo $! > {} ; wait", pidfile.to_str().unwrap());
let mut cmd = Command::new("sh");
cmd.args(["-c", &script]);
let cancel = CancellationToken::new();
let cancel_clone = cancel.clone();
let handle = tokio::spawn(async move { run_with_cancel(cmd, &cancel_clone).await });
for _ in 0..50 {
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
if pidfile.exists() && std::fs::metadata(&pidfile).is_ok_and(|m| m.len() > 0) {
break;
}
}
let pid_text = std::fs::read_to_string(&pidfile).expect("pidfile must be written");
let grandchild_pid: i32 = pid_text.trim().parse().expect("pid is a number");
cancel.cancel();
let _ = tokio::time::timeout(std::time::Duration::from_secs(3), handle).await;
tokio::time::sleep(SIGTERM_GRACE + std::time::Duration::from_millis(300)).await;
let probe = kill(Pid::from_raw(grandchild_pid), None);
assert!(
matches!(probe, Err(Errno::ESRCH)),
"grandchild pid {grandchild_pid} still alive after cancel + grace: {probe:?}"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn kill_group_skips_kill_after_leader_reaped() {
let mut cmd = Command::new("sh");
cmd.args(["-c", "exit 0"]);
cmd.process_group(0);
let child = cmd.spawn().expect("spawn");
let pgid = i32::try_from(child.id().expect("pid")).expect("pid fits");
let _ = child.wait_with_output().await;
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let start = std::time::Instant::now();
kill_group(pgid).await;
let elapsed = start.elapsed();
assert!(
elapsed >= SIGTERM_GRACE && elapsed < SIGTERM_GRACE * 3,
"kill_group took {elapsed:?}, expected ~{SIGTERM_GRACE:?}"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn kill_group_kills_grandchild_after_leader_exits() {
use nix::errno::Errno;
use nix::sys::signal::kill;
use nix::unistd::Pid;
let tmp = tempfile::tempdir().expect("tempdir");
let pidfile = tmp.path().join("sleep.pid");
let script = format!(
r"trap '' TERM ; sleep 30 & echo $! > {} ; exit 0",
pidfile.to_str().unwrap()
);
let mut cmd = Command::new("sh");
cmd.args(["-c", &script]);
cmd.process_group(0);
let child = cmd.spawn().expect("spawn");
let pgid = i32::try_from(child.id().expect("pid")).expect("pid fits");
for _ in 0..50 {
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
if pidfile.exists() && std::fs::metadata(&pidfile).is_ok_and(|m| m.len() > 0) {
break;
}
}
let pid_text = std::fs::read_to_string(&pidfile).expect("pidfile must be written");
let grandchild_pid: i32 = pid_text.trim().parse().expect("pid is a number");
let _ = child.wait_with_output().await;
kill_group(pgid).await;
tokio::time::sleep(std::time::Duration::from_millis(150)).await;
let probe = kill(Pid::from_raw(grandchild_pid), None);
assert!(
matches!(probe, Err(Errno::ESRCH)),
"grandchild pid {grandchild_pid} still alive after leader exited and kill_group ran: {probe:?}"
);
}
}