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
42#[cfg(not(target_os = "windows"))]
46const GRACE_PERIOD: Duration = Duration::from_millis(400);
47
48#[cfg(target_os = "windows")]
54pub const DETACHED_PROCESS: u32 = 0x0000_0008;
55#[cfg(target_os = "windows")]
58pub const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
59
60fn is_signalable(pid: u32) -> bool {
67 pid > 1
68}
69
70pub async fn terminate_tree(pid: u32, grace: Grace) {
73 if !is_signalable(pid) {
74 return;
75 }
76 #[cfg(not(target_os = "windows"))]
77 {
78 if grace == Grace::Graceful {
79 unix_kill("-TERM", pid).await;
80 tokio::time::sleep(GRACE_PERIOD).await;
81 }
82 unix_kill("-KILL", pid).await;
83 }
84 #[cfg(target_os = "windows")]
85 {
86 let _ = grace; taskkill_tree(pid).await;
88 }
89}
90
91pub fn terminate_tree_blocking(pid: u32, grace: Grace) {
94 if !is_signalable(pid) {
95 return;
96 }
97 #[cfg(not(target_os = "windows"))]
98 {
99 if grace == Grace::Graceful {
100 unix_kill_blocking("-TERM", pid);
101 std::thread::sleep(GRACE_PERIOD);
102 }
103 unix_kill_blocking("-KILL", pid);
104 }
105 #[cfg(target_os = "windows")]
106 {
107 let _ = grace;
108 taskkill_tree_blocking(pid);
109 }
110}
111
112#[cfg(not(target_os = "windows"))]
113async fn unix_kill(signal: &str, pid: u32) {
114 let _ = tokio::process::Command::new("kill")
115 .args([signal, "--", &format!("-{pid}"), &pid.to_string()])
116 .stdin(Stdio::null())
117 .stdout(Stdio::null())
118 .stderr(Stdio::null())
119 .status()
120 .await;
121}
122
123#[cfg(not(target_os = "windows"))]
124fn unix_kill_blocking(signal: &str, pid: u32) {
125 let _ = std::process::Command::new("kill")
126 .args([signal, "--", &format!("-{pid}"), &pid.to_string()])
127 .stdin(Stdio::null())
128 .stdout(Stdio::null())
129 .stderr(Stdio::null())
130 .status();
131}
132
133#[cfg(target_os = "windows")]
134async fn taskkill_tree(pid: u32) {
135 let _ = tokio::process::Command::new("taskkill")
136 .args(["/PID", &pid.to_string(), "/T", "/F"])
137 .stdin(Stdio::null())
138 .stdout(Stdio::null())
139 .stderr(Stdio::null())
140 .status()
141 .await;
142}
143
144#[cfg(target_os = "windows")]
145fn taskkill_tree_blocking(pid: u32) {
146 let _ = std::process::Command::new("taskkill")
147 .args(["/PID", &pid.to_string(), "/T", "/F"])
148 .stdin(Stdio::null())
149 .stdout(Stdio::null())
150 .stderr(Stdio::null())
151 .status();
152}
153
154const POLL_INTERVAL: Duration = Duration::from_millis(10);
161
162const READER_GRACE: Duration = Duration::from_millis(250);
166
167const REAP_GRACE: Duration = Duration::from_millis(500);
171
172pub fn output_with_timeout(cmd: &mut Command, timeout: Duration) -> std::io::Result<Output> {
180 cmd.stdin(Stdio::null())
181 .stdout(Stdio::piped())
182 .stderr(Stdio::piped());
183 let mut child = cmd.spawn()?;
184 let stdout_rx = drain_pipe(child.stdout.take());
185 let stderr_rx = drain_pipe(child.stderr.take());
186
187 match wait_deadline(&mut child, timeout)? {
188 Some(status) => Ok(Output {
189 status,
190 stdout: collect_drained(stdout_rx),
191 stderr: collect_drained(stderr_rx),
192 }),
193 None => Err(timed_out(cmd, timeout)),
194 }
195}
196
197pub fn write_stdin_with_timeout(
207 cmd: &mut Command,
208 input: Vec<u8>,
209 timeout: Duration,
210) -> std::io::Result<ExitStatus> {
211 cmd.stdin(Stdio::piped())
212 .stdout(Stdio::null())
213 .stderr(Stdio::null());
214 let mut child = cmd.spawn()?;
215 if let Some(mut stdin) = child.stdin.take() {
216 std::thread::spawn(move || {
217 let _ = stdin.write_all(&input);
218 });
220 }
221 match wait_deadline(&mut child, timeout)? {
222 Some(status) => Ok(status),
223 None => Err(timed_out(cmd, timeout)),
224 }
225}
226
227fn wait_deadline(child: &mut Child, timeout: Duration) -> std::io::Result<Option<ExitStatus>> {
233 let deadline = Instant::now() + timeout;
234 loop {
235 if let Some(status) = child.try_wait()? {
236 return Ok(Some(status));
237 }
238 if Instant::now() >= deadline {
239 let _ = child.kill();
240 let reap_deadline = Instant::now() + REAP_GRACE;
241 while Instant::now() < reap_deadline {
242 if matches!(child.try_wait(), Ok(Some(_)) | Err(_)) {
243 break;
244 }
245 std::thread::sleep(POLL_INTERVAL);
246 }
247 return Ok(None);
248 }
249 std::thread::sleep(POLL_INTERVAL);
250 }
251}
252
253fn drain_pipe<R: Read + Send + 'static>(pipe: Option<R>) -> mpsc::Receiver<Vec<u8>> {
258 let (tx, rx) = mpsc::channel();
259 if let Some(mut pipe) = pipe {
260 std::thread::spawn(move || {
261 let mut buf = [0u8; 8192];
262 loop {
263 match pipe.read(&mut buf) {
264 Ok(0) | Err(_) => break,
265 Ok(n) => {
266 if tx.send(buf[..n].to_vec()).is_err() {
267 break;
268 }
269 },
270 }
271 }
272 });
273 }
274 rx
275}
276
277fn collect_drained(rx: mpsc::Receiver<Vec<u8>>) -> Vec<u8> {
282 let mut out = Vec::new();
283 let deadline = Instant::now() + READER_GRACE;
284 while let Some(remaining) = deadline.checked_duration_since(Instant::now()) {
285 match rx.recv_timeout(remaining) {
286 Ok(chunk) => out.extend_from_slice(&chunk),
287 Err(_) => break,
289 }
290 }
291 out
292}
293
294fn timed_out(cmd: &Command, timeout: Duration) -> std::io::Error {
297 std::io::Error::new(
298 std::io::ErrorKind::TimedOut,
299 format!(
300 "{} did not exit within {timeout:?} and was killed",
301 cmd.get_program().to_string_lossy()
302 ),
303 )
304}
305
306#[cfg(test)]
307mod tests {
308 use super::*;
309
310 #[test]
311 fn pids_0_and_1_are_never_signalable() {
312 assert!(!is_signalable(0));
315 assert!(!is_signalable(1));
316 }
317
318 #[test]
319 fn real_pids_are_signalable() {
320 assert!(is_signalable(2));
321 assert!(is_signalable(1234));
322 assert!(is_signalable(u32::MAX));
323 }
324
325 #[tokio::test]
326 async fn terminate_tree_is_a_noop_for_unsafe_pids() {
327 terminate_tree(0, Grace::Immediate).await;
331 terminate_tree(1, Grace::Graceful).await;
332 terminate_tree_blocking(0, Grace::Immediate);
333 terminate_tree_blocking(1, Grace::Graceful);
334 }
335
336 #[cfg(unix)]
339 fn sh(script: &str) -> Command {
340 let mut c = Command::new("sh");
341 c.args(["-c", script]);
342 c
343 }
344
345 #[cfg(windows)]
346 fn cmd_c(script: &str) -> Command {
347 let mut c = Command::new("cmd");
348 c.args(["/C", script]);
349 c
350 }
351
352 #[cfg(unix)]
353 #[test]
354 fn output_with_timeout_captures_output() {
355 let out = output_with_timeout(&mut sh("echo hello"), Duration::from_secs(10)).unwrap();
356 assert!(out.status.success());
357 assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "hello");
358 }
359
360 #[cfg(unix)]
361 #[test]
362 fn output_with_timeout_kills_a_hung_child() {
363 let start = Instant::now();
364 let err = output_with_timeout(&mut sh("sleep 30"), Duration::from_millis(200))
365 .expect_err("a hung child must surface as an error");
366 assert_eq!(err.kind(), std::io::ErrorKind::TimedOut);
367 assert!(
368 start.elapsed() < Duration::from_secs(10),
369 "deadline kill must not wait for the child's natural exit"
370 );
371 }
372
373 #[cfg(unix)]
374 #[test]
375 fn output_with_timeout_drains_more_than_a_pipe_buffer() {
376 let out = output_with_timeout(
379 &mut sh("head -c 1048576 /dev/zero"),
380 Duration::from_secs(10),
381 )
382 .unwrap();
383 assert!(out.status.success());
384 assert_eq!(out.stdout.len(), 1_048_576);
385 }
386
387 #[cfg(unix)]
388 #[test]
389 fn output_with_timeout_tolerates_a_grandchild_holding_the_pipe() {
390 let start = Instant::now();
396 let out = output_with_timeout(
397 &mut sh("echo early; sleep 5 & exit 0"),
398 Duration::from_secs(10),
399 )
400 .unwrap();
401 assert!(out.status.success());
402 assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "early");
403 assert!(
404 start.elapsed() < Duration::from_secs(4),
405 "an fd-holding grandchild must not stall collection"
406 );
407 }
408
409 #[cfg(unix)]
410 #[test]
411 fn write_stdin_with_timeout_feeds_the_child() {
412 let status = write_stdin_with_timeout(
414 &mut sh(r#"input=$(cat); [ "$input" = "hello" ]"#),
415 b"hello".to_vec(),
416 Duration::from_secs(10),
417 )
418 .unwrap();
419 assert!(status.success());
420 }
421
422 #[cfg(unix)]
423 #[test]
424 fn write_stdin_with_timeout_kills_a_child_that_never_reads() {
425 let start = Instant::now();
429 let err = write_stdin_with_timeout(
430 &mut sh("sleep 30"),
431 vec![b'x'; 1_048_576],
432 Duration::from_millis(200),
433 )
434 .expect_err("a child that never reads must time out");
435 assert_eq!(err.kind(), std::io::ErrorKind::TimedOut);
436 assert!(start.elapsed() < Duration::from_secs(10));
437 }
438
439 #[cfg(windows)]
440 #[test]
441 fn output_with_timeout_captures_output() {
442 let out = output_with_timeout(&mut cmd_c("echo hello"), Duration::from_secs(20)).unwrap();
443 assert!(out.status.success());
444 assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "hello");
445 }
446
447 #[cfg(windows)]
448 #[test]
449 fn output_with_timeout_kills_a_hung_child() {
450 let start = Instant::now();
451 let err = output_with_timeout(
452 &mut cmd_c("ping -n 30 127.0.0.1"),
453 Duration::from_millis(500),
454 )
455 .expect_err("a hung child must surface as an error");
456 assert_eq!(err.kind(), std::io::ErrorKind::TimedOut);
457 assert!(
458 start.elapsed() < Duration::from_secs(20),
459 "deadline kill must not wait for the child's natural exit"
460 );
461 }
462
463 #[cfg(windows)]
464 #[test]
465 fn write_stdin_with_timeout_feeds_the_child() {
466 let status = write_stdin_with_timeout(
468 &mut cmd_c("findstr hello"),
469 b"hello\r\n".to_vec(),
470 Duration::from_secs(20),
471 )
472 .unwrap();
473 assert!(status.success());
474 }
475}