1use std::process::Command;
18use std::time::{Duration, Instant};
19
20use crate::spawn::{Finished, SpawnError, Started};
21
22const POLL: Duration = Duration::from_millis(100);
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum Ended {
32 Exited,
34 TimedOut,
36 Halted,
38}
39
40pub 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
76pub fn kill_tree(pid: u32) -> Result<(), SpawnError> {
83 #[cfg(windows)]
84 {
85 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 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 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 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 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 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 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}