Skip to main content

layover_tower/
wait.rs

1//! Waiting for a run, and ending one that will not end by itself.
2//!
3//! # Why a timeout is not automatic recovery
4//!
5//! Recovery exists for interruptions, where the cause has gone away — the machine restarted, the
6//! supervisor died. A timeout means the work was too big or something is wedged, and both of those
7//! repeat identically, so retrying spends money to arrive in the same place. The run is killed, it
8//! is recorded as `timed_out`, and whoever sent the flight is told.
9//!
10//! # Why the whole tree is killed
11//!
12//! An agent CLI is rarely one process. It starts language servers, shells out to `git`, runs test
13//! suites. Killing only the process the supervisor spawned leaves those behind — still holding the
14//! workspace, still burning CPU, and on the next run, still there. What "kill the tree" means
15//! differs sharply by platform, which is why it is here and not inlined.
16
17use std::process::Command;
18use std::time::{Duration, Instant};
19
20use crate::spawn::{Finished, SpawnError, Started};
21
22/// How often a waiting run is checked.
23///
24/// Polling rather than blocking, because the wait has to be interruptible: a run being watched for
25/// a timeout, or for a Ground Stop, cannot be sitting in an uninterruptible `wait()`. A tenth of a
26/// second is far below any timeout worth configuring and costs nothing measurable.
27const POLL: Duration = Duration::from_millis(100);
28
29/// Why waiting stopped.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum Ended {
32    /// The child exited on its own.
33    Exited,
34    /// The child outlived `timeout_sec` and was killed.
35    TimedOut,
36    /// A Ground Stop was engaged and the child was killed.
37    Halted,
38}
39
40/// Waits for `started`, killing it if it outlives `timeout` or if `should_stop` becomes true.
41///
42/// `should_stop` is polled rather than passed as a signal so the caller decides what stopping
43/// means — a Ground Stop file appearing, an operator request, a shutdown.
44///
45/// # Errors
46///
47/// Returns [`SpawnError::Io`] when the child cannot be waited on or killed.
48pub fn wait_for(
49    mut started: Started,
50    timeout: Option<Duration>,
51    mut should_stop: impl FnMut() -> bool,
52) -> Result<(Finished, Ended), SpawnError> {
53    let began = Instant::now();
54
55    loop {
56        if let Some(status) = started.try_wait()? {
57            return Ok((started.into_finished(status), Ended::Exited));
58        }
59
60        if should_stop() {
61            kill_tree(started.pid())?;
62            let status = started.wait_after_kill()?;
63            return Ok((started.into_finished(status), Ended::Halted));
64        }
65
66        if timeout.is_some_and(|limit| began.elapsed() >= limit) {
67            kill_tree(started.pid())?;
68            let status = started.wait_after_kill()?;
69            return Ok((started.into_finished(status), Ended::TimedOut));
70        }
71
72        std::thread::sleep(POLL);
73    }
74}
75
76/// Ends a process and everything it started.
77///
78/// # Errors
79///
80/// Returns [`SpawnError::Io`] when the platform's terminating command cannot be run. A process
81/// that has already exited is not an error: the outcome wanted is that it is gone, and it is.
82pub fn kill_tree(pid: u32) -> Result<(), SpawnError> {
83    #[cfg(windows)]
84    {
85        // Windows has no process groups in the Unix sense. `taskkill /T` walks the parent chain
86        // the kernel records, which is the only reliable way to reach a CLI's children.
87        let status = Command::new("taskkill")
88            .args(["/T", "/F", "/PID", &pid.to_string()])
89            .stdout(std::process::Stdio::null())
90            .stderr(std::process::Stdio::null())
91            .status()
92            .map_err(SpawnError::Io)?;
93
94        // 128 means "no such process", which is the state being asked for.
95        if status.success() || status.code() == Some(128) {
96            return Ok(());
97        }
98
99        Err(SpawnError::Io(std::io::Error::other(format!(
100            "taskkill refused to end process tree {pid}: {status}"
101        ))))
102    }
103
104    #[cfg(not(windows))]
105    {
106        // Negating the identifier addresses the process group, which is what catches the children.
107        let status = Command::new("kill")
108            .args(["-KILL", &format!("-{pid}")])
109            .stdout(std::process::Stdio::null())
110            .stderr(std::process::Stdio::null())
111            .status()
112            .map_err(SpawnError::Io)?;
113
114        if status.success() {
115            return Ok(());
116        }
117
118        // The group may not exist because the child never made one. Fall back to the process.
119        Command::new("kill")
120            .args(["-KILL", &pid.to_string()])
121            .stdout(std::process::Stdio::null())
122            .stderr(std::process::Stdio::null())
123            .status()
124            .map_err(SpawnError::Io)?;
125
126        Ok(())
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133    use crate::spawn::{Plan, start};
134    use layover_core::agent::AgentName;
135    use layover_core::config::Runner;
136    use std::collections::BTreeMap;
137    use std::path::PathBuf;
138
139    fn sleeping(seconds: u32) -> Runner {
140        let command = if cfg!(windows) {
141            // `timeout` needs a console; ping against loopback is the portable idle.
142            format!(
143                r#"["cmd", "/c", "ping -n {} 127.0.0.1 > nul"]"#,
144                seconds + 1
145            )
146        } else {
147            format!(r#"["sh", "-c", "sleep {seconds}"]"#)
148        };
149        toml::from_str(&format!("command = {command}")).expect("parses")
150    }
151
152    fn quick() -> Runner {
153        let command = if cfg!(windows) {
154            r#"["cmd", "/c", "exit 0"]"#
155        } else {
156            r#"["sh", "-c", "exit 0"]"#
157        };
158        toml::from_str(&format!("command = {command}")).expect("parses")
159    }
160
161    struct Temp(PathBuf);
162
163    impl Temp {
164        fn new(name: &str) -> Self {
165            let path =
166                std::env::temp_dir().join(format!("layover-wait-{name}-{}", std::process::id()));
167            let _ = std::fs::remove_dir_all(&path);
168            std::fs::create_dir_all(&path).expect("temp dir");
169            Self(path)
170        }
171    }
172
173    impl Drop for Temp {
174        fn drop(&mut self) {
175            let _ = std::fs::remove_dir_all(&self.0);
176        }
177    }
178
179    fn plan(temp: &Temp, runner: Runner) -> Plan {
180        Plan {
181            agent: AgentName::new("tester"),
182            runner,
183            model: None,
184            payload: "go".to_owned(),
185            hangar: temp.0.join("hangar"),
186            work_dir: temp.0.clone(),
187            env: BTreeMap::new(),
188            mcp_config: None,
189        }
190    }
191
192    #[test]
193    fn a_run_that_finishes_on_its_own_is_not_reported_as_killed() {
194        let temp = Temp::new("exits");
195        let started = start(&plan(&temp, quick())).expect("starts");
196
197        let (finished, ended) =
198            wait_for(started, Some(Duration::from_secs(30)), || false).expect("waits");
199
200        assert_eq!(ended, Ended::Exited);
201        assert!(finished.succeeded());
202    }
203
204    #[test]
205    fn a_run_that_outlives_its_timeout_is_killed_and_says_so() {
206        let temp = Temp::new("timeout");
207        let started = start(&plan(&temp, sleeping(30))).expect("starts");
208
209        let (_, ended) =
210            wait_for(started, Some(Duration::from_millis(300)), || false).expect("waits");
211
212        assert_eq!(ended, Ended::TimedOut, "a wedged run has to be endable");
213    }
214
215    #[test]
216    fn a_stop_request_ends_a_running_child() {
217        // This is what makes Ground Stop real rather than advisory: a kill switch that only
218        // blocks new work while the expensive thing keeps running is not a kill switch.
219        let temp = Temp::new("halt");
220        let started = start(&plan(&temp, sleeping(30))).expect("starts");
221
222        let mut checks = 0;
223        let (_, ended) = wait_for(started, None, || {
224            checks += 1;
225            checks > 1
226        })
227        .expect("waits");
228
229        assert_eq!(ended, Ended::Halted);
230    }
231
232    #[test]
233    fn no_timeout_means_waiting_indefinitely_not_killing_immediately() {
234        let temp = Temp::new("untimed");
235        let started = start(&plan(&temp, quick())).expect("starts");
236
237        let (_, ended) = wait_for(started, None, || false).expect("waits");
238        assert_eq!(ended, Ended::Exited);
239    }
240
241    #[test]
242    fn killing_something_already_gone_is_not_an_error() {
243        // The wanted outcome is that the process is not running, and it is not.
244        let temp = Temp::new("gone");
245        let started = start(&plan(&temp, quick())).expect("starts");
246        let pid = started.pid();
247        let _ = wait_for(started, None, || false).expect("waits");
248
249        kill_tree(pid).expect("killing a finished process is a no-op");
250    }
251}