use std::io::Read;
use std::process::Stdio;
use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
use crate::core::registry::SourceId;
use crate::core::retain::{Keep, LineCap};
#[derive(Clone, Copy)]
pub struct Retention {
pub max_lines: usize,
pub keep: Keep,
}
#[derive(Clone, Default)]
pub struct ChildSlot(Arc<Mutex<SlotState>>);
#[derive(Default)]
struct SlotState {
child: Option<std::process::Child>,
shutdown: bool,
}
impl ChildSlot {
pub fn shutdown(&self) {
let mut state = self.lock();
state.shutdown = true;
if let Some(child) = state.child.as_mut() {
let _ = child.kill();
}
}
pub fn guard(&self) -> ShutdownGuard {
ShutdownGuard(self.clone())
}
fn lock(&self) -> MutexGuard<'_, SlotState> {
self.0.lock().unwrap_or_else(PoisonError::into_inner)
}
}
pub struct ShutdownGuard(ChildSlot);
impl Drop for ShutdownGuard {
fn drop(&mut self) {
self.0.shutdown();
}
}
pub struct TickOutcome {
pub source: SourceId,
pub stdout: Vec<Vec<u8>>,
pub stderr: Vec<Vec<u8>>,
pub at: jiff::Timestamp,
pub spawn_error: Option<std::io::Error>,
pub close_stamps: Vec<(std::path::PathBuf, crate::core::trigger::PathStamp)>,
pub closed_at: std::time::Instant,
pub status: Option<std::process::ExitStatus>,
pub dropped: usize,
}
pub fn run_tick(
command: std::process::Command,
source: SourceId,
union: Vec<std::path::PathBuf>,
retention: Retention,
) -> TickOutcome {
run_parked(command, source, &ChildSlot::default(), &union, retention)
}
pub fn spawn_tick(
command: std::process::Command,
source: SourceId,
slot: ChildSlot,
tx: std::sync::mpsc::Sender<TickOutcome>,
union: Vec<std::path::PathBuf>,
retention: Retention,
) -> std::io::Result<()> {
std::thread::Builder::new()
.name("rat-watch-child".into())
.spawn(move || {
let _ = tx.send(run_parked(command, source, &slot, &union, retention));
})?;
Ok(())
}
fn run_parked(
mut command: std::process::Command,
source: SourceId,
slot: &ChildSlot,
union: &[std::path::PathBuf],
retention: Retention,
) -> TickOutcome {
command.stdout(Stdio::piped()).stderr(Stdio::piped());
let (stdout, stderr) = {
let mut state = slot.lock();
if state.shutdown {
return outcome_err(source, std::io::ErrorKind::Interrupted.into());
}
let mut child = match command.spawn() {
Ok(child) => child,
Err(err) => return outcome_err(source, err),
};
let pipes = (child.stdout.take(), child.stderr.take());
state.child = Some(child);
pipes
};
let err_reader = std::thread::Builder::new()
.name("rat-watch-stderr".into())
.spawn(move || read_all(stderr, retention));
let (out, out_dropped) = read_all(stdout, retention);
let (err, err_dropped) =
err_reader.map_or_else(|_| (Vec::new(), 0), |h| h.join().unwrap_or_default());
let status = if let Some(mut child) = slot.lock().child.take() {
child.wait().ok()
} else {
None
};
let close_stamps = crate::core::trigger::stamps(union);
TickOutcome {
source,
stdout: out,
stderr: err,
at: jiff::Timestamp::now(),
closed_at: std::time::Instant::now(),
spawn_error: None,
status,
close_stamps,
dropped: out_dropped + err_dropped,
}
}
fn outcome_err(source: SourceId, err: std::io::Error) -> TickOutcome {
TickOutcome {
closed_at: std::time::Instant::now(),
source,
stdout: Vec::new(),
stderr: Vec::new(),
at: jiff::Timestamp::now(),
close_stamps: Vec::new(),
spawn_error: Some(err),
status: None,
dropped: 0,
}
}
fn read_all<R: Read>(pipe: Option<R>, retention: Retention) -> (Vec<Vec<u8>>, usize) {
let mut cap = LineCap::new(retention.max_lines, retention.keep);
if let Some(mut pipe) = pipe {
let mut buf = [0u8; 8 * 1024];
loop {
match pipe.read(&mut buf) {
Ok(0) => break,
Ok(n) => cap.feed(&buf[..n]),
Err(err) if err.kind() == std::io::ErrorKind::Interrupted => continue,
Err(_) => break,
}
}
}
cap.finish()
}
#[cfg(test)]
mod tests {
use std::sync::mpsc;
use std::time::{Duration, Instant};
use super::*;
#[cfg(unix)]
fn script(body: &str) -> std::process::Command {
let mut cmd = std::process::Command::new("sh");
cmd.arg("-c").arg(body);
cmd
}
#[cfg(windows)]
fn script(body: &str) -> std::process::Command {
let mut cmd = std::process::Command::new("cmd");
cmd.arg("/C").arg(body);
cmd
}
fn sleeper() -> std::process::Command {
#[cfg(unix)]
{
let mut cmd = std::process::Command::new("sleep");
cmd.arg("30");
cmd
}
#[cfg(windows)]
{
let mut cmd = std::process::Command::new("ping");
cmd.args(["-n", "31", "127.0.0.1"]);
cmd
}
}
fn parked(slot: &ChildSlot) -> bool {
slot.lock().child.is_some()
}
fn wait_until_parked(slot: &ChildSlot) {
let deadline = Instant::now() + Duration::from_secs(2);
while !parked(slot) {
assert!(Instant::now() < deadline, "the child never parked");
std::thread::sleep(Duration::from_millis(10));
}
}
fn contains(haystack: &[u8], needle: &[u8]) -> bool {
needle.len() <= haystack.len() && haystack.windows(needle.len()).any(|w| w == needle)
}
fn ample() -> Retention {
Retention {
max_lines: 10_000,
keep: Keep::Bottom,
}
}
fn flooder(n: usize) -> std::process::Command {
assert!(
n > 0,
"flooder(0) has no contract; the tests all want output"
);
#[cfg(unix)]
{
script(&format!(
"i=0; while [ $i -lt {n} ]; do echo $i; i=$((i+1)); done"
))
}
#[cfg(windows)]
{
script(&format!("for /l %i in (0,1,{}) do @echo %i", n - 1))
}
}
fn line_text(line: &[u8]) -> &str {
std::str::from_utf8(line)
.expect("the fixture emits ASCII")
.trim_end_matches('\n')
.trim_end_matches('\r')
}
struct BreaksAfterOneRead<'a> {
first: &'a [u8],
delivered: bool,
}
impl Read for BreaksAfterOneRead<'_> {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
if self.delivered {
return Err(std::io::Error::other("the pipe broke"));
}
self.delivered = true;
let n = self.first.len().min(buf.len());
buf[..n].copy_from_slice(&self.first[..n]);
Ok(n)
}
}
#[test]
fn the_outcome_reports_what_it_dropped() {
let outcome = run_tick(
flooder(100),
SourceId(0),
Vec::new(),
Retention {
max_lines: 10,
keep: Keep::Bottom,
},
);
assert_eq!(outcome.dropped, 90);
assert_eq!(line_text(outcome.stdout.last().unwrap()), "99");
}
#[test]
fn a_child_that_outruns_its_cap_still_exits_and_posts() {
let outcome = run_tick(
flooder(200_000),
SourceId(0),
Vec::new(),
Retention {
max_lines: 50,
keep: Keep::Bottom,
},
);
assert_eq!(outcome.stdout.len(), 50);
assert!(outcome.dropped >= 199_950);
assert!(
outcome.status.is_some_and(|status| status.success()),
"the child never exited cleanly"
);
}
#[test]
fn the_retained_window_is_the_tail_under_keep_bottom() {
let outcome = run_tick(
flooder(1_000),
SourceId(0),
Vec::new(),
Retention {
max_lines: 3,
keep: Keep::Bottom,
},
);
assert_eq!(line_text(outcome.stdout.last().unwrap()), "999");
assert_eq!(line_text(outcome.stdout.first().unwrap()), "997");
}
#[test]
fn a_command_inside_its_bound_reports_zero_dropped() {
let outcome = run_tick(
flooder(3),
SourceId(0),
Vec::new(),
Retention {
max_lines: 100,
keep: Keep::Top,
},
);
assert_eq!(outcome.dropped, 0);
assert_eq!(outcome.stdout.len(), 3);
}
#[test]
fn a_spawn_error_reports_no_drops() {
let outcome = run_tick(
std::process::Command::new("definitely-no-such-binary-xyz"),
SourceId(0),
Vec::new(),
Retention {
max_lines: 10,
keep: Keep::Bottom,
},
);
assert!(outcome.spawn_error.is_some());
assert_eq!(outcome.dropped, 0);
}
#[test]
fn a_pipe_longer_than_the_bound_yields_only_the_bound() {
let body: Vec<u8> = (0..1000)
.flat_map(|i| format!("line{i}\n").into_bytes())
.collect();
let (lines, dropped) = read_all(
Some(&body[..]),
Retention {
max_lines: 10,
keep: Keep::Bottom,
},
);
assert_eq!(lines.len(), 10);
assert_eq!(dropped, 990);
assert_eq!(lines[9], b"line999\n");
}
#[test]
fn a_pipe_under_the_bound_is_byte_identical_to_today() {
let body = b"alpha\nbeta\n".to_vec();
let (lines, dropped) = read_all(
Some(&body[..]),
Retention {
max_lines: 100,
keep: Keep::Top,
},
);
assert_eq!(
lines.concat(),
body,
"an under-cap stream must round-trip byte-for-byte"
);
assert_eq!(dropped, 0);
}
#[test]
fn an_absent_pipe_is_empty_and_drops_nothing() {
let (lines, dropped) = read_all(
None::<&[u8]>,
Retention {
max_lines: 10,
keep: Keep::Top,
},
);
assert!(lines.is_empty());
assert_eq!(dropped, 0);
}
#[test]
fn an_under_cap_stream_round_trips_invalid_utf8_and_its_exact_framing() {
let body = b"warn: \xff\nno trailing newline".to_vec();
let (lines, dropped) = read_all(
Some(&body[..]),
Retention {
max_lines: 100,
keep: Keep::Bottom,
},
);
assert_eq!(lines.concat(), body);
assert_eq!(dropped, 0);
}
#[test]
fn empty_input_retains_nothing_and_drops_nothing() {
let (lines, dropped) = read_all(
Some(&b""[..]),
Retention {
max_lines: 10,
keep: Keep::Top,
},
);
assert!(lines.is_empty());
assert_eq!(dropped, 0);
}
#[test]
fn trailing_blank_lines_survive_as_bytes() {
let body = b"a\n\n\n".to_vec();
let (lines, _) = read_all(
Some(&body[..]),
Retention {
max_lines: 10,
keep: Keep::Bottom,
},
);
assert_eq!(
lines.concat(),
body,
"the bytes survive; collapsing is the renderer's job"
);
}
#[test]
fn a_read_error_yields_what_arrived_rather_than_nothing() {
let (lines, dropped) = read_all(
Some(BreaksAfterOneRead {
first: b"a\nb",
delivered: false,
}),
Retention {
max_lines: 10,
keep: Keep::Bottom,
},
);
assert_eq!(lines.concat(), b"a\nb");
assert_eq!(dropped, 0);
}
#[test]
fn the_outcome_carries_post_exit_stamps_for_the_watched_union() {
let dir = tempfile::tempdir().unwrap();
let f = dir.path().join("sa");
std::fs::write(&f, b"0").unwrap();
let outcome = run_tick(script("echo hi"), SourceId(0), vec![f.clone()], ample());
assert_eq!(outcome.close_stamps.len(), 1);
assert_eq!(outcome.close_stamps[0].0, f);
}
#[test]
fn the_bracket_closes_on_the_worker_not_at_the_drain() {
let dir = tempfile::tempdir().unwrap();
let f = dir.path().join("sa");
std::fs::write(&f, b"0").unwrap();
let before = std::time::Instant::now();
let outcome = run_tick(script("echo hi"), SourceId(0), vec![f.clone()], ample());
let drained = std::time::Instant::now();
assert!(
outcome.closed_at >= before,
"the child cannot have finished before it started"
);
assert!(
outcome.closed_at <= drained,
"and it must be stamped by the worker, before the caller sees it"
);
}
#[test]
fn a_childs_own_write_is_visible_in_the_closing_stamps() {
let dir = tempfile::tempdir().unwrap();
let f = dir.path().join("sa");
std::fs::write(&f, b"0").unwrap();
let before = crate::core::trigger::stamps(std::slice::from_ref(&f));
#[cfg(unix)]
let cmd = script(&format!("printf 1 >> {}", f.display()));
#[cfg(windows)]
let cmd = script(&format!("echo 1 >> {}", f.display()));
let outcome = run_tick(cmd, SourceId(0), vec![f.clone()], ample());
assert_ne!(
outcome.close_stamps, before,
"the child's write must be visible after it exits"
);
}
#[test]
fn an_empty_union_costs_no_stats_and_returns_empty() {
let outcome = run_tick(script("echo hi"), SourceId(0), Vec::new(), ample());
assert!(outcome.close_stamps.is_empty());
}
#[test]
fn a_spawn_error_still_returns_well_formed_closing_stamps() {
let outcome = run_tick(
std::process::Command::new("definitely-not-a-program-here"),
SourceId(0),
vec![std::path::PathBuf::from("/sa")],
ample(),
);
assert!(outcome.spawn_error.is_some());
assert!(outcome.close_stamps.is_empty());
}
#[test]
fn a_tick_captures_both_streams_separately() {
#[cfg(unix)]
let cmd = script("echo out; echo err >&2");
#[cfg(windows)]
let cmd = script("echo out & echo err 1>&2");
let outcome = run_tick(cmd, SourceId(0), Vec::new(), ample());
assert!(outcome.spawn_error.is_none());
assert!(contains(&outcome.stdout.concat(), b"out"));
assert!(contains(&outcome.stderr.concat(), b"err"));
assert!(!contains(&outcome.stdout.concat(), b"err"));
}
#[cfg(unix)]
#[test]
fn a_tick_that_floods_both_pipes_still_finishes() {
let line = "x".repeat(100);
let body = format!(
"i=0; while [ $i -lt 3000 ]; do echo {line}; echo {line} >&2; i=$((i+1)); done"
);
let outcome = run_tick(
script(&body),
SourceId(0),
Vec::new(),
Retention {
max_lines: 1000,
keep: Keep::Bottom,
},
);
assert!(outcome.spawn_error.is_none());
assert_eq!(outcome.stdout.len(), 1000);
assert_eq!(outcome.stderr.len(), 1000);
assert!(outcome.stdout.iter().all(|line| line.len() == 101));
assert!(outcome.stderr.iter().all(|line| line.len() == 101));
}
#[test]
fn a_command_that_cannot_start_reports_the_error() {
let outcome = run_tick(
std::process::Command::new("definitely-no-such-binary-xyz"),
SourceId(0),
Vec::new(),
ample(),
);
assert!(outcome.spawn_error.is_some());
assert!(outcome.stdout.is_empty());
assert!(outcome.stderr.is_empty());
}
#[test]
fn a_nonzero_exit_is_still_an_outcome() {
#[cfg(unix)]
let cmd = script("echo hi; exit 3");
#[cfg(windows)]
let cmd = script("echo hi & exit 3");
let outcome = run_tick(cmd, SourceId(0), Vec::new(), ample());
assert!(outcome.spawn_error.is_none());
assert!(contains(&outcome.stdout.concat(), b"hi"));
}
#[test]
fn a_worker_posts_exactly_one_outcome() {
let (tx, rx) = mpsc::channel();
spawn_tick(
script("echo once"),
SourceId(0),
ChildSlot::default(),
tx,
Vec::new(),
ample(),
)
.expect("spawn worker");
let outcome = rx
.recv_timeout(Duration::from_secs(5))
.expect("one outcome");
assert!(contains(&outcome.stdout.concat(), b"once"));
assert!(rx.recv_timeout(Duration::from_secs(5)).is_err());
}
#[test]
fn a_parked_child_can_be_killed_from_another_thread() {
let slot = ChildSlot::default();
let (tx, rx) = mpsc::channel();
spawn_tick(
sleeper(),
SourceId(0),
slot.clone(),
tx,
Vec::new(),
ample(),
)
.expect("spawn worker");
wait_until_parked(&slot);
slot.shutdown();
assert!(rx.recv_timeout(Duration::from_secs(5)).is_ok());
}
#[test]
fn a_shutdown_before_the_spawn_prevents_the_child() {
let dir = tempfile::tempdir().expect("tempdir");
let marker = dir.path().join("marker");
#[cfg(unix)]
let cmd = script(&format!(": > {}", marker.display()));
#[cfg(windows)]
let cmd = script(&format!("type nul > {}", marker.display()));
let slot = ChildSlot::default();
slot.shutdown();
let outcome = run_parked(cmd, SourceId(0), &slot, &[], ample());
let err = outcome.spawn_error.expect("barred spawn reports an error");
assert_eq!(err.kind(), std::io::ErrorKind::Interrupted);
assert!(!marker.exists(), "the child must never have spawned");
}
#[test]
fn dropping_the_guard_shuts_the_slot_down() {
let slot = ChildSlot::default();
let (tx, rx) = mpsc::channel();
spawn_tick(
sleeper(),
SourceId(0),
slot.clone(),
tx,
Vec::new(),
ample(),
)
.expect("spawn worker");
wait_until_parked(&slot);
drop(slot.guard());
assert!(rx.recv_timeout(Duration::from_secs(5)).is_ok());
}
#[test]
fn an_outcome_carries_its_source_tag() {
let outcome = run_tick(script("echo tagged"), SourceId(2), Vec::new(), ample());
assert_eq!(outcome.source, SourceId(2));
let (tx, rx) = mpsc::channel();
spawn_tick(
script("echo tagged"),
SourceId(5),
ChildSlot::default(),
tx,
Vec::new(),
ample(),
)
.expect("spawn worker");
let posted = rx
.recv_timeout(Duration::from_secs(5))
.expect("one outcome");
assert_eq!(posted.source, SourceId(5));
}
#[test]
fn a_nonzero_exit_is_reported_in_the_outcome() {
#[cfg(unix)]
let cmd = script("echo hi; exit 3");
#[cfg(windows)]
let cmd = script("echo hi & exit 3");
let outcome = run_tick(cmd, SourceId(0), Vec::new(), ample());
assert!(outcome.spawn_error.is_none());
assert!(contains(&outcome.stdout.concat(), b"hi"));
assert_eq!(outcome.status.and_then(|status| status.code()), Some(3));
}
#[test]
fn a_spawn_error_still_has_no_status() {
let outcome = run_tick(
std::process::Command::new("definitely-no-such-binary-xyz"),
SourceId(0),
Vec::new(),
ample(),
);
assert!(outcome.spawn_error.is_some());
assert!(outcome.status.is_none());
}
}