use std::collections::VecDeque;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::sync::{
atomic::{AtomicBool, Ordering},
Arc, Mutex,
};
use tokio::io::AsyncReadExt;
pub const DEFAULT_MAX_LINE_BYTES: usize = 2048;
pub const DEFAULT_MAX_LINES: usize = 200;
pub const DEFAULT_MAX_BYTES: usize = 64 * 1024;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CaptureState {
Captured,
Incomplete { reason: String },
NotCaptured { reason: String },
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TailEntry {
Line {
text: String,
truncated: bool,
},
ProcessStart,
}
impl TailEntry {
fn cost(&self) -> usize {
match self {
Self::Line { text, .. } => text.len(),
Self::ProcessStart => 0,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct StderrTailConfig {
max_lines: usize,
max_bytes: usize,
max_line_bytes: usize,
}
impl StderrTailConfig {
pub const fn new(max_lines: usize, max_bytes: usize, max_line_bytes: usize) -> Self {
Self {
max_lines,
max_bytes,
max_line_bytes: if max_line_bytes > max_bytes {
max_bytes
} else {
max_line_bytes
},
}
}
}
impl Default for StderrTailConfig {
fn default() -> Self {
Self::new(DEFAULT_MAX_LINES, DEFAULT_MAX_BYTES, DEFAULT_MAX_LINE_BYTES)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StderrTailSnapshot {
pub capture: CaptureState,
pub entries: Vec<TailEntry>,
pub dropped_lines: u64,
}
impl StderrTailSnapshot {
pub fn not_captured(reason: impl Into<String>) -> Self {
Self {
capture: CaptureState::NotCaptured {
reason: reason.into(),
},
entries: Vec::new(),
dropped_lines: 0,
}
}
}
#[derive(Debug)]
pub struct StderrRing {
config: StderrTailConfig,
entries: VecDeque<TailEntry>,
lines: usize,
bytes: usize,
dropped_lines: u64,
capture: CaptureState,
}
impl StderrRing {
pub fn new(config: StderrTailConfig) -> Self {
Self {
config,
entries: VecDeque::new(),
lines: 0,
bytes: 0,
dropped_lines: 0,
capture: CaptureState::NotCaptured {
reason: "stderr reader has not started".to_string(),
},
}
}
pub fn mark_captured(&mut self) {
if matches!(self.capture, CaptureState::NotCaptured { .. }) {
self.capture = CaptureState::Captured;
}
}
pub fn mark_incomplete(&mut self, reason: impl Into<String>) {
self.capture = CaptureState::Incomplete {
reason: reason.into(),
};
}
pub fn mark_not_captured(&mut self, reason: impl Into<String>) {
self.capture = CaptureState::NotCaptured {
reason: reason.into(),
};
}
pub fn push_process_start(&mut self) {
if self.entries.is_empty() && self.dropped_lines == 0 {
return;
}
if matches!(self.entries.back(), Some(TailEntry::ProcessStart)) {
return;
}
self.push_entry(TailEntry::ProcessStart);
}
pub fn push_line(&mut self, line: &str) {
let (text, truncated) = truncate_line(line, self.config.max_line_bytes);
self.push_entry(TailEntry::Line { text, truncated });
}
fn push_entry(&mut self, entry: TailEntry) {
self.bytes += entry.cost();
if matches!(entry, TailEntry::Line { .. }) {
self.lines += 1;
}
self.entries.push_back(entry);
self.evict_to_fit();
}
fn evict_to_fit(&mut self) {
while self.lines > self.config.max_lines
|| (self.bytes > self.config.max_bytes && self.entries.len() > 1)
{
let Some(evicted) = self.entries.pop_front() else {
break;
};
self.bytes -= evicted.cost();
if matches!(evicted, TailEntry::Line { .. }) {
self.lines -= 1;
self.dropped_lines += 1;
}
}
}
pub fn snapshot(
&self,
max_lines: Option<usize>,
max_bytes: Option<usize>,
) -> StderrTailSnapshot {
let line_limit = max_lines.unwrap_or(self.config.max_lines);
let byte_limit = max_bytes.unwrap_or(self.config.max_bytes);
let mut taken: Vec<TailEntry> = Vec::new();
let mut bytes = 0usize;
let mut lines = 0usize;
for entry in self.entries.iter().rev() {
match entry {
TailEntry::Line { .. } => {
if lines >= line_limit {
break;
}
let cost = entry.cost();
if lines > 0 && bytes + cost > byte_limit {
break;
}
bytes += cost;
lines += 1;
taken.push(entry.clone());
}
TailEntry::ProcessStart if lines > 0 => taken.push(entry.clone()),
TailEntry::ProcessStart => {}
}
}
taken.reverse();
let withheld = self
.entries
.iter()
.filter(|entry| matches!(entry, TailEntry::Line { .. }))
.count()
.saturating_sub(
taken
.iter()
.filter(|entry| matches!(entry, TailEntry::Line { .. }))
.count(),
);
StderrTailSnapshot {
capture: self.capture.clone(),
entries: taken,
dropped_lines: self.dropped_lines + withheld as u64,
}
}
}
const MAX_PENDING_LINE_BYTES: usize = 1024 * 1024;
pub async fn pump_stderr<R>(source: R, ring: Arc<Mutex<StderrRing>>)
where
R: AsyncReadExt + Unpin,
{
pump_stderr_into(source, ring, &mut StderrSink).await
}
#[derive(Clone)]
pub(crate) enum ChildOutputSink {
File {
sink: Arc<Mutex<cortexkit_log::LineSink>>,
path: Arc<PathBuf>,
failure_reported: Arc<AtomicBool>,
},
Stderr,
}
impl ChildOutputSink {
pub(crate) fn open(path: &Path, retention: cortexkit_log::Retention) -> io::Result<Self> {
Ok(Self::File {
sink: Arc::new(Mutex::new(cortexkit_log::LineSink::open(path, retention)?)),
path: Arc::new(path.to_path_buf()),
failure_reported: Arc::new(AtomicBool::new(false)),
})
}
}
pub trait OutputSink {
fn write_line(&mut self, line: &[u8]);
}
struct StderrSink;
impl OutputSink for StderrSink {
fn write_line(&mut self, line: &[u8]) {
let stderr = std::io::stderr();
let mut handle = stderr.lock();
let _ = handle.write_all(line);
}
}
impl OutputSink for ChildOutputSink {
fn write_line(&mut self, line: &[u8]) {
match self {
Self::File {
sink,
path,
failure_reported,
} => {
let result = sink
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.write_line(line);
if let Err(error) = result {
if !failure_reported.swap(true, Ordering::Relaxed) {
tracing::warn!(
path = %path.display(),
error = %error,
"child output capture write failed; later failures are suppressed"
);
}
}
}
Self::Stderr => StderrSink.write_line(line),
}
}
}
pub(crate) async fn pump_stderr_to<R>(
source: R,
ring: Arc<Mutex<StderrRing>>,
mut sink: ChildOutputSink,
) where
R: AsyncReadExt + Unpin,
{
pump_stderr_into(source, ring, &mut sink).await;
}
pub(crate) async fn pump_stdout_to<R>(source: R, mut sink: ChildOutputSink)
where
R: AsyncReadExt + Unpin,
{
pump_lines_into(source, None, &mut sink, "stdout").await;
}
async fn pump_stderr_into<R, S>(source: R, ring: Arc<Mutex<StderrRing>>, sink: &mut S)
where
R: AsyncReadExt + Unpin,
S: OutputSink,
{
pump_lines_into(source, Some(&ring), sink, "stderr").await;
}
async fn pump_lines_into<R, S>(
mut source: R,
ring: Option<&Arc<Mutex<StderrRing>>>,
sink: &mut S,
stream_name: &str,
) where
R: AsyncReadExt + Unpin,
S: OutputSink,
{
if let Some(ring) = ring {
lock_ring(ring).mark_captured();
}
let mut pending: Vec<u8> = Vec::new();
let mut scanned_upto = 0usize;
let mut cursor = 0usize;
let mut chunk = [0u8; 8192];
loop {
let read = match source.read(&mut chunk).await {
Ok(0) => break,
Ok(n) => n,
Err(error) => {
if let Some(ring) = ring {
lock_ring(ring).mark_incomplete(format!("{stream_name} read failed: {error}"));
} else {
tracing::warn!(stream = stream_name, error = %error, "child output capture read failed");
}
return;
}
};
pending.extend_from_slice(&chunk[..read]);
while let Some(relative) = find_newline(&pending[scanned_upto..]) {
let newline = scanned_upto + relative;
emit_line(ring, sink, &pending[cursor..newline], true);
cursor = newline + 1;
scanned_upto = cursor;
}
scanned_upto = pending.len();
if cursor > 0 {
pending.drain(..cursor);
scanned_upto -= cursor;
cursor = 0;
}
if pending.len() >= MAX_PENDING_LINE_BYTES {
let line = std::mem::take(&mut pending);
emit_line(ring, sink, &line, false);
scanned_upto = 0;
}
}
if !pending.is_empty() {
emit_line(ring, sink, &pending, false);
}
}
#[cfg(test)]
thread_local! {
static SCANNED_BYTES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
#[cfg(test)]
fn take_scanned_bytes() -> usize {
SCANNED_BYTES.with(|scanned| scanned.replace(0))
}
fn find_newline(haystack: &[u8]) -> Option<usize> {
let found = memchr::memchr(b'\n', haystack);
#[cfg(test)]
SCANNED_BYTES.with(|scanned| {
scanned.set(scanned.get() + found.map(|index| index + 1).unwrap_or(haystack.len()));
});
found
}
fn emit_line<S: OutputSink>(
ring: Option<&Arc<Mutex<StderrRing>>>,
sink: &mut S,
raw: &[u8],
terminated: bool,
) {
if let Some(ring) = ring {
lock_ring(ring).push_line(&String::from_utf8_lossy(raw));
}
if terminated {
let mut framed = Vec::with_capacity(raw.len() + 1);
framed.extend_from_slice(raw);
framed.push(b'\n');
sink.write_line(&framed);
} else {
sink.write_line(raw);
}
}
fn lock_ring(ring: &Arc<Mutex<StderrRing>>) -> std::sync::MutexGuard<'_, StderrRing> {
ring.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
}
fn truncate_line(line: &str, max_bytes: usize) -> (String, bool) {
if line.len() <= max_bytes {
return (line.to_string(), false);
}
let mut end = max_bytes;
while end > 0 && !line.is_char_boundary(end) {
end -= 1;
}
(line[..end].to_string(), true)
}
#[cfg(test)]
mod tests {
use std::{
io,
pin::Pin,
task::{Context, Poll},
};
use super::*;
use tokio::io::{AsyncRead, ReadBuf};
fn ring(max_lines: usize, max_bytes: usize, max_line_bytes: usize) -> StderrRing {
StderrRing::new(StderrTailConfig::new(max_lines, max_bytes, max_line_bytes))
}
fn lines(snapshot: &StderrTailSnapshot) -> Vec<String> {
snapshot
.entries
.iter()
.filter_map(|entry| match entry {
TailEntry::Line { text, .. } => Some(text.clone()),
TailEntry::ProcessStart => None,
})
.collect()
}
#[test]
fn a_fresh_ring_reports_not_captured_rather_than_empty() {
let ring = ring(10, 1024, 128);
let snapshot = ring.snapshot(None, None);
assert!(matches!(snapshot.capture, CaptureState::NotCaptured { .. }));
assert!(snapshot.entries.is_empty());
}
#[test]
fn a_captured_module_that_printed_nothing_is_distinguishable_from_an_uncaptured_one() {
let mut captured = ring(10, 1024, 128);
captured.mark_captured();
let uncaptured = ring(10, 1024, 128);
let captured = captured.snapshot(None, None);
let uncaptured = uncaptured.snapshot(None, None);
assert!(captured.entries.is_empty());
assert!(uncaptured.entries.is_empty());
assert_eq!(captured.capture, CaptureState::Captured);
assert!(matches!(
uncaptured.capture,
CaptureState::NotCaptured { .. }
));
}
#[test]
fn the_line_cap_evicts_oldest_first_and_counts_what_it_dropped() {
let mut ring = ring(3, 10_000, 128);
ring.mark_captured();
for i in 0..6 {
ring.push_line(&format!("line{i}"));
}
let snapshot = ring.snapshot(None, None);
assert_eq!(lines(&snapshot), vec!["line3", "line4", "line5"]);
assert_eq!(snapshot.dropped_lines, 3);
}
#[test]
fn the_byte_cap_binds_before_the_line_cap_when_lines_are_large() {
let mut ring = ring(100, 30, 128);
ring.mark_captured();
for i in 0..10 {
ring.push_line(&format!("{i}--------")); }
let snapshot = ring.snapshot(None, None);
assert!(
snapshot.entries.len() < 10,
"byte cap did not bind: {} entries retained",
snapshot.entries.len()
);
let retained: usize = lines(&snapshot).iter().map(String::len).sum();
assert!(
retained <= 30,
"retained {retained} bytes over a 30 byte cap"
);
assert!(snapshot.dropped_lines > 0);
}
#[test]
fn one_enormous_line_is_truncated_rather_than_evicting_the_tail() {
let mut ring = ring(10, 10_000, 64);
ring.mark_captured();
ring.push_line("context line that must survive");
ring.push_line(&"x".repeat(40_000));
let snapshot = ring.snapshot(None, None);
let kept = &snapshot.entries;
assert!(matches!(
&kept[0],
TailEntry::Line { text, truncated: false }
if text == "context line that must survive"
));
let TailEntry::Line { text, truncated } = &kept[1] else {
panic!("expected a truncated line");
};
assert_eq!(text, &"x".repeat(64));
assert!(*truncated);
}
#[test]
fn truncation_is_visible_so_a_cut_line_is_not_mistaken_for_a_short_one() {
let mut ring = ring(10, 10_000, 16);
ring.mark_captured();
ring.push_line("0123456789abcdefghij");
ring.push_line("short");
let snapshot = ring.snapshot(None, None);
let TailEntry::Line { truncated, .. } = &snapshot.entries[0] else {
panic!("expected a line");
};
assert!(truncated);
let TailEntry::Line { truncated, .. } = &snapshot.entries[1] else {
panic!("expected a line");
};
assert!(!truncated, "a short line must not be reported as truncated");
}
#[test]
fn truncation_cuts_on_a_char_boundary_rather_than_splitting_utf8() {
let mut ring = ring(10, 10_000, 5);
ring.mark_captured();
ring.push_line("aa€€€€");
let snapshot = ring.snapshot(None, None);
let TailEntry::Line { text, truncated } = &snapshot.entries[0] else {
panic!("expected a line");
};
assert!(truncated);
assert!(text.starts_with("aa"));
}
#[test]
fn a_restart_boundary_keeps_generations_distinguishable() {
let mut ring = ring(10, 10_000, 128);
ring.mark_captured();
ring.push_line("before the crash");
ring.push_process_start();
ring.push_line("after the respawn");
let snapshot = ring.snapshot(None, None);
assert_eq!(
snapshot.entries,
vec![
TailEntry::Line {
text: "before the crash".to_string(),
truncated: false
},
TailEntry::ProcessStart,
TailEntry::Line {
text: "after the respawn".to_string(),
truncated: false
},
]
);
}
#[test]
fn the_ring_survives_respawn_because_the_cause_is_written_before_the_exit() {
let mut ring = ring(10, 10_000, 128);
ring.mark_captured();
ring.push_line("Error: storage section missing");
ring.push_process_start();
let snapshot = ring.snapshot(None, None);
assert!(lines(&snapshot).contains(&"Error: storage section missing".to_string()));
}
#[test]
fn a_caller_limit_returns_the_newest_lines_not_the_oldest() {
let mut ring = ring(100, 100_000, 128);
ring.mark_captured();
for i in 0..10 {
ring.push_line(&format!("line{i}"));
}
let snapshot = ring.snapshot(Some(3), None);
assert_eq!(lines(&snapshot), vec!["line7", "line8", "line9"]);
}
#[test]
fn a_caller_line_limit_keeps_the_boundary_before_the_selected_line() {
let mut ring = ring(100, 100_000, 128);
ring.mark_captured();
ring.push_line("before restart");
ring.push_process_start();
ring.push_line("after restart");
let snapshot = ring.snapshot(Some(1), None);
assert_eq!(
snapshot.entries,
vec![
TailEntry::ProcessStart,
TailEntry::Line {
text: "after restart".to_string(),
truncated: false,
},
]
);
}
#[test]
fn a_caller_line_limit_omits_a_trailing_boundary_after_the_selected_line() {
let mut ring = ring(100, 100_000, 128);
ring.mark_captured();
ring.push_line("before restart");
ring.push_process_start();
let snapshot = ring.snapshot(Some(1), None);
assert_eq!(
snapshot.entries,
vec![TailEntry::Line {
text: "before restart".to_string(),
truncated: false,
}]
);
}
#[test]
fn a_caller_limit_reports_what_it_withheld_rather_than_looking_complete() {
let mut ring = ring(100, 100_000, 128);
ring.mark_captured();
for i in 0..10 {
ring.push_line(&format!("line{i}"));
}
assert_eq!(ring.snapshot(Some(3), None).dropped_lines, 7);
assert_eq!(ring.snapshot(None, None).dropped_lines, 0);
}
#[test]
fn a_caller_limit_cannot_widen_the_rings_own_caps() {
let mut ring = ring(2, 10_000, 128);
ring.mark_captured();
for i in 0..5 {
ring.push_line(&format!("line{i}"));
}
let snapshot = ring.snapshot(Some(1000), Some(1_000_000));
assert_eq!(lines(&snapshot).len(), 2);
}
fn shared(max_lines: usize, max_bytes: usize, max_line_bytes: usize) -> Arc<Mutex<StderrRing>> {
Arc::new(Mutex::new(ring(max_lines, max_bytes, max_line_bytes)))
}
#[derive(Default)]
struct RecordingSink {
writes: Vec<Vec<u8>>,
}
impl OutputSink for RecordingSink {
fn write_line(&mut self, line: &[u8]) {
self.writes.push(line.to_vec());
}
}
struct ChunkedReader {
chunks: VecDeque<Vec<u8>>,
}
impl AsyncRead for ChunkedReader {
fn poll_read(
mut self: Pin<&mut Self>,
_cx: &mut Context<'_>,
buf: &mut ReadBuf<'_>,
) -> Poll<io::Result<()>> {
match self.chunks.pop_front() {
None => Poll::Ready(Ok(())),
Some(chunk) => {
buf.put_slice(&chunk);
Poll::Ready(Ok(()))
}
}
}
}
struct FailingReader {
bytes: Vec<u8>,
emitted: bool,
}
impl AsyncRead for FailingReader {
fn poll_read(
mut self: Pin<&mut Self>,
_cx: &mut Context<'_>,
buf: &mut ReadBuf<'_>,
) -> Poll<io::Result<()>> {
if self.emitted {
return Poll::Ready(Err(io::Error::other("reader failed")));
}
self.emitted = true;
buf.put_slice(&self.bytes);
Poll::Ready(Ok(()))
}
}
#[tokio::test]
async fn the_pump_splits_on_newlines_and_keeps_a_trailing_fragment() {
let ring = shared(10, 10_000, 128);
let source = std::io::Cursor::new(b"one\ntwo\nthree".to_vec());
let mut sink = RecordingSink::default();
pump_stderr_into(source, Arc::clone(&ring), &mut sink).await;
let snapshot = lock_ring(&ring).snapshot(None, None);
assert_eq!(lines(&snapshot), vec!["one", "two", "three"]);
assert_eq!(snapshot.capture, CaptureState::Captured);
assert_eq!(
sink.writes,
vec![b"one\n".to_vec(), b"two\n".to_vec(), b"three".to_vec()]
);
}
#[tokio::test]
async fn a_read_failure_keeps_prior_lines_and_marks_the_capture_incomplete() {
let ring = shared(10, 10_000, 128);
let source = FailingReader {
bytes: b"crash cause\n".to_vec(),
emitted: false,
};
let mut sink = RecordingSink::default();
pump_stderr_into(source, Arc::clone(&ring), &mut sink).await;
let snapshot = lock_ring(&ring).snapshot(None, None);
assert_eq!(lines(&snapshot), vec!["crash cause"]);
assert!(matches!(
snapshot.capture,
CaptureState::Incomplete { ref reason } if reason.contains("reader failed")
));
assert_eq!(sink.writes, vec![b"crash cause\n".to_vec()]);
}
#[tokio::test]
async fn every_captured_line_is_also_forwarded() {
let ring = shared(10, 10_000, 128);
let source = std::io::Cursor::new(b"alpha\nbeta\n".to_vec());
let mut sink = RecordingSink::default();
pump_stderr_into(source, Arc::clone(&ring), &mut sink).await;
assert_eq!(sink.writes, vec![b"alpha\n".to_vec(), b"beta\n".to_vec()]);
}
#[tokio::test]
async fn each_forwarded_line_is_exactly_one_write() {
let ring = shared(10, 10_000, 128);
let source = std::io::Cursor::new(b"first\nsecond\nthird\n".to_vec());
let mut sink = RecordingSink::default();
pump_stderr_into(source, Arc::clone(&ring), &mut sink).await;
assert_eq!(sink.writes.len(), 3);
for write in &sink.writes {
assert_eq!(
write.iter().filter(|byte| **byte == b'\n').count(),
1,
"a write carried something other than exactly one complete line"
);
assert_eq!(*write.last().unwrap(), b'\n');
}
}
#[test]
fn the_first_process_start_is_not_recorded_because_it_divides_nothing() {
let mut ring = ring(10, 10_000, 128);
ring.push_process_start();
assert!(ring.snapshot(None, None).entries.is_empty());
ring.push_line("first process said this");
ring.push_process_start();
assert!(
matches!(ring.entries.back(), Some(TailEntry::ProcessStart)),
"a boundary with output before it must be recorded"
);
}
#[test]
fn a_process_start_is_recorded_when_only_dropped_lines_precede_it() {
let mut ring = ring(1, 10_000, 128);
ring.push_line("evicted");
ring.push_line("also evicted");
ring.entries.clear();
ring.lines = 0;
ring.bytes = 0;
ring.push_process_start();
assert!(matches!(
ring.entries.front(),
Some(TailEntry::ProcessStart)
));
}
#[tokio::test]
async fn the_pump_marks_captured_even_when_the_module_writes_nothing() {
let ring = shared(10, 10_000, 128);
let source = std::io::Cursor::new(Vec::new());
let mut sink = RecordingSink::default();
pump_stderr_into(source, Arc::clone(&ring), &mut sink).await;
let snapshot = lock_ring(&ring).snapshot(None, None);
assert!(snapshot.entries.is_empty());
assert_eq!(snapshot.capture, CaptureState::Captured);
assert!(sink.writes.is_empty());
}
#[tokio::test]
async fn a_line_with_no_newline_cannot_grow_the_reader_without_bound() {
let ring = shared(10, 10_000_000, 4 * 1024 * 1024);
let source = std::io::Cursor::new(vec![b'x'; MAX_PENDING_LINE_BYTES + 4096]);
let mut sink = RecordingSink::default();
pump_stderr_into(source, Arc::clone(&ring), &mut sink).await;
let snapshot = lock_ring(&ring).snapshot(None, None);
assert_eq!(
lines(&snapshot).len(),
2,
"expected a forced flush at the ceiling plus the remainder"
);
assert_eq!(
sink.writes,
vec![vec![b'x'; MAX_PENDING_LINE_BYTES], vec![b'x'; 4096],],
"forced flushes and EOF fragments must not invent delimiters"
);
}
#[tokio::test]
async fn boundaries_truncation_and_framing_do_not_depend_on_chunk_splits() {
let ring = shared(100, 100_000, 8);
let source = ChunkedReader {
chunks: vec![
b"fir".to_vec(),
b"st\nsec".to_vec(),
b"ond\ncarry\r".to_vec(),
b"\nover\n".to_vec(),
b"12345678\n".to_vec(),
b"1234567".to_vec(),
b"89\n".to_vec(),
b"tail".to_vec(),
]
.into_iter()
.collect(),
};
let mut sink = RecordingSink::default();
pump_stderr_into(source, Arc::clone(&ring), &mut sink).await;
let snapshot = lock_ring(&ring).snapshot(None, None);
assert_eq!(snapshot.capture, CaptureState::Captured);
assert_eq!(
snapshot.entries,
vec![
TailEntry::Line {
text: "first".to_string(),
truncated: false
},
TailEntry::Line {
text: "second".to_string(),
truncated: false
},
TailEntry::Line {
text: "carry\r".to_string(),
truncated: false
},
TailEntry::Line {
text: "over".to_string(),
truncated: false
},
TailEntry::Line {
text: "12345678".to_string(),
truncated: false
},
TailEntry::Line {
text: "12345678".to_string(),
truncated: true
},
TailEntry::Line {
text: "tail".to_string(),
truncated: false
},
]
);
assert_eq!(
sink.writes,
vec![
b"first\n".to_vec(),
b"second\n".to_vec(),
b"carry\r\n".to_vec(),
b"over\n".to_vec(),
b"12345678\n".to_vec(),
b"123456789\n".to_vec(),
b"tail".to_vec(),
]
);
}
#[tokio::test]
async fn a_line_with_no_newline_is_not_rescanned_from_byte_zero_on_every_chunk() {
let input = vec![b'x'; MAX_PENDING_LINE_BYTES + 4096];
let ring = shared(10, 10_000_000, 4 * 1024 * 1024);
let source = std::io::Cursor::new(input.clone());
let mut sink = RecordingSink::default();
take_scanned_bytes();
pump_stderr_into(source, Arc::clone(&ring), &mut sink).await;
let scanned = take_scanned_bytes();
assert!(
scanned <= 2 * input.len(),
"newline searches examined {scanned} bytes for {} bytes of input; \
each chunk must search only newly arrived bytes",
input.len()
);
}
#[test]
fn a_byte_limit_smaller_than_one_line_still_returns_that_line() {
let mut ring = ring(10, 10_000, 128);
ring.mark_captured();
ring.push_line("a line considerably longer than the request limit");
let snapshot = ring.snapshot(None, Some(4));
assert_eq!(snapshot.entries.len(), 1);
}
#[test]
fn an_incoherent_config_clamps_the_line_cap_and_keeps_its_restart_boundary() {
let config = StderrTailConfig::new(2, 10, 100);
assert_eq!(config.max_line_bytes, config.max_bytes);
let mut ring = StderrRing::new(config);
ring.mark_captured();
ring.push_line("old");
ring.push_process_start();
ring.push_line("new process line longer than the ring byte cap");
assert_eq!(
ring.snapshot(None, None).entries,
vec![
TailEntry::ProcessStart,
TailEntry::Line {
text: "new proces".to_string(),
truncated: true,
},
]
);
}
}