diff --git a/crates/aura-cli/tests/cli_broken_pipe.rs b/crates/aura-cli/tests/cli_broken_pipe.rs new file mode 100644 index 0000000..2942e3a --- /dev/null +++ b/crates/aura-cli/tests/cli_broken_pipe.rs @@ -0,0 +1,89 @@ +//! Integration test: a family-emitting subcommand must survive its stdout +//! reader closing early (the most natural CLI piping — `| head`, `| less`, a +//! closed UI pane). Drives the built `aura` binary and closes the read end of +//! its stdout pipe before the writer finishes, then asserts the EPIPE is +//! handled cleanly, never a panic. + +use std::io::Read; +use std::process::{Command, Stdio}; + +/// Path to the freshly-built `aura` binary (Cargo sets this env var for the test +/// crate; the binary is named `aura` in `Cargo.toml`). +const BIN: &str = env!("CARGO_BIN_EXE_aura"); + +/// A fresh, unique working directory so the spawned `aura sweep` writes its +/// `./runs/runs.jsonl` into a throwaway dir, never dirtying the repo. Unique +/// per test + per process; no external tempfile dependency (mirrors `cli_run.rs`). +fn temp_cwd(name: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("aura-cli-{}-{}", std::process::id(), name)); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("create temp cwd"); + dir +} + +/// Property: a family-emitting command whose stdout reader closes early exits on +/// the EPIPE cleanly — it does NOT panic (`failed printing to stdout: Broken +/// pipe`) and does NOT exit 101. `aura sweep` streams four family-member JSON +/// lines via `println!`; when the reader closes stdout after the first line, the +/// next write hits a closed pipe. Rust's runtime sets SIGPIPE to SIG_IGN at +/// startup, so that write returns EPIPE and `println!` panics rather than the +/// process terminating quietly — exactly the behaviour a CLI must not have. This +/// is the contract: any aura subcommand can be piped into `| head` / `| less` / +/// a UI pane that goes away, and it must finish quietly, not panic. +/// +/// Autonomous: constructs its own early-closing reader inline (no `head`, no +/// shell, no fixture files) by spawning the binary with a piped stdout and +/// dropping the read handle after a single short read, which closes the pipe's +/// read end and makes the writer's next write hit EPIPE. +#[test] +fn family_emitting_command_survives_early_reader_close() { + let cwd = temp_cwd("broken-pipe-sweep"); + + let mut child = Command::new(BIN) + .arg("sweep") + .current_dir(&cwd) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn aura sweep"); + + // Read just enough to guarantee the writer is mid-stream, then drop the read + // handle. Dropping it closes the read end of the pipe; the next `println!` + // in the writer then writes to a closed pipe and gets EPIPE. + { + let mut stdout = child.stdout.take().expect("child stdout piped"); + let mut buf = [0u8; 64]; + // One short read advances the stream; we deliberately ignore the rest. + let _ = stdout.read(&mut buf); + // `stdout` drops here, closing the read end of the pipe. + } + + // Drain stderr so the child cannot block writing the panic message, then reap. + let mut stderr = String::new(); + if let Some(mut err) = child.stderr.take() { + let _ = err.read_to_string(&mut stderr); + } + let status = child.wait().expect("reap aura sweep"); + + let _ = std::fs::remove_dir_all(&cwd); + + // The defect: stderr carries the broken-pipe panic. + assert!( + !stderr.contains("panicked"), + "a closed stdout reader must not panic the writer; stderr was: {stderr:?}" + ); + assert!( + !stderr.contains("Broken pipe"), + "the broken pipe must be handled cleanly, not surfaced as an error; stderr was: {stderr:?}" + ); + + // And the exit is the clean broken-pipe outcome, never the panic's 101. + // A clean SIGPIPE termination is signal 13 (code is None); a deliberate + // clean-exit code is anything but 101. The only forbidden outcome is the + // panic abort. + assert_ne!( + status.code(), + Some(101), + "a closed stdout reader must not exit with the panic code 101; status: {status:?}" + ); +}