Skip to main content

oxdock_process/
child.rs

1use anyhow::Result;
2#[allow(clippy::disallowed_types, clippy::disallowed_methods)]
3use std::process::{Child, ExitStatus};
4use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
5use std::sync::{Arc, Mutex};
6
7use crate::contract::BackgroundHandle;
8
9/// Shared inner state for `ChildHandle`, enabling safe cloning.
10/// The OS PID is stored separately so `kill()` can signal
11/// the process without needing `&mut Child`, avoiding undefined behavior.
12#[allow(clippy::disallowed_types, clippy::disallowed_methods)]
13struct ChildInner {
14    child: Option<Child>,
15    io_threads: Vec<std::thread::JoinHandle<()>>,
16    reaped: bool,
17    exit_status: Option<ExitStatus>,
18    killed: AtomicBool,
19}
20
21#[derive(Clone)]
22#[allow(clippy::disallowed_types, clippy::disallowed_methods)]
23pub struct ChildHandle {
24    inner: Arc<Mutex<ChildInner>>,
25    /// OS process ID for signal-based kill on Unix and `OpenProcess`-based
26    /// terminate on Windows.
27    pid: Arc<AtomicU32>,
28}
29
30impl ChildHandle {
31    #[allow(clippy::disallowed_types, clippy::disallowed_methods)]
32    pub(crate) fn new(
33        child: Child,
34        stdin_thread: Option<std::thread::JoinHandle<()>>,
35        io_threads: Vec<std::thread::JoinHandle<()>>,
36    ) -> Self {
37        // The stdin feeder is deliberately detached, never joined (see
38        // below): it blocks reading the producer pipe, whose writers may
39        // legitimately outlive a short-lived child (a session pump feeding
40        // the next command), so joining would hang `wait()` forever — the
41        // same reason `Drop` below never joins pump threads. The detached
42        // thread ends on pipe EOF or write failure and releases its
43        // handles then; it holds no lock anyone else needs (fresh reader
44        // handle per spawn).
45        let _ = stdin_thread;
46        #[cfg(unix)]
47        let pid = child.id();
48        #[cfg(windows)]
49        let pid = child.id();
50        Self {
51            inner: Arc::new(Mutex::new(ChildInner {
52                child: Some(child),
53                io_threads,
54                reaped: false,
55                exit_status: None,
56                killed: AtomicBool::new(false),
57            })),
58            pid: Arc::new(AtomicU32::new(pid)),
59        }
60    }
61}
62
63impl BackgroundHandle for ChildHandle {
64    fn try_wait(&mut self) -> Result<Option<ExitStatus>> {
65        let mut guard = self.inner.lock().unwrap_or_else(|e| e.into_inner());
66        if let Some(ref mut child) = guard.child {
67            match child.try_wait()? {
68                Some(status) => {
69                    guard.reaped = true;
70                    guard.exit_status = Some(status);
71                    // Zero PID to prevent killing recycled PIDs
72                    self.pid.store(0, Ordering::SeqCst);
73                    // Join output pumps only: their EOF is guaranteed by
74                    // child exit, while the stdin feeder may legitimately
75                    // outlive it (the stdin feeder is detached in `new`).
76                    for thread in guard.io_threads.drain(..) {
77                        let _ = thread.join();
78                    }
79                    guard.child = None;
80                    Ok(Some(status))
81                }
82                None => Ok(None),
83            }
84        } else if guard.reaped {
85            // Process already reaped — return cached exit status
86            Ok(Some(
87                guard
88                    .exit_status
89                    .unwrap_or_else(|| exit_status_from_code(0)),
90            ))
91        } else {
92            // wait() is executing on another thread — process is still running
93            Ok(None)
94        }
95    }
96
97    fn wait(&mut self) -> Result<ExitStatus> {
98        // Take the child out of the mutex. This ensures only one thread
99        // performs the blocking OS wait, preventing ECHILD from dual waitpid.
100        let child_opt = {
101            let mut guard = self.inner.lock().unwrap_or_else(|e| e.into_inner());
102            guard.child.take()
103        };
104
105        if let Some(mut child) = child_opt {
106            let status = child.wait()?;
107            // Zero PID to prevent killing recycled PIDs
108            self.pid.store(0, Ordering::SeqCst);
109            // Re-acquire lock to store result and join IO threads.
110            // Output pumps only: the stdin feeder is detached in `new`
111            // since its producer may outlive the child.
112            let mut guard = self.inner.lock().unwrap_or_else(|e| e.into_inner());
113            for thread in guard.io_threads.drain(..) {
114                let _ = thread.join();
115            }
116            guard.reaped = true;
117            guard.exit_status = Some(status);
118            Ok(status)
119        } else {
120            // Already reaped — return cached exit status
121            let guard = self.inner.lock().unwrap_or_else(|e| e.into_inner());
122            Ok(guard
123                .exit_status
124                .unwrap_or_else(|| exit_status_from_code(0)))
125        }
126    }
127
128    fn kill(&mut self) -> Result<()> {
129        let pid = self.pid.load(Ordering::SeqCst);
130        if pid == 0 {
131            return Ok(());
132        }
133        // Signal the process directly via OS PID. This does NOT need
134        // &mut Child — no aliasing, no UB.
135        #[cfg(unix)]
136        {
137            unsafe {
138                libc::kill(pid as i32, libc::SIGKILL);
139            }
140        }
141        #[cfg(windows)]
142        {
143            // Open the process by PID and terminate it. This works even when
144            // wait() has taken the `Child` out of `ChildInner`, and avoids
145            // storing a raw HANDLE (which is neither Send nor Sync and would
146            // break the `BackgroundHandle: Send` bound).
147            // `pid` is zeroed after successful wait, so recycled PIDs are safe.
148            use windows_sys::Win32::Foundation::CloseHandle;
149            use windows_sys::Win32::System::Threading::{
150                OpenProcess, PROCESS_TERMINATE, TerminateProcess,
151            };
152            unsafe {
153                let handle = OpenProcess(PROCESS_TERMINATE, 0, pid);
154                if !handle.is_null() {
155                    TerminateProcess(handle, 1);
156                    CloseHandle(handle);
157                }
158            }
159        }
160        self.inner
161            .lock()
162            .unwrap_or_else(|e| e.into_inner())
163            .killed
164            .store(true, Ordering::SeqCst);
165        Ok(())
166    }
167}
168
169impl Drop for ChildHandle {
170    fn drop(&mut self) {
171        // Only the last clone (when Arc refcount is 1) runs the actual cleanup.
172        if Arc::strong_count(&self.inner) > 1 {
173            return;
174        }
175        let mut guard = self.inner.lock().unwrap_or_else(|e| e.into_inner());
176        if guard.reaped {
177            return;
178        }
179        if let Some(ref mut child) = guard.child
180            && matches!(child.try_wait(), Ok(None))
181        {
182            let _ = child.kill();
183            let _ = child.wait();
184        }
185        guard.reaped = true;
186        // `io_threads` are deliberately NOT joined: a grandchild inheriting
187        // the pipe can keep pump threads alive indefinitely. They terminate
188        // on pipe EOF after the kill and only ever write into Arc'd buffers.
189    }
190}
191
192/// Helper to create an exit status from a raw code. Used for synthetic statuses.
193fn exit_status_from_code(code: i32) -> ExitStatus {
194    #[cfg(unix)]
195    {
196        use std::os::unix::process::ExitStatusExt;
197        ExitStatus::from_raw(code << 8)
198    }
199    #[cfg(windows)]
200    {
201        use std::os::windows::process::ExitStatusExt;
202        ExitStatus::from_raw(code as u32)
203    }
204}