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    ///
184    /// # Panics
185    ///
186    /// Does not panic in practice: only the terminal wait methods take the
187    /// child, and each consumes `self`.
188    #[expect(
189        clippy::expect_used,
190        reason = "ScopedChild owns inner until one terminal wait method consumes it"
191    )]
192    pub fn wait_with_output(mut self) -> io::Result<Output> {
193        let child = self.inner.take().expect("inner already taken");
194        let id = self.id.take();
195        let result = child.wait_with_output();
196        if let Some(id) = id {
197            registry::deregister(id);
198        }
199        result
200    }
201
202    /// Wait for the child to exit, returning the status. Same signal-cleanup
203    /// semantics as `wait_with_output`.
204    ///
205    /// # Panics
206    ///
207    /// Does not panic in practice: only the terminal wait methods take the
208    /// child, and each consumes `self`.
209    #[expect(
210        clippy::expect_used,
211        reason = "ScopedChild owns inner until one terminal wait method consumes it"
212    )]
213    pub fn wait(mut self) -> io::Result<ExitStatus> {
214        let mut child = self.inner.take().expect("inner already taken");
215        let id = self.id.take();
216        let result = child.wait();
217        if let Some(id) = id {
218            registry::deregister(id);
219        }
220        result
221    }
222}
223
224fn terminate_failed_setup(child: &mut Child) {
225    let _ = crate::process_tree::cleanup_std_child(None, child);
226}
227
228impl Drop for ScopedChild {
229    fn drop(&mut self) {
230        if let Some(mut child) = self.inner.take() {
231            let running = !matches!(child.try_wait(), Ok(Some(_)));
232            if running {
233                let _ = crate::process_tree::cleanup_std_child(
234                    self.process_tree.as_deref(),
235                    &mut child,
236                );
237            }
238        }
239        if let Some(id) = self.id.take() {
240            registry::deregister(id);
241        }
242    }
243}
244
245/// Convenience: spawn and wait for exit, returning the status.
246pub fn status(command: &mut Command) -> io::Result<ExitStatus> {
247    let scoped = ScopedChild::spawn(command)?;
248    scoped.wait()
249}
250
251/// Convenience: spawn and collect full output (stdout + stderr).
252///
253/// Mirrors `Command::output` semantics by unconditionally setting
254/// stdout / stderr to piped and stdin to null. Callers that need
255/// different stdio (e.g. inherited stdin for interactive prompts)
256/// must use `ScopedChild::spawn` directly and drive the wait
257/// themselves.
258pub fn output(command: &mut Command) -> io::Result<Output> {
259    command
260        .stdin(Stdio::null())
261        .stdout(Stdio::piped())
262        .stderr(Stdio::piped());
263    ScopedChild::spawn(command)?.wait_with_output()
264}
265
266#[cfg(test)]
267#[expect(
268    clippy::expect_used,
269    reason = "test setup failures should fail at the exact setup operation"
270)]
271mod tests {
272    use super::*;
273
274    #[test]
275    #[cfg(unix)]
276    fn scoped_child_drop_deregisters() {
277        let mut cmd = Command::new("true");
278        let child = ScopedChild::spawn(&mut cmd).expect("spawn true");
279        let id = child.id.expect("freshly spawned wrapper has an id");
280        assert!(registry::is_registered(id));
281        drop(child);
282        assert!(!registry::is_registered(id));
283    }
284
285    #[test]
286    #[cfg(unix)]
287    fn scoped_child_drop_terminates_and_reaps_a_running_child() {
288        let mut command = Command::new("sleep");
289        command.arg("30");
290        let child = ScopedChild::spawn(&mut command).expect("spawn sleep");
291        let pid = child.id();
292
293        drop(child);
294
295        assert!(
296            !registry::pid_is_alive(pid),
297            "running child {pid} survived ScopedChild::drop"
298        );
299    }
300
301    #[test]
302    #[cfg(unix)]
303    fn scoped_child_wait_deregisters_and_succeeds() {
304        let mut cmd = Command::new("true");
305        let child = ScopedChild::spawn(&mut cmd).expect("spawn true");
306        let id = child.id.expect("freshly spawned wrapper has an id");
307        assert!(registry::is_registered(id));
308        let status = child.wait().expect("wait true");
309        assert!(status.success());
310        assert!(!registry::is_registered(id));
311    }
312
313    #[test]
314    #[cfg(unix)]
315    fn scoped_child_wait_with_output_deregisters_and_collects_stdout() {
316        let mut cmd = Command::new("echo");
317        cmd.arg("hello").stdout(Stdio::piped());
318        let child = ScopedChild::spawn(&mut cmd).expect("spawn echo");
319        let id = child.id.expect("freshly spawned wrapper has an id");
320        assert!(registry::is_registered(id));
321        let output = child.wait_with_output().expect("wait echo");
322        assert!(output.status.success());
323        assert_eq!(output.stdout, b"hello\n");
324        assert!(!registry::is_registered(id));
325    }
326
327    #[test]
328    #[cfg(unix)]
329    fn output_helper_collects_stdout() {
330        let mut cmd = Command::new("echo");
331        cmd.arg("hello")
332            .stdout(Stdio::piped())
333            .stderr(Stdio::piped());
334        let output = output(&mut cmd).expect("echo");
335        assert!(output.status.success());
336        assert_eq!(output.stdout, b"hello\n");
337    }
338
339    #[cfg(any(unix, windows))]
340    #[test]
341    fn nested_managed_process_tree_terminates_inherited_child() {
342        const HELPER_ENV: &str = "FALLOW_NESTED_PROCESS_TREE_TEST";
343        const ROOT_ENV: &str = "FALLOW_NESTED_PROCESS_TREE_ROOT";
344        const TEST_NAME: &str =
345            "scoped_child::tests::nested_managed_process_tree_terminates_inherited_child";
346
347        match std::env::var(HELPER_ENV).ok().as_deref() {
348            Some("nested") => {
349                let root = std::env::var_os(ROOT_ENV).expect("nested helper root");
350                std::fs::write(
351                    std::path::Path::new(&root).join("nested.pid"),
352                    std::process::id().to_string(),
353                )
354                .expect("write nested PID");
355                std::thread::sleep(std::time::Duration::from_secs(30));
356                return;
357            }
358            Some("outer") => {
359                let root = std::env::var_os(ROOT_ENV).expect("outer helper root");
360                let mut command =
361                    Command::new(std::env::current_exe().expect("current test executable"));
362                command
363                    .args(["--exact", TEST_NAME, "--nocapture"])
364                    .env(HELPER_ENV, "nested")
365                    .env(ROOT_ENV, root);
366                let child =
367                    ScopedChild::spawn_process_tree(&mut command).expect("spawn nested helper");
368                let _ = child.wait();
369                return;
370            }
371            _ => {}
372        }
373
374        let root = tempfile::tempdir().expect("temporary nested process-tree root");
375        let mut command = Command::new(std::env::current_exe().expect("current test executable"));
376        command
377            .args(["--exact", TEST_NAME, "--nocapture"])
378            .env(HELPER_ENV, "outer")
379            .env(ROOT_ENV, root.path());
380        let outer = ScopedChild::spawn_process_tree(&mut command).expect("spawn outer helper");
381        let terminator = outer
382            .process_tree_terminator()
383            .expect("outer process-tree terminator");
384        let pid_path = root.path().join("nested.pid");
385        let ready_deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
386        while !pid_path.exists() && std::time::Instant::now() < ready_deadline {
387            std::thread::sleep(std::time::Duration::from_millis(20));
388        }
389        let nested_pid = std::fs::read_to_string(&pid_path)
390            .expect("nested PID")
391            .trim()
392            .parse::<u32>()
393            .expect("numeric nested PID");
394
395        terminator.terminate().expect("terminate outer tree");
396        let status = outer.wait().expect("reap outer helper");
397        assert!(!status.success(), "outer helper was not terminated");
398
399        let exit_deadline = std::time::Instant::now() + std::time::Duration::from_secs(3);
400        while registry::pid_is_alive(nested_pid) && std::time::Instant::now() < exit_deadline {
401            std::thread::sleep(std::time::Duration::from_millis(20));
402        }
403        assert!(
404            !registry::pid_is_alive(nested_pid),
405            "nested managed child {nested_pid} survived outer cleanup"
406        );
407    }
408
409    #[cfg(windows)]
410    #[test]
411    fn windows_job_object_terminates_descendants_without_taskkill_lookup() {
412        const HELPER_ENV: &str = "FALLOW_WINDOWS_JOB_OBJECT_TEST_ROOT";
413        const TASKKILL_MARKER_ENV: &str = "FALLOW_FAKE_TASKKILL_MARKER";
414        const TEST_NAME: &str = "scoped_child::tests::windows_job_object_terminates_descendants_without_taskkill_lookup";
415
416        if let Some(root) = std::env::var_os(HELPER_ENV) {
417            run_windows_job_object_helper(std::path::Path::new(&root));
418            return;
419        }
420
421        let root = tempfile::tempdir().expect("temporary Windows Job Object root");
422        compile_fake_taskkill(root.path(), TASKKILL_MARKER_ENV);
423        std::fs::write(
424            root.path().join("descendant.cmd"),
425            "@echo off\r\necho ready>descendant-ready\r\nping.exe -n 30 127.0.0.1 >NUL\r\n",
426        )
427        .expect("write descendant script");
428        std::fs::write(
429            root.path().join("leader.cmd"),
430            "@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",
431        )
432        .expect("write leader script");
433        let mut search_paths = vec![root.path().to_path_buf()];
434        if let Some(path) = std::env::var_os("PATH") {
435            search_paths.extend(std::env::split_paths(&path));
436        }
437        let search_path =
438            std::env::join_paths(search_paths).expect("prepend fake taskkill to PATH");
439
440        let output = Command::new(std::env::current_exe().expect("current test executable"))
441            .args(["--exact", TEST_NAME, "--nocapture"])
442            .current_dir(root.path())
443            .env(HELPER_ENV, root.path())
444            .env(TASKKILL_MARKER_ENV, root.path().join("taskkill-invoked"))
445            .env("PATH", search_path)
446            .env_remove("NoDefaultCurrentDirectoryInExePath")
447            .output()
448            .expect("run Windows Job Object helper");
449
450        assert!(
451            output.status.success(),
452            "helper failed: {}",
453            String::from_utf8_lossy(&output.stderr)
454        );
455        assert!(
456            !root.path().join("taskkill-invoked").exists(),
457            "cleanup executed project-local taskkill"
458        );
459    }
460
461    #[cfg(windows)]
462    fn compile_fake_taskkill(root: &std::path::Path, marker_env: &str) {
463        let source = root.join("fake-taskkill.rs");
464        let executable = root.join("taskkill.exe");
465        std::fs::write(
466            &source,
467            format!(
468                "fn main() {{ let marker = std::env::var_os({marker_env:?}).expect(\"marker path\"); std::fs::write(marker, b\"invoked\").expect(\"write marker\"); }}"
469            ),
470        )
471        .expect("write fake taskkill source");
472        let rustc = std::env::var_os("RUSTC").unwrap_or_else(|| "rustc".into());
473        let output = Command::new(rustc)
474            .args(["--edition=2024", "-o"])
475            .arg(&executable)
476            .arg(&source)
477            .output()
478            .expect("compile fake taskkill executable");
479        assert!(
480            output.status.success(),
481            "fake taskkill compilation failed: {}",
482            String::from_utf8_lossy(&output.stderr)
483        );
484    }
485
486    #[cfg(windows)]
487    fn run_windows_job_object_helper(root: &std::path::Path) {
488        use std::time::{Duration, Instant};
489
490        let mut command = Command::new("cmd.exe");
491        command
492            .args(["/D", "/S", "/C", "call leader.cmd"])
493            .current_dir(root)
494            .stdin(Stdio::null())
495            .stdout(Stdio::null())
496            .stderr(Stdio::null());
497        let child = ScopedChild::spawn_process_tree(&mut command).expect("spawn Windows job tree");
498        let terminator = child
499            .process_tree_terminator()
500            .expect("Windows process-tree terminator");
501        let ready = root.join("descendant-ready");
502        let ready_deadline = Instant::now() + Duration::from_secs(5);
503        while !ready.exists() && Instant::now() < ready_deadline {
504            std::thread::sleep(Duration::from_millis(20));
505        }
506        assert!(ready.exists(), "descendant did not start inside the job");
507
508        let started = Instant::now();
509        terminator
510            .terminate()
511            .expect("terminate Windows Job Object");
512        let status = child.wait().expect("reap Windows job leader");
513        assert!(
514            !status.success(),
515            "terminated job leader exited successfully"
516        );
517        assert!(
518            started.elapsed() < Duration::from_secs(2),
519            "wait remained blocked after Job Object termination"
520        );
521
522        let exit_deadline = Instant::now() + Duration::from_secs(2);
523        while terminator.is_alive() && Instant::now() < exit_deadline {
524            std::thread::sleep(Duration::from_millis(20));
525        }
526        assert!(
527            !terminator.is_alive(),
528            "job descendants survived termination"
529        );
530    }
531}