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 reaps non-blockingly if the child
42    /// is still here.
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    #[cfg(unix)]
265    fn assert_deregistered(id: u64) {
266        registry::deregister(id);
267    }
268
269    #[test]
270    #[cfg(unix)]
271    fn scoped_child_drop_deregisters() {
272        let mut cmd = Command::new("true");
273        let child = ScopedChild::spawn(&mut cmd).expect("spawn true");
274        let id = child.id.expect("freshly spawned wrapper has an id");
275        assert!(id > 0);
276        drop(child);
277        assert_deregistered(id);
278    }
279
280    #[test]
281    #[cfg(unix)]
282    fn scoped_child_drop_terminates_and_reaps_a_running_child() {
283        let mut command = Command::new("sleep");
284        command.arg("30");
285        let child = ScopedChild::spawn(&mut command).expect("spawn sleep");
286        let pid = child.id();
287
288        drop(child);
289
290        assert!(
291            !registry::pid_is_alive(pid),
292            "running child {pid} survived ScopedChild::drop"
293        );
294    }
295
296    #[test]
297    #[cfg(unix)]
298    fn scoped_child_wait_deregisters_and_succeeds() {
299        let mut cmd = Command::new("true");
300        let child = ScopedChild::spawn(&mut cmd).expect("spawn true");
301        let id = child.id.expect("freshly spawned wrapper has an id");
302        let status = child.wait().expect("wait true");
303        assert!(status.success());
304        assert_deregistered(id);
305    }
306
307    #[test]
308    #[cfg(unix)]
309    fn output_helper_collects_stdout() {
310        let mut cmd = Command::new("echo");
311        cmd.arg("hello")
312            .stdout(Stdio::piped())
313            .stderr(Stdio::piped());
314        let output = output(&mut cmd).expect("echo");
315        assert!(output.status.success());
316        assert_eq!(output.stdout, b"hello\n");
317    }
318
319    #[cfg(any(unix, windows))]
320    #[test]
321    fn nested_managed_process_tree_terminates_inherited_child() {
322        const HELPER_ENV: &str = "FALLOW_NESTED_PROCESS_TREE_TEST";
323        const ROOT_ENV: &str = "FALLOW_NESTED_PROCESS_TREE_ROOT";
324        const TEST_NAME: &str =
325            "scoped_child::tests::nested_managed_process_tree_terminates_inherited_child";
326
327        match std::env::var(HELPER_ENV).ok().as_deref() {
328            Some("nested") => {
329                let root = std::env::var_os(ROOT_ENV).expect("nested helper root");
330                std::fs::write(
331                    std::path::Path::new(&root).join("nested.pid"),
332                    std::process::id().to_string(),
333                )
334                .expect("write nested PID");
335                std::thread::sleep(std::time::Duration::from_secs(30));
336                return;
337            }
338            Some("outer") => {
339                let root = std::env::var_os(ROOT_ENV).expect("outer helper root");
340                let mut command =
341                    Command::new(std::env::current_exe().expect("current test executable"));
342                command
343                    .args(["--exact", TEST_NAME, "--nocapture"])
344                    .env(HELPER_ENV, "nested")
345                    .env(ROOT_ENV, root);
346                let child =
347                    ScopedChild::spawn_process_tree(&mut command).expect("spawn nested helper");
348                let _ = child.wait();
349                return;
350            }
351            _ => {}
352        }
353
354        let root = tempfile::tempdir().expect("temporary nested process-tree root");
355        let mut command = Command::new(std::env::current_exe().expect("current test executable"));
356        command
357            .args(["--exact", TEST_NAME, "--nocapture"])
358            .env(HELPER_ENV, "outer")
359            .env(ROOT_ENV, root.path());
360        let outer = ScopedChild::spawn_process_tree(&mut command).expect("spawn outer helper");
361        let terminator = outer
362            .process_tree_terminator()
363            .expect("outer process-tree terminator");
364        let pid_path = root.path().join("nested.pid");
365        let ready_deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
366        while !pid_path.exists() && std::time::Instant::now() < ready_deadline {
367            std::thread::sleep(std::time::Duration::from_millis(20));
368        }
369        let nested_pid = std::fs::read_to_string(&pid_path)
370            .expect("nested PID")
371            .trim()
372            .parse::<u32>()
373            .expect("numeric nested PID");
374
375        terminator.terminate().expect("terminate outer tree");
376        let status = outer.wait().expect("reap outer helper");
377        assert!(!status.success(), "outer helper was not terminated");
378
379        let exit_deadline = std::time::Instant::now() + std::time::Duration::from_secs(3);
380        while registry::pid_is_alive(nested_pid) && std::time::Instant::now() < exit_deadline {
381            std::thread::sleep(std::time::Duration::from_millis(20));
382        }
383        assert!(
384            !registry::pid_is_alive(nested_pid),
385            "nested managed child {nested_pid} survived outer cleanup"
386        );
387    }
388
389    #[cfg(windows)]
390    #[test]
391    fn windows_job_object_terminates_descendants_without_taskkill_lookup() {
392        const HELPER_ENV: &str = "FALLOW_WINDOWS_JOB_OBJECT_TEST_ROOT";
393        const TASKKILL_MARKER_ENV: &str = "FALLOW_FAKE_TASKKILL_MARKER";
394        const TEST_NAME: &str = "scoped_child::tests::windows_job_object_terminates_descendants_without_taskkill_lookup";
395
396        if let Some(root) = std::env::var_os(HELPER_ENV) {
397            run_windows_job_object_helper(std::path::Path::new(&root));
398            return;
399        }
400
401        let root = tempfile::tempdir().expect("temporary Windows Job Object root");
402        compile_fake_taskkill(root.path(), TASKKILL_MARKER_ENV);
403        std::fs::write(
404            root.path().join("descendant.cmd"),
405            "@echo off\r\necho ready>descendant-ready\r\nping.exe -n 30 127.0.0.1 >NUL\r\n",
406        )
407        .expect("write descendant script");
408        std::fs::write(
409            root.path().join("leader.cmd"),
410            "@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",
411        )
412        .expect("write leader script");
413        let mut search_paths = vec![root.path().to_path_buf()];
414        if let Some(path) = std::env::var_os("PATH") {
415            search_paths.extend(std::env::split_paths(&path));
416        }
417        let search_path =
418            std::env::join_paths(search_paths).expect("prepend fake taskkill to PATH");
419
420        let output = Command::new(std::env::current_exe().expect("current test executable"))
421            .args(["--exact", TEST_NAME, "--nocapture"])
422            .current_dir(root.path())
423            .env(HELPER_ENV, root.path())
424            .env(TASKKILL_MARKER_ENV, root.path().join("taskkill-invoked"))
425            .env("PATH", search_path)
426            .env_remove("NoDefaultCurrentDirectoryInExePath")
427            .output()
428            .expect("run Windows Job Object helper");
429
430        assert!(
431            output.status.success(),
432            "helper failed: {}",
433            String::from_utf8_lossy(&output.stderr)
434        );
435        assert!(
436            !root.path().join("taskkill-invoked").exists(),
437            "cleanup executed project-local taskkill"
438        );
439    }
440
441    #[cfg(windows)]
442    fn compile_fake_taskkill(root: &std::path::Path, marker_env: &str) {
443        let source = root.join("fake-taskkill.rs");
444        let executable = root.join("taskkill.exe");
445        std::fs::write(
446            &source,
447            format!(
448                "fn main() {{ let marker = std::env::var_os({marker_env:?}).expect(\"marker path\"); std::fs::write(marker, b\"invoked\").expect(\"write marker\"); }}"
449            ),
450        )
451        .expect("write fake taskkill source");
452        let rustc = std::env::var_os("RUSTC").unwrap_or_else(|| "rustc".into());
453        let output = Command::new(rustc)
454            .args(["--edition=2024", "-o"])
455            .arg(&executable)
456            .arg(&source)
457            .output()
458            .expect("compile fake taskkill executable");
459        assert!(
460            output.status.success(),
461            "fake taskkill compilation failed: {}",
462            String::from_utf8_lossy(&output.stderr)
463        );
464    }
465
466    #[cfg(windows)]
467    fn run_windows_job_object_helper(root: &std::path::Path) {
468        use std::time::{Duration, Instant};
469
470        let mut command = Command::new("cmd.exe");
471        command
472            .args(["/D", "/S", "/C", "call leader.cmd"])
473            .current_dir(root)
474            .stdin(Stdio::null())
475            .stdout(Stdio::null())
476            .stderr(Stdio::null());
477        let child = ScopedChild::spawn_process_tree(&mut command).expect("spawn Windows job tree");
478        let terminator = child
479            .process_tree_terminator()
480            .expect("Windows process-tree terminator");
481        let ready = root.join("descendant-ready");
482        let ready_deadline = Instant::now() + Duration::from_secs(5);
483        while !ready.exists() && Instant::now() < ready_deadline {
484            std::thread::sleep(Duration::from_millis(20));
485        }
486        assert!(ready.exists(), "descendant did not start inside the job");
487
488        let started = Instant::now();
489        terminator
490            .terminate()
491            .expect("terminate Windows Job Object");
492        let status = child.wait().expect("reap Windows job leader");
493        assert!(
494            !status.success(),
495            "terminated job leader exited successfully"
496        );
497        assert!(
498            started.elapsed() < Duration::from_secs(2),
499            "wait remained blocked after Job Object termination"
500        );
501
502        let exit_deadline = Instant::now() + Duration::from_secs(2);
503        while terminator.is_alive() && Instant::now() < exit_deadline {
504            std::thread::sleep(Duration::from_millis(20));
505        }
506        assert!(
507            !terminator.is_alive(),
508            "job descendants survived termination"
509        );
510    }
511}