Skip to main content

rightkit_child_process/
capture.rs

1//! Byte-exact, bounded capture of a child's output (stdout of a CLI that speaks JSON or
2//! another binary-safe protocol).
3//!
4//! [`StderrTail`](crate::StderrTail) is a diagnostic tail: it keeps the most recent *lines* as
5//! text, joins them with `\n` and drops the trailing newline, so it cannot carry a protocol.
6//! [`OutputCapture`] keeps the *first* `max_bytes` bytes exactly as the child wrote them, and
7//! keeps draining past the cap (discarding) so a chatty child never blocks on a full pipe.
8//! [`CapturedOutput::truncated`] says whether anything was discarded; a caller that parses the
9//! bytes must treat a truncated capture as incomplete.
10
11use std::io::{self, Read};
12use std::sync::{Arc, Condvar, Mutex};
13use std::thread::{self, JoinHandle};
14use std::time::Duration;
15
16const CHUNK: usize = 8 * 1024;
17
18/// What a capture holds.
19#[derive(Clone, Debug, Default, Eq, PartialEq)]
20pub struct CapturedOutput {
21    /// The first `max_bytes` bytes of the stream, unmodified.
22    pub bytes: Vec<u8>,
23    /// True when the stream held more than `max_bytes` bytes and the rest was discarded.
24    pub truncated: bool,
25    /// Every byte the stream produced so far, kept or not.
26    pub total_bytes: u64,
27}
28
29impl CapturedOutput {
30    pub(crate) fn push(&mut self, chunk: &[u8], max_bytes: usize) {
31        self.total_bytes = self.total_bytes.saturating_add(chunk.len() as u64);
32        let room = max_bytes.saturating_sub(self.bytes.len());
33        let keep = room.min(chunk.len());
34        self.bytes.extend_from_slice(&chunk[..keep]);
35        if keep < chunk.len() {
36            self.truncated = true;
37        }
38    }
39}
40
41/// Reads `reader` to its end on the calling thread, keeping the first `max_bytes` bytes. The
42/// rest is read and discarded. Use this when the caller can block until the stream closes.
43pub fn read_bounded<R: Read>(mut reader: R, max_bytes: usize) -> io::Result<CapturedOutput> {
44    let mut captured = CapturedOutput::default();
45    let mut buffer = [0u8; CHUNK];
46    loop {
47        match reader.read(&mut buffer) {
48            Ok(0) => return Ok(captured),
49            Ok(count) => captured.push(&buffer[..count], max_bytes),
50            Err(error) if error.kind() == io::ErrorKind::Interrupted => {}
51            Err(error) => return Err(error),
52        }
53    }
54}
55
56type Done = (Mutex<bool>, Condvar);
57
58/// Marks the drain over when the thread's body ends, however it ends.
59struct DoneGuard(Arc<Done>);
60
61impl Drop for DoneGuard {
62    fn drop(&mut self) {
63        *self.0 .0.lock().unwrap_or_else(|e| e.into_inner()) = true;
64        self.0 .1.notify_all();
65    }
66}
67
68/// A background drain of one output stream into a bounded byte buffer. See the module
69/// documentation.
70pub struct OutputCapture {
71    data: Arc<Mutex<CapturedOutput>>,
72    /// The error that ended the drain, if the stream did not end cleanly.
73    error: Arc<Mutex<Option<io::Error>>>,
74    handle: Option<JoinHandle<()>>,
75    done: Arc<Done>,
76}
77
78impl OutputCapture {
79    /// Drains `reader` on a thread, keeping the first `max_bytes` bytes. Best effort: if the
80    /// thread cannot be spawned the capture holds nothing (use
81    /// [`try_capture`](Self::try_capture) to learn that).
82    pub fn capture<R: Read + Send + 'static>(reader: R, max_bytes: usize) -> Self {
83        Self::try_capture(reader, max_bytes).unwrap_or_else(|_| Self {
84            data: Arc::default(),
85            error: Arc::default(),
86            handle: None,
87            done: Arc::new((Mutex::new(true), Condvar::new())),
88        })
89    }
90
91    /// Like [`capture`](Self::capture), but returns the operating-system error when the drain
92    /// thread cannot be spawned.
93    pub fn try_capture<R: Read + Send + 'static>(reader: R, max_bytes: usize) -> io::Result<Self> {
94        let data: Arc<Mutex<CapturedOutput>> = Arc::default();
95        let error: Arc<Mutex<Option<io::Error>>> = Arc::default();
96        let done: Arc<Done> = Arc::new((Mutex::new(false), Condvar::new()));
97        let guard = DoneGuard(Arc::clone(&done));
98        let shared = Arc::clone(&data);
99        let failure = Arc::clone(&error);
100        let handle = thread::Builder::new()
101            .name("rightkit-output-capture".into())
102            .spawn(move || {
103                let _guard = guard;
104                let mut reader = reader;
105                let mut buffer = [0u8; CHUNK];
106                loop {
107                    match reader.read(&mut buffer) {
108                        Ok(0) => return,
109                        Ok(count) => shared
110                            .lock()
111                            .unwrap_or_else(|e| e.into_inner())
112                            .push(&buffer[..count], max_bytes),
113                        Err(e) if e.kind() == io::ErrorKind::Interrupted => {}
114                        Err(e) => {
115                            *failure.lock().unwrap_or_else(|e| e.into_inner()) = Some(e);
116                            return;
117                        }
118                    }
119                }
120            })?;
121        Ok(Self {
122            data,
123            error,
124            handle: Some(handle),
125            done,
126        })
127    }
128
129    /// What has been captured so far.
130    pub fn snapshot(&self) -> CapturedOutput {
131        self.data.lock().unwrap_or_else(|e| e.into_inner()).clone()
132    }
133
134    /// The error that stopped the drain before the end of the stream, taken once. `None` when
135    /// the stream ended cleanly or is still open.
136    pub fn take_error(&self) -> Option<io::Error> {
137        self.error.lock().unwrap_or_else(|e| e.into_inner()).take()
138    }
139
140    /// Waits for the stream to end (the child exited and no grandchild holds the pipe), then
141    /// returns the complete capture.
142    pub fn finish(&mut self) -> CapturedOutput {
143        if let Some(handle) = self.handle.take() {
144            let _ = handle.join();
145        }
146        self.snapshot()
147    }
148
149    /// Like [`finish`](Self::finish) but gives up after `timeout` (a grandchild that inherited
150    /// the pipe keeps it open after the child is gone) and returns what was captured so far.
151    /// Never joins the thread; [`finish`](Self::finish) can still do that later.
152    pub fn finish_within(&mut self, timeout: Duration) -> CapturedOutput {
153        {
154            let guard = self.done.0.lock().unwrap_or_else(|e| e.into_inner());
155            let _ = self
156                .done
157                .1
158                .wait_timeout_while(guard, timeout, |over| !*over)
159                .unwrap_or_else(|e| e.into_inner());
160        }
161        self.snapshot()
162    }
163
164    /// True once the stream has ended (or failed).
165    pub fn is_finished(&self) -> bool {
166        *self.done.0.lock().unwrap_or_else(|e| e.into_inner())
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173    use std::io::Cursor;
174
175    #[test]
176    fn keeps_bytes_exactly_including_trailing_newline_and_non_utf8() {
177        let input: Vec<u8> = vec![b'{', b'}', b'\n', 0xff, 0x00, b'\r', b'\n'];
178        let out = read_bounded(Cursor::new(input.clone()), 1024).unwrap();
179        assert_eq!(out.bytes, input);
180        assert!(!out.truncated);
181        assert_eq!(out.total_bytes, input.len() as u64);
182    }
183
184    #[test]
185    fn keeps_the_first_max_bytes_and_counts_the_rest() {
186        let input = vec![7u8; CHUNK * 3 + 5];
187        let out = read_bounded(Cursor::new(input), 10).unwrap();
188        assert_eq!(out.bytes, vec![7u8; 10]);
189        assert!(out.truncated);
190        assert_eq!(out.total_bytes, (CHUNK * 3 + 5) as u64);
191    }
192
193    #[test]
194    fn exactly_the_cap_is_not_truncated() {
195        let out = read_bounded(Cursor::new(vec![1u8; 64]), 64).unwrap();
196        assert_eq!(out.bytes.len(), 64);
197        assert!(!out.truncated);
198        let zero = read_bounded(Cursor::new(vec![1u8; 3]), 0).unwrap();
199        assert!(zero.bytes.is_empty() && zero.truncated);
200    }
201
202    #[test]
203    fn a_read_error_is_returned_by_the_blocking_reader() {
204        struct Bad;
205        impl Read for Bad {
206            fn read(&mut self, _: &mut [u8]) -> io::Result<usize> {
207                Err(io::Error::other("boom"))
208            }
209        }
210        assert!(read_bounded(Bad, 8).is_err());
211    }
212
213    #[test]
214    fn the_background_capture_matches_the_blocking_reader() {
215        let input: Vec<u8> = (0..=255u8).cycle().take(CHUNK * 2 + 17).collect();
216        let mut capture = OutputCapture::capture(Cursor::new(input.clone()), 1000);
217        let out = capture.finish();
218        assert_eq!(out.bytes, input[..1000].to_vec());
219        assert!(out.truncated);
220        assert_eq!(out.total_bytes, input.len() as u64);
221        assert!(capture.is_finished());
222        assert!(capture.take_error().is_none());
223    }
224
225    #[test]
226    fn the_background_capture_records_a_read_error() {
227        struct Bad(bool);
228        impl Read for Bad {
229            fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
230                if self.0 {
231                    return Err(io::Error::other("boom"));
232                }
233                self.0 = true;
234                buf[..2].copy_from_slice(b"ok");
235                Ok(2)
236            }
237        }
238        let mut capture = OutputCapture::capture(Bad(false), 8);
239        let out = capture.finish();
240        assert_eq!(out.bytes, b"ok");
241        assert!(capture.take_error().is_some());
242    }
243
244    #[test]
245    fn finish_within_returns_early_data_while_the_stream_stays_open() {
246        let (reader, mut writer) = std::io::pipe().expect("pipe");
247        use std::io::Write;
248        writer.write_all(b"partial").unwrap();
249        let mut capture = OutputCapture::capture(reader, 64);
250        let out = capture.finish_within(Duration::from_millis(300));
251        assert!(!capture.is_finished());
252        assert!(out.bytes.is_empty() || out.bytes == b"partial");
253        drop(writer);
254        let out = capture.finish();
255        assert_eq!(out.bytes, b"partial");
256        assert!(!out.truncated);
257    }
258}