1use std::collections::HashSet;
7use std::io::Read;
8use std::process::{Command, Output, Stdio};
9use std::sync::mpsc;
10use std::thread;
11use std::time::{Duration, Instant};
12
13use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, System, UpdateKind};
14
15use crate::types::{Pid as StacklessPid, ProcessStartTime};
16
17#[derive(Debug)]
19pub enum TimedCommand {
20 Finished(Output),
21 TimedOut { pid: u32 },
22 Spawn(std::io::Error),
23}
24
25const DRAIN_JOIN: Duration = Duration::from_secs(2);
26
27pub fn run_with_timeout(cmd: &mut Command, budget: Duration) -> TimedCommand {
33 cmd.stdout(Stdio::piped());
34 cmd.stderr(Stdio::piped());
35 #[cfg(unix)]
36 {
37 use std::os::unix::process::CommandExt;
38 cmd.process_group(0);
39 }
40 cmd.stdin(Stdio::null());
41 let cookie = uuid::Uuid::new_v4().to_string();
42 cmd.env("STACKLESS_SPAWN", &cookie);
43 let mut child = match cmd.spawn() {
44 Ok(child) => child,
45 Err(err) => return TimedCommand::Spawn(err),
46 };
47 let pid = child.id();
48 let stdout = drain_pipe(child.stdout.take());
49 let stderr = drain_pipe_err(child.stderr.take());
50 let deadline = Instant::now() + budget;
51 let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
52 let seen = std::sync::Arc::new(std::sync::Mutex::new(HashSet::new()));
53 {
54 let stop = stop.clone();
55 let seen = seen.clone();
56 thread::spawn(move || {
57 while !stop.load(std::sync::atomic::Ordering::Relaxed) {
58 seen.lock()
59 .unwrap_or_else(|e| e.into_inner())
60 .extend(descendant_pids(pid));
61 thread::sleep(Duration::from_millis(1));
62 }
63 });
64 }
65 let outcome = loop {
66 match child.try_wait() {
67 Ok(Some(status)) => {
68 break TimedCommand::Finished(Output {
69 status,
70 stdout: Vec::new(),
71 stderr: Vec::new(),
72 });
73 }
74 Ok(None) if Instant::now() < deadline => {
75 thread::sleep(Duration::from_millis(20));
76 }
77 Ok(None) => {
78 break TimedCommand::TimedOut { pid };
79 }
80 Err(err) => {
81 break TimedCommand::Spawn(err);
82 }
83 }
84 };
85 stop.store(true, std::sync::atomic::Ordering::Relaxed);
86 let mut pids = seen.lock().unwrap_or_else(|e| e.into_inner()).clone();
87 pids.extend(descendant_pids(pid));
88 pids.extend(cookie_pids(&cookie));
89 kill_spawn(pid, &pids);
92 match outcome {
93 TimedCommand::Finished(mut output) => {
94 let out = take_drain(stdout, DRAIN_JOIN);
95 let err = take_drain(stderr, DRAIN_JOIN);
96 output.stdout = out.bytes;
97 output.stderr = err.bytes;
98 TimedCommand::Finished(output)
99 }
100 TimedCommand::TimedOut { pid } => {
101 let _ = child.kill();
102 let _ = child.wait();
103 drop(take_drain(stdout, DRAIN_JOIN));
104 drop(take_drain(stderr, DRAIN_JOIN));
105 TimedCommand::TimedOut { pid }
106 }
107 TimedCommand::Spawn(err) => {
108 let _ = child.kill();
109 drop(take_drain(stdout, DRAIN_JOIN));
110 drop(take_drain(stderr, DRAIN_JOIN));
111 TimedCommand::Spawn(err)
112 }
113 }
114}
115
116struct Drain {
117 buf: std::sync::Arc<std::sync::Mutex<Vec<u8>>>,
118 handle: thread::JoinHandle<()>,
119}
120
121struct DrainResult {
122 bytes: Vec<u8>,
123}
124
125fn drain_pipe(pipe: Option<std::process::ChildStdout>) -> Drain {
126 drain_read(pipe.map(|p| Box::new(p) as Box<dyn Read + Send>))
127}
128
129fn drain_pipe_err(pipe: Option<std::process::ChildStderr>) -> Drain {
130 drain_read(pipe.map(|p| Box::new(p) as Box<dyn Read + Send>))
131}
132
133fn drain_read(pipe: Option<Box<dyn Read + Send>>) -> Drain {
134 let buf = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
135 let shared = buf.clone();
136 let handle = thread::spawn(move || {
137 let Some(mut pipe) = pipe else {
138 return;
139 };
140 let mut chunk = [0u8; 8192];
141 loop {
142 match pipe.read(&mut chunk) {
143 Ok(0) | Err(_) => break,
144 Ok(n) => shared
145 .lock()
146 .unwrap_or_else(|e| e.into_inner())
147 .extend_from_slice(&chunk[..n]),
148 }
149 }
150 });
151 Drain { buf, handle }
152}
153
154fn take_drain(drain: Drain, budget: Duration) -> DrainResult {
155 let _ = join_timeout(drain.handle, budget);
156 let bytes = drain.buf.lock().unwrap_or_else(|e| e.into_inner()).clone();
157 DrainResult { bytes }
158}
159
160fn join_timeout<T: Send + 'static>(handle: thread::JoinHandle<T>, budget: Duration) -> Option<T> {
161 let (tx, rx) = mpsc::channel();
162 thread::spawn(move || {
163 let _ = tx.send(handle.join());
164 });
165 match rx.recv_timeout(budget) {
166 Ok(Ok(value)) => Some(value),
167 _ => None,
168 }
169}
170
171pub fn kill_process_tree(root: u32) {
174 kill_spawn(root, &descendant_pids(root));
175}
176
177fn descendant_pids(root: u32) -> HashSet<u32> {
178 let mut system = System::new();
179 system.refresh_processes(ProcessesToUpdate::All, true);
180 let mut stack = vec![root];
181 let mut seen = HashSet::new();
182 while let Some(pid) = stack.pop() {
183 if !seen.insert(pid) {
184 continue;
185 }
186 for (child, proc) in system.processes() {
187 if proc.parent() == Some(Pid::from_u32(pid)) {
188 stack.push(child.as_u32());
189 }
190 }
191 }
192 seen
193}
194
195fn cookie_pids(cookie: &str) -> HashSet<u32> {
196 let mut system = System::new();
197 system.refresh_processes_specifics(
198 ProcessesToUpdate::All,
199 true,
200 ProcessRefreshKind::nothing().with_environ(UpdateKind::Always),
201 );
202 system
203 .processes()
204 .iter()
205 .filter_map(|(pid, proc)| {
206 let hit = proc
207 .environ()
208 .iter()
209 .any(|var| var.to_string_lossy().contains(cookie));
210 hit.then_some(pid.as_u32())
211 })
212 .collect()
213}
214
215fn kill_spawn(root: u32, pids: &HashSet<u32>) {
220 kill_process_group(root);
221 for pid in pids {
222 if *pid != root && is_process_group_leader(*pid) {
223 kill_process_group(*pid);
224 }
225 kill_one(*pid);
226 }
227 kill_one(root);
228}
229
230fn is_process_group_leader(pid: u32) -> bool {
231 #[cfg(unix)]
232 {
233 let Ok(raw) = i32::try_from(pid) else {
234 return false;
235 };
236 let Some(pid) = rustix::process::Pid::from_raw(raw) else {
237 return false;
238 };
239 rustix::process::getpgid(Some(pid)).ok() == Some(pid)
240 }
241 #[cfg(not(unix))]
242 {
243 let _ = pid;
244 false
245 }
246}
247
248pub fn kill_process_group(pid: u32) {
250 #[cfg(unix)]
251 if let Ok(raw) = i32::try_from(pid)
252 && let Some(pgid) = rustix::process::Pid::from_raw(raw)
253 {
254 let _ = rustix::process::kill_process_group(pgid, rustix::process::Signal::KILL);
255 }
256 #[cfg(not(unix))]
257 let _ = pid;
258}
259
260fn kill_one(pid: u32) {
261 #[cfg(unix)]
262 if let Ok(raw) = i32::try_from(pid)
263 && let Some(pid) = rustix::process::Pid::from_raw(raw)
264 {
265 let _ = rustix::process::kill_process(pid, rustix::process::Signal::KILL);
266 }
267 #[cfg(not(unix))]
268 let _ = pid;
269}
270
271#[derive(Debug, Clone, Copy, PartialEq, Eq)]
273pub struct ProcessStamp {
274 pub pid: StacklessPid,
275 pub start_time: ProcessStartTime,
277}
278
279impl ProcessStamp {
280 pub fn current() -> Self {
282 let pid = StacklessPid::from_os(std::process::id());
283 Self {
284 pid,
285 start_time: start_time_of(pid).unwrap_or(ProcessStartTime::from_os(0)),
286 }
287 }
288
289 pub fn of(pid: u32) -> Option<Self> {
291 let pid = StacklessPid::from_os(pid);
292 start_time_of(pid).map(|start_time| Self { pid, start_time })
293 }
294
295 pub fn is_alive(&self) -> bool {
298 start_time_of(self.pid).is_some_and(|start| start == self.start_time)
299 }
300}
301
302fn start_time_of(pid: StacklessPid) -> Option<ProcessStartTime> {
303 let raw = pid.get();
304 let mut system = System::new();
305 system.refresh_processes_specifics(
306 ProcessesToUpdate::Some(&[Pid::from_u32(raw)]),
307 false,
308 ProcessRefreshKind::nothing(),
309 );
310 system
311 .process(Pid::from_u32(raw))
312 .map(sysinfo::Process::start_time)
313 .map(ProcessStartTime::from_os)
314}
315
316#[cfg(test)]
317mod tests {
318 use super::*;
319
320 #[test]
321 fn current_process_is_alive() {
322 let stamp = ProcessStamp::current();
323 assert!(stamp.start_time.get() > 0);
324 assert!(stamp.is_alive());
325 }
326
327 #[test]
328 fn wrong_start_time_is_not_alive() {
329 let stamp = ProcessStamp {
330 pid: StacklessPid::from_os(std::process::id()),
331 start_time: ProcessStartTime::from_os(1),
332 };
333 assert!(!stamp.is_alive());
334 }
335
336 #[test]
337 fn bogus_pid_is_not_alive() {
338 let stamp = ProcessStamp {
339 pid: StacklessPid::from_os(u32::MAX - 1),
340 start_time: ProcessStartTime::from_os(1),
341 };
342 assert!(!stamp.is_alive());
343 }
344
345 #[test]
346 fn run_with_timeout_finishes_a_quick_command() {
347 let mut cmd = Command::new("echo");
348 cmd.arg("stackless-timeout-ok");
349 match run_with_timeout(&mut cmd, Duration::from_secs(5)) {
350 TimedCommand::Finished(out) => {
351 assert!(out.status.success());
352 assert!(String::from_utf8_lossy(&out.stdout).contains("stackless-timeout-ok"));
353 }
354 other => panic!("expected finish, got {other:?}"),
355 }
356 }
357
358 #[test]
359 fn run_with_timeout_kills_a_sleeper() {
360 let mut cmd = Command::new("sleep");
361 cmd.arg("30");
362 let started = Instant::now();
363 match run_with_timeout(&mut cmd, Duration::from_millis(250)) {
364 TimedCommand::TimedOut { pid } => {
365 assert!(pid > 0);
366 assert!(started.elapsed() < Duration::from_secs(3));
367 assert!(
368 ProcessStamp::of(pid).is_none_or(|stamp| !stamp.is_alive()),
369 "sleeper pid {pid} still alive after timeout kill"
370 );
371 }
372 other => panic!("expected timeout, got {other:?}"),
373 }
374 }
375
376 #[test]
377 fn run_with_timeout_drains_stdout_larger_than_a_pipe() {
378 let mut cmd = Command::new("python3");
379 cmd.args(["-c", "print('x' * 200_000, end='')"]);
380 match run_with_timeout(&mut cmd, Duration::from_secs(5)) {
381 TimedCommand::Finished(out) => {
382 assert!(out.status.success());
383 assert_eq!(out.stdout.len(), 200_000);
384 }
385 other => panic!("expected finish, got {other:?}"),
386 }
387 }
388
389 #[test]
390 fn run_with_timeout_keeps_stdout_when_helper_holds_the_pipe() {
391 let marker = "stackless-bound-helper-marker";
392 let mut cmd = Command::new("python3");
393 cmd.args([
394 "-c",
395 &format!(
396 r#"
397import os, sys
398sys.stdout.write('{{"ok":true}}')
399sys.stdout.flush()
400if os.fork() == 0:
401 os.setsid()
402 import time
403 time.sleep(30) # {marker}
404os._exit(0)
405"#
406 ),
407 ]);
408 match run_with_timeout(&mut cmd, Duration::from_secs(8)) {
409 TimedCommand::Finished(out) => {
410 assert!(out.status.success());
411 assert!(
412 String::from_utf8_lossy(&out.stdout).contains(r#"{"ok":true}"#),
413 "stdout was {:?}",
414 String::from_utf8_lossy(&out.stdout)
415 );
416 }
417 other => panic!("expected finish, got {other:?}"),
418 }
419 let leftover = Command::new("pgrep")
420 .args(["-f", &format!("python3.*{marker}")])
421 .output()
422 .expect("pgrep");
423 assert!(
424 leftover.stdout.is_empty(),
425 "setsid helper still alive: {}",
426 String::from_utf8_lossy(&leftover.stdout)
427 );
428 }
429
430 #[test]
431 fn run_with_timeout_reaps_setsid_helper_that_closes_stdio() {
432 let marker = "stackless-closed-stdio-helper-marker";
433 let mut cmd = Command::new("python3");
434 cmd.args([
435 "-c",
436 &format!(
437 r#"
438import os, sys
439sys.stdout.write('{{"ok":true}}')
440sys.stdout.flush()
441if os.fork() == 0:
442 os.setsid()
443 os.close(1)
444 os.close(2)
445 import time
446 time.sleep(30) # {marker}
447os._exit(0)
448"#
449 ),
450 ]);
451 match run_with_timeout(&mut cmd, Duration::from_secs(8)) {
452 TimedCommand::Finished(out) => {
453 assert!(out.status.success());
454 assert!(
455 String::from_utf8_lossy(&out.stdout).contains(r#"{"ok":true}"#),
456 "stdout was {:?}",
457 String::from_utf8_lossy(&out.stdout)
458 );
459 }
460 other => panic!("expected finish, got {other:?}"),
461 }
462 let leftover = Command::new("pgrep")
463 .args(["-f", &format!("python3.*{marker}")])
464 .output()
465 .expect("pgrep");
466 assert!(
467 leftover.stdout.is_empty(),
468 "closed-stdio helper still alive: {}",
469 String::from_utf8_lossy(&leftover.stdout)
470 );
471 }
472
473 #[test]
474 fn run_with_timeout_reaps_setsid_helper_grandchildren() {
475 let marker = "stackless-setsid-grandchild-marker";
476 let mut cmd = Command::new("python3");
477 cmd.args([
478 "-c",
479 &format!(
480 r#"
481import os, sys, time
482sys.stdout.write('{{"ok":true}}')
483sys.stdout.flush()
484if os.fork() == 0:
485 os.setsid()
486 if os.fork() == 0:
487 time.sleep(30) # {marker}
488 os._exit(0)
489 time.sleep(30)
490os._exit(0)
491"#
492 ),
493 ]);
494 match run_with_timeout(&mut cmd, Duration::from_secs(8)) {
495 TimedCommand::Finished(out) => {
496 assert!(out.status.success());
497 assert!(
498 String::from_utf8_lossy(&out.stdout).contains(r#"{"ok":true}"#),
499 "stdout was {:?}",
500 String::from_utf8_lossy(&out.stdout)
501 );
502 }
503 other => panic!("expected finish, got {other:?}"),
504 }
505 let leftover = Command::new("pgrep")
506 .args(["-f", &format!("python3.*{marker}")])
507 .output()
508 .expect("pgrep");
509 assert!(
510 leftover.stdout.is_empty(),
511 "setsid grandchild still alive: {}",
512 String::from_utf8_lossy(&leftover.stdout)
513 );
514 }
515
516 #[test]
517 fn run_with_timeout_does_not_kill_unrelated_stripe_named_process() {
518 let decoy = std::env::temp_dir().join(format!(
519 "stripe-cli-projects-unrelated-{}",
520 std::process::id()
521 ));
522 std::fs::write(&decoy, b"#!/bin/sh\nexec sleep 60\n").expect("write decoy");
523 #[cfg(unix)]
524 {
525 use std::os::unix::fs::PermissionsExt;
526 let mut perms = std::fs::metadata(&decoy).expect("meta").permissions();
527 perms.set_mode(0o755);
528 std::fs::set_permissions(&decoy, perms).expect("chmod");
529 }
530 let mut decoy_child = Command::new(&decoy)
531 .stdin(Stdio::null())
532 .stdout(Stdio::null())
533 .stderr(Stdio::null())
534 .spawn()
535 .expect("spawn decoy");
536 let decoy_pid = decoy_child.id();
537 let mut cmd = Command::new("sleep");
538 cmd.arg("30");
539 let timed = run_with_timeout(&mut cmd, Duration::from_millis(250));
540 let decoy_alive = ProcessStamp::of(decoy_pid).is_some_and(|s| s.is_alive());
541 let _ = decoy_child.kill();
542 let _ = decoy_child.wait();
543 let _ = std::fs::remove_file(&decoy);
544 match timed {
545 TimedCommand::TimedOut { .. } => {}
546 other => panic!("expected timeout, got {other:?}"),
547 }
548 assert!(
549 decoy_alive,
550 "unrelated stripe-cli-projects decoy {decoy_pid} was killed"
551 );
552 }
553}