use std::process::Command;
use std::time::{Duration, Instant};
use crate::spawn::{Finished, SpawnError, Started};
const POLL: Duration = Duration::from_millis(100);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Ended {
Exited,
TimedOut,
Halted,
}
pub fn wait_for(
mut started: Started,
timeout: Option<Duration>,
mut should_stop: impl FnMut() -> bool,
) -> Result<(Finished, Ended), SpawnError> {
let began = Instant::now();
loop {
if let Some(status) = started.try_wait()? {
return Ok((started.into_finished(status), Ended::Exited));
}
if should_stop() {
kill_tree(started.pid())?;
let status = started.wait_after_kill()?;
return Ok((started.into_finished(status), Ended::Halted));
}
if timeout.is_some_and(|limit| began.elapsed() >= limit) {
kill_tree(started.pid())?;
let status = started.wait_after_kill()?;
return Ok((started.into_finished(status), Ended::TimedOut));
}
std::thread::sleep(POLL);
}
}
pub fn kill_tree(pid: u32) -> Result<(), SpawnError> {
#[cfg(windows)]
{
let status = Command::new("taskkill")
.args(["/T", "/F", "/PID", &pid.to_string()])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map_err(SpawnError::Io)?;
if status.success() || status.code() == Some(128) {
return Ok(());
}
Err(SpawnError::Io(std::io::Error::other(format!(
"taskkill refused to end process tree {pid}: {status}"
))))
}
#[cfg(not(windows))]
{
let status = Command::new("kill")
.args(["-KILL", &format!("-{pid}")])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map_err(SpawnError::Io)?;
if status.success() {
return Ok(());
}
Command::new("kill")
.args(["-KILL", &pid.to_string()])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map_err(SpawnError::Io)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::spawn::{Plan, start};
use layover_core::agent::AgentName;
use layover_core::config::Runner;
use std::collections::BTreeMap;
use std::path::PathBuf;
fn sleeping(seconds: u32) -> Runner {
let command = if cfg!(windows) {
format!(
r#"["cmd", "/c", "ping -n {} 127.0.0.1 > nul"]"#,
seconds + 1
)
} else {
format!(r#"["sh", "-c", "sleep {seconds}"]"#)
};
toml::from_str(&format!("command = {command}")).expect("parses")
}
fn quick() -> Runner {
let command = if cfg!(windows) {
r#"["cmd", "/c", "exit 0"]"#
} else {
r#"["sh", "-c", "exit 0"]"#
};
toml::from_str(&format!("command = {command}")).expect("parses")
}
struct Temp(PathBuf);
impl Temp {
fn new(name: &str) -> Self {
let path =
std::env::temp_dir().join(format!("layover-wait-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&path);
std::fs::create_dir_all(&path).expect("temp dir");
Self(path)
}
}
impl Drop for Temp {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn plan(temp: &Temp, runner: Runner) -> Plan {
Plan {
agent: AgentName::new("tester"),
runner,
model: None,
payload: "go".to_owned(),
hangar: temp.0.join("hangar"),
work_dir: temp.0.clone(),
env: BTreeMap::new(),
mcp_config: None,
}
}
#[test]
fn a_run_that_finishes_on_its_own_is_not_reported_as_killed() {
let temp = Temp::new("exits");
let started = start(&plan(&temp, quick())).expect("starts");
let (finished, ended) =
wait_for(started, Some(Duration::from_secs(30)), || false).expect("waits");
assert_eq!(ended, Ended::Exited);
assert!(finished.succeeded());
}
#[test]
fn a_run_that_outlives_its_timeout_is_killed_and_says_so() {
let temp = Temp::new("timeout");
let started = start(&plan(&temp, sleeping(30))).expect("starts");
let (_, ended) =
wait_for(started, Some(Duration::from_millis(300)), || false).expect("waits");
assert_eq!(ended, Ended::TimedOut, "a wedged run has to be endable");
}
#[test]
fn a_stop_request_ends_a_running_child() {
let temp = Temp::new("halt");
let started = start(&plan(&temp, sleeping(30))).expect("starts");
let mut checks = 0;
let (_, ended) = wait_for(started, None, || {
checks += 1;
checks > 1
})
.expect("waits");
assert_eq!(ended, Ended::Halted);
}
#[test]
fn no_timeout_means_waiting_indefinitely_not_killing_immediately() {
let temp = Temp::new("untimed");
let started = start(&plan(&temp, quick())).expect("starts");
let (_, ended) = wait_for(started, None, || false).expect("waits");
assert_eq!(ended, Ended::Exited);
}
#[test]
fn killing_something_already_gone_is_not_an_error() {
let temp = Temp::new("gone");
let started = start(&plan(&temp, quick())).expect("starts");
let pid = started.pid();
let _ = wait_for(started, None, || false).expect("waits");
kill_tree(pid).expect("killing a finished process is a no-op");
}
}