1ccce9ad32
Every hand-rolled test-sandbox helper keyed its directory on
std::process::id() under env::temp_dir(), so the pre-create wipe never
matched a prior run's path: one directory stranded per helper site per
test-binary invocation, >50k dirs / 15.5 GiB on the 16 GiB tmpfs by
2026-07-13.
The remedy extends commit 84e1075's knob-lab pattern to every site:
fixed, tag-keyed, PID-FREE names under the build-tree tmp anchor, each
site keeping its pre-create remove_dir_all — which now genuinely
reclaims the previous run. Integration/bench targets use
env!("CARGO_TARGET_TMPDIR"); src-internal unit-test helpers (cargo sets
that var only for integration/bench targets) derive the same anchor
from env!("CARGO_MANIFEST_DIR") + ../../target/tmp. Residue is bounded
to one directory per site, on disk instead of the tmpfs, per checkout.
One deliberate exception: project_new.rs's tmp() stays under
env::temp_dir() because two of its tests (new_outside_a_work_tree_*)
need a base genuinely detached from any git work tree, and target/
lives inside the checkout's work tree. Its name carries a per-checkout
hash discriminator instead, so concurrently running checkouts (which
share /tmp) cannot race on the fixed name while each run still
reclaims its predecessor.
tempfile-RAII was considered and rejected (issue #258 decision log):
it cannot cover the process-lifetime OnceLock scaffolds (statics never
drop), adds a dependency, and still strands kill-leftovers on the
tmpfs.
Verified: full workspace suite green (incl. the new RED regression
test temp_cwd_sandbox_does_not_leak_under_env_temp_dir), clippy clean,
sandboxes observed under target/tmp with stable names across runs.
Stale pre-fix PID-keyed dirs in /tmp are not swept by this change and
age out only by manual cleanup.
closes #258
90 lines
3.9 KiB
Rust
90 lines
3.9 KiB
Rust
//! 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::path::Path::new(env!("CARGO_TARGET_TMPDIR")).join(format!("aura-cli-{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:?}"
|
|
);
|
|
}
|