From c750b9fbc39cfe3e68912eb21c2a58c6dbf7c81e Mon Sep 17 00:00:00 2001 From: Brummel Date: Fri, 26 Jun 2026 21:18:46 +0200 Subject: [PATCH] =?UTF-8?q?test:=20RED=20=E2=80=94=20family-emitting=20CLI?= =?UTF-8?q?=20commands=20must=20survive=20an=20early-closing=20stdout=20re?= =?UTF-8?q?ader?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Captures the pre-existing broken-pipe panic surfaced by the milestone fieldtest ([bug] finding): every multi-line subcommand (sweep / mc / walkforward / runs family / generalize) panics "failed printing to stdout: Broken pipe (os error 32)" and exits 101 when the stdout reader closes early (| head, | less, a closed UI pane), instead of terminating cleanly. Root cause: Rust's runtime resets SIGPIPE to SIG_IGN at startup, so a write to a closed pipe returns EPIPE and println! panics rather than the process dying quietly. crates/aura-cli/tests/cli_broken_pipe.rs spawns `aura sweep`, closes the stdout read end mid-stream, and asserts no panic in stderr + exit != 101. Verified RED (reproduces 6/6). The GREEN fix follows in the next commit. refs #146 --- crates/aura-cli/tests/cli_broken_pipe.rs | 89 ++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 crates/aura-cli/tests/cli_broken_pipe.rs 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:?}" + ); +}