use std::io::Read;
use std::process::Stdio;
use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
use crate::core::live::{Emissions, Stream};
use crate::core::registry::SourceId;
use crate::core::retain::{Retention, read_all};
#[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 kill_current(&self) {
let mut state = self.lock();
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 enum TickEvent {
Progress { source: SourceId },
Completed(TickOutcome),
}
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,
}
enum Sink {
Batch(Retention),
Live(Emissions, std::sync::mpsc::Sender<TickEvent>),
}
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,
Sink::Batch(retention),
)
}
pub fn spawn_tick(
command: std::process::Command,
source: SourceId,
slot: ChildSlot,
tx: std::sync::mpsc::Sender<TickEvent>,
union: Vec<std::path::PathBuf>,
retention: Retention,
) -> std::io::Result<()> {
std::thread::Builder::new()
.name("rat-watch-child".into())
.spawn(move || {
let _ = tx.send(TickEvent::Completed(run_parked(
command,
source,
&slot,
&union,
Sink::Batch(retention),
)));
})?;
Ok(())
}
pub fn spawn_live_tick(
command: std::process::Command,
source: SourceId,
slot: ChildSlot,
emissions: Emissions,
tx: std::sync::mpsc::Sender<TickEvent>,
union: Vec<std::path::PathBuf>,
) -> std::io::Result<()> {
std::thread::Builder::new()
.name("rat-watch-child".into())
.spawn(move || {
let sink = Sink::Live(emissions, tx.clone());
let _ = tx.send(TickEvent::Completed(run_parked(
command, source, &slot, &union, sink,
)));
})?;
Ok(())
}
fn pump_live<R: Read>(
pipe: Option<R>,
stream: Stream,
source: SourceId,
emissions: &Emissions,
tx: &std::sync::mpsc::Sender<TickEvent>,
) {
let Some(mut pipe) = pipe else { return };
let mut buf = [0u8; 8 * 1024];
loop {
match pipe.read(&mut buf) {
Ok(0) => break,
Ok(n) => {
if emissions.feed(stream, &buf[..n], jiff::Timestamp::now()) {
let _ = tx.send(TickEvent::Progress { source });
}
}
Err(err) if err.kind() == std::io::ErrorKind::Interrupted => continue,
Err(_) => break,
}
}
}
fn run_parked(
mut command: std::process::Command,
source: SourceId,
slot: &ChildSlot,
union: &[std::path::PathBuf],
sink: Sink,
) -> TickOutcome {
command.stdout(Stdio::piped()).stderr(Stdio::piped());
let (stdout, stderr) = {
let mut state = slot.lock();
if state.shutdown {
return not_started(source, std::io::ErrorKind::Interrupted.into());
}
let mut child = match command.spawn() {
Ok(child) => child,
Err(err) => return not_started(source, err),
};
let pipes = (child.stdout.take(), child.stderr.take());
state.child = Some(child);
pipes
};
let (out, err, dropped) = match sink {
Sink::Batch(retention) => {
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());
(out, err, out_dropped + err_dropped)
}
Sink::Live(emissions, tx) => {
let (their_emissions, their_tx) = (emissions.clone(), tx.clone());
let err_reader = std::thread::Builder::new()
.name("rat-watch-stderr".into())
.spawn(move || {
pump_live(stderr, Stream::Stderr, source, &their_emissions, &their_tx);
});
pump_live(stdout, Stream::Stdout, source, &emissions, &tx);
if let Ok(handle) = err_reader {
let _ = handle.join();
}
emissions.finish()
}
};
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,
}
}
pub fn not_started(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,
}
}
#[cfg(test)]
mod tests {
use std::io::{Read, Write as _};
use std::sync::mpsc;
use std::time::{Duration, Instant};
use super::*;
use crate::core::live::Emission;
use crate::core::retain::{Keep, read_all};
#[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 rat_bin() -> std::path::PathBuf {
assert_cmd::cargo::cargo_bin("rat")
}
fn seeded_log(contents: &str) -> (tempfile::TempDir, std::path::PathBuf) {
let dir = tempfile::tempdir().expect("tempdir");
let log = dir.path().join("log");
std::fs::write(&log, contents).expect("seed the log");
(dir, log)
}
fn follow_cmd(log: &std::path::Path) -> std::process::Command {
let mut cmd = std::process::Command::new(rat_bin());
cmd.arg("__follow").arg(log);
cmd
}
fn flood_then_stay_alive(n: usize) -> (tempfile::TempDir, std::process::Command) {
let body: String = (0..n).map(|i| format!("{i}\n")).collect();
let (dir, log) = seeded_log(&body);
let cmd = follow_cmd(&log);
(dir, cmd)
}
fn wait_for_body_where(
slot: &Emissions,
within: Duration,
ready: impl Fn(&Emission) -> bool,
) -> Emission {
let deadline = Instant::now() + within;
let mut seen: Vec<(usize, usize, usize)> = Vec::new();
loop {
if let Some(body) = slot.take() {
if ready(&body) {
return body;
}
seen.push((body.stdout.len(), body.stderr.len(), body.dropped));
}
assert!(
Instant::now() < deadline,
"no body satisfied the predicate; \
saw (stdout lines, stderr lines, dropped): {seen:?}"
);
std::thread::sleep(Duration::from_millis(10));
}
}
fn wait_for_body(slot: &Emissions, within: Duration) -> Emission {
wait_for_body_where(slot, within, |_| true)
}
fn collect_until_closed(rx: &mpsc::Receiver<TickEvent>, within: Duration) -> Vec<TickEvent> {
let mut events = Vec::new();
while let Ok(event) = rx.recv_timeout(within) {
events.push(event);
}
events
}
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 TickEvent::Completed(outcome) = rx
.recv_timeout(Duration::from_secs(5))
.expect("one outcome")
else {
panic!("a batch tick posts a completion");
};
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, &[], Sink::Batch(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 a_revoked_kill_lets_the_next_spawn_through_after_the_old_worker_finishes() {
let slot = ChildSlot::default();
let (tx, rx) = mpsc::channel();
spawn_tick(
sleeper(),
SourceId(0),
slot.clone(),
tx.clone(),
Vec::new(),
ample(),
)
.expect("spawn worker");
wait_until_parked(&slot);
slot.kill_current();
let done = rx
.recv_timeout(Duration::from_secs(5))
.expect("the killed child completes");
assert!(matches!(done, TickEvent::Completed(_)));
spawn_tick(
sleeper(),
SourceId(0),
slot.clone(),
tx,
Vec::new(),
ample(),
)
.expect("spawn worker");
wait_until_parked(&slot);
assert!(parked(&slot), "kill_current must not bar the slot");
}
#[test]
fn a_killed_child_still_posts_its_completion() {
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.kill_current();
assert!(
rx.recv_timeout(Duration::from_secs(5)).is_ok(),
"a killed child must complete, or nothing can sequence a respawn"
);
}
#[test]
fn shutdown_still_bars_the_slot_forever() {
let slot = ChildSlot::default();
slot.shutdown();
let (tx, rx) = mpsc::channel();
spawn_tick(
sleeper(),
SourceId(0),
slot.clone(),
tx,
Vec::new(),
ample(),
)
.expect("spawn worker");
let done = rx
.recv_timeout(Duration::from_secs(5))
.expect("a barred spawn still posts");
let TickEvent::Completed(outcome) = done else {
panic!("a barred spawn posts a completion");
};
assert_eq!(
outcome
.spawn_error
.expect("barred spawn reports an error")
.kind(),
std::io::ErrorKind::Interrupted
);
assert!(!parked(&slot), "shutdown must keep barring spawns");
}
#[test]
fn kill_current_on_an_empty_slot_is_a_no_op() {
ChildSlot::default().kill_current(); }
#[test]
fn kill_current_after_shutdown_does_not_clear_the_bar() {
let slot = ChildSlot::default();
slot.shutdown();
slot.kill_current();
let (tx, rx) = mpsc::channel();
spawn_tick(
sleeper(),
SourceId(0),
slot.clone(),
tx,
Vec::new(),
ample(),
)
.expect("spawn worker");
let done = rx
.recv_timeout(Duration::from_secs(5))
.expect("a barred spawn still posts");
let TickEvent::Completed(outcome) = done else {
panic!("a barred spawn posts a completion");
};
assert!(
outcome.spawn_error.is_some(),
"kill_current must never lift the bar"
);
assert!(!parked(&slot));
}
#[test]
fn a_killed_live_child_is_reaped_not_left_a_zombie() {
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);
let pid = slot.lock().child.as_ref().expect("parked").id();
slot.kill_current();
assert!(
rx.recv_timeout(Duration::from_secs(5)).is_ok(),
"the killed child must complete"
);
#[cfg(unix)]
{
let out = std::process::Command::new("ps")
.args(["-o", "stat=", "-p", &pid.to_string()])
.output()
.expect("ps");
let stat = String::from_utf8_lossy(&out.stdout).trim().to_string();
assert!(!stat.starts_with('Z'), "pid {pid} is a zombie: {stat:?}");
}
#[cfg(windows)]
let _ = pid;
}
#[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 TickEvent::Completed(posted) = rx
.recv_timeout(Duration::from_secs(5))
.expect("one outcome")
else {
panic!("a batch tick posts a completion");
};
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());
}
#[test]
fn a_batch_tick_posts_exactly_one_completed_event() {
let (tx, rx) = mpsc::channel();
spawn_tick(
flooder(1),
SourceId(0),
ChildSlot::default(),
tx,
Vec::new(),
ample(),
)
.expect("spawn worker");
let mut events = Vec::new();
while let Ok(event) = rx.recv_timeout(Duration::from_secs(5)) {
events.push(event);
}
assert_eq!(events.len(), 1);
assert!(matches!(events[0], TickEvent::Completed(_)));
}
#[test]
fn a_completed_event_still_carries_everything_the_loop_reads() {
#[cfg(unix)]
let cmd = script("echo hi; exit 3");
#[cfg(windows)]
let cmd = script("echo hi & exit 3");
let (tx, rx) = mpsc::channel();
spawn_tick(
cmd,
SourceId(4),
ChildSlot::default(),
tx,
Vec::new(),
ample(),
)
.expect("spawn worker");
let TickEvent::Completed(outcome) =
rx.recv_timeout(Duration::from_secs(5)).expect("one event")
else {
panic!("a batch tick posts a completion");
};
assert_eq!(outcome.source, SourceId(4));
assert!(contains(&outcome.stdout.concat(), b"hi"));
assert_eq!(outcome.status.and_then(|status| status.code()), Some(3));
}
#[test]
fn a_live_source_publishes_before_its_child_exits() {
let (_dir, log) = seeded_log("early\n");
let slot = Emissions::new(ample(), ample());
let child = ChildSlot::default();
let _shutdown = child.guard();
let (tx, rx) = mpsc::channel();
spawn_live_tick(
follow_cmd(&log),
SourceId(0),
child.clone(),
slot.clone(),
tx,
Vec::new(),
)
.expect("spawn worker");
let body = wait_for_body(&slot, Duration::from_secs(5));
assert_eq!(body.stdout, vec![b"early\n".to_vec()]);
assert!(body.stderr.is_empty());
assert!(
matches!(
rx.recv_timeout(Duration::from_secs(5)),
Ok(TickEvent::Progress {
source: SourceId(0)
})
),
"a published body must come with a wake"
);
}
#[test]
fn appending_to_a_followed_file_publishes_again() {
let (_dir, log) = seeded_log("first\n");
let slot = Emissions::new(ample(), ample());
let child = ChildSlot::default();
let _shutdown = child.guard();
let (tx, _rx) = mpsc::channel();
spawn_live_tick(
follow_cmd(&log),
SourceId(0),
child.clone(),
slot.clone(),
tx,
Vec::new(),
)
.expect("spawn worker");
let first = wait_for_body(&slot, Duration::from_secs(5));
assert_eq!(first.stdout, vec![b"first\n".to_vec()]);
std::fs::OpenOptions::new()
.append(true)
.open(&log)
.expect("reopen the log")
.write_all(b"second\n")
.expect("append");
let next =
wait_for_body_where(&slot, Duration::from_secs(5), |body| body.stdout.len() == 2);
assert_eq!(next.stdout, vec![b"first\n".to_vec(), b"second\n".to_vec()]);
}
#[test]
fn a_live_child_that_does_exit_still_posts_exactly_one_completion() {
let child = ChildSlot::default();
let _shutdown = child.guard();
let (tx, rx) = mpsc::channel();
spawn_live_tick(
flooder(3),
SourceId(0),
child.clone(),
Emissions::new(ample(), ample()),
tx,
Vec::new(),
)
.expect("spawn worker");
let events = collect_until_closed(&rx, Duration::from_secs(5));
assert_eq!(
events
.iter()
.filter(|e| matches!(e, TickEvent::Completed(_)))
.count(),
1,
"exactly one completion, however many wakes preceded it"
);
let Some(TickEvent::Completed(outcome)) = events.into_iter().next_back() else {
panic!("the completion must come LAST — the body it carries is final");
};
assert_eq!(outcome.stdout.len(), 3, "the final body is the whole body");
}
#[test]
fn a_batch_source_publishes_nothing_and_completes_once() {
let slot = Emissions::new(ample(), ample());
let (tx, rx) = mpsc::channel();
spawn_tick(
flooder(3),
SourceId(0),
ChildSlot::default(),
tx,
Vec::new(),
ample(),
)
.expect("spawn worker");
let events = collect_until_closed(&rx, Duration::from_secs(5));
assert!(slot.take().is_none(), "a batch source must never publish");
assert_eq!(events.len(), 1);
assert!(matches!(events[0], TickEvent::Completed(_)));
}
#[test]
fn a_flooding_live_child_does_not_grow_the_wake_queue() {
let (_dir, cmd) = flood_then_stay_alive(5_000);
let slot = Emissions::new(ample(), ample());
let child = ChildSlot::default();
let _shutdown = child.guard();
let (tx, rx) = mpsc::channel();
spawn_live_tick(cmd, SourceId(0), child.clone(), slot, tx, Vec::new())
.expect("spawn worker");
std::thread::sleep(Duration::from_millis(800));
let wakes = rx
.try_iter()
.filter(|e| matches!(e, TickEvent::Progress { .. }))
.count();
assert!(wakes <= 1, "{wakes} wakes queued behind one unread body");
}
#[test]
fn both_pipes_reach_the_slot_and_stay_apart() {
let dir = tempfile::tempdir().expect("tempdir");
let (out, err) = (dir.path().join("out"), dir.path().join("err"));
std::fs::write(&out, "to-stdout\n").expect("seed stdout");
std::fs::write(&err, "to-stderr\n").expect("seed stderr");
let mut cmd = follow_cmd(&out);
cmd.arg("--stderr-file").arg(&err);
let slot = Emissions::new(ample(), ample());
let child = ChildSlot::default();
let _shutdown = child.guard();
let (tx, _rx) = mpsc::channel();
spawn_live_tick(
cmd,
SourceId(0),
child.clone(),
slot.clone(),
tx,
Vec::new(),
)
.expect("spawn worker");
let body = wait_for_body_where(&slot, Duration::from_secs(5), |body| {
!body.stdout.is_empty() && !body.stderr.is_empty()
});
assert_eq!(body.stdout, vec![b"to-stdout\n".to_vec()]);
assert_eq!(body.stderr, vec![b"to-stderr\n".to_vec()]);
}
#[test]
fn the_bound_still_applies_to_a_live_source() {
let (_dir, cmd) = flood_then_stay_alive(100);
let slot = Emissions::new(
Retention {
max_lines: 10,
keep: Keep::Bottom,
},
ample(),
);
let child = ChildSlot::default();
let _shutdown = child.guard();
let (tx, _rx) = mpsc::channel();
spawn_live_tick(
cmd,
SourceId(0),
child.clone(),
slot.clone(),
tx,
Vec::new(),
)
.expect("spawn worker");
let body = wait_for_body_where(&slot, Duration::from_secs(5), |body| body.dropped > 0);
assert!(
body.stdout.len() <= 10,
"the cap must hold on the live path too: {} lines",
body.stdout.len()
);
assert_eq!(
line_text(body.stdout.last().expect("a retained line")),
"99",
"keep-bottom retains the NEWEST, and a follower's newest is what a pane wants"
);
}
#[test]
fn run_tick_still_returns_a_bare_outcome() {
let outcome: TickOutcome = run_tick(flooder(1), SourceId(0), Vec::new(), ample());
assert!(outcome.spawn_error.is_none());
}
}