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 let mut skip_lf = false;
106 loop {
107 raw.clear();
108 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; }
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 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 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 pub fn finish_within(&mut self, timeout: std::time::Duration) -> String {
165 {
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 self.snapshot()
183 }
184
185 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
194fn 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
238fn 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
266fn 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 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 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 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 #[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 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 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 #[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 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 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 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 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 #[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 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 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 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}