use std::collections::VecDeque;
use std::io::{self, BufRead, BufReader, Read};
use std::sync::{Arc, Condvar, Mutex};
use std::thread::{self, JoinHandle};
type Retained = (VecDeque<String>, usize);
pub struct StderrTail {
lines: Arc<Mutex<Retained>>,
handle: Option<JoinHandle<()>>,
done: Arc<Done>,
}
type Done = (Mutex<bool>, Condvar);
struct DoneGuard(Arc<Done>);
impl Drop for DoneGuard {
fn drop(&mut self) {
*self.0 .0.lock().unwrap_or_else(|e| e.into_inner()) = true;
self.0 .1.notify_all();
}
}
impl StderrTail {
pub fn capture<R: Read + Send + 'static>(
stderr: R,
max_bytes: usize,
map: impl Fn(&str) -> Option<String> + Send + 'static,
on_line: impl Fn(&str) + Send + 'static,
) -> Self {
Self::try_capture(stderr, max_bytes, map, on_line).unwrap_or_else(|_| Self {
lines: Arc::default(),
handle: None,
done: Arc::new((Mutex::new(true), Condvar::new())),
})
}
pub fn try_capture<R: Read + Send + 'static>(
stderr: R,
max_bytes: usize,
map: impl Fn(&str) -> Option<String> + Send + 'static,
on_line: impl Fn(&str) + Send + 'static,
) -> io::Result<Self> {
Self::try_capture_with(
|name, body| thread::Builder::new().name(name).spawn(body),
stderr,
max_bytes,
map,
on_line,
)
}
fn try_capture_with<R: Read + Send + 'static>(
spawn: impl FnOnce(String, Box<dyn FnOnce() + Send + 'static>) -> io::Result<JoinHandle<()>>,
stderr: R,
max_bytes: usize,
map: impl Fn(&str) -> Option<String> + Send + 'static,
on_line: impl Fn(&str) + Send + 'static,
) -> io::Result<Self> {
let lines: Arc<Mutex<Retained>> = Arc::default();
let shared = Arc::clone(&lines);
let done: Arc<Done> = Arc::new((Mutex::new(false), Condvar::new()));
let guard = DoneGuard(Arc::clone(&done));
let handle = spawn(
"rightkit-stderr-tail".into(),
Box::new(move || {
let _guard = guard;
let line_cap = max_bytes.max(1).saturating_mul(4);
let mut reader = BufReader::new(stderr);
let mut raw = Vec::new();
let mut skip_lf = false;
loop {
raw.clear();
let (terminated, stream_ended) =
read_line_bounded(&mut reader, &mut raw, line_cap, &mut skip_lf);
if raw.is_empty() && !terminated {
return; }
let oversized = !terminated && !stream_ended && raw.len() > line_cap;
let (kept, ended) = if oversized {
let ended = discard_rest_of_line(&mut reader, &mut skip_lf);
(
Some(format!("[stderr line over {line_cap} bytes dropped]")),
ended,
)
} else {
let text = String::from_utf8_lossy(&raw);
(map(text.trim_end_matches(['\r', '\n'])), stream_ended)
};
if let Some(kept) = kept {
on_line(&kept);
let mut g = shared.lock().unwrap_or_else(|e| e.into_inner());
retain(&mut g, kept, max_bytes);
}
if ended {
return;
}
}
}),
)?;
Ok(Self {
lines,
handle: Some(handle),
done,
})
}
pub fn plain<R: Read + Send + 'static>(stderr: R, max_bytes: usize) -> Self {
Self::capture(stderr, max_bytes, |l| Some(l.to_string()), |_| {})
}
pub fn snapshot(&self) -> String {
let g = self.lines.lock().unwrap_or_else(|e| e.into_inner());
g.0.iter()
.map(String::as_str)
.collect::<Vec<_>>()
.join("\n")
}
pub fn finish_within(&mut self, timeout: std::time::Duration) -> String {
{
let guard = self.done.0.lock().unwrap_or_else(|e| e.into_inner());
let _ = self
.done
.1
.wait_timeout_while(guard, timeout, |over| !*over)
.unwrap_or_else(|e| e.into_inner());
}
self.snapshot()
}
pub fn finish(&mut self) -> String {
if let Some(h) = self.handle.take() {
let _ = h.join();
}
self.snapshot()
}
}
fn read_line_bounded(
reader: &mut impl BufRead,
raw: &mut Vec<u8>,
line_cap: usize,
skip_lf: &mut bool,
) -> (bool, bool) {
loop {
let buffer = match reader.fill_buf() {
Ok([]) => return (false, true),
Ok(buffer) => buffer,
Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue,
Err(_) => return (false, true),
};
if std::mem::take(skip_lf) && buffer[0] == b'\n' {
reader.consume(1);
continue;
}
let room = line_cap.saturating_add(1).saturating_sub(raw.len());
let scan = &buffer[..buffer.len().min(room)];
match scan.iter().position(|&byte| byte == b'\n' || byte == b'\r') {
Some(index) => {
let carriage = scan[index] == b'\r';
raw.extend_from_slice(&scan[..=index]);
reader.consume(index + 1);
*skip_lf = carriage;
return (true, false);
}
None => {
let used = scan.len();
raw.extend_from_slice(scan);
reader.consume(used);
if raw.len() > line_cap {
return (false, false);
}
}
}
}
}
fn discard_rest_of_line(reader: &mut impl BufRead, skip_lf: &mut bool) -> bool {
loop {
let (used, terminator) = match reader.fill_buf() {
Ok([]) => return true,
Ok(buffer) => match buffer
.iter()
.position(|&byte| byte == b'\n' || byte == b'\r')
{
Some(index) => (index + 1, Some(buffer[index])),
None => (buffer.len(), None),
},
Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue,
Err(_) => return true,
};
reader.consume(used);
match terminator {
Some(b'\r') => {
*skip_lf = true;
return false;
}
Some(_) => return false,
None => {}
}
}
}
fn retain(state: &mut Retained, kept: String, max_bytes: usize) {
let room = max_bytes.saturating_sub(1);
let kept = if kept.len() > room {
let mut start = kept.len() - room;
while !kept.is_char_boundary(start) {
start += 1;
}
kept[start..].to_string()
} else {
kept
};
let cost = kept.len() + 1;
if cost > max_bytes {
return;
}
state.1 += cost;
state.0.push_back(kept);
while state.1 > max_bytes {
match state.0.pop_front() {
Some(old) => state.1 -= old.len() + 1,
None => break,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Cursor;
#[test]
fn carriage_return_is_delivered_while_live_writer_stays_open() {
use std::io::Write;
use std::process::{Command, Stdio};
use std::time::Duration;
let mut child = Command::new(std::env::current_exe().unwrap())
.args([
"--exact",
"stderr_tail::tests::live_pipe_writer",
"--nocapture",
])
.env("RIGHTKIT_STDERR_LIVE_PIPE_WRITER", "1")
.stdin(Stdio::piped())
.stderr(Stdio::piped())
.stdout(Stdio::null())
.spawn()
.unwrap();
let mut writer = child.stdin.take().unwrap();
let reader = child.stderr.take().unwrap();
let (tx, rx) = std::sync::mpsc::channel();
let mut tail = StderrTail::capture(
reader,
64,
|l| Some(l.to_owned()),
move |l| {
tx.send(l.to_owned()).unwrap();
},
);
let first = rx.recv_timeout(Duration::from_secs(2));
writer.write_all(b"x").unwrap();
let second = rx.recv_timeout(Duration::from_secs(2));
drop(writer);
child.wait().unwrap();
tail.finish();
assert_eq!(first.unwrap(), "progress");
assert_eq!(second.unwrap(), "next");
assert!(rx.try_recv().is_err());
}
#[test]
fn live_pipe_writer() {
if std::env::var_os("RIGHTKIT_STDERR_LIVE_PIPE_WRITER").is_none() {
return;
}
use std::io::Write;
let mut stderr = std::io::stderr().lock();
stderr.write_all(b"progress\r").unwrap();
stderr.flush().unwrap();
let mut release = [0];
std::io::stdin().read_exact(&mut release).unwrap();
stderr.write_all(b"\nnext\r").unwrap();
stderr.flush().unwrap();
}
#[test]
fn split_crlf_and_oversized_cr_line_preserve_line_boundaries() {
let mut reader = BufReader::with_capacity(1, Cursor::new(b"one\r\ntwo\rthree\n"));
let mut skip_lf = false;
for expected in ["one", "two", "three"] {
let mut raw = Vec::new();
assert_eq!(
read_line_bounded(&mut reader, &mut raw, 32, &mut skip_lf),
(true, false)
);
assert_eq!(
String::from_utf8(raw)
.unwrap()
.trim_end_matches(['\r', '\n']),
expected
);
}
let (snapshot, seen, _) =
capture_all(format!("{}\r\nafter\r", "x".repeat(64)).into_bytes(), 8);
assert_eq!(seen, vec!["after"]);
assert!(snapshot.ends_with("after"));
}
fn redact(line: &str) -> Option<String> {
Some(match line.split_once("key=") {
Some((head, _)) => format!("{head}key=[redacted]"),
None => line.to_string(),
})
}
fn capture_all(input: Vec<u8>, max_bytes: usize) -> (String, Vec<String>, Vec<String>) {
let seen = Arc::new(Mutex::new(Vec::new()));
let forwarded = Arc::new(Mutex::new(Vec::new()));
let (map_seen, on_line_seen) = (Arc::clone(&seen), Arc::clone(&forwarded));
let mut tail = StderrTail::capture(
Cursor::new(input),
max_bytes,
move |line| {
map_seen.lock().unwrap().push(line.to_string());
redact(line)
},
move |line| on_line_seen.lock().unwrap().push(line.to_string()),
);
let snapshot = tail.finish();
let seen = seen.lock().unwrap().clone();
let forwarded = forwarded.lock().unwrap().clone();
(snapshot, seen, forwarded)
}
#[test]
fn finish_within_returns_while_the_callback_is_still_being_dropped() {
struct WaitsOnDrop(Arc<Mutex<()>>);
impl Drop for WaitsOnDrop {
fn drop(&mut self) {
drop(self.0.lock().unwrap_or_else(|e| e.into_inner()));
}
}
let gate = Arc::new(Mutex::new(()));
let held = gate.lock().unwrap();
let captured = WaitsOnDrop(Arc::clone(&gate));
let mut tail = StderrTail::capture(
Cursor::new(b"kept\n".to_vec()),
64,
|l| Some(l.to_string()),
move |_| {
let _ = &captured;
},
);
let started = std::time::Instant::now();
let snapshot = tail.finish_within(std::time::Duration::from_millis(100));
assert_eq!(snapshot, "kept");
assert!(
started.elapsed() < std::time::Duration::from_secs(5),
"finish_within waited for the callback's destructor"
);
drop(held);
assert_eq!(tail.finish(), "kept");
}
#[test]
fn try_capture_surfaces_a_spawn_failure_and_capture_degrades_to_an_empty_tail() {
let failed = StderrTail::try_capture_with(
|_, _| Err(io::Error::new(io::ErrorKind::WouldBlock, "no threads")),
Cursor::new(b"lost\n".to_vec()),
64,
|l| Some(l.to_string()),
|_| {},
);
let error = failed.err().expect("the spawn error must reach the caller");
assert_eq!(error.kind(), io::ErrorKind::WouldBlock);
let mut tail = StderrTail::try_capture(
Cursor::new(b"one\ntwo\n".to_vec()),
64,
|l| Some(l.to_string()),
|_| {},
)
.expect("spawn succeeds");
assert_eq!(tail.finish(), "one\ntwo");
}
#[test]
fn the_continuation_of_an_oversized_line_never_reaches_the_redactor_or_the_sinks() {
let secret = "S".repeat(60);
let input = format!("key={secret}\nafter\n").into_bytes();
let (snapshot, seen, forwarded) = capture_all(input, 8);
assert_eq!(
seen,
vec!["after".to_string()],
"the redactor saw a fragment"
);
assert!(
!forwarded.iter().any(|line| line.contains('S')),
"forwarded: {forwarded:?}"
);
assert!(!snapshot.contains('S'), "retained: {snapshot:?}");
assert!(
snapshot.len() <= 8,
"{} bytes retained: {snapshot:?}",
snapshot.len()
);
assert_eq!(forwarded.len(), 2, "{forwarded:?}");
assert!(forwarded[0].starts_with("[stderr line over 32 bytes dropped]"));
assert_eq!(forwarded[1], "after");
}
#[test]
fn an_oversized_line_is_drained_across_many_reads_and_the_next_line_survives() {
let mut input = vec![b'x'; 200_000];
input.extend_from_slice(b"\nfinal\n");
let (snapshot, seen, _) = capture_all(input, 2048);
assert_eq!(seen, vec!["final".to_string()]);
assert!(snapshot.ends_with("final"), "{snapshot:?}");
assert!(snapshot.len() <= 2048);
}
#[test]
fn an_oversized_line_that_ends_the_stream_is_dropped_without_hanging() {
let (snapshot, seen, forwarded) = capture_all(vec![b'x'; 5000], 100);
assert!(seen.is_empty(), "{seen:?}");
assert_eq!(forwarded.len(), 1);
assert!(snapshot.len() <= 100);
}
#[test]
fn a_line_of_exactly_the_cap_is_inspected_and_one_byte_more_is_not() {
let exact = "a".repeat(32);
let over = "b".repeat(33);
let input = format!("{exact}\n{over}\n").into_bytes();
let (_, seen, forwarded) = capture_all(input, 8);
assert_eq!(seen, vec![exact]);
assert_eq!(forwarded.len(), 2);
assert!(forwarded[1].starts_with("[stderr line over"));
let (_, seen, _) = capture_all(b"tail with no newline".to_vec(), 8);
assert_eq!(seen, vec!["tail with no newline".to_string()]);
}
#[test]
fn one_entry_larger_than_the_budget_is_cut_to_its_last_bytes() {
let (snapshot, _, _) = capture_all(format!("{}END\n", "a".repeat(50)).into_bytes(), 16);
assert!(
snapshot.len() <= 16,
"{} bytes: {snapshot:?}",
snapshot.len()
);
assert!(
snapshot.ends_with("END"),
"the newest bytes are the ones kept: {snapshot:?}"
);
}
#[test]
fn a_cut_never_splits_a_character() {
let (snapshot, _, _) = capture_all(format!("{}\n", "é".repeat(20)).into_bytes(), 16);
assert!(snapshot.len() <= 16);
assert!(!snapshot.is_empty());
assert!(snapshot.chars().all(|c| c == 'é'), "{snapshot:?}");
}
#[test]
fn the_total_stays_within_the_budget_over_many_lines() {
let input: String = (0..500).map(|n| format!("line-{n}\n")).collect();
let (snapshot, _, _) = capture_all(input.into_bytes(), 40);
assert!(snapshot.len() <= 40, "{} bytes", snapshot.len());
assert!(snapshot.ends_with("line-499"), "{snapshot:?}");
}
#[test]
fn carriage_returns_end_lines_and_crlf_is_one_terminator() {
let (snapshot, seen, forwarded) = capture_all(b"one\rtwo\r\nthree\nfour\r".to_vec(), 2048);
assert_eq!(
seen,
vec![
"one".to_string(),
"two".to_string(),
"three".to_string(),
"four".to_string()
]
);
assert_eq!(forwarded, seen, "CRLF must not yield an empty line");
assert_eq!(snapshot, "one\ntwo\nthree\nfour");
let (_, seen, _) = capture_all(b"a\rb".to_vec(), 2048);
assert_eq!(seen, vec!["a".to_string(), "b".to_string()]);
}
#[test]
fn an_oversized_cr_line_is_dropped_up_to_the_cr_and_the_next_line_survives() {
let secret = "S".repeat(60);
let input = format!("key={secret}\rafter\n").into_bytes();
let (snapshot, seen, forwarded) = capture_all(input, 8);
assert_eq!(seen, vec!["after".to_string()]);
assert!(!forwarded.iter().any(|line| line.contains('S')));
assert!(!snapshot.contains('S'));
}
#[test]
fn a_zero_budget_retains_nothing_and_still_drains() {
let (snapshot, seen, _) = capture_all(b"one\ntwo\n".to_vec(), 0);
assert_eq!(snapshot, "");
assert_eq!(seen, vec!["one".to_string(), "two".to_string()]);
}
}