test: RED — family-emitting CLI commands must survive an early-closing stdout reader

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
This commit is contained in:
2026-06-26 21:18:46 +02:00
parent ab04ed01c3
commit c750b9fbc3
+89
View File
@@ -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:?}"
);
}