Skip to main content

rightkit_child_process/
native_capture.rs

1//! Cancellable capture of native child pipes. Generic `Read` capture remains separate.
2
3use crate::CapturedOutput;
4use std::{
5    fs::File,
6    io,
7    process::{ChildStderr, ChildStdout},
8    sync::{
9        atomic::{AtomicBool, Ordering},
10        Arc, Condvar, Mutex,
11    },
12    thread::{self, JoinHandle},
13    time::Duration,
14};
15
16#[cfg(unix)]
17#[path = "native_capture/unix.rs"]
18mod platform;
19#[cfg(windows)]
20#[path = "native_capture/windows.rs"]
21pub(crate) mod platform;
22
23const CHUNK: usize = 8 * 1024;
24const IDLE_WAIT: Duration = Duration::from_millis(20);
25
26#[derive(Default)]
27struct State {
28    output: CapturedOutput,
29    error: Option<io::Error>,
30    eof: bool,
31    finished: bool,
32}
33
34type Shared = (Mutex<State>, Condvar);
35
36struct Done(Arc<Shared>);
37
38impl Drop for Done {
39    fn drop(&mut self) {
40        self.0 .0.lock().unwrap_or_else(|e| e.into_inner()).finished = true;
41        self.0 .1.notify_all();
42    }
43}
44
45/// An owned reader of a native child stdout/stderr pipe, with bounded byte retention.
46///
47/// Unix read ends use nonblocking I/O; Windows read ends use owned overlapped I/O.
48/// Cancellation wakes the private reader; no user callbacks run
49/// on it. [`cancel`](Self::cancel), [`finish_within`](Self::finish_within) and `Drop` all join
50/// the reader and close its pipe, even when a descendant keeps the write end open. Idle
51/// readers check the pipe every 20 ms; cancellation also wakes that wait immediately.
52///
53/// This guarantee cannot be provided for arbitrary `Read`; use [`crate::OutputCapture`]
54/// only when the reader's lifetime is controlled separately. Do not change the native
55/// read end's blocking mode through another handle while this capture owns it.
56/// Windows requires a byte pipe opened for overlapped I/O (including `OwnedCommand` pipes);
57/// disconnected overlapped pipes are accepted as EOF without issuing a read.
58/// Construction rejects unsupported handles before starting a reader.
59pub struct NativeOutputCapture {
60    shared: Arc<Shared>,
61    stop: Arc<AtomicBool>,
62    handle: Option<JoinHandle<()>>,
63}
64
65impl NativeOutputCapture {
66    /// Own & drain a child's stdout. Errors before spawning a reader if the pipe cannot
67    /// support cancellable native I/O or the operating system cannot start the reader thread.
68    pub fn stdout(pipe: ChildStdout, max_bytes: usize) -> io::Result<Self> {
69        Self::start(platform::stdout(pipe), max_bytes)
70    }
71
72    /// Own & drain a child's stderr, with the same guarantees as [`stdout`](Self::stdout).
73    pub fn stderr(pipe: ChildStderr, max_bytes: usize) -> io::Result<Self> {
74        Self::start(platform::stderr(pipe), max_bytes)
75    }
76
77    fn start(pipe: File, max_bytes: usize) -> io::Result<Self> {
78        let prepared = platform::prepare(pipe)?;
79        let shared: Arc<Shared> = Arc::default();
80        let stop = Arc::new(AtomicBool::new(false));
81        let state = Arc::clone(&shared);
82        let cancelled = Arc::clone(&stop);
83        let handle = thread::Builder::new()
84            .name("rightkit-native-output-capture".into())
85            .spawn(move || {
86                let _done = Done(Arc::clone(&state));
87                let mut pipe = platform::reader(prepared);
88                let mut buffer = [0; CHUNK];
89                while !cancelled.load(Ordering::Acquire) {
90                    match platform::read(&mut pipe, &mut buffer) {
91                        Ok(0) => {
92                            state.0.lock().unwrap_or_else(|e| e.into_inner()).eof = true;
93                            break;
94                        }
95                        Ok(count) => state
96                            .0
97                            .lock()
98                            .unwrap_or_else(|e| e.into_inner())
99                            .output
100                            .push(&buffer[..count], max_bytes),
101                        Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
102                            thread::park_timeout(IDLE_WAIT);
103                        }
104                        Err(error) if error.kind() == io::ErrorKind::Interrupted => {}
105                        Err(error) => {
106                            state.0.lock().unwrap_or_else(|e| e.into_inner()).error = Some(error);
107                            break;
108                        }
109                    }
110                }
111                if let Err(error) = platform::shutdown(&mut pipe) {
112                    let mut state = state.0.lock().unwrap_or_else(|e| e.into_inner());
113                    if state.error.is_none() {
114                        state.error = Some(error);
115                    }
116                }
117                // Close the read end before Done signals completion to finish_within.
118                drop(pipe);
119            })?;
120        Ok(Self {
121            shared,
122            stop,
123            handle: Some(handle),
124        })
125    }
126
127    /// What has been captured so far. This does not stop the reader.
128    pub fn snapshot(&self) -> CapturedOutput {
129        self.shared
130            .0
131            .lock()
132            .unwrap_or_else(|e| e.into_inner())
133            .output
134            .clone()
135    }
136
137    /// True only if the pipe reached EOF. A deadline/cancellation can return partial bytes
138    /// without exceeding the retention cap; `CapturedOutput::truncated` describes cap loss.
139    pub fn is_complete(&self) -> bool {
140        self.shared.0.lock().unwrap_or_else(|e| e.into_inner()).eof
141    }
142
143    /// Take the native read error, if any. Cancellation is not an error.
144    pub fn take_error(&self) -> Option<io::Error> {
145        self.shared
146            .0
147            .lock()
148            .unwrap_or_else(|e| e.into_inner())
149            .error
150            .take()
151    }
152
153    /// Wait up to `timeout` for EOF/error, then cancel & join the reader. Always closes the
154    /// read end before returning; a writer retained by a descendant cannot extend the wait.
155    /// Returned bytes may be partial: consult [`is_complete`](Self::is_complete).
156    pub fn finish_within(&mut self, timeout: Duration) -> CapturedOutput {
157        {
158            let state = self.shared.0.lock().unwrap_or_else(|e| e.into_inner());
159            drop(
160                self.shared
161                    .1
162                    .wait_timeout_while(state, timeout, |state| !state.finished)
163                    .unwrap_or_else(|e| e.into_inner()),
164            );
165        }
166        self.stop_and_join();
167        self.snapshot()
168    }
169
170    /// Cancel immediately, join the reader & return retained bytes. Idempotent.
171    pub fn cancel(&mut self) -> CapturedOutput {
172        self.stop_and_join();
173        self.snapshot()
174    }
175
176    fn stop_and_join(&mut self) {
177        self.stop.store(true, Ordering::Release);
178        if let Some(handle) = self.handle.take() {
179            handle.thread().unpark();
180            if handle.join().is_err() {
181                self.shared
182                    .0
183                    .lock()
184                    .unwrap_or_else(|e| e.into_inner())
185                    .error = Some(io::Error::other("native pipe reader panicked"));
186            }
187        }
188    }
189}
190
191impl Drop for NativeOutputCapture {
192    fn drop(&mut self) {
193        self.stop_and_join();
194    }
195}