Files
Aura/crates/aura-cli/tests/cli_broken_pipe.rs
T
claude db8f947441 feat(aura-cli)!: retire the research-verb sugar — run/sweep/walkforward/mc/generalize removed
Slice 6 of the #319 retirement, the destructive half. The five flag verbs,
their five Args structs and dispatch arms, the whole argv->document
translator (verb_sugar.rs, 1257 lines) and 33 quintet-only helpers are
gone; retired verbs now refuse at the clap layer as unknown commands.
main.rs shrinks 4445 -> 2764 lines. The gated-intake route list narrows to
the canonical layer + exec's blueprint leg; exec's own refusal prose no
longer names dead verbs; test seeding recipes ride graph register.

cli_run.rs walked the full disposition table: 94 tests retired with their
dead flag surface (each with a named green twin where the property
survives), 75 ported — exec ports keep assertions byte-identical, campaign
ports inherit their golden grades unchanged (walkforward's 90/30/30 real
roller and the archive window clipping the sugar applied silently are now
explicit document fields — the equivalence held exactly, one reconstructed
walkforward pooled expectancy_r within 1e-12, cause documented inline).
Surface deltas recorded by the ports: per-cell refusals are #272 contained
faults (exit 3 + warning) rather than hard exit 1; the synthetic per-seed
mc family and generalize's merged cross-instrument family record were
sugar-only constructs and retire with it (the campaign record's
generalizations[] carries the data).

refs #319
2026-07-25 21:36:58 +02:00

177 lines
7.4 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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::path::Path;
use std::process::{Command, Stdio};
mod common;
use common::{fresh_project_with_data, ScratchGuard, ScratchPath};
/// 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");
fn run_code_in(dir: &Path, args: &[&str]) -> (String, Option<i32>) {
let out = Command::new(BIN).args(args).current_dir(dir).output().expect("binary runs");
let text = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
(text, out.status.code())
}
fn write_doc(dir: &Path, name: &str, text: &str) -> std::path::PathBuf {
let p = dir.join(name);
std::fs::write(&p, text).expect("write doc");
p
}
/// The minimal executable pipeline (one sweep stage) — copied verbatim from
/// `research_docs.rs`'s constant of the same name.
const SWEEP_ONLY_PROCESS_DOC: &str = r#"{
"format_version": 1,
"kind": "process",
"name": "sweep-only",
"pipeline": [ { "block": "std::sweep", "metric": "sqn_normalized", "select": "argmax" } ]
}"#;
/// Seed one blueprint into the built demo project's store via `aura graph
/// register` and return its content id — copied verbatim from
/// `research_docs.rs`'s recipe of the same name (#319 — the retired `sweep`
/// side-effect seeding this used to ride played no role in the returned
/// content id, a pure function of the loaded blueprint's canonical bytes;
/// `name` labels the registered id, mirroring the old per-call sweep family
/// name).
fn seed_blueprint(dir: &Path, name: &str) -> String {
let closed_bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
let (out, code) = run_code_in(dir, &["graph", "register", &closed_bp, "--name", name]);
assert_eq!(code, Some(0), "seed register failed: {out}");
out.lines()
.find(|l| l.starts_with("registered blueprint "))
.expect("register line")
.trim_start_matches("registered blueprint ")
.split(' ')
.next()
.expect("id")
.trim_start_matches("content:")
.to_string()
}
/// Register `doc` as a process document in the project store; returns its
/// id — copied verbatim from `research_docs.rs`'s recipe of the same name.
fn register_process_doc(dir: &Path, file: &str, doc: &str) -> String {
write_doc(dir, file, doc);
let (out, code) = run_code_in(dir, &["process", "register", file]);
assert_eq!(code, Some(0), "process register failed: {out}");
out.lines()
.find(|l| l.starts_with("registered process "))
.expect("register line")
.trim_start_matches("registered process ")
.split(' ')
.next()
.expect("id")
.trim_start_matches("content:")
.to_string()
}
/// A one-strategy, two-axis (2×2) campaign document over `bp_id`/`proc_id` —
/// copied verbatim from `exec.rs`'s `campaign_doc_json_for` of the same
/// shape, which yields exactly four family members (the four-line stream
/// this test's property needs).
fn campaign_doc_json_for(instrument: &str, bp_id: &str, proc_id: &str, window: (i64, i64)) -> String {
format!(
r#"{{
"format_version": 1,
"kind": "campaign",
"name": "broken-pipe",
"data": {{ "instruments": ["{instrument}"], "windows": [ {{ "from_ms": {from}, "to_ms": {to} }} ] }},
"strategies": [ {{ "ref": {{ "content_id": "{bp_id}" }},
"axes": {{ "fast.length": {{ "kind": "I64", "values": [2, 4] }},
"slow.length": {{ "kind": "I64", "values": [8, 16] }} }} }} ],
"process": {{ "ref": {{ "content_id": "{proc_id}" }} }},
"seed": 7,
"presentation": {{ "persist_taps": [], "emit": ["family_table"] }}
}}"#,
from = window.0,
to = window.1,
)
}
/// 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 exec <campaign.json>` streams four
/// family-member JSON lines via `println!` (a 2×2 axis grid — #319: the
/// surviving surface for what a bare `aura sweep` demo grid used to stream);
/// 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.
#[test]
fn family_emitting_command_survives_early_reader_close() {
let (dir, _fixture) = fresh_project_with_data();
let runs_dir = dir.join("runs");
std::fs::remove_dir_all(&runs_dir).ok();
let _cleanup = ScratchGuard(vec![
ScratchPath::Dir(runs_dir),
ScratchPath::File(dir.join("broken-pipe.process.json")),
ScratchPath::File(dir.join("broken-pipe.campaign.json")),
]);
let bp_id = seed_blueprint(&dir, "broken-pipe-seed");
let proc_id = register_process_doc(&dir, "broken-pipe.process.json", SWEEP_ONLY_PROCESS_DOC);
let doc = campaign_doc_json_for("SYMA", &bp_id, &proc_id, (1709251200000, 1719791999999));
write_doc(&dir, "broken-pipe.campaign.json", &doc);
let mut child = Command::new(BIN)
.args(["exec", "broken-pipe.campaign.json"])
.current_dir(&dir)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("spawn aura exec");
// 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 exec");
// 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:?}"
);
}