1use std::collections::VecDeque;
7use std::io::{self, BufRead, BufReader, Read};
8use std::sync::{Arc, Condvar, Mutex};
9use std::thread::{self, JoinHandle};
10
11type Retained = (VecDeque<String>, usize);
14
15pub struct StderrTail {
20 lines: Arc<Mutex<Retained>>,
21 handle: Option<JoinHandle<()>>,
22 done: Arc<Done>,
25}
26
27type Done = (Mutex<bool>, Condvar);
28
29struct 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 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 done: Arc::new((Mutex::new(true), Condvar::new())),
65 })
66 }
67
68 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 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 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 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 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 pub fn finish_within(&mut self, timeout: std::time::Duration) -> String {
164 {
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 self.snapshot()
182 }
183
184 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
193fn 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
213fn 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 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 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 #[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 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 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 #[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 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 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 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 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 #[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 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}