Skip to main content

rightkit_qa/
process.rs

1//! Owned processes: every child is spawned through `rightkit-child-process` (process
2//! group / Job Object), registered with a [`Tracker`], and killed by recorded pid
3//! only. Nothing here ever matches processes by name for killing.
4use crate::util::{err, Result};
5use rightkit_child_process::{OwnedChild, OwnedCommand, ReapOutcome};
6use std::collections::BTreeMap;
7use std::io::Read;
8use std::path::PathBuf;
9use std::process::{Command, ExitStatus, Stdio};
10use std::sync::{Arc, Mutex};
11use std::time::{Duration, Instant};
12
13/// Longest a kill helper (`taskkill`, `kill`, `pgrep`, ...) may run before it is abandoned.
14pub const KILL_HELPER_TIMEOUT: Duration = Duration::from_secs(10);
15/// Longest a process-tree termination may wait to reap the leader.
16pub const TERMINATE_REAP_TIMEOUT: Duration = Duration::from_secs(5);
17/// After a helper is killed for overrunning, how long to wait for it to be reaped.
18const HELPER_REAP: Duration = Duration::from_secs(2);
19
20fn poll_exit(
21    child: &mut std::process::Child,
22    deadline: Instant,
23) -> std::io::Result<Option<ExitStatus>> {
24    loop {
25        if let Some(st) = child.try_wait()? {
26            return Ok(Some(st));
27        }
28        if Instant::now() >= deadline {
29            return Ok(None);
30        }
31        std::thread::sleep(Duration::from_millis(15));
32    }
33}
34
35/// Kill an overrunning helper and reap it, bounded.
36fn abandon(child: &mut std::process::Child) {
37    let _ = child.kill();
38    let _ = poll_exit(child, Instant::now() + HELPER_REAP);
39}
40
41/// Run a command to completion for its exit status, waiting at most `timeout`. The command
42/// is killed when it overruns. `Ok(None)` means it timed out. The caller sets stdio.
43pub fn status_bounded(cmd: &mut Command, timeout: Duration) -> std::io::Result<Option<ExitStatus>> {
44    let mut child = cmd.spawn()?;
45    match poll_exit(&mut child, Instant::now() + timeout)? {
46        Some(st) => Ok(Some(st)),
47        None => {
48            abandon(&mut child);
49            Ok(None)
50        }
51    }
52}
53
54/// Run a command for its captured output, waiting at most `timeout` (pipe draining is bounded
55/// too, so a descendant holding a pipe open cannot hang the caller). `Ok(None)` means it
56/// timed out and was killed. Stdin is null.
57pub fn output_bounded(
58    cmd: &mut Command,
59    timeout: Duration,
60) -> std::io::Result<Option<std::process::Output>> {
61    cmd.stdin(Stdio::null())
62        .stdout(Stdio::piped())
63        .stderr(Stdio::piped());
64    let mut child = cmd.spawn()?;
65    let drain = |stream: Option<Box<dyn Read + Send>>| {
66        let (tx, rx) = std::sync::mpsc::channel::<Vec<u8>>();
67        std::thread::spawn(move || {
68            let mut buf = Vec::new();
69            if let Some(mut s) = stream {
70                let _ = s.read_to_end(&mut buf);
71            }
72            let _ = tx.send(buf);
73        });
74        rx
75    };
76    let out_rx = drain(
77        child
78            .stdout
79            .take()
80            .map(|s| Box::new(s) as Box<dyn Read + Send>),
81    );
82    let err_rx = drain(
83        child
84            .stderr
85            .take()
86            .map(|s| Box::new(s) as Box<dyn Read + Send>),
87    );
88    match poll_exit(&mut child, Instant::now() + timeout)? {
89        Some(status) => Ok(Some(std::process::Output {
90            status,
91            stdout: out_rx.recv_timeout(HELPER_REAP).unwrap_or_default(),
92            stderr: err_rx.recv_timeout(HELPER_REAP).unwrap_or_default(),
93        })),
94        None => {
95            abandon(&mut child);
96            Ok(None)
97        }
98    }
99}
100
101/// Bounded `status().success()` with null stdio; a spawn failure or timeout is `false`.
102#[cfg(unix)]
103fn quiet_ok(cmd: &mut Command) -> bool {
104    cmd.stdin(Stdio::null())
105        .stdout(Stdio::null())
106        .stderr(Stdio::null());
107    matches!(status_bounded(cmd, KILL_HELPER_TIMEOUT), Ok(Some(s)) if s.success())
108}
109
110/// Run a kill helper (e.g. `taskkill`) bounded by `timeout`. When it cannot finish (it
111/// overran, or could not start) `fallback(pid)` runs instead (`TerminateProcess` on Windows)
112/// and a note for the run log is returned; `None` means the helper finished. Never blocks
113/// longer than `timeout` plus the helper reap.
114pub fn kill_with_fallback(
115    helper: &mut Command,
116    pid: u32,
117    timeout: Duration,
118    fallback: impl FnOnce(u32) -> bool,
119) -> Option<String> {
120    helper
121        .stdin(Stdio::null())
122        .stdout(Stdio::null())
123        .stderr(Stdio::null());
124    let why = match status_bounded(helper, timeout) {
125        Ok(Some(_)) => return None,
126        Ok(None) => format!("timed out after {:.1}s", timeout.as_secs_f32()),
127        Err(e) => format!("could not run ({e})"),
128    };
129    let ok = fallback(pid);
130    Some(format!(
131        "[rightkit-qa] kill helper for pid {pid} {why}; fell back to direct termination of the known pid ({})",
132        if ok { "terminated" } else { "not terminated" }
133    ))
134}
135
136/// `TerminateProcess` on one pid (no descendants; the owning Job Object takes those).
137#[cfg(windows)]
138fn terminate_pid(pid: u32) -> bool {
139    use windows_sys::Win32::Foundation::CloseHandle;
140    use windows_sys::Win32::System::Threading::{OpenProcess, TerminateProcess, PROCESS_TERMINATE};
141    unsafe {
142        let h = OpenProcess(PROCESS_TERMINATE, 0, pid);
143        if h.is_null() {
144            return false;
145        }
146        let ok = TerminateProcess(h, 1) != 0;
147        CloseHandle(h);
148        ok
149    }
150}
151
152#[cfg(windows)]
153fn taskkill_tree(pid: u32) -> Option<String> {
154    kill_with_fallback(
155        hidden(Command::new("taskkill.exe").args(["/PID", &pid.to_string(), "/T", "/F"])),
156        pid,
157        KILL_HELPER_TIMEOUT,
158        terminate_pid,
159    )
160}
161
162/// Terminate an owned child's tree, waiting at most [`TERMINATE_REAP_TIMEOUT`] for the leader to
163/// be reaped. Returns the exit status when reaped and a run-log note when it was not.
164pub fn terminate_bounded(child: &mut OwnedChild) -> (Option<ExitStatus>, Option<String>) {
165    match child.terminate_tree_bounded(TERMINATE_REAP_TIMEOUT) {
166        Ok(ReapOutcome::Exited(st)) => (Some(st), None),
167        Ok(ReapOutcome::TimedOut) => (
168            None,
169            Some(format!(
170                "[rightkit-qa] pid {} was not reaped within {}s of tree termination; moving on",
171                child.id(),
172                TERMINATE_REAP_TIMEOUT.as_secs()
173            )),
174        ),
175        Err(e) => (
176            None,
177            Some(format!(
178                "[rightkit-qa] tree termination of pid {} failed: {e}",
179                child.id()
180            )),
181        ),
182    }
183}
184
185pub fn is_alive(pid: u32) -> bool {
186    if pid == 0 {
187        return false;
188    }
189    #[cfg(unix)]
190    {
191        if !quiet_ok(Command::new("kill").args(["-0", &pid.to_string()])) {
192            return false;
193        }
194        // A zombie has exited and accepts signals, but is not running. It stays a zombie until
195        // its parent reaps it, which may never happen (a parent that forgot its child), so it
196        // must not count as a survivor.
197        let stat = output_bounded(
198            Command::new("ps").args(["-o", "stat=", "-p", &pid.to_string()]),
199            KILL_HELPER_TIMEOUT,
200        );
201        !matches!(
202            stat,
203            Ok(Some(o)) if String::from_utf8_lossy(&o.stdout).trim_start().starts_with('Z')
204        )
205    }
206    #[cfg(windows)]
207    {
208        // Native query: spawning `tasklist.exe` cost several hundred ms per call and was made a
209        // dozen times per `stop` (2.8 s of a 3 s stop). Fail closed: a process that exists but cannot
210        // be opened (access denied) counts as alive.
211        use windows_sys::Win32::Foundation::{CloseHandle, GetLastError, ERROR_ACCESS_DENIED};
212        use windows_sys::Win32::System::Threading::{
213            GetExitCodeProcess, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION,
214        };
215        const STILL_ACTIVE: u32 = 259;
216        unsafe {
217            let h = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid);
218            if h.is_null() {
219                return GetLastError() == ERROR_ACCESS_DENIED;
220            }
221            let mut code = 0u32;
222            let ok = GetExitCodeProcess(h, &mut code) != 0;
223            CloseHandle(h);
224            !ok || code == STILL_ACTIVE
225        }
226    }
227}
228
229#[cfg(windows)]
230fn hidden(cmd: &mut Command) -> &mut Command {
231    use std::os::windows::process::CommandExt;
232    cmd.creation_flags(0x0800_0000)
233}
234
235#[cfg(unix)]
236fn children_of(pid: u32) -> Vec<u32> {
237    let out = output_bounded(
238        Command::new("pgrep").args(["-P", &pid.to_string()]),
239        KILL_HELPER_TIMEOUT,
240    );
241    match out {
242        Ok(Some(o)) => String::from_utf8_lossy(&o.stdout)
243            .lines()
244            .filter_map(|l| l.trim().parse().ok())
245            .collect(),
246        _ => Vec::new(),
247    }
248}
249
250/// Terminate `pid` and its descendants. TERM first, KILL after a short grace.
251pub fn kill_tree(pid: u32) {
252    if !is_alive(pid) {
253        return;
254    }
255    #[cfg(windows)]
256    {
257        if let Some(note) = taskkill_tree(pid) {
258            eprintln!("{note}");
259        }
260    }
261    #[cfg(unix)]
262    {
263        let mut all = vec![pid];
264        let mut i = 0;
265        while i < all.len() {
266            let kids = children_of(all[i]);
267            all.extend(kids);
268            i += 1;
269        }
270        for sig in ["-TERM", "-KILL"] {
271            for p in all.iter().rev() {
272                let _ = quiet_ok(Command::new("kill").args([sig, &p.to_string()]));
273            }
274            let deadline = std::time::Instant::now() + Duration::from_millis(1500);
275            while std::time::Instant::now() < deadline && all.iter().any(|p| is_alive(*p)) {
276                crate::util::sleep_ms(25);
277            }
278            if !all.iter().any(|p| is_alive(*p)) {
279                break;
280            }
281        }
282    }
283}
284
285/// Kill the process group led by `pid` (the owned-command leader) so descendants that
286/// outlived the leader are cleaned up too. On Windows the Job Object dies with the child handle.
287fn kill_group(pid: u32) -> Option<String> {
288    #[cfg(unix)]
289    {
290        let _ = quiet_ok(Command::new("kill").args(["-KILL", "--", &format!("-{pid}")]));
291        None
292    }
293    #[cfg(windows)]
294    {
295        taskkill_tree(pid)
296    }
297}
298
299/// Registry of every pid a run started, so the run can assert nothing leaked.
300#[derive(Clone, Default)]
301pub struct Tracker {
302    inner: Arc<Mutex<BTreeMap<u32, String>>>,
303    registry: Arc<Mutex<Option<PathBuf>>>,
304}
305
306impl Tracker {
307    pub fn new() -> Self {
308        Self::default()
309    }
310    pub fn register(&self, pid: u32, label: &str) {
311        self.inner.lock().unwrap().insert(pid, label.to_string());
312        self.persist();
313    }
314    pub fn forget(&self, pid: u32) {
315        self.inner.lock().unwrap().remove(&pid);
316        self.persist();
317    }
318    pub fn alive(&self) -> Vec<(u32, String)> {
319        self.inner
320            .lock()
321            .unwrap()
322            .iter()
323            .filter(|(p, _)| is_alive(**p))
324            .map(|(p, l)| (*p, l.clone()))
325            .collect()
326    }
327    pub fn pids(&self) -> Vec<u32> {
328        self.inner.lock().unwrap().keys().copied().collect()
329    }
330    pub fn kill_all(&self) {
331        for (pid, _) in self.alive() {
332            kill_tree(pid);
333        }
334    }
335    /// Fail when any tracked process survived; survivors are killed first so a
336    /// failing assertion never leaves its own litter.
337    pub fn assert_no_orphans(&self) -> Result<()> {
338        let alive = self.alive();
339        if alive.is_empty() {
340            return Ok(());
341        }
342        self.kill_all();
343        let list = alive
344            .iter()
345            .map(|(p, l)| format!("{p} ({l})"))
346            .collect::<Vec<_>>()
347            .join(", ");
348        err(format!("orphan processes still running: {list}"))
349    }
350}
351
352#[derive(Debug, Clone, Default)]
353pub struct RunOptions {
354    pub cwd: Option<PathBuf>,
355    pub env: Vec<(String, String)>,
356    /// When true the inherited environment is dropped first.
357    pub clear_env: bool,
358    pub timeout: Option<Duration>,
359    pub label: String,
360}
361
362#[derive(Debug, Clone)]
363pub struct Output {
364    pub pid: u32,
365    pub code: Option<i32>,
366    pub stdout: String,
367    pub stderr: String,
368    pub timed_out: bool,
369}
370
371/// Run to completion with a hard deadline; on timeout the whole tree is killed.
372pub fn run_owned(
373    program: &str,
374    args: &[String],
375    opts: &RunOptions,
376    tracker: &Tracker,
377) -> Result<Output> {
378    let mut cmd = Command::new(program);
379    cmd.args(args)
380        .stdin(Stdio::null())
381        .stdout(Stdio::piped())
382        .stderr(Stdio::piped());
383    #[cfg(windows)]
384    {
385        use std::os::windows::process::CommandExt;
386        cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW: never flash a console from a GUI host
387    }
388    if let Some(cwd) = &opts.cwd {
389        cmd.current_dir(cwd);
390    }
391    if opts.clear_env {
392        cmd.env_clear();
393    }
394    for (k, v) in &opts.env {
395        cmd.env(k, v);
396    }
397    let mut owned = OwnedCommand::from_command(cmd);
398    owned.windows_hide();
399    let mut child = owned
400        .spawn()
401        .map_err(|e| crate::util::Error(format!("failed to start {program}: {e}")))?;
402    let pid = child.id();
403    tracker.register(
404        pid,
405        if opts.label.is_empty() {
406            program
407        } else {
408            &opts.label
409        },
410    );
411    let out_buf: Arc<Mutex<Vec<u8>>> = Arc::default();
412    let err_buf: Arc<Mutex<Vec<u8>>> = Arc::default();
413    let (done_tx, done_rx) = std::sync::mpsc::channel::<()>();
414    for (stream, buf) in [
415        (
416            child
417                .take_stdout()
418                .map(|s| Box::new(s) as Box<dyn Read + Send>),
419            out_buf.clone(),
420        ),
421        (
422            child
423                .take_stderr()
424                .map(|s| Box::new(s) as Box<dyn Read + Send>),
425            err_buf.clone(),
426        ),
427    ] {
428        let tx = done_tx.clone();
429        std::thread::spawn(move || {
430            if let Some(mut s) = stream {
431                let mut chunk = [0u8; 8192];
432                while let Ok(n) = s.read(&mut chunk) {
433                    if n == 0 {
434                        break;
435                    }
436                    buf.lock().unwrap().extend_from_slice(&chunk[..n]);
437                }
438            }
439            let _ = tx.send(());
440        });
441    }
442    drop(done_tx);
443    let mut notes: Vec<String> = Vec::new();
444    let timeout = opts.timeout.unwrap_or(Duration::from_secs(120));
445    let status = child.wait_timeout(timeout)?;
446    let (code, timed_out) = match status {
447        Some(s) => {
448            // owned descendants never outlive their command
449            notes.extend(kill_group(pid));
450            (s.code(), false)
451        }
452        None => {
453            let (st, note) = terminate_bounded(&mut child);
454            notes.extend(note);
455            notes.extend(kill_group(pid));
456            (st.and_then(|s| s.code()), true)
457        }
458    };
459    // Stream collection is bounded: a descendant that outlives the direct child and
460    // keeps the pipes open must not hang the run. Give readers a short grace, then
461    // kill the child's whole process group and give them a final bounded wait.
462    let mut finished = 0;
463    let grace = Instant::now() + Duration::from_secs(2);
464    while finished < 2 {
465        match done_rx.recv_timeout(grace.saturating_duration_since(Instant::now())) {
466            Ok(()) => finished += 1,
467            Err(_) => break,
468        }
469    }
470    let mut leaked_descendants = false;
471    if finished < 2 {
472        leaked_descendants = true;
473        notes.extend(kill_group(pid));
474        let last = Instant::now() + Duration::from_secs(3);
475        while finished < 2 {
476            match done_rx.recv_timeout(last.saturating_duration_since(Instant::now())) {
477                Ok(()) => finished += 1,
478                Err(_) => break,
479            }
480        }
481    }
482    drop(child);
483    tracker.forget(pid);
484    let mut stderr = String::from_utf8_lossy(&err_buf.lock().unwrap()).into_owned();
485    if leaked_descendants {
486        stderr.push_str("\n[rightkit-qa] descendant processes outlived the command and held its output open; the process group was killed");
487    }
488    for note in &notes {
489        stderr.push('\n');
490        stderr.push_str(note);
491    }
492    let stdout = String::from_utf8_lossy(&out_buf.lock().unwrap()).into_owned();
493    Ok(Output {
494        pid,
495        code,
496        stdout,
497        stderr,
498        timed_out,
499    })
500}
501
502// ---------------------------------------------------------------- crash sweep
503
504/// `ps` command line for a pid (unix); the fingerprint that makes a recorded pid
505/// safe to kill later without risking an unrelated process that reused it.
506pub fn fingerprint(pid: u32) -> Option<String> {
507    #[cfg(unix)]
508    {
509        let out = output_bounded(
510            Command::new("ps").args(["-o", "command=", "-p", &pid.to_string()]),
511            KILL_HELPER_TIMEOUT,
512        )
513        .ok()??;
514        let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
515        if s.is_empty() {
516            None
517        } else {
518            Some(s)
519        }
520    }
521    #[cfg(windows)]
522    {
523        let _ = pid;
524        None
525    }
526}
527
528impl Tracker {
529    /// Persist every registration to `path` as `[{pid, label, command}]`, so a
530    /// crashed run's survivors can be swept by the next run.
531    pub fn with_registry(path: PathBuf) -> Self {
532        let t = Self::default();
533        *t.registry.lock().unwrap() = Some(path);
534        t
535    }
536
537    pub(crate) fn persist(&self) {
538        let Some(path) = self.registry.lock().unwrap().clone() else {
539            return;
540        };
541        let rows: Vec<serde_json::Value> = self
542            .inner
543            .lock()
544            .unwrap()
545            .iter()
546            .map(|(pid, label)| serde_json::json!({"pid": pid, "label": label, "command": fingerprint(*pid)}))
547            .collect();
548        if let Some(p) = path.parent() {
549            let _ = std::fs::create_dir_all(p);
550        }
551        let _ = std::fs::write(path, serde_json::to_vec_pretty(&rows).unwrap_or_default());
552    }
553}
554
555/// Kill processes recorded by earlier runs that are still alive AND still have
556/// the exact command line that was recorded. Returns the pids killed.
557pub fn sweep_registries(runs_root: &std::path::Path, skip_run: &str) -> Vec<u32> {
558    let mut killed = vec![];
559    let Ok(entries) = std::fs::read_dir(runs_root) else {
560        return killed;
561    };
562    for e in entries.flatten() {
563        if e.file_name().to_string_lossy() == skip_run {
564            continue;
565        }
566        let Ok(ft) = e.file_type() else { continue };
567        if !ft.is_dir() || ft.is_symlink() {
568            continue;
569        }
570        let Some(marker) = crate::storage::read_marker(&e.path().join(crate::storage::RUN_MARKER))
571        else {
572            continue;
573        };
574        if marker.run_id != e.file_name().to_string_lossy()
575            || crate::storage::marker_active(&marker, crate::util::now_ms())
576        {
577            continue;
578        }
579        let file = e.path().join("pids.json");
580        let Ok(bytes) = std::fs::read(&file) else {
581            continue;
582        };
583        let rows: Vec<serde_json::Value> = serde_json::from_slice(&bytes).unwrap_or_default();
584        for r in rows {
585            let (Some(pid), Some(cmd)) =
586                (r["pid"].as_u64().map(|p| p as u32), r["command"].as_str())
587            else {
588                continue;
589            };
590            if is_alive(pid) && fingerprint(pid).as_deref() == Some(cmd) {
591                kill_tree(pid);
592                killed.push(pid);
593            }
594        }
595        let _ = std::fs::remove_file(&file);
596    }
597    killed
598}
599
600#[cfg(test)]
601mod tests {
602    use super::*;
603
604    /// A command that runs far longer than any test timeout.
605    fn slow() -> Command {
606        #[cfg(windows)]
607        {
608            let mut c = Command::new("ping");
609            c.args(["-n", "60", "127.0.0.1"]);
610            c
611        }
612        #[cfg(unix)]
613        {
614            let mut c = Command::new("sleep");
615            c.arg("60");
616            c
617        }
618    }
619
620    fn fast() -> Command {
621        #[cfg(windows)]
622        {
623            let mut c = Command::new("cmd");
624            c.args(["/C", "exit 0"]);
625            c
626        }
627        #[cfg(unix)]
628        {
629            Command::new("true")
630        }
631    }
632
633    #[test]
634    fn slow_helper_is_bounded_and_falls_back() {
635        let called = std::cell::Cell::new(None);
636        let started = Instant::now();
637        let note = kill_with_fallback(&mut slow(), 4242, Duration::from_millis(300), |pid| {
638            called.set(Some(pid));
639            true
640        });
641        assert!(started.elapsed() < Duration::from_secs(10), "bounded wait");
642        assert_eq!(called.get(), Some(4242), "fallback ran on the known pid");
643        let note = note.expect("timeout is reported");
644        assert!(note.contains("timed out"), "{note}");
645        assert!(note.contains("4242"), "{note}");
646        assert!(note.contains("(terminated)"), "{note}");
647    }
648
649    #[test]
650    fn finished_helper_skips_fallback_and_reports_nothing() {
651        let called = std::cell::Cell::new(false);
652        let note = kill_with_fallback(&mut fast(), 1, Duration::from_secs(20), |_| {
653            called.set(true);
654            true
655        });
656        assert!(note.is_none());
657        assert!(!called.get());
658    }
659
660    #[test]
661    fn unstartable_helper_falls_back_and_reports() {
662        let called = std::cell::Cell::new(false);
663        let mut missing = Command::new("rightkit-qa-no-such-helper-binary");
664        let note = kill_with_fallback(&mut missing, 7, Duration::from_secs(1), |_| {
665            called.set(true);
666            false
667        })
668        .expect("start failure is reported");
669        assert!(called.get());
670        assert!(note.contains("could not run"), "{note}");
671        assert!(note.contains("(not terminated)"), "{note}");
672    }
673
674    #[test]
675    fn status_bounded_times_out_and_reaps() {
676        let started = Instant::now();
677        let st = status_bounded(
678            slow().stdout(Stdio::null()).stderr(Stdio::null()),
679            Duration::from_millis(200),
680        )
681        .unwrap();
682        assert!(st.is_none());
683        assert!(started.elapsed() < Duration::from_secs(10));
684    }
685
686    #[test]
687    fn output_bounded_times_out_and_returns_output_when_fast() {
688        let started = Instant::now();
689        assert!(output_bounded(&mut slow(), Duration::from_millis(200))
690            .unwrap()
691            .is_none());
692        assert!(started.elapsed() < Duration::from_secs(10));
693        let out = output_bounded(&mut fast(), Duration::from_secs(20))
694            .unwrap()
695            .expect("finishes");
696        assert!(out.status.success());
697    }
698}