Skip to main content

fallow_process/
scoped_child.rs

1//! RAII wrapper around `std::process::Child` that registers the child's
2//! PID with the process-wide signal registry on spawn and deregisters
3//! on drop or explicit consume (`wait_with_output`, `wait`).
4//!
5//! Storage model: the wrapper owns the `Child` outright. Regular subprocesses
6//! register their PID. Top-level process-tree subprocesses share an owned
7//! process group or Windows Job Object handle with the signal registry and
8//! timeout watchers. Nested subprocesses inherit that outer tree and retain a
9//! direct-process terminator for their local timeout.
10//!
11//! Why PID-based and not Child-based: the wrapper needs to call
12//! `Child::wait_with_output(self)` which consumes the Child by value.
13//! If the registry also held the Child, there would be no clean way to
14//! transfer ownership for the wait while still letting the signal
15//! handler kill it. Storing the PID sidesteps the problem entirely:
16//! kill-by-PID is a side channel that does not interfere with wait.
17//!
18//! Known race (small window, low consequence): a child that completes
19//! naturally is reaped inside `wait_with_output` BEFORE we deregister
20//! its PID from the registry. If a signal arrives in the microseconds-
21//! wide window between `wait_with_output` returning and `deregister`
22//! running, the drain snapshots a now-recycled PID and sends `kill -9`
23//! to whatever process the kernel assigned that PID to. The window is
24//! small (one async-write to a Mutex), the consequence is one stray
25//! SIGKILL during shutdown, and recovery requires a more invasive
26//! design (an `Arc<Mutex<Option<Child>>>` shared with the registry).
27//! Documented here so future maintainers don't re-derive the trade-off.
28
29use std::io;
30use std::process::{
31    Child, ChildStderr, ChildStdin, ChildStdout, Command, ExitStatus, Output, Stdio,
32};
33use std::sync::Arc;
34
35use super::registry;
36use super::spawn_retry::spawn_retrying_busy_executable;
37
38/// RAII handle wrapping a spawned `Child` with registry tracking.
39pub struct ScopedChild {
40    /// `None` after the wrapper has consumed the child (`wait_with_output`,
41    /// `wait`). Drop checks this and terminates a still-running child,
42    /// then reaps it with bounded cleanup retries.
43    inner: Option<Child>,
44    /// Registry key. `None` after deregister so Drop does not redo it.
45    id: Option<u64>,
46    /// Shared ownership of the reserved process-tree identity, when requested.
47    process_tree: Option<registry::ProcessTreeHandle>,
48    /// PID of a child that inherited an already-managed outer process tree.
49    inherited_tree_pid: Option<u32>,
50}
51
52/// Cloneable process-tree termination capability for timeout and I/O guards.
53#[derive(Clone)]
54pub struct ProcessTreeTerminator {
55    target: TerminationTarget,
56    direct_pid: u32,
57}
58
59#[derive(Clone)]
60enum TerminationTarget {
61    ProcessTree(registry::ProcessTreeHandle),
62    Process(u32),
63}
64
65impl ProcessTreeTerminator {
66    pub fn terminate(&self) -> io::Result<()> {
67        let result = match &self.target {
68            TerminationTarget::ProcessTree(process_tree) => process_tree.terminate(),
69            TerminationTarget::Process(pid) => {
70                registry::kill_pid(*pid);
71                Ok(())
72            }
73        };
74        if result.is_err() {
75            registry::kill_pid(self.direct_pid);
76        }
77        result
78    }
79
80    #[cfg(all(test, windows))]
81    fn is_alive(&self) -> bool {
82        match &self.target {
83            TerminationTarget::ProcessTree(process_tree) => process_tree.is_alive(),
84            TerminationTarget::Process(pid) => registry::pid_is_alive(*pid),
85        }
86    }
87}
88
89impl ScopedChild {
90    /// Spawn the command and register the resulting child's PID.
91    pub fn spawn(command: &mut Command) -> io::Result<Self> {
92        let child = spawn_retrying_busy_executable(command)?;
93        let id = registry::register(child.id());
94        Ok(Self {
95            inner: Some(child),
96            id: Some(id),
97            process_tree: None,
98            inherited_tree_pid: None,
99        })
100    }
101
102    /// Spawn a subprocess wrapper in a dedicated process tree and register the
103    /// tree for process-wide signal cleanup.
104    pub fn spawn_process_tree(command: &mut Command) -> io::Result<Self> {
105        if crate::process_tree::inherits_managed_process_tree() {
106            let child = spawn_retrying_busy_executable(command)?;
107            let pid = child.id();
108            let id = registry::register(pid);
109            return Ok(Self {
110                inner: Some(child),
111                id: Some(id),
112                process_tree: None,
113                inherited_tree_pid: Some(pid),
114            });
115        }
116
117        crate::process_tree::configure_std_command(command);
118        let mut child = spawn_retrying_busy_executable(command)?;
119        let process_tree = match crate::process_tree::ProcessTree::for_std_child(&child) {
120            Ok(process_tree) => Arc::new(process_tree),
121            Err(error) => {
122                terminate_failed_setup(&mut child);
123                return Err(error);
124            }
125        };
126        let id = registry::register_process_tree(Arc::clone(&process_tree));
127        Ok(Self {
128            inner: Some(child),
129            id: Some(id),
130            process_tree: Some(process_tree),
131            inherited_tree_pid: None,
132        })
133    }
134
135    /// Return a cloneable handle that terminates the owned tree, or the direct
136    /// child when it inherited a tree from a Fallow parent.
137    pub fn process_tree_terminator(&self) -> Option<ProcessTreeTerminator> {
138        let direct_pid = self.inner.as_ref().map(Child::id)?;
139        if let Some(process_tree) = self.process_tree.as_ref() {
140            return Some(ProcessTreeTerminator {
141                target: TerminationTarget::ProcessTree(Arc::clone(process_tree)),
142                direct_pid,
143            });
144        }
145        self.inherited_tree_pid.map(|pid| ProcessTreeTerminator {
146            target: TerminationTarget::Process(pid),
147            direct_pid,
148        })
149    }
150
151    /// OS-level process id of the underlying child. Returns `0` if the
152    /// child has been consumed.
153    pub fn id(&self) -> u32 {
154        self.inner.as_ref().map_or(0, Child::id)
155    }
156
157    /// Take the child's stdin handle, if it was piped. Same semantics
158    /// as `Child::stdin.take()`. Returns `None` if stdin was not piped
159    /// or the child has been consumed.
160    pub fn take_stdin(&mut self) -> Option<ChildStdin> {
161        self.inner.as_mut().and_then(|c| c.stdin.take())
162    }
163
164    /// Take the child's stdout handle, if it was piped. Same semantics as
165    /// `Child::stdout.take()`. Returns `None` if stdout was not piped or the
166    /// child has been consumed. Used by long-lived readers (e.g. the audit
167    /// base-file `cat-file --batch` reader) that drive both pipes while leaving
168    /// the wrapper owning the child for registry tracking and the terminal wait.
169    pub fn take_stdout(&mut self) -> Option<ChildStdout> {
170        self.inner.as_mut().and_then(|c| c.stdout.take())
171    }
172
173    /// Take the child's stderr handle, if it was piped. Same semantics as
174    /// `Child::stderr.take()`.
175    pub fn take_stderr(&mut self) -> Option<ChildStderr> {
176        self.inner.as_mut().and_then(|child| child.stderr.take())
177    }
178
179    /// Consume self and wait for the child to exit, collecting stdout
180    /// and stderr. The signal handler may have already killed the
181    /// child via the registered termination target; in that case wait returns
182    /// normally with a non-zero status.
183    #[expect(
184        clippy::expect_used,
185        reason = "ScopedChild owns inner until one terminal wait method consumes it"
186    )]
187    pub fn wait_with_output(mut self) -> io::Result<Output> {
188        let child = self.inner.take().expect("inner already taken");
189        let id = self.id.take();
190        let result = child.wait_with_output();
191        if let Some(id) = id {
192            registry::deregister(id);
193        }
194        result
195    }
196
197    /// Wait for the child to exit, returning the status. Same signal-cleanup
198    /// semantics as `wait_with_output`.
199    #[expect(
200        clippy::expect_used,
201        reason = "ScopedChild owns inner until one terminal wait method consumes it"
202    )]
203    pub fn wait(mut self) -> io::Result<ExitStatus> {
204        let mut child = self.inner.take().expect("inner already taken");
205        let id = self.id.take();
206        let result = child.wait();
207        if let Some(id) = id {
208            registry::deregister(id);
209        }
210        result
211    }
212}
213
214fn terminate_failed_setup(child: &mut Child) {
215    let _ = crate::process_tree::cleanup_std_child(None, child);
216}
217
218impl Drop for ScopedChild {
219    fn drop(&mut self) {
220        if let Some(mut child) = self.inner.take() {
221            let running = !matches!(child.try_wait(), Ok(Some(_)));
222            if running {
223                let _ = crate::process_tree::cleanup_std_child(
224                    self.process_tree.as_deref(),
225                    &mut child,
226                );
227            }
228        }
229        if let Some(id) = self.id.take() {
230            registry::deregister(id);
231        }
232    }
233}
234
235/// Convenience: spawn and wait for exit, returning the status.
236pub fn status(command: &mut Command) -> io::Result<ExitStatus> {
237    let scoped = ScopedChild::spawn(command)?;
238    scoped.wait()
239}
240
241/// Convenience: spawn and collect full output (stdout + stderr).
242///
243/// Mirrors `Command::output` semantics by unconditionally setting
244/// stdout / stderr to piped and stdin to null. Callers that need
245/// different stdio (e.g. inherited stdin for interactive prompts)
246/// must use `ScopedChild::spawn` directly and drive the wait
247/// themselves.
248pub fn output(command: &mut Command) -> io::Result<Output> {
249    command
250        .stdin(Stdio::null())
251        .stdout(Stdio::piped())
252        .stderr(Stdio::piped());
253    ScopedChild::spawn(command)?.wait_with_output()
254}
255
256#[cfg(test)]
257#[expect(
258    clippy::expect_used,
259    reason = "test setup failures should fail at the exact setup operation"
260)]
261mod tests {
262    use super::*;
263
264    #[test]
265    #[cfg(unix)]
266    fn scoped_child_drop_deregisters() {
267        let mut cmd = Command::new("true");
268        let child = ScopedChild::spawn(&mut cmd).expect("spawn true");
269        let id = child.id.expect("freshly spawned wrapper has an id");
270        assert!(registry::is_registered(id));
271        drop(child);
272        assert!(!registry::is_registered(id));
273    }
274
275    #[test]
276    #[cfg(unix)]
277    fn scoped_child_drop_terminates_and_reaps_a_running_child() {
278        let mut command = Command::new("sleep");
279        command.arg("30");
280        let child = ScopedChild::spawn(&mut command).expect("spawn sleep");
281        let pid = child.id();
282
283        drop(child);
284
285        assert!(
286            !registry::pid_is_alive(pid),
287            "running child {pid} survived ScopedChild::drop"
288        );
289    }
290
291    #[test]
292    #[cfg(unix)]
293    fn scoped_child_wait_deregisters_and_succeeds() {
294        let mut cmd = Command::new("true");
295        let child = ScopedChild::spawn(&mut cmd).expect("spawn true");
296        let id = child.id.expect("freshly spawned wrapper has an id");
297        assert!(registry::is_registered(id));
298        let status = child.wait().expect("wait true");
299        assert!(status.success());
300        assert!(!registry::is_registered(id));
301    }
302
303    #[test]
304    #[cfg(unix)]
305    fn scoped_child_wait_with_output_deregisters_and_collects_stdout() {
306        let mut cmd = Command::new("echo");
307        cmd.arg("hello").stdout(Stdio::piped());
308        let child = ScopedChild::spawn(&mut cmd).expect("spawn echo");
309        let id = child.id.expect("freshly spawned wrapper has an id");
310        assert!(registry::is_registered(id));
311        let output = child.wait_with_output().expect("wait echo");
312        assert!(output.status.success());
313        assert_eq!(output.stdout, b"hello\n");
314        assert!(!registry::is_registered(id));
315    }
316
317    #[test]
318    #[cfg(unix)]
319    fn output_helper_collects_stdout() {
320        let mut cmd = Command::new("echo");
321        cmd.arg("hello")
322            .stdout(Stdio::piped())
323            .stderr(Stdio::piped());
324        let output = output(&mut cmd).expect("echo");
325        assert!(output.status.success());
326        assert_eq!(output.stdout, b"hello\n");
327    }
328
329    #[cfg(any(unix, windows))]
330    #[test]
331    fn nested_managed_process_tree_terminates_inherited_child() {
332        const HELPER_ENV: &str = "FALLOW_NESTED_PROCESS_TREE_TEST";
333        const ROOT_ENV: &str = "FALLOW_NESTED_PROCESS_TREE_ROOT";
334        const TEST_NAME: &str =
335            "scoped_child::tests::nested_managed_process_tree_terminates_inherited_child";
336
337        match std::env::var(HELPER_ENV).ok().as_deref() {
338            Some("nested") => {
339                let root = std::env::var_os(ROOT_ENV).expect("nested helper root");
340                std::fs::write(
341                    std::path::Path::new(&root).join("nested.pid"),
342                    std::process::id().to_string(),
343                )
344                .expect("write nested PID");
345                std::thread::sleep(std::time::Duration::from_secs(30));
346                return;
347            }
348            Some("outer") => {
349                let root = std::env::var_os(ROOT_ENV).expect("outer helper root");
350                let mut command =
351                    Command::new(std::env::current_exe().expect("current test executable"));
352                command
353                    .args(["--exact", TEST_NAME, "--nocapture"])
354                    .env(HELPER_ENV, "nested")
355                    .env(ROOT_ENV, root);
356                let child =
357                    ScopedChild::spawn_process_tree(&mut command).expect("spawn nested helper");
358                let _ = child.wait();
359                return;
360            }
361            _ => {}
362        }
363
364        let root = tempfile::tempdir().expect("temporary nested process-tree root");
365        let mut command = Command::new(std::env::current_exe().expect("current test executable"));
366        command
367            .args(["--exact", TEST_NAME, "--nocapture"])
368            .env(HELPER_ENV, "outer")
369            .env(ROOT_ENV, root.path());
370        let outer = ScopedChild::spawn_process_tree(&mut command).expect("spawn outer helper");
371        let terminator = outer
372            .process_tree_terminator()
373            .expect("outer process-tree terminator");
374        let pid_path = root.path().join("nested.pid");
375        let ready_deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
376        while !pid_path.exists() && std::time::Instant::now() < ready_deadline {
377            std::thread::sleep(std::time::Duration::from_millis(20));
378        }
379        let nested_pid = std::fs::read_to_string(&pid_path)
380            .expect("nested PID")
381            .trim()
382            .parse::<u32>()
383            .expect("numeric nested PID");
384
385        terminator.terminate().expect("terminate outer tree");
386        let status = outer.wait().expect("reap outer helper");
387        assert!(!status.success(), "outer helper was not terminated");
388
389        let exit_deadline = std::time::Instant::now() + std::time::Duration::from_secs(3);
390        while registry::pid_is_alive(nested_pid) && std::time::Instant::now() < exit_deadline {
391            std::thread::sleep(std::time::Duration::from_millis(20));
392        }
393        assert!(
394            !registry::pid_is_alive(nested_pid),
395            "nested managed child {nested_pid} survived outer cleanup"
396        );
397    }
398
399    #[cfg(windows)]
400    #[test]
401    fn windows_job_object_terminates_descendants_without_taskkill_lookup() {
402        const HELPER_ENV: &str = "FALLOW_WINDOWS_JOB_OBJECT_TEST_ROOT";
403        const TASKKILL_MARKER_ENV: &str = "FALLOW_FAKE_TASKKILL_MARKER";
404        const TEST_NAME: &str = "scoped_child::tests::windows_job_object_terminates_descendants_without_taskkill_lookup";
405
406        if let Some(root) = std::env::var_os(HELPER_ENV) {
407            run_windows_job_object_helper(std::path::Path::new(&root));
408            return;
409        }
410
411        let root = tempfile::tempdir().expect("temporary Windows Job Object root");
412        compile_fake_taskkill(root.path(), TASKKILL_MARKER_ENV);
413        std::fs::write(
414            root.path().join("descendant.cmd"),
415            "@echo off\r\necho ready>descendant-ready\r\nping.exe -n 30 127.0.0.1 >NUL\r\n",
416        )
417        .expect("write descendant script");
418        std::fs::write(
419            root.path().join("leader.cmd"),
420            "@echo off\r\nstart \"\" /B cmd.exe /D /S /C call descendant.cmd\r\nping.exe -n 30 127.0.0.1 >NUL\r\n",
421        )
422        .expect("write leader script");
423        let mut search_paths = vec![root.path().to_path_buf()];
424        if let Some(path) = std::env::var_os("PATH") {
425            search_paths.extend(std::env::split_paths(&path));
426        }
427        let search_path =
428            std::env::join_paths(search_paths).expect("prepend fake taskkill to PATH");
429
430        let output = Command::new(std::env::current_exe().expect("current test executable"))
431            .args(["--exact", TEST_NAME, "--nocapture"])
432            .current_dir(root.path())
433            .env(HELPER_ENV, root.path())
434            .env(TASKKILL_MARKER_ENV, root.path().join("taskkill-invoked"))
435            .env("PATH", search_path)
436            .env_remove("NoDefaultCurrentDirectoryInExePath")
437            .output()
438            .expect("run Windows Job Object helper");
439
440        assert!(
441            output.status.success(),
442            "helper failed: {}",
443            String::from_utf8_lossy(&output.stderr)
444        );
445        assert!(
446            !root.path().join("taskkill-invoked").exists(),
447            "cleanup executed project-local taskkill"
448        );
449    }
450
451    #[cfg(windows)]
452    fn compile_fake_taskkill(root: &std::path::Path, marker_env: &str) {
453        let source = root.join("fake-taskkill.rs");
454        let executable = root.join("taskkill.exe");
455        std::fs::write(
456            &source,
457            format!(
458                "fn main() {{ let marker = std::env::var_os({marker_env:?}).expect(\"marker path\"); std::fs::write(marker, b\"invoked\").expect(\"write marker\"); }}"
459            ),
460        )
461        .expect("write fake taskkill source");
462        let rustc = std::env::var_os("RUSTC").unwrap_or_else(|| "rustc".into());
463        let output = Command::new(rustc)
464            .args(["--edition=2024", "-o"])
465            .arg(&executable)
466            .arg(&source)
467            .output()
468            .expect("compile fake taskkill executable");
469        assert!(
470            output.status.success(),
471            "fake taskkill compilation failed: {}",
472            String::from_utf8_lossy(&output.stderr)
473        );
474    }
475
476    #[cfg(windows)]
477    fn run_windows_job_object_helper(root: &std::path::Path) {
478        use std::time::{Duration, Instant};
479
480        let mut command = Command::new("cmd.exe");
481        command
482            .args(["/D", "/S", "/C", "call leader.cmd"])
483            .current_dir(root)
484            .stdin(Stdio::null())
485            .stdout(Stdio::null())
486            .stderr(Stdio::null());
487        let child = ScopedChild::spawn_process_tree(&mut command).expect("spawn Windows job tree");
488        let terminator = child
489            .process_tree_terminator()
490            .expect("Windows process-tree terminator");
491        let ready = root.join("descendant-ready");
492        let ready_deadline = Instant::now() + Duration::from_secs(5);
493        while !ready.exists() && Instant::now() < ready_deadline {
494            std::thread::sleep(Duration::from_millis(20));
495        }
496        assert!(ready.exists(), "descendant did not start inside the job");
497
498        let started = Instant::now();
499        terminator
500            .terminate()
501            .expect("terminate Windows Job Object");
502        let status = child.wait().expect("reap Windows job leader");
503        assert!(
504            !status.success(),
505            "terminated job leader exited successfully"
506        );
507        assert!(
508            started.elapsed() < Duration::from_secs(2),
509            "wait remained blocked after Job Object termination"
510        );
511
512        let exit_deadline = Instant::now() + Duration::from_secs(2);
513        while terminator.is_alive() && Instant::now() < exit_deadline {
514            std::thread::sleep(Duration::from_millis(20));
515        }
516        assert!(
517            !terminator.is_alive(),
518            "job descendants survived termination"
519        );
520    }
521}