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