Skip to main content

oxdock_process/
child.rs

1use anyhow::Result;
2#[allow(clippy::disallowed_types, clippy::disallowed_methods)]
3use std::process::{Child, ExitStatus};
4
5use crate::contract::BackgroundHandle;
6
7#[derive(Debug)]
8#[allow(clippy::disallowed_types, clippy::disallowed_methods)]
9pub struct ChildHandle {
10    pub(crate) child: Child,
11    pub(crate) io_threads: Vec<std::thread::JoinHandle<()>>,
12    reaped: bool,
13}
14
15impl ChildHandle {
16    #[allow(clippy::disallowed_types, clippy::disallowed_methods)]
17    pub(crate) fn new(child: Child, io_threads: Vec<std::thread::JoinHandle<()>>) -> Self {
18        Self {
19            child,
20            io_threads,
21            reaped: false,
22        }
23    }
24}
25
26impl BackgroundHandle for ChildHandle {
27    fn try_wait(&mut self) -> Result<Option<ExitStatus>> {
28        let res = self.child.try_wait()?;
29        // Observing `Some(_)` means the OS handle has been reaped; record it
30        // so `Drop` short-circuits instead of issuing redundant queries.
31        if res.is_some() {
32            self.reaped = true;
33        }
34        Ok(res)
35    }
36
37    fn wait(&mut self) -> Result<ExitStatus> {
38        let status = self.child.wait()?;
39        // Wait for IO threads to finish to ensure all output is captured
40        for thread in self.io_threads.drain(..) {
41            let _ = thread.join();
42        }
43        self.reaped = true;
44        Ok(status)
45    }
46
47    fn kill(&mut self) -> Result<()> {
48        // Deliberately does NOT set `reaped`: killing is not reaping, so
49        // `Drop` must still wait afterwards to avoid zombies.
50        if self.child.try_wait()?.is_none() {
51            let _ = self.child.kill();
52        }
53        Ok(())
54    }
55}
56
57impl Drop for ChildHandle {
58    fn drop(&mut self) {
59        // Safety net for error/panic paths that abandon live children between
60        // spawn and the next poll. The executor's explicit teardown paths
61        // remain the primary mechanism; this only bounds leakage.
62        if self.reaped {
63            return;
64        }
65        if matches!(self.child.try_wait(), Ok(None)) {
66            let _ = self.child.kill();
67            // Bounded after SIGKILL/TerminateProcess; best-effort reap.
68            let _ = self.child.wait();
69        }
70        // `io_threads` are deliberately NOT joined: a grandchild inheriting
71        // the pipe can keep pump threads alive indefinitely. They terminate
72        // on pipe EOF after the kill and only ever write into Arc'd buffers.
73    }
74}
75
76#[cfg(test)]
77mod tests {
78    use super::*;
79    use crate::contract::{
80        CommandContext, CommandMode, CommandOptions, CommandResult, CommandStdout, ProcessManager,
81    };
82    use crate::shell_manager::ShellProcessManager;
83    use oxdock_fs::{GuardedPath, PolicyPath};
84    use std::collections::HashMap;
85
86    fn make_ctx() -> (oxdock_fs::GuardedTempDir, CommandContext) {
87        let temp = GuardedPath::tempdir().expect("tempdir");
88        let guard = temp.as_guarded_path().clone();
89        let cwd: PolicyPath = guard.clone().into();
90        let map: HashMap<String, String> = HashMap::new();
91        let ctx = CommandContext::from_map(&cwd, &map, &guard, &guard, &guard);
92        (temp, ctx)
93    }
94
95    #[cfg(unix)]
96    #[cfg_attr(
97        miri,
98        ignore = "spawns processes; Miri does not support process execution"
99    )]
100    #[test]
101    fn drop_kills_spawned_child_before_it_can_finish() {
102        use crate::builder::CommandBuilder;
103        use oxdock_fs::PathResolver;
104
105        let temp = oxdock_fs::GuardedPath::tempdir().expect("tempdir");
106        let root = temp.as_guarded_path().clone();
107        let marker = root.join("late.txt").expect("marker path");
108        let marker_display = marker.display().to_string();
109
110        let resolver = PathResolver::new_guarded(root.clone(), root.clone()).expect("resolver");
111        let mut builder = CommandBuilder::new("sh");
112        builder.args(["-c", &format!("sleep 1; echo done > {marker_display}")]);
113        let handle = builder.spawn().expect("spawn");
114        // Drop while the child is still sleeping; the safety net must kill it
115        // before the delayed write can happen.
116        drop(handle);
117
118        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3);
119        while std::time::Instant::now() < deadline {
120            assert!(
121                resolver.read_file(&marker).is_err(),
122                "child must be killed by Drop before writing {marker_display}"
123            );
124            std::thread::sleep(std::time::Duration::from_millis(50));
125        }
126    }
127
128    fn long_running_script() -> &'static str {
129        #[cfg(windows)]
130        {
131            "ping -n 30 127.0.0.1 >NUL"
132        }
133        #[cfg(not(windows))]
134        {
135            "sleep 30"
136        }
137    }
138
139    #[cfg_attr(
140        miri,
141        ignore = "spawns processes; Miri does not support process execution"
142    )]
143    #[test]
144    fn child_handle_background_lifecycle_polls_then_waits() {
145        let (_temp, ctx) = make_ctx();
146        let mut pm = ShellProcessManager;
147        let options = CommandOptions {
148            mode: CommandMode::Background,
149            ..Default::default()
150        };
151        let mut handle = match pm.run_command(&ctx, "exit 0", options).expect("run") {
152            CommandResult::Background(handle) => handle,
153            CommandResult::Completed => panic!("expected Background, got Completed"),
154            CommandResult::Captured(_) => panic!("expected Background, got Captured"),
155        };
156
157        let mut status = None;
158        for _ in 0..500 {
159            if let Some(done) = handle.try_wait().expect("try_wait") {
160                status = Some(done);
161                break;
162            }
163            std::thread::sleep(std::time::Duration::from_millis(10));
164        }
165        let status = status.expect("child should exit within polling window");
166        assert!(status.success());
167
168        let waited = handle.wait().expect("wait");
169        assert!(waited.success());
170    }
171
172    #[cfg_attr(
173        miri,
174        ignore = "spawns processes; Miri does not support process execution"
175    )]
176    #[test]
177    fn child_handle_kill_is_idempotent_and_wait_joins_threads() {
178        let (_temp, ctx) = make_ctx();
179        let mut pm = ShellProcessManager;
180        let options = CommandOptions {
181            mode: CommandMode::Background,
182            stdout: CommandStdout::Stream(std::sync::Arc::new(std::sync::Mutex::new(
183                Vec::<u8>::new(),
184            ))),
185            ..Default::default()
186        };
187        let mut handle = match pm
188            .run_command(&ctx, long_running_script(), options)
189            .expect("run")
190        {
191            CommandResult::Background(handle) => handle,
192            CommandResult::Completed => panic!("expected Background, got Completed"),
193            CommandResult::Captured(_) => panic!("expected Background, got Captured"),
194        };
195
196        handle.kill().expect("first kill");
197        handle.kill().expect("second kill must be idempotent");
198        let _status = handle.wait().expect("wait after kill");
199    }
200}