Skip to main content

rightkit_child_process/
stderr_tail.rs

1//! Bounded capture of a child's stderr so a crash is diagnosable without a
2//! debugger (ScrapeRight keeps a 16 KiB tail; HeardRight redacts each line
3//! before it reaches telemetry). Draining stderr is also what keeps a chatty
4//! child from blocking on a full pipe.
5
6use std::collections::VecDeque;
7use std::io::{self, BufRead, BufReader, Read};
8use std::sync::{Arc, Condvar, Mutex};
9use std::thread::{self, JoinHandle};
10
11/// The retained lines and their cost: each line counts its bytes plus one for the newline
12/// that joins it to the next.
13type Retained = (VecDeque<String>, usize);
14
15/// A background drain of one stderr stream. Keeps the most recent lines whose
16/// total size is at most `max_bytes`; older lines fall off the front. A single line that
17/// alone exceeds the budget is cut to its last bytes, so [`snapshot`](Self::snapshot) never
18/// returns more than `max_bytes` bytes.
19pub struct StderrTail {
20    lines: Arc<Mutex<Retained>>,
21    handle: Option<JoinHandle<()>>,
22    /// Set (and signalled) when the drain thread is over, so a bounded wait sleeps on the
23    /// condition instead of polling `is_finished`.
24    done: Arc<Done>,
25}
26
27type Done = (Mutex<bool>, Condvar);
28
29/// Marks the drain over when the thread's body ends, however it ends.
30struct DoneGuard(Arc<Done>);
31
32impl Drop for DoneGuard {
33    fn drop(&mut self) {
34        *self.0 .0.lock().unwrap_or_else(|e| e.into_inner()) = true;
35        self.0 .1.notify_all();
36    }
37}
38
39impl StderrTail {
40    /// Drain `stderr` on a thread. `map` sees every line first and returns the
41    /// text to keep (redaction), or `None` to drop it. `on_line` receives the
42    /// kept text as it arrives (forward to logs/telemetry).
43    ///
44    /// A line longer than four times `max_bytes` cannot be inspected whole, and `map` is a
45    /// line-based redactor: handing it the first piece and then the rest as if each were a
46    /// line would let a secret that spans the cut through unredacted. So such a line never
47    /// reaches `map`. Its bytes are read and discarded up to its newline, and one marker line,
48    /// `[stderr line over N bytes dropped]`, takes its place (forwarded to `on_line`, and
49    /// retained, without passing through `map`: it holds nothing from the child).
50    ///
51    /// Best effort: if the drain thread cannot be spawned, this returns a tail that retains
52    /// nothing (the error is discarded, and `stderr` is dropped undrained). Use
53    /// [`try_capture`](Self::try_capture) to learn that the drain never started.
54    pub fn capture<R: Read + Send + 'static>(
55        stderr: R,
56        max_bytes: usize,
57        map: impl Fn(&str) -> Option<String> + Send + 'static,
58        on_line: impl Fn(&str) + Send + 'static,
59    ) -> Self {
60        Self::try_capture(stderr, max_bytes, map, on_line).unwrap_or_else(|_| Self {
61            lines: Arc::default(),
62            handle: None,
63            // No drain thread exists, so it is over from the start.
64            done: Arc::new((Mutex::new(true), Condvar::new())),
65        })
66    }
67
68    /// Like [`capture`](Self::capture), but returns the operating-system error when the drain
69    /// thread cannot be spawned, so the caller can fail, retry, or log that stderr is not being
70    /// captured. On success the behaviour is identical to `capture`.
71    pub fn try_capture<R: Read + Send + 'static>(
72        stderr: R,
73        max_bytes: usize,
74        map: impl Fn(&str) -> Option<String> + Send + 'static,
75        on_line: impl Fn(&str) + Send + 'static,
76    ) -> io::Result<Self> {
77        Self::try_capture_with(
78            |name, body| thread::Builder::new().name(name).spawn(body),
79            stderr,
80            max_bytes,
81            map,
82            on_line,
83        )
84    }
85
86    /// `try_capture` with the thread spawner injected, so a test can make the spawn fail.
87    fn try_capture_with<R: Read + Send + 'static>(
88        spawn: impl FnOnce(String, Box<dyn FnOnce() + Send + 'static>) -> io::Result<JoinHandle<()>>,
89        stderr: R,
90        max_bytes: usize,
91        map: impl Fn(&str) -> Option<String> + Send + 'static,
92        on_line: impl Fn(&str) + Send + 'static,
93    ) -> io::Result<Self> {
94        let lines: Arc<Mutex<Retained>> = Arc::default();
95        let shared = Arc::clone(&lines);
96        let done: Arc<Done> = Arc::new((Mutex::new(false), Condvar::new()));
97        let guard = DoneGuard(Arc::clone(&done));
98        let handle = spawn(
99            "rightkit-stderr-tail".into(),
100            Box::new(move || {
101                let _guard = guard;
102                let line_cap = max_bytes.max(1).saturating_mul(4);
103                let mut reader = BufReader::new(stderr);
104                let mut raw = Vec::new();
105                let mut skip_lf = false;
106                loop {
107                    raw.clear();
108                    // One byte more than the cap tells a line of exactly the cap from a longer
109                    // one. A pathological unterminated line must not grow without bound. A
110                    // line ends at `\n`, at `\r`, or at the pair `\r\n` as one terminator:
111                    // progress output rewrites its line with bare carriage returns, and each
112                    // rewrite is a line the redactor must see whole.
113                    let (terminated, stream_ended) =
114                        read_line_bounded(&mut reader, &mut raw, line_cap, &mut skip_lf);
115                    if raw.is_empty() && !terminated {
116                        return; // clean end of stream (or an unreadable one)
117                    }
118                    let oversized = !terminated && !stream_ended && raw.len() > line_cap;
119                    let (kept, ended) = if oversized {
120                        let ended = discard_rest_of_line(&mut reader, &mut skip_lf);
121                        (
122                            Some(format!("[stderr line over {line_cap} bytes dropped]")),
123                            ended,
124                        )
125                    } else {
126                        let text = String::from_utf8_lossy(&raw);
127                        (map(text.trim_end_matches(['\r', '\n'])), stream_ended)
128                    };
129                    if let Some(kept) = kept {
130                        on_line(&kept);
131                        let mut g = shared.lock().unwrap_or_else(|e| e.into_inner());
132                        retain(&mut g, kept, max_bytes);
133                    }
134                    if ended {
135                        return;
136                    }
137                }
138            }),
139        )?;
140        Ok(Self {
141            lines,
142            handle: Some(handle),
143            done,
144        })
145    }
146
147    /// Capture with no redaction and no forwarding.
148    pub fn plain<R: Read + Send + 'static>(stderr: R, max_bytes: usize) -> Self {
149        Self::capture(stderr, max_bytes, |l| Some(l.to_string()), |_| {})
150    }
151
152    /// The retained lines, oldest first, joined by newlines.
153    pub fn snapshot(&self) -> String {
154        let g = self.lines.lock().unwrap_or_else(|e| e.into_inner());
155        g.0.iter()
156            .map(String::as_str)
157            .collect::<Vec<_>>()
158            .join("\n")
159    }
160
161    /// Like [`Self::finish`] but gives up after `timeout` (a grandchild that
162    /// inherited stderr can keep the stream open after the child is gone) and
163    /// returns what was captured so far.
164    pub fn finish_within(&mut self, timeout: std::time::Duration) -> String {
165        // Sleeps on the condition the drain thread signals when its body ends: one wake-up,
166        // however long the stream stays open.
167        {
168            let guard = self.done.0.lock().unwrap_or_else(|e| e.into_inner());
169            let _ = self
170                .done
171                .1
172                .wait_timeout_while(guard, timeout, |over| !*over)
173                .unwrap_or_else(|e| e.into_inner());
174        }
175        // This call never joins the thread. After the signal the thread still runs the caller's
176        // code: the captured `map` and `on_line` are dropped, and then any thread-local
177        // destructors those callbacks registered; `JoinHandle::is_finished` can already be true
178        // while they run, and a join would wait for them (0.3.3 joined here and could wait for
179        // ever on a destructor blocked on a lock the caller holds). The tail is complete without
180        // a join: every line was retained before the signal. The handle is kept, so
181        // [`Self::finish`] can still join later; if it is never called the thread ends by itself.
182        self.snapshot()
183    }
184
185    /// Wait for the stream to end (the child exited) so the tail is complete.
186    pub fn finish(&mut self) -> String {
187        if let Some(h) = self.handle.take() {
188            let _ = h.join();
189        }
190        self.snapshot()
191    }
192}
193
194/// Reads one line into `raw` (terminator included), stopping one byte past `line_cap` so the
195/// caller can tell an exactly-cap line from a longer one. A line ends at `\n`, at `\r`, or at
196/// `\r\n` consumed as one terminator. Returns `(terminated, stream_ended)`: an unterminated
197/// final line at the end of the stream is still returned, with `stream_ended` set. A read
198/// error other than `Interrupted` ends the stream like EOF does.
199fn read_line_bounded(
200    reader: &mut impl BufRead,
201    raw: &mut Vec<u8>,
202    line_cap: usize,
203    skip_lf: &mut bool,
204) -> (bool, bool) {
205    loop {
206        let buffer = match reader.fill_buf() {
207            Ok([]) => return (false, true),
208            Ok(buffer) => buffer,
209            Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue,
210            Err(_) => return (false, true),
211        };
212        if std::mem::take(skip_lf) && buffer[0] == b'\n' {
213            reader.consume(1);
214            continue;
215        }
216        let room = line_cap.saturating_add(1).saturating_sub(raw.len());
217        let scan = &buffer[..buffer.len().min(room)];
218        match scan.iter().position(|&byte| byte == b'\n' || byte == b'\r') {
219            Some(index) => {
220                let carriage = scan[index] == b'\r';
221                raw.extend_from_slice(&scan[..=index]);
222                reader.consume(index + 1);
223                *skip_lf = carriage;
224                return (true, false);
225            }
226            None => {
227                let used = scan.len();
228                raw.extend_from_slice(scan);
229                reader.consume(used);
230                if raw.len() > line_cap {
231                    return (false, false);
232                }
233            }
234        }
235    }
236}
237
238/// Reads and drops bytes up to and including the next line terminator (`\n`, `\r`, or the
239/// pair `\r\n`). True when the stream ended first.
240fn discard_rest_of_line(reader: &mut impl BufRead, skip_lf: &mut bool) -> bool {
241    loop {
242        let (used, terminator) = match reader.fill_buf() {
243            Ok([]) => return true,
244            Ok(buffer) => match buffer
245                .iter()
246                .position(|&byte| byte == b'\n' || byte == b'\r')
247            {
248                Some(index) => (index + 1, Some(buffer[index])),
249                None => (buffer.len(), None),
250            },
251            Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue,
252            Err(_) => return true,
253        };
254        reader.consume(used);
255        match terminator {
256            Some(b'\r') => {
257                *skip_lf = true;
258                return false;
259            }
260            Some(_) => return false,
261            None => {}
262        }
263    }
264}
265
266/// Adds `kept` and drops the oldest lines until the total fits `max_bytes`. A line that alone
267/// costs more than the budget is cut to its last bytes (at a character boundary) first, so the
268/// bound holds for a single line too.
269fn retain(state: &mut Retained, kept: String, max_bytes: usize) {
270    let room = max_bytes.saturating_sub(1);
271    let kept = if kept.len() > room {
272        let mut start = kept.len() - room;
273        while !kept.is_char_boundary(start) {
274            start += 1;
275        }
276        kept[start..].to_string()
277    } else {
278        kept
279    };
280    let cost = kept.len() + 1;
281    if cost > max_bytes {
282        // Only a zero budget: nothing, not even an empty line, fits.
283        return;
284    }
285    state.1 += cost;
286    state.0.push_back(kept);
287    while state.1 > max_bytes {
288        match state.0.pop_front() {
289            Some(old) => state.1 -= old.len() + 1,
290            None => break,
291        }
292    }
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298    use std::io::Cursor;
299
300    #[test]
301    fn carriage_return_is_delivered_while_live_writer_stays_open() {
302        use std::io::Write;
303        use std::process::{Command, Stdio};
304        use std::time::Duration;
305        let mut child = Command::new(std::env::current_exe().unwrap())
306            .args([
307                "--exact",
308                "stderr_tail::tests::live_pipe_writer",
309                "--nocapture",
310            ])
311            .env("RIGHTKIT_STDERR_LIVE_PIPE_WRITER", "1")
312            .stdin(Stdio::piped())
313            .stderr(Stdio::piped())
314            .stdout(Stdio::null())
315            .spawn()
316            .unwrap();
317        let mut writer = child.stdin.take().unwrap();
318        let reader = child.stderr.take().unwrap();
319        let (tx, rx) = std::sync::mpsc::channel();
320        let mut tail = StderrTail::capture(
321            reader,
322            64,
323            |l| Some(l.to_owned()),
324            move |l| {
325                tx.send(l.to_owned()).unwrap();
326            },
327        );
328        let first = rx.recv_timeout(Duration::from_secs(2));
329        // Keep CRLF split across reads, with no callback for its optional LF.
330        writer.write_all(b"x").unwrap();
331        let second = rx.recv_timeout(Duration::from_secs(2));
332        drop(writer);
333        child.wait().unwrap();
334        tail.finish();
335        assert_eq!(first.unwrap(), "progress");
336        assert_eq!(second.unwrap(), "next");
337        assert!(rx.try_recv().is_err());
338    }
339
340    #[test]
341    fn live_pipe_writer() {
342        if std::env::var_os("RIGHTKIT_STDERR_LIVE_PIPE_WRITER").is_none() {
343            return;
344        }
345        use std::io::Write;
346        let mut stderr = std::io::stderr().lock();
347        stderr.write_all(b"progress\r").unwrap();
348        stderr.flush().unwrap();
349        let mut release = [0];
350        std::io::stdin().read_exact(&mut release).unwrap();
351        stderr.write_all(b"\nnext\r").unwrap();
352        stderr.flush().unwrap();
353    }
354
355    #[test]
356    fn split_crlf_and_oversized_cr_line_preserve_line_boundaries() {
357        let mut reader = BufReader::with_capacity(1, Cursor::new(b"one\r\ntwo\rthree\n"));
358        let mut skip_lf = false;
359        for expected in ["one", "two", "three"] {
360            let mut raw = Vec::new();
361            assert_eq!(
362                read_line_bounded(&mut reader, &mut raw, 32, &mut skip_lf),
363                (true, false)
364            );
365            assert_eq!(
366                String::from_utf8(raw)
367                    .unwrap()
368                    .trim_end_matches(['\r', '\n']),
369                expected
370            );
371        }
372        let (snapshot, seen, _) =
373            capture_all(format!("{}\r\nafter\r", "x".repeat(64)).into_bytes(), 8);
374        assert_eq!(seen, vec!["after"]);
375        assert!(snapshot.ends_with("after"));
376    }
377
378    /// A redactor like the line-based ones the apps pass: everything from `key=` on goes.
379    fn redact(line: &str) -> Option<String> {
380        Some(match line.split_once("key=") {
381            Some((head, _)) => format!("{head}key=[redacted]"),
382            None => line.to_string(),
383        })
384    }
385
386    fn capture_all(input: Vec<u8>, max_bytes: usize) -> (String, Vec<String>, Vec<String>) {
387        let seen = Arc::new(Mutex::new(Vec::new()));
388        let forwarded = Arc::new(Mutex::new(Vec::new()));
389        let (map_seen, on_line_seen) = (Arc::clone(&seen), Arc::clone(&forwarded));
390        let mut tail = StderrTail::capture(
391            Cursor::new(input),
392            max_bytes,
393            move |line| {
394                map_seen.lock().unwrap().push(line.to_string());
395                redact(line)
396            },
397            move |line| on_line_seen.lock().unwrap().push(line.to_string()),
398        );
399        let snapshot = tail.finish();
400        let seen = seen.lock().unwrap().clone();
401        let forwarded = forwarded.lock().unwrap().clone();
402        (snapshot, seen, forwarded)
403    }
404
405    /// Comparative review, 2026-10-12: the bounded finish joined as soon as the drain body was
406    /// over, but the callback is dropped after that, and its destructor here waits on a lock
407    /// the caller holds. 0.3.3 returned at the deadline; so must this.
408    #[test]
409    fn finish_within_returns_while_the_callback_is_still_being_dropped() {
410        struct WaitsOnDrop(Arc<Mutex<()>>);
411        impl Drop for WaitsOnDrop {
412            fn drop(&mut self) {
413                drop(self.0.lock().unwrap_or_else(|e| e.into_inner()));
414            }
415        }
416        let gate = Arc::new(Mutex::new(()));
417        let held = gate.lock().unwrap();
418        let captured = WaitsOnDrop(Arc::clone(&gate));
419        let mut tail = StderrTail::capture(
420            Cursor::new(b"kept\n".to_vec()),
421            64,
422            |l| Some(l.to_string()),
423            move |_| {
424                let _ = &captured;
425            },
426        );
427        let started = std::time::Instant::now();
428        let snapshot = tail.finish_within(std::time::Duration::from_millis(100));
429        assert_eq!(snapshot, "kept");
430        assert!(
431            started.elapsed() < std::time::Duration::from_secs(5),
432            "finish_within waited for the callback's destructor"
433        );
434        // Released: the thread ends and an unbounded finish joins it.
435        drop(held);
436        assert_eq!(tail.finish(), "kept");
437    }
438
439    #[test]
440    fn try_capture_surfaces_a_spawn_failure_and_capture_degrades_to_an_empty_tail() {
441        let failed = StderrTail::try_capture_with(
442            |_, _| Err(io::Error::new(io::ErrorKind::WouldBlock, "no threads")),
443            Cursor::new(b"lost\n".to_vec()),
444            64,
445            |l| Some(l.to_string()),
446            |_| {},
447        );
448        let error = failed.err().expect("the spawn error must reach the caller");
449        assert_eq!(error.kind(), io::ErrorKind::WouldBlock);
450        // The public constructor reports success as an `io::Result` and still captures.
451        let mut tail = StderrTail::try_capture(
452            Cursor::new(b"one\ntwo\n".to_vec()),
453            64,
454            |l| Some(l.to_string()),
455            |_| {},
456        )
457        .expect("spawn succeeds");
458        assert_eq!(tail.finish(), "one\ntwo");
459    }
460
461    /// `max_bytes = 8` gives a 32-byte line cap. The secret line is 64 bytes, so the old code
462    /// cut it after 32 bytes and presented both halves to the redactor as separate lines: the
463    /// second half, `SSSS...`, holds no `key=` and went through unredacted.
464    #[test]
465    fn the_continuation_of_an_oversized_line_never_reaches_the_redactor_or_the_sinks() {
466        let secret = "S".repeat(60);
467        let input = format!("key={secret}\nafter\n").into_bytes();
468        let (snapshot, seen, forwarded) = capture_all(input, 8);
469        assert_eq!(
470            seen,
471            vec!["after".to_string()],
472            "the redactor saw a fragment"
473        );
474        assert!(
475            !forwarded.iter().any(|line| line.contains('S')),
476            "forwarded: {forwarded:?}"
477        );
478        assert!(!snapshot.contains('S'), "retained: {snapshot:?}");
479        assert!(
480            snapshot.len() <= 8,
481            "{} bytes retained: {snapshot:?}",
482            snapshot.len()
483        );
484        // The marker stands in for the dropped line, and the line after it is intact.
485        assert_eq!(forwarded.len(), 2, "{forwarded:?}");
486        assert!(forwarded[0].starts_with("[stderr line over 32 bytes dropped]"));
487        assert_eq!(forwarded[1], "after");
488    }
489
490    #[test]
491    fn an_oversized_line_is_drained_across_many_reads_and_the_next_line_survives() {
492        // Far longer than the reader's buffer, so the discard loop runs many times.
493        let mut input = vec![b'x'; 200_000];
494        input.extend_from_slice(b"\nfinal\n");
495        let (snapshot, seen, _) = capture_all(input, 2048);
496        assert_eq!(seen, vec!["final".to_string()]);
497        assert!(snapshot.ends_with("final"), "{snapshot:?}");
498        assert!(snapshot.len() <= 2048);
499    }
500
501    #[test]
502    fn an_oversized_line_that_ends_the_stream_is_dropped_without_hanging() {
503        let (snapshot, seen, forwarded) = capture_all(vec![b'x'; 5000], 100);
504        assert!(seen.is_empty(), "{seen:?}");
505        assert_eq!(forwarded.len(), 1);
506        assert!(snapshot.len() <= 100);
507    }
508
509    #[test]
510    fn a_line_of_exactly_the_cap_is_inspected_and_one_byte_more_is_not() {
511        // max_bytes = 8: the cap is 32.
512        let exact = "a".repeat(32);
513        let over = "b".repeat(33);
514        let input = format!("{exact}\n{over}\n").into_bytes();
515        let (_, seen, forwarded) = capture_all(input, 8);
516        assert_eq!(seen, vec![exact]);
517        assert_eq!(forwarded.len(), 2);
518        assert!(forwarded[1].starts_with("[stderr line over"));
519        // A final line with no newline that fits the cap is still a line.
520        let (_, seen, _) = capture_all(b"tail with no newline".to_vec(), 8);
521        assert_eq!(seen, vec!["tail with no newline".to_string()]);
522    }
523
524    /// One line, within the cap but larger than the budget: the old code kept it whole.
525    #[test]
526    fn one_entry_larger_than_the_budget_is_cut_to_its_last_bytes() {
527        let (snapshot, _, _) = capture_all(format!("{}END\n", "a".repeat(50)).into_bytes(), 16);
528        assert!(
529            snapshot.len() <= 16,
530            "{} bytes: {snapshot:?}",
531            snapshot.len()
532        );
533        assert!(
534            snapshot.ends_with("END"),
535            "the newest bytes are the ones kept: {snapshot:?}"
536        );
537    }
538
539    #[test]
540    fn a_cut_never_splits_a_character() {
541        // Each `é` is two bytes; 20 of them are 40 bytes. A 16-byte budget leaves 15 for text.
542        let (snapshot, _, _) = capture_all(format!("{}\n", "é".repeat(20)).into_bytes(), 16);
543        assert!(snapshot.len() <= 16);
544        assert!(!snapshot.is_empty());
545        assert!(snapshot.chars().all(|c| c == 'é'), "{snapshot:?}");
546    }
547
548    #[test]
549    fn the_total_stays_within_the_budget_over_many_lines() {
550        let input: String = (0..500).map(|n| format!("line-{n}\n")).collect();
551        let (snapshot, _, _) = capture_all(input.into_bytes(), 40);
552        assert!(snapshot.len() <= 40, "{} bytes", snapshot.len());
553        assert!(snapshot.ends_with("line-499"), "{snapshot:?}");
554    }
555
556    #[test]
557    fn carriage_returns_end_lines_and_crlf_is_one_terminator() {
558        let (snapshot, seen, forwarded) = capture_all(b"one\rtwo\r\nthree\nfour\r".to_vec(), 2048);
559        assert_eq!(
560            seen,
561            vec![
562                "one".to_string(),
563                "two".to_string(),
564                "three".to_string(),
565                "four".to_string()
566            ]
567        );
568        assert_eq!(forwarded, seen, "CRLF must not yield an empty line");
569        assert_eq!(snapshot, "one\ntwo\nthree\nfour");
570        // A final progress rewrite with no terminator is still a line.
571        let (_, seen, _) = capture_all(b"a\rb".to_vec(), 2048);
572        assert_eq!(seen, vec!["a".to_string(), "b".to_string()]);
573    }
574
575    #[test]
576    fn an_oversized_cr_line_is_dropped_up_to_the_cr_and_the_next_line_survives() {
577        // max_bytes = 8: the cap is 32; the secret line ends at a bare `\r`.
578        let secret = "S".repeat(60);
579        let input = format!("key={secret}\rafter\n").into_bytes();
580        let (snapshot, seen, forwarded) = capture_all(input, 8);
581        assert_eq!(seen, vec!["after".to_string()]);
582        assert!(!forwarded.iter().any(|line| line.contains('S')));
583        assert!(!snapshot.contains('S'));
584    }
585
586    #[test]
587    fn a_zero_budget_retains_nothing_and_still_drains() {
588        let (snapshot, seen, _) = capture_all(b"one\ntwo\n".to_vec(), 0);
589        assert_eq!(snapshot, "");
590        assert_eq!(seen, vec!["one".to_string(), "two".to_string()]);
591    }
592}