Skip to main content

stackless_core/
process.rs

1//! PID + process start time: the PID-reuse-safe liveness identity used
2//! for operation locks (§2) and daemon supervision (§3). Bounded
3//! subprocess waits (Stripe / launchctl / reaper children) live here so
4//! a hung helper cannot pin the control plane forever.
5
6use 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/// Result of [`run_with_timeout`].
18#[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
27/// Spawn `cmd` in its own process group, wait up to `budget`, and
28/// SIGKILL the process tree if it overruns.
29///
30/// Stdout/stderr are drained on background threads so a chatty child
31/// cannot fill the pipe and deadlock (unlike a post-exit `read_to_end`).
32pub 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    // Always reap this spawn's tree plus processes that inherited the
90    // per-spawn cookie (setsid leftovers the PPID walk can miss).
91    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
171/// SIGKILL `root` and every descendant, including children that created
172/// their own process group (Stripe CLI helpers).
173pub 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
215/// SIGKILL this spawn's process group and each observed descendant.
216/// A descendant that is its own process-group leader (`setsid`) is
217/// group-killed so its unseen children die with it. Name-scan hits
218/// never enter this set, so another stack's Stripe helper is safe.
219fn 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
248/// SIGKILL the process group whose leader is `pid` (set by `process_group(0)`).
249pub 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/// Identifies one incarnation of one process.
272#[derive(Debug, Clone, Copy, PartialEq, Eq)]
273pub struct ProcessStamp {
274    pub pid: StacklessPid,
275    /// Unix seconds the process started, per the OS.
276    pub start_time: ProcessStartTime,
277}
278
279impl ProcessStamp {
280    /// The stamp of the calling process.
281    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    /// The stamp of an arbitrary live process, if it exists.
290    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    /// True only if a process with this PID exists *and* started at the
296    /// recorded time — a recycled PID does not count.
297    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}