Skip to main content

rightkit_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                loop {
106                    raw.clear();
107                    // One byte more than the cap tells a line of exactly the cap from a longer
108                    // one. A pathological unterminated line must not grow without bound.
109                    match reader
110                        .by_ref()
111                        .take(line_cap as u64 + 1)
112                        .read_until(b'\n', &mut raw)
113                    {
114                        Ok(0) | Err(_) => return,
115                        Ok(_) => {}
116                    }
117                    let oversized = raw.last() != Some(&b'\n') && raw.len() > line_cap;
118                    let (kept, ended) = if oversized {
119                        let ended = discard_rest_of_line(&mut reader);
120                        (
121                            Some(format!("[stderr line over {line_cap} bytes dropped]")),
122                            ended,
123                        )
124                    } else {
125                        let text = String::from_utf8_lossy(&raw);
126                        (map(text.trim_end_matches(['\r', '\n'])), false)
127                    };
128                    if let Some(kept) = kept {
129                        on_line(&kept);
130                        let mut g = shared.lock().unwrap_or_else(|e| e.into_inner());
131                        retain(&mut g, kept, max_bytes);
132                    }
133                    if ended {
134                        return;
135                    }
136                }
137            }),
138        )?;
139        Ok(Self {
140            lines,
141            handle: Some(handle),
142            done,
143        })
144    }
145
146    /// Capture with no redaction and no forwarding.
147    pub fn plain<R: Read + Send + 'static>(stderr: R, max_bytes: usize) -> Self {
148        Self::capture(stderr, max_bytes, |l| Some(l.to_string()), |_| {})
149    }
150
151    /// The retained lines, oldest first, joined by newlines.
152    pub fn snapshot(&self) -> String {
153        let g = self.lines.lock().unwrap_or_else(|e| e.into_inner());
154        g.0.iter()
155            .map(String::as_str)
156            .collect::<Vec<_>>()
157            .join("\n")
158    }
159
160    /// Like [`Self::finish`] but gives up after `timeout` (a grandchild that
161    /// inherited stderr can keep the stream open after the child is gone) and
162    /// returns what was captured so far.
163    pub fn finish_within(&mut self, timeout: std::time::Duration) -> String {
164        // Sleeps on the condition the drain thread signals when its body ends: one wake-up,
165        // however long the stream stays open.
166        {
167            let guard = self.done.0.lock().unwrap_or_else(|e| e.into_inner());
168            let _ = self
169                .done
170                .1
171                .wait_timeout_while(guard, timeout, |over| !*over)
172                .unwrap_or_else(|e| e.into_inner());
173        }
174        // This call never joins the thread. After the signal the thread still runs the caller's
175        // code: the captured `map` and `on_line` are dropped, and then any thread-local
176        // destructors those callbacks registered; `JoinHandle::is_finished` can already be true
177        // while they run, and a join would wait for them (0.3.3 joined here and could wait for
178        // ever on a destructor blocked on a lock the caller holds). The tail is complete without
179        // a join: every line was retained before the signal. The handle is kept, so
180        // [`Self::finish`] can still join later; if it is never called the thread ends by itself.
181        self.snapshot()
182    }
183
184    /// Wait for the stream to end (the child exited) so the tail is complete.
185    pub fn finish(&mut self) -> String {
186        if let Some(h) = self.handle.take() {
187            let _ = h.join();
188        }
189        self.snapshot()
190    }
191}
192
193/// Reads and drops bytes up to and including the next newline. True when the stream ended
194/// first.
195fn discard_rest_of_line(reader: &mut impl BufRead) -> bool {
196    loop {
197        let (used, found) = match reader.fill_buf() {
198            Ok([]) => return true,
199            Ok(buffer) => match buffer.iter().position(|&byte| byte == b'\n') {
200                Some(index) => (index + 1, true),
201                None => (buffer.len(), false),
202            },
203            Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue,
204            Err(_) => return true,
205        };
206        reader.consume(used);
207        if found {
208            return false;
209        }
210    }
211}
212
213/// Adds `kept` and drops the oldest lines until the total fits `max_bytes`. A line that alone
214/// costs more than the budget is cut to its last bytes (at a character boundary) first, so the
215/// bound holds for a single line too.
216fn retain(state: &mut Retained, kept: String, max_bytes: usize) {
217    let room = max_bytes.saturating_sub(1);
218    let kept = if kept.len() > room {
219        let mut start = kept.len() - room;
220        while !kept.is_char_boundary(start) {
221            start += 1;
222        }
223        kept[start..].to_string()
224    } else {
225        kept
226    };
227    let cost = kept.len() + 1;
228    if cost > max_bytes {
229        // Only a zero budget: nothing, not even an empty line, fits.
230        return;
231    }
232    state.1 += cost;
233    state.0.push_back(kept);
234    while state.1 > max_bytes {
235        match state.0.pop_front() {
236            Some(old) => state.1 -= old.len() + 1,
237            None => break,
238        }
239    }
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245    use std::io::Cursor;
246
247    /// A redactor like the line-based ones the apps pass: everything from `key=` on goes.
248    fn redact(line: &str) -> Option<String> {
249        Some(match line.split_once("key=") {
250            Some((head, _)) => format!("{head}key=[redacted]"),
251            None => line.to_string(),
252        })
253    }
254
255    fn capture_all(input: Vec<u8>, max_bytes: usize) -> (String, Vec<String>, Vec<String>) {
256        let seen = Arc::new(Mutex::new(Vec::new()));
257        let forwarded = Arc::new(Mutex::new(Vec::new()));
258        let (map_seen, on_line_seen) = (Arc::clone(&seen), Arc::clone(&forwarded));
259        let mut tail = StderrTail::capture(
260            Cursor::new(input),
261            max_bytes,
262            move |line| {
263                map_seen.lock().unwrap().push(line.to_string());
264                redact(line)
265            },
266            move |line| on_line_seen.lock().unwrap().push(line.to_string()),
267        );
268        let snapshot = tail.finish();
269        let seen = seen.lock().unwrap().clone();
270        let forwarded = forwarded.lock().unwrap().clone();
271        (snapshot, seen, forwarded)
272    }
273
274    /// Comparative review, 2026-10-12: the bounded finish joined as soon as the drain body was
275    /// over, but the callback is dropped after that, and its destructor here waits on a lock
276    /// the caller holds. 0.3.3 returned at the deadline; so must this.
277    #[test]
278    fn finish_within_returns_while_the_callback_is_still_being_dropped() {
279        struct WaitsOnDrop(Arc<Mutex<()>>);
280        impl Drop for WaitsOnDrop {
281            fn drop(&mut self) {
282                drop(self.0.lock().unwrap_or_else(|e| e.into_inner()));
283            }
284        }
285        let gate = Arc::new(Mutex::new(()));
286        let held = gate.lock().unwrap();
287        let captured = WaitsOnDrop(Arc::clone(&gate));
288        let mut tail = StderrTail::capture(
289            Cursor::new(b"kept\n".to_vec()),
290            64,
291            |l| Some(l.to_string()),
292            move |_| {
293                let _ = &captured;
294            },
295        );
296        let started = std::time::Instant::now();
297        let snapshot = tail.finish_within(std::time::Duration::from_millis(100));
298        assert_eq!(snapshot, "kept");
299        assert!(
300            started.elapsed() < std::time::Duration::from_secs(5),
301            "finish_within waited for the callback's destructor"
302        );
303        // Released: the thread ends and an unbounded finish joins it.
304        drop(held);
305        assert_eq!(tail.finish(), "kept");
306    }
307
308    #[test]
309    fn try_capture_surfaces_a_spawn_failure_and_capture_degrades_to_an_empty_tail() {
310        let failed = StderrTail::try_capture_with(
311            |_, _| Err(io::Error::new(io::ErrorKind::WouldBlock, "no threads")),
312            Cursor::new(b"lost\n".to_vec()),
313            64,
314            |l| Some(l.to_string()),
315            |_| {},
316        );
317        let error = failed.err().expect("the spawn error must reach the caller");
318        assert_eq!(error.kind(), io::ErrorKind::WouldBlock);
319        // The public constructor reports success as an `io::Result` and still captures.
320        let mut tail = StderrTail::try_capture(
321            Cursor::new(b"one\ntwo\n".to_vec()),
322            64,
323            |l| Some(l.to_string()),
324            |_| {},
325        )
326        .expect("spawn succeeds");
327        assert_eq!(tail.finish(), "one\ntwo");
328    }
329
330    /// `max_bytes = 8` gives a 32-byte line cap. The secret line is 64 bytes, so the old code
331    /// cut it after 32 bytes and presented both halves to the redactor as separate lines: the
332    /// second half, `SSSS...`, holds no `key=` and went through unredacted.
333    #[test]
334    fn the_continuation_of_an_oversized_line_never_reaches_the_redactor_or_the_sinks() {
335        let secret = "S".repeat(60);
336        let input = format!("key={secret}\nafter\n").into_bytes();
337        let (snapshot, seen, forwarded) = capture_all(input, 8);
338        assert_eq!(
339            seen,
340            vec!["after".to_string()],
341            "the redactor saw a fragment"
342        );
343        assert!(
344            !forwarded.iter().any(|line| line.contains('S')),
345            "forwarded: {forwarded:?}"
346        );
347        assert!(!snapshot.contains('S'), "retained: {snapshot:?}");
348        assert!(
349            snapshot.len() <= 8,
350            "{} bytes retained: {snapshot:?}",
351            snapshot.len()
352        );
353        // The marker stands in for the dropped line, and the line after it is intact.
354        assert_eq!(forwarded.len(), 2, "{forwarded:?}");
355        assert!(forwarded[0].starts_with("[stderr line over 32 bytes dropped]"));
356        assert_eq!(forwarded[1], "after");
357    }
358
359    #[test]
360    fn an_oversized_line_is_drained_across_many_reads_and_the_next_line_survives() {
361        // Far longer than the reader's buffer, so the discard loop runs many times.
362        let mut input = vec![b'x'; 200_000];
363        input.extend_from_slice(b"\nfinal\n");
364        let (snapshot, seen, _) = capture_all(input, 2048);
365        assert_eq!(seen, vec!["final".to_string()]);
366        assert!(snapshot.ends_with("final"), "{snapshot:?}");
367        assert!(snapshot.len() <= 2048);
368    }
369
370    #[test]
371    fn an_oversized_line_that_ends_the_stream_is_dropped_without_hanging() {
372        let (snapshot, seen, forwarded) = capture_all(vec![b'x'; 5000], 100);
373        assert!(seen.is_empty(), "{seen:?}");
374        assert_eq!(forwarded.len(), 1);
375        assert!(snapshot.len() <= 100);
376    }
377
378    #[test]
379    fn a_line_of_exactly_the_cap_is_inspected_and_one_byte_more_is_not() {
380        // max_bytes = 8: the cap is 32.
381        let exact = "a".repeat(32);
382        let over = "b".repeat(33);
383        let input = format!("{exact}\n{over}\n").into_bytes();
384        let (_, seen, forwarded) = capture_all(input, 8);
385        assert_eq!(seen, vec![exact]);
386        assert_eq!(forwarded.len(), 2);
387        assert!(forwarded[1].starts_with("[stderr line over"));
388        // A final line with no newline that fits the cap is still a line.
389        let (_, seen, _) = capture_all(b"tail with no newline".to_vec(), 8);
390        assert_eq!(seen, vec!["tail with no newline".to_string()]);
391    }
392
393    /// One line, within the cap but larger than the budget: the old code kept it whole.
394    #[test]
395    fn one_entry_larger_than_the_budget_is_cut_to_its_last_bytes() {
396        let (snapshot, _, _) = capture_all(format!("{}END\n", "a".repeat(50)).into_bytes(), 16);
397        assert!(
398            snapshot.len() <= 16,
399            "{} bytes: {snapshot:?}",
400            snapshot.len()
401        );
402        assert!(
403            snapshot.ends_with("END"),
404            "the newest bytes are the ones kept: {snapshot:?}"
405        );
406    }
407
408    #[test]
409    fn a_cut_never_splits_a_character() {
410        // Each `é` is two bytes; 20 of them are 40 bytes. A 16-byte budget leaves 15 for text.
411        let (snapshot, _, _) = capture_all(format!("{}\n", "é".repeat(20)).into_bytes(), 16);
412        assert!(snapshot.len() <= 16);
413        assert!(!snapshot.is_empty());
414        assert!(snapshot.chars().all(|c| c == 'é'), "{snapshot:?}");
415    }
416
417    #[test]
418    fn the_total_stays_within_the_budget_over_many_lines() {
419        let input: String = (0..500).map(|n| format!("line-{n}\n")).collect();
420        let (snapshot, _, _) = capture_all(input.into_bytes(), 40);
421        assert!(snapshot.len() <= 40, "{} bytes", snapshot.len());
422        assert!(snapshot.ends_with("line-499"), "{snapshot:?}");
423    }
424
425    #[test]
426    fn a_zero_budget_retains_nothing_and_still_drains() {
427        let (snapshot, seen, _) = capture_all(b"one\ntwo\n".to_vec(), 0);
428        assert_eq!(snapshot, "");
429        assert_eq!(seen, vec!["one".to_string(), "two".to_string()]);
430    }
431}