1use std::io::{Read, Write};
29use std::process::{Child, Command, ExitStatus, Output, Stdio};
30use std::sync::mpsc;
31use std::time::{Duration, Instant};
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum Grace {
36 Immediate,
38 Graceful,
40}
41
42const GRACE_PERIOD: Duration = Duration::from_millis(400);
44
45fn is_signalable(pid: u32) -> bool {
52 pid > 1
53}
54
55pub async fn terminate_tree(pid: u32, grace: Grace) {
58 if !is_signalable(pid) {
59 return;
60 }
61 #[cfg(not(target_os = "windows"))]
62 {
63 if grace == Grace::Graceful {
64 unix_kill("-TERM", pid).await;
65 tokio::time::sleep(GRACE_PERIOD).await;
66 }
67 unix_kill("-KILL", pid).await;
68 }
69 #[cfg(target_os = "windows")]
70 {
71 let _ = grace; taskkill_tree(pid).await;
73 }
74}
75
76pub fn terminate_tree_blocking(pid: u32, grace: Grace) {
79 if !is_signalable(pid) {
80 return;
81 }
82 #[cfg(not(target_os = "windows"))]
83 {
84 if grace == Grace::Graceful {
85 unix_kill_blocking("-TERM", pid);
86 std::thread::sleep(GRACE_PERIOD);
87 }
88 unix_kill_blocking("-KILL", pid);
89 }
90 #[cfg(target_os = "windows")]
91 {
92 let _ = grace;
93 taskkill_tree_blocking(pid);
94 }
95}
96
97#[cfg(not(target_os = "windows"))]
98async fn unix_kill(signal: &str, pid: u32) {
99 let _ = tokio::process::Command::new("kill")
100 .args([signal, "--", &format!("-{pid}"), &pid.to_string()])
101 .stdin(Stdio::null())
102 .stdout(Stdio::null())
103 .stderr(Stdio::null())
104 .status()
105 .await;
106}
107
108#[cfg(not(target_os = "windows"))]
109fn unix_kill_blocking(signal: &str, pid: u32) {
110 let _ = std::process::Command::new("kill")
111 .args([signal, "--", &format!("-{pid}"), &pid.to_string()])
112 .stdin(Stdio::null())
113 .stdout(Stdio::null())
114 .stderr(Stdio::null())
115 .status();
116}
117
118#[cfg(target_os = "windows")]
119async fn taskkill_tree(pid: u32) {
120 let _ = tokio::process::Command::new("taskkill")
121 .args(["/PID", &pid.to_string(), "/T", "/F"])
122 .stdin(Stdio::null())
123 .stdout(Stdio::null())
124 .stderr(Stdio::null())
125 .status()
126 .await;
127}
128
129#[cfg(target_os = "windows")]
130fn taskkill_tree_blocking(pid: u32) {
131 let _ = std::process::Command::new("taskkill")
132 .args(["/PID", &pid.to_string(), "/T", "/F"])
133 .stdin(Stdio::null())
134 .stdout(Stdio::null())
135 .stderr(Stdio::null())
136 .status();
137}
138
139const POLL_INTERVAL: Duration = Duration::from_millis(10);
146
147const READER_GRACE: Duration = Duration::from_millis(250);
151
152const REAP_GRACE: Duration = Duration::from_millis(500);
156
157pub fn output_with_timeout(cmd: &mut Command, timeout: Duration) -> std::io::Result<Output> {
165 cmd.stdin(Stdio::null())
166 .stdout(Stdio::piped())
167 .stderr(Stdio::piped());
168 let mut child = cmd.spawn()?;
169 let stdout_rx = drain_pipe(child.stdout.take());
170 let stderr_rx = drain_pipe(child.stderr.take());
171
172 match wait_deadline(&mut child, timeout)? {
173 Some(status) => Ok(Output {
174 status,
175 stdout: collect_drained(stdout_rx),
176 stderr: collect_drained(stderr_rx),
177 }),
178 None => Err(timed_out(cmd, timeout)),
179 }
180}
181
182pub fn write_stdin_with_timeout(
192 cmd: &mut Command,
193 input: Vec<u8>,
194 timeout: Duration,
195) -> std::io::Result<ExitStatus> {
196 cmd.stdin(Stdio::piped())
197 .stdout(Stdio::null())
198 .stderr(Stdio::null());
199 let mut child = cmd.spawn()?;
200 if let Some(mut stdin) = child.stdin.take() {
201 std::thread::spawn(move || {
202 let _ = stdin.write_all(&input);
203 });
205 }
206 match wait_deadline(&mut child, timeout)? {
207 Some(status) => Ok(status),
208 None => Err(timed_out(cmd, timeout)),
209 }
210}
211
212fn wait_deadline(child: &mut Child, timeout: Duration) -> std::io::Result<Option<ExitStatus>> {
218 let deadline = Instant::now() + timeout;
219 loop {
220 if let Some(status) = child.try_wait()? {
221 return Ok(Some(status));
222 }
223 if Instant::now() >= deadline {
224 let _ = child.kill();
225 let reap_deadline = Instant::now() + REAP_GRACE;
226 while Instant::now() < reap_deadline {
227 if matches!(child.try_wait(), Ok(Some(_)) | Err(_)) {
228 break;
229 }
230 std::thread::sleep(POLL_INTERVAL);
231 }
232 return Ok(None);
233 }
234 std::thread::sleep(POLL_INTERVAL);
235 }
236}
237
238fn drain_pipe<R: Read + Send + 'static>(pipe: Option<R>) -> mpsc::Receiver<Vec<u8>> {
243 let (tx, rx) = mpsc::channel();
244 if let Some(mut pipe) = pipe {
245 std::thread::spawn(move || {
246 let mut buf = [0u8; 8192];
247 loop {
248 match pipe.read(&mut buf) {
249 Ok(0) | Err(_) => break,
250 Ok(n) => {
251 if tx.send(buf[..n].to_vec()).is_err() {
252 break;
253 }
254 },
255 }
256 }
257 });
258 }
259 rx
260}
261
262fn collect_drained(rx: mpsc::Receiver<Vec<u8>>) -> Vec<u8> {
267 let mut out = Vec::new();
268 let deadline = Instant::now() + READER_GRACE;
269 while let Some(remaining) = deadline.checked_duration_since(Instant::now()) {
270 match rx.recv_timeout(remaining) {
271 Ok(chunk) => out.extend_from_slice(&chunk),
272 Err(_) => break,
274 }
275 }
276 out
277}
278
279fn timed_out(cmd: &Command, timeout: Duration) -> std::io::Error {
282 std::io::Error::new(
283 std::io::ErrorKind::TimedOut,
284 format!(
285 "{} did not exit within {timeout:?} and was killed",
286 cmd.get_program().to_string_lossy()
287 ),
288 )
289}
290
291#[cfg(test)]
292mod tests {
293 use super::*;
294
295 #[test]
296 fn pids_0_and_1_are_never_signalable() {
297 assert!(!is_signalable(0));
300 assert!(!is_signalable(1));
301 }
302
303 #[test]
304 fn real_pids_are_signalable() {
305 assert!(is_signalable(2));
306 assert!(is_signalable(1234));
307 assert!(is_signalable(u32::MAX));
308 }
309
310 #[tokio::test]
311 async fn terminate_tree_is_a_noop_for_unsafe_pids() {
312 terminate_tree(0, Grace::Immediate).await;
316 terminate_tree(1, Grace::Graceful).await;
317 terminate_tree_blocking(0, Grace::Immediate);
318 terminate_tree_blocking(1, Grace::Graceful);
319 }
320
321 #[cfg(unix)]
324 fn sh(script: &str) -> Command {
325 let mut c = Command::new("sh");
326 c.args(["-c", script]);
327 c
328 }
329
330 #[cfg(windows)]
331 fn cmd_c(script: &str) -> Command {
332 let mut c = Command::new("cmd");
333 c.args(["/C", script]);
334 c
335 }
336
337 #[cfg(unix)]
338 #[test]
339 fn output_with_timeout_captures_output() {
340 let out = output_with_timeout(&mut sh("echo hello"), Duration::from_secs(10)).unwrap();
341 assert!(out.status.success());
342 assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "hello");
343 }
344
345 #[cfg(unix)]
346 #[test]
347 fn output_with_timeout_kills_a_hung_child() {
348 let start = Instant::now();
349 let err = output_with_timeout(&mut sh("sleep 30"), Duration::from_millis(200))
350 .expect_err("a hung child must surface as an error");
351 assert_eq!(err.kind(), std::io::ErrorKind::TimedOut);
352 assert!(
353 start.elapsed() < Duration::from_secs(10),
354 "deadline kill must not wait for the child's natural exit"
355 );
356 }
357
358 #[cfg(unix)]
359 #[test]
360 fn output_with_timeout_drains_more_than_a_pipe_buffer() {
361 let out = output_with_timeout(
364 &mut sh("head -c 1048576 /dev/zero"),
365 Duration::from_secs(10),
366 )
367 .unwrap();
368 assert!(out.status.success());
369 assert_eq!(out.stdout.len(), 1_048_576);
370 }
371
372 #[cfg(unix)]
373 #[test]
374 fn output_with_timeout_tolerates_a_grandchild_holding_the_pipe() {
375 let start = Instant::now();
381 let out = output_with_timeout(
382 &mut sh("echo early; sleep 5 & exit 0"),
383 Duration::from_secs(10),
384 )
385 .unwrap();
386 assert!(out.status.success());
387 assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "early");
388 assert!(
389 start.elapsed() < Duration::from_secs(4),
390 "an fd-holding grandchild must not stall collection"
391 );
392 }
393
394 #[cfg(unix)]
395 #[test]
396 fn write_stdin_with_timeout_feeds_the_child() {
397 let status = write_stdin_with_timeout(
399 &mut sh(r#"input=$(cat); [ "$input" = "hello" ]"#),
400 b"hello".to_vec(),
401 Duration::from_secs(10),
402 )
403 .unwrap();
404 assert!(status.success());
405 }
406
407 #[cfg(unix)]
408 #[test]
409 fn write_stdin_with_timeout_kills_a_child_that_never_reads() {
410 let start = Instant::now();
414 let err = write_stdin_with_timeout(
415 &mut sh("sleep 30"),
416 vec![b'x'; 1_048_576],
417 Duration::from_millis(200),
418 )
419 .expect_err("a child that never reads must time out");
420 assert_eq!(err.kind(), std::io::ErrorKind::TimedOut);
421 assert!(start.elapsed() < Duration::from_secs(10));
422 }
423
424 #[cfg(windows)]
425 #[test]
426 fn output_with_timeout_captures_output() {
427 let out = output_with_timeout(&mut cmd_c("echo hello"), Duration::from_secs(20)).unwrap();
428 assert!(out.status.success());
429 assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "hello");
430 }
431
432 #[cfg(windows)]
433 #[test]
434 fn output_with_timeout_kills_a_hung_child() {
435 let start = Instant::now();
436 let err = output_with_timeout(
437 &mut cmd_c("ping -n 30 127.0.0.1"),
438 Duration::from_millis(500),
439 )
440 .expect_err("a hung child must surface as an error");
441 assert_eq!(err.kind(), std::io::ErrorKind::TimedOut);
442 assert!(
443 start.elapsed() < Duration::from_secs(20),
444 "deadline kill must not wait for the child's natural exit"
445 );
446 }
447
448 #[cfg(windows)]
449 #[test]
450 fn write_stdin_with_timeout_feeds_the_child() {
451 let status = write_stdin_with_timeout(
453 &mut cmd_c("findstr hello"),
454 b"hello\r\n".to_vec(),
455 Duration::from_secs(20),
456 )
457 .unwrap();
458 assert!(status.success());
459 }
460}