use std::fs;
use std::io::{BufRead, BufReader, Read};
use std::path::Path;
use std::process::{Command, Output};
use std::sync::mpsc::{self, Receiver};
use std::sync::{Arc, Mutex, MutexGuard};
use std::thread;
use std::time::{Duration, Instant};
fn bin() -> Command {
Command::new(env!("CARGO_BIN_EXE_spec-spine"))
}
fn code(out: &Output) -> i32 {
out.status.code().unwrap_or(-1)
}
fn stdout(out: &Output) -> String {
String::from_utf8_lossy(&out.stdout).into_owned()
}
fn stderr(out: &Output) -> String {
String::from_utf8_lossy(&out.stderr).into_owned()
}
fn write_spec(root: &Path, dir: &str, commands: &str) {
let spec_dir = root.join("specs").join(dir);
fs::create_dir_all(&spec_dir).unwrap();
let body = format!(
"---\nid: \"{dir}\"\ntitle: \"T\"\nstatus: approved\ncreated: \"2026-09-19\"\nsummary: \"s\"\n---\n# {dir}\n\n## Verification\n\n```verify:cli\n{commands}\n```\n"
);
fs::write(spec_dir.join("spec.md"), body).unwrap();
}
fn run(root: &Path, args: &[&str]) -> Output {
bin().arg("--repo").arg(root).args(args).output().unwrap()
}
fn one_envelope(out: &Output) -> serde_json::Value {
serde_json::from_slice(&out.stdout).unwrap_or_else(|e| {
panic!(
"the whole of stdout must be one verdict envelope: {e}\n--- stdout ---\n{}\n--- stderr ---\n{}",
stdout(out),
stderr(out)
)
})
}
#[test]
fn json_keeps_a_passing_commands_output_off_stdout() {
let tmp = tempfile::tempdir().unwrap();
write_spec(
tmp.path(),
"001-noisy",
"printf 'O%sT-A\\n' U; printf 'E%sR-A\\n' R >&2",
);
let out = run(tmp.path(), &["verify", "001-noisy", "--json"]);
assert_eq!(code(&out), 0);
let v = one_envelope(&out);
assert_eq!(v["verb"], "verify");
assert_eq!(v["outcome"], "ok");
assert_eq!(v["exitCode"], 0);
assert_eq!(v["report"]["outcome"], "passed");
assert_eq!(v["report"]["ran"], 1);
assert_eq!(v["report"]["total"], 1);
let so = stdout(&out);
let se = stderr(&out);
assert!(
!so.contains("OUT-A"),
"child stdout must not be on stdout: {so}"
);
assert!(
!so.contains("ERR-A"),
"child stderr must not be on stdout: {so}"
);
assert!(se.contains("OUT-A"), "child stdout must be on stderr: {se}");
assert!(se.contains("ERR-A"), "child stderr must be on stderr: {se}");
assert!(
se.contains("[verify] $ printf 'O%sT-A\\n' U; printf 'E%sR-A\\n' R >&2"),
"the command line belongs on stderr: {se}"
);
assert!(se.contains("[verify] exit 0"), "the exit line too: {se}");
}
#[test]
fn json_keeps_a_failing_commands_output_off_stdout_and_stops_there() {
let tmp = tempfile::tempdir().unwrap();
write_spec(
tmp.path(),
"002-fail",
"printf 'O%sT-B\\n' U; printf 'E%sR-B\\n' R >&2; exit 7\nprintf c > ran-c.txt",
);
let out = run(tmp.path(), &["verify", "002-fail", "--json"]);
assert_eq!(code(&out), 1);
let v = one_envelope(&out);
assert_eq!(v["exitCode"], 1);
assert_eq!(v["report"]["outcome"], "failed");
assert_eq!(v["report"]["failure"]["exitCode"], 7);
assert_eq!(v["report"]["failure"]["index"], 1);
assert_eq!(
v["report"]["failure"]["command"],
"printf 'O%sT-B\\n' U; printf 'E%sR-B\\n' R >&2; exit 7"
);
assert_eq!(v["report"]["ran"], 1);
assert_eq!(v["report"]["total"], 2);
let so = stdout(&out);
let se = stderr(&out);
assert!(!so.contains("OUT-B"), "{so}");
assert!(!so.contains("ERR-B"), "{so}");
assert!(se.contains("OUT-B"), "{se}");
assert!(se.contains("ERR-B"), "{se}");
assert!(se.contains("[verify] exit 7"), "{se}");
assert!(
!tmp.path().join("ran-c.txt").exists(),
"a later command must not run after a failure"
);
}
#[test]
fn json_error_envelope_is_the_whole_of_stdout() {
let tmp = tempfile::tempdir().unwrap();
write_spec(tmp.path(), "004-loop", "printf 'never-runs\\n'");
let out = bin()
.arg("--repo")
.arg(tmp.path())
.args(["verify", "004-loop", "--json"])
.env("SPEC_SPINE_VERIFY_STACK", "004-loop")
.output()
.unwrap();
assert_eq!(code(&out), 1);
let v = one_envelope(&out);
assert_eq!(v["error"]["kind"], "validation");
assert_eq!(v["error"]["violations"][0]["code"], "R-001");
assert!(
v.get("report").is_none(),
"`report` and `error` are exclusive (spec 034 §3.1): {v}"
);
assert!(!stdout(&out).contains("never-runs"), "{}", stdout(&out));
}
#[test]
fn plan_json_is_one_envelope_and_runs_nothing() {
let tmp = tempfile::tempdir().unwrap();
write_spec(
tmp.path(),
"003-plan",
"printf ran > side_effect.txt; printf 'ERR-C\\n' >&2",
);
let out = run(tmp.path(), &["verify", "003-plan", "--plan", "--json"]);
assert_eq!(code(&out), 0);
let v = one_envelope(&out);
assert_eq!(v["verb"], "verify");
assert_eq!(
v["report"]["commands"][0],
"printf ran > side_effect.txt; printf 'ERR-C\\n' >&2"
);
assert!(
!tmp.path().join("side_effect.txt").exists(),
"--plan must run nothing"
);
assert!(!stderr(&out).contains("ERR-C"), "{}", stderr(&out));
}
#[test]
fn prose_mode_channels_are_unchanged() {
let tmp = tempfile::tempdir().unwrap();
write_spec(
tmp.path(),
"005-prose",
"printf 'O%sT-D\\n' U; printf 'E%sR-D\\n' R >&2",
);
let out = run(tmp.path(), &["verify", "005-prose"]);
assert_eq!(code(&out), 0);
let so = stdout(&out);
let se = stderr(&out);
assert!(
so.contains("OUT-D"),
"the child's stdout stays on stdout: {so}"
);
assert!(se.contains("ERR-D"), "and its stderr on stderr: {se}");
assert!(
so.contains("[verify] $ printf 'O%sT-D\\n' U; printf 'E%sR-D\\n' R >&2"),
"the transcript stays on stdout: {so}"
);
assert!(so.contains("[verify] exit 0"), "{so}");
assert!(so.contains("passed (1 command(s))"), "{so}");
assert!(
!se.contains("[verify] $"),
"and does not also appear on stderr: {se}"
);
}
#[test]
fn json_forwards_more_than_a_pipe_buffer_without_deadlocking() {
let tmp = tempfile::tempdir().unwrap();
write_spec(
tmp.path(),
"006-flood",
"i=0; while [ $i -lt 8192 ]; do printf 'O%sT-E-0123456789012345678901234567890123456789012345678901\\n' U; printf 'E%sR-E-0123456789012345678901234567890123456789012345678901\\n' R >&2; i=$((i+1)); done",
);
let out = run(tmp.path(), &["verify", "006-flood", "--json"]);
assert_eq!(code(&out), 0);
let v = one_envelope(&out);
assert_eq!(v["report"]["outcome"], "passed");
let se = stderr(&out);
assert_eq!(
se.lines().filter(|l| l.starts_with("OUT-E-")).count(),
8192,
"every forwarded line must arrive"
);
assert_eq!(se.lines().filter(|l| l.starts_with("ERR-E-")).count(), 8192);
}
const VOLUME: &str =
r#"awk 'BEGIN{s=sprintf("%1000s","");gsub(/ /,"X",s);for(i=0;i<1000;i++)print s}'"#;
const DEADLINE: Duration = Duration::from_secs(30);
const TRANSCRIPT: &str = "[verify] $ ";
#[derive(Debug)]
struct Consumed {
status: std::process::ExitStatus,
stdout: Vec<u8>,
stderr: Vec<u8>,
}
impl Consumed {
fn envelope(&self) -> serde_json::Value {
serde_json::from_slice(&self.stdout).unwrap_or_else(|e| {
panic!(
"the whole of stdout must be one verdict envelope: {e}\n--- stdout ---\n{}",
String::from_utf8_lossy(&self.stdout)
)
})
}
}
#[derive(Debug)]
enum FixtureFailure {
NoTranscript {
waited: Duration,
status: std::process::ExitStatus,
},
BadTranscript { got: String },
TranscriptUnreadable { error: String },
NotFinished { waited: Duration },
ReaderStuck {
stream: &'static str,
waited: Duration,
},
Cancelled {
leader: Option<std::process::ExitStatus>,
},
}
impl std::fmt::Display for FixtureFailure {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
FixtureFailure::NoTranscript { waited, status } => write!(
f,
"no transcript line on the fixture's stderr within {waited:?} (the leader was {} when the harness terminated it)",
if looks_killed_by_the_harness(status) {
"still running".to_string()
} else {
format!("already finished, {status}")
}
),
FixtureFailure::BadTranscript { got } => write!(
f,
"the transcript's first line must start with {TRANSCRIPT:?} under --json, got {got:?}"
),
FixtureFailure::TranscriptUnreadable { error } => {
write!(f, "the fixture's stderr could not be read: {error}")
}
FixtureFailure::NotFinished { waited } => {
write!(f, "the fixture did not finish within {waited:?}")
}
FixtureFailure::ReaderStuck { stream, waited } => write!(
f,
"the fixture exited but its {stream} was still open after {waited:?}: a descendant held it"
),
FixtureFailure::Cancelled { leader: None } => write!(
f,
"the run was cancelled before the fixture was spawned; no process was created"
),
FixtureFailure::Cancelled {
leader: Some(status),
} => write!(
f,
"the run was cancelled between the spawn and the publication of the leader's pid; the leader was terminated and reaped ({status})"
),
}
}
}
#[cfg(unix)]
fn signal_fixture_group(pid: u32) {
let _ = Command::new("/bin/sh")
.arg("-c")
.arg(format!("kill -9 -{pid} 2>/dev/null"))
.status();
}
#[cfg(not(unix))]
fn signal_fixture_group(_pid: u32) {}
#[cfg(unix)]
fn looks_killed_by_the_harness(status: &std::process::ExitStatus) -> bool {
use std::os::unix::process::ExitStatusExt;
status.signal() == Some(9)
}
#[cfg(not(unix))]
fn looks_killed_by_the_harness(_status: &std::process::ExitStatus) -> bool {
true
}
#[derive(Debug, Clone, Copy)]
enum TreeState {
Unspawned,
Cancelled,
CancellingSpawn(u32),
Live(u32),
Reaped {
status: std::process::ExitStatus,
after_cancel: bool,
},
}
#[derive(Debug)]
struct Tree {
state: Mutex<TreeState>,
}
#[derive(Debug)]
enum Cleanup {
Reaped(std::process::ExitStatus),
SignalledNotReaped,
NeverStarted,
CancelledThenCleanedUp(std::process::ExitStatus),
CancelledCleanupInFlight(u32),
AlreadyReaped(std::process::ExitStatus),
}
impl Cleanup {
fn reaped(&self) -> Option<std::process::ExitStatus> {
match self {
Cleanup::Reaped(status)
| Cleanup::CancelledThenCleanedUp(status)
| Cleanup::AlreadyReaped(status) => Some(*status),
Cleanup::SignalledNotReaped
| Cleanup::NeverStarted
| Cleanup::CancelledCleanupInFlight(_) => None,
}
}
}
#[derive(Debug, Clone, Copy)]
enum CancelOutcome {
Signalled,
Recorded,
AlreadyReaped(std::process::ExitStatus),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Permit {
Proceed,
Cancelled,
}
impl Tree {
fn new() -> Arc<Tree> {
Arc::new(Tree {
state: Mutex::new(TreeState::Unspawned),
})
}
fn lock(&self) -> MutexGuard<'_, TreeState> {
self.state.lock().unwrap_or_else(|e| e.into_inner())
}
fn may_spawn(&self) -> Permit {
match *self.lock() {
TreeState::Unspawned => Permit::Proceed,
TreeState::Cancelled => Permit::Cancelled,
TreeState::CancellingSpawn(_) | TreeState::Live(_) | TreeState::Reaped { .. } => {
unreachable!("a tree is spawned into once")
}
}
}
fn publish(&self, pid: u32) -> Permit {
let mut guard = self.lock();
match *guard {
TreeState::Unspawned => {
*guard = TreeState::Live(pid);
Permit::Proceed
}
TreeState::Cancelled => {
*guard = TreeState::CancellingSpawn(pid);
Permit::Cancelled
}
TreeState::CancellingSpawn(_) | TreeState::Live(_) | TreeState::Reaped { .. } => {
unreachable!("a tree is published into once")
}
}
}
fn cancelled_reap(&self, status: std::process::ExitStatus) {
let mut guard = self.lock();
match *guard {
TreeState::CancellingSpawn(_) => {
*guard = TreeState::Reaped {
status,
after_cancel: true,
};
}
other => unreachable!("a cancelled reap follows a cancelled spawn, not {other:?}"),
}
}
fn cancel(&self) -> CancelOutcome {
let mut guard = self.lock();
match *guard {
TreeState::Live(pid) => {
signal_fixture_group(pid);
CancelOutcome::Signalled
}
TreeState::Unspawned => {
*guard = TreeState::Cancelled;
CancelOutcome::Recorded
}
TreeState::Cancelled => CancelOutcome::Recorded,
TreeState::CancellingSpawn(_) => CancelOutcome::Recorded,
TreeState::Reaped { status, .. } => CancelOutcome::AlreadyReaped(status),
}
}
fn live_pid(&self) -> Option<u32> {
match *self.lock() {
TreeState::Live(pid) => Some(pid),
_ => None,
}
}
fn reaped_status(&self) -> Option<std::process::ExitStatus> {
match *self.lock() {
TreeState::Reaped { status, .. } => Some(status),
_ => None,
}
}
fn cleanup(&self) -> Cleanup {
match *self.lock() {
TreeState::Reaped {
status,
after_cancel: true,
} => Cleanup::CancelledThenCleanedUp(status),
TreeState::Reaped {
status,
after_cancel: false,
} => Cleanup::Reaped(status),
TreeState::Live(_) => Cleanup::SignalledNotReaped,
TreeState::CancellingSpawn(pid) => Cleanup::CancelledCleanupInFlight(pid),
TreeState::Cancelled | TreeState::Unspawned => Cleanup::NeverStarted,
}
}
}
struct Fixture {
child: std::process::Child,
tree: Arc<Tree>,
}
#[derive(Default)]
struct SpawnGates {
before_spawn: Option<Box<dyn FnOnce() + Send>>,
before_publish: Option<Box<dyn FnOnce() + Send>>,
}
enum Spawned {
Started(Fixture),
RefusedBeforeSpawn,
CleanedUpAfterSpawn(std::process::ExitStatus),
}
impl Fixture {
fn spawn(mut cmd: Command, tree: Arc<Tree>, gates: SpawnGates) -> Spawned {
if let Some(gate) = gates.before_spawn {
gate();
}
if tree.may_spawn() == Permit::Cancelled {
return Spawned::RefusedBeforeSpawn;
}
cmd.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
cmd.process_group(0);
}
let mut child = cmd.spawn().expect("the fixture must spawn");
let pid = child.id();
if let Some(gate) = gates.before_publish {
gate();
}
if tree.publish(pid) == Permit::Cancelled {
signal_fixture_group(pid);
let _ = child.kill();
let status = child.wait().expect("the fixture leader must reap");
tree.cancelled_reap(status);
return Spawned::CleanedUpAfterSpawn(status);
}
Spawned::Started(Fixture { child, tree })
}
fn terminate(&mut self) -> std::process::ExitStatus {
let tree = Arc::clone(&self.tree);
let mut guard = tree.lock();
match *guard {
TreeState::Reaped { status, .. } => status,
TreeState::Live(pid) => {
signal_fixture_group(pid);
let _ = self.child.kill();
let status = self.child.wait().expect("the fixture leader must reap");
*guard = TreeState::Reaped {
status,
after_cancel: false,
};
status
}
TreeState::Unspawned | TreeState::Cancelled | TreeState::CancellingSpawn(_) => {
unreachable!("a Fixture exists only once its leader has been published")
}
}
}
fn poll_exit(&mut self) -> Option<std::process::ExitStatus> {
let tree = Arc::clone(&self.tree);
let mut guard = tree.lock();
match *guard {
TreeState::Reaped { status, .. } => Some(status),
TreeState::Live(_) => match self.child.try_wait().expect("try_wait on the fixture") {
Some(status) => {
*guard = TreeState::Reaped {
status,
after_cancel: false,
};
Some(status)
}
None => None,
},
TreeState::Unspawned | TreeState::Cancelled | TreeState::CancellingSpawn(_) => {
unreachable!("a Fixture exists only once its leader has been published")
}
}
}
}
impl Drop for Fixture {
fn drop(&mut self) {
let tree = Arc::clone(&self.tree);
let mut guard = tree.lock();
if let TreeState::Live(pid) = *guard {
signal_fixture_group(pid);
let _ = self.child.kill();
if let Ok(status) = self.child.wait() {
*guard = TreeState::Reaped {
status,
after_cancel: false,
};
}
}
}
}
fn left(started: Instant, budget: Duration) -> Duration {
budget.saturating_sub(started.elapsed())
}
fn drain(mut pipe: impl Read + Send + 'static) -> Receiver<Vec<u8>> {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let mut buf = Vec::new();
let _ = pipe.read_to_end(&mut buf);
let _ = tx.send(buf);
});
rx
}
fn try_run_fixture(
cmd: Command,
close: bool,
budget: Duration,
tree: Arc<Tree>,
) -> Result<Consumed, FixtureFailure> {
try_run_fixture_gated(cmd, close, budget, tree, SpawnGates::default())
}
fn try_run_fixture_gated(
cmd: Command,
close: bool,
budget: Duration,
tree: Arc<Tree>,
gates: SpawnGates,
) -> Result<Consumed, FixtureFailure> {
let started = Instant::now();
let mut fixture = match Fixture::spawn(cmd, tree, gates) {
Spawned::Started(fixture) => fixture,
Spawned::RefusedBeforeSpawn => return Err(FixtureFailure::Cancelled { leader: None }),
Spawned::CleanedUpAfterSpawn(status) => {
return Err(FixtureFailure::Cancelled {
leader: Some(status),
});
}
};
let out_rx = drain(fixture.child.stdout.take().expect("stdout was piped"));
let err_pipe = fixture.child.stderr.take().expect("stderr was piped");
let (first_tx, first_rx) = mpsc::channel();
thread::spawn(move || {
let mut err = BufReader::new(err_pipe);
let mut first = String::new();
let read = err.read_line(&mut first);
let _ = first_tx.send((read.map(|_| first), err));
});
let (read, err) = match first_rx.recv_timeout(left(started, budget)) {
Ok(pair) => pair,
Err(_) => {
let waited = started.elapsed();
let status = fixture.terminate();
return Err(FixtureFailure::NoTranscript { waited, status });
}
};
match read {
Ok(line) if line.starts_with(TRANSCRIPT) => {}
Ok(line) => {
fixture.terminate();
return Err(FixtureFailure::BadTranscript { got: line });
}
Err(e) => {
fixture.terminate();
return Err(FixtureFailure::TranscriptUnreadable {
error: e.to_string(),
});
}
}
let err_rx = if close {
drop(err);
None
} else {
Some(drain(err))
};
let stdout = match out_rx.recv_timeout(left(started, budget)) {
Ok(bytes) => bytes,
Err(_) => return Err(stuck(&mut fixture, started, "stdout")),
};
let stderr = match err_rx {
None => Vec::new(),
Some(rx) => match rx.recv_timeout(left(started, budget)) {
Ok(bytes) => bytes,
Err(_) => return Err(stuck(&mut fixture, started, "stderr")),
},
};
loop {
if let Some(status) = fixture.poll_exit() {
return Ok(Consumed {
status,
stdout,
stderr,
});
}
if left(started, budget).is_zero() {
let waited = started.elapsed();
fixture.terminate();
return Err(FixtureFailure::NotFinished { waited });
}
thread::sleep(Duration::from_millis(5));
}
}
fn stuck(fixture: &mut Fixture, started: Instant, stream: &'static str) -> FixtureFailure {
let waited = started.elapsed();
let status = fixture.terminate();
if looks_killed_by_the_harness(&status) {
FixtureFailure::NotFinished { waited }
} else {
FixtureFailure::ReaderStuck { stream, waited }
}
}
fn run_with_stderr_consumer(root: &Path, id: &str, close: bool) -> Consumed {
let mut cmd = bin();
cmd.arg("--repo").arg(root).args(["verify", id, "--json"]);
try_run_fixture(cmd, close, DEADLINE, Tree::new()).unwrap_or_else(|e| {
panic!(
"`verify {id}` with the stderr consumer {}: {e}",
if close { "closed" } else { "open" }
)
})
}
#[test]
fn a_closed_stderr_consumer_does_not_change_a_quiet_passs_verdict() {
let tmp = tempfile::tempdir().unwrap();
write_spec(tmp.path(), "007-quiet", "sleep 0.2; true");
let run = run_with_stderr_consumer(tmp.path(), "007-quiet", true);
assert_eq!(
run.status.code(),
Some(0),
"a passing acceptance run stays exit 0 when its logs cannot be delivered"
);
let v = run.envelope();
assert_eq!(v["verb"], "verify");
assert_eq!(v["exitCode"], 0);
assert_eq!(v["report"]["outcome"], "passed");
assert_eq!(v["report"]["ran"], 1);
}
#[test]
fn a_closed_stderr_consumer_does_not_hang_a_child_flooding_its_stderr() {
let tmp = tempfile::tempdir().unwrap();
write_spec(tmp.path(), "008-flood-err", &format!("{VOLUME} >&2"));
let run = run_with_stderr_consumer(tmp.path(), "008-flood-err", true);
assert_eq!(run.status.code(), Some(0));
assert_eq!(run.envelope()["report"]["outcome"], "passed");
}
#[test]
fn a_closed_stderr_consumer_does_not_hang_a_child_flooding_its_stdout() {
let tmp = tempfile::tempdir().unwrap();
write_spec(tmp.path(), "009-flood-out", VOLUME);
let run = run_with_stderr_consumer(tmp.path(), "009-flood-out", true);
assert_eq!(run.status.code(), Some(0));
assert_eq!(run.envelope()["report"]["outcome"], "passed");
}
#[test]
fn a_closed_stderr_consumer_keeps_a_failing_commands_details() {
let tmp = tempfile::tempdir().unwrap();
write_spec(
tmp.path(),
"010-flood-fail",
&format!("{VOLUME}; {VOLUME} >&2; exit 7\nprintf c > ran-c.txt"),
);
let run = run_with_stderr_consumer(tmp.path(), "010-flood-fail", true);
assert_eq!(run.status.code(), Some(1));
let v = run.envelope();
assert_eq!(v["exitCode"], 1);
assert_eq!(v["report"]["outcome"], "failed");
assert_eq!(v["report"]["failure"]["exitCode"], 7);
assert_eq!(v["report"]["failure"]["index"], 1);
assert_eq!(v["report"]["ran"], 1);
assert_eq!(v["report"]["total"], 2);
assert!(
!tmp.path().join("ran-c.txt").exists(),
"the command after a failing one must not run"
);
}
#[test]
fn an_open_stderr_consumer_receives_every_forwarded_byte() {
let tmp = tempfile::tempdir().unwrap();
write_spec(
tmp.path(),
"011-flood-open",
&format!("{VOLUME}; {VOLUME} >&2"),
);
let run = run_with_stderr_consumer(tmp.path(), "011-flood-open", false);
assert_eq!(run.status.code(), Some(0));
assert_eq!(run.envelope()["report"]["outcome"], "passed");
let whole = String::from_utf8_lossy(&run.stderr);
let payload = "X".repeat(1000);
assert_eq!(
whole.lines().filter(|l| *l == payload).count(),
2000,
"both forwarded streams must arrive whole, got {} bytes",
run.stderr.len()
);
}
#[cfg(unix)]
const SAFEGUARD_BUDGET: Duration = Duration::from_secs(2);
#[cfg(unix)]
const OUTER_BOUND: Duration = Duration::from_secs(40);
#[cfg(unix)]
const OUTER_GRACE: Duration = Duration::from_secs(5);
#[derive(Debug)]
enum Supervised<T> {
Returned(T),
Overran {
cleanup: Cleanup,
},
}
#[cfg(unix)]
fn supervise<T: Send + 'static>(
bound: Duration,
tree: &Arc<Tree>,
f: impl FnOnce() -> T + Send + 'static,
) -> Supervised<T> {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let _ = tx.send(f());
});
match rx.recv_timeout(bound) {
Ok(value) => Supervised::Returned(value),
Err(mpsc::RecvTimeoutError::Disconnected) => {
panic!("the fixture harness panicked; its own message is above this one")
}
Err(mpsc::RecvTimeoutError::Timeout) => {
let outcome = tree.cancel();
let _ = rx.recv_timeout(OUTER_GRACE);
let cleanup = match outcome {
CancelOutcome::AlreadyReaped(status) => Cleanup::AlreadyReaped(status),
CancelOutcome::Signalled | CancelOutcome::Recorded => tree.cleanup(),
};
Supervised::Overran { cleanup }
}
}
}
#[cfg(unix)]
fn within<T: Send + 'static>(
bound: Duration,
tree: &Arc<Tree>,
f: impl FnOnce() -> T + Send + 'static,
) -> T {
match supervise(bound, tree, f) {
Supervised::Returned(value) => value,
Supervised::Overran { cleanup } => panic!(
"the fixture harness did not return within its outer bound of {bound:?} \
(the supervisor cancelled the tree: {cleanup:?})"
),
}
}
#[test]
#[cfg(unix)]
fn a_fixture_that_never_emits_a_transcript_is_given_up_at_the_budget() {
let mut cmd = Command::new("/bin/sh");
cmd.arg("-c").arg("sleep 600");
let tree = Tree::new();
let started = Instant::now();
let outcome = within(OUTER_BOUND, &tree, {
let tree = Arc::clone(&tree);
move || try_run_fixture(cmd, true, SAFEGUARD_BUDGET, tree)
});
let elapsed = started.elapsed();
match outcome {
Err(FixtureFailure::NoTranscript { .. }) => {}
other => panic!("expected the budget to expire waiting for a transcript, got {other:?}"),
}
assert!(
elapsed < SAFEGUARD_BUDGET * 4,
"giving up took {elapsed:?}, which is not inside the budget of {SAFEGUARD_BUDGET:?}"
);
}
#[test]
#[cfg(unix)]
fn a_timed_out_fixtures_descendant_is_terminated_before_its_side_effect() {
let tmp = tempfile::tempdir().unwrap();
let marker = tmp.path().join("delayed-side-effect.txt");
let delay = Duration::from_secs(6);
let mut cmd = Command::new("/bin/sh");
cmd.arg("-c").arg(format!(
"( sleep {}; printf x > \"{}\" ) &\nprintf '{TRANSCRIPT}fixture\\n' >&2\nsleep 600\n",
delay.as_secs(),
marker.display()
));
let tree = Tree::new();
let started = Instant::now();
let outcome = within(OUTER_BOUND, &tree, {
let tree = Arc::clone(&tree);
move || try_run_fixture(cmd, true, SAFEGUARD_BUDGET, tree)
});
let elapsed = started.elapsed();
match outcome {
Err(FixtureFailure::NotFinished { .. }) => {}
other => panic!("expected the budget to expire on a live fixture, got {other:?}"),
}
assert!(
elapsed < SAFEGUARD_BUDGET * 4,
"giving up took {elapsed:?}, which is not inside the budget of {SAFEGUARD_BUDGET:?}"
);
thread::sleep((delay + Duration::from_secs(2)).saturating_sub(started.elapsed()));
assert!(
!marker.exists(),
"a descendant of the fixture survived cleanup and performed its side effect at {}",
marker.display()
);
}
#[test]
#[cfg(unix)]
fn a_broken_inner_deadline_is_terminated_by_the_outer_supervisor() {
let tmp = tempfile::tempdir().unwrap();
let marker = tmp.path().join("delayed-side-effect.txt");
let delay = Duration::from_secs(6);
let mut cmd = Command::new("/bin/sh");
cmd.arg("-c").arg(format!(
"( sleep {}; printf x > \"{}\" ) &\nprintf '{TRANSCRIPT}fixture\\n' >&2\nsleep 600\n",
delay.as_secs(),
marker.display()
));
let broken_budget = Duration::from_secs(600);
let tree = Tree::new();
let started = Instant::now();
let outcome = supervise(SAFEGUARD_BUDGET, &tree, {
let tree = Arc::clone(&tree);
move || try_run_fixture(cmd, true, broken_budget, tree)
});
let elapsed = started.elapsed();
match outcome {
Supervised::Overran { cleanup } => match cleanup {
Cleanup::Reaped(status) => assert!(
looks_killed_by_the_harness(&status),
"the leader was terminated by the supervisor, got {status:?}"
),
Cleanup::SignalledNotReaped => panic!(
"the tree was terminated but its leader was never reaped: the group signal \
closes the pipes its owner is blocked on, which is what lets that owner \
finish, and it did not within {OUTER_GRACE:?}"
),
Cleanup::NeverStarted => panic!(
"the supervisor found nothing to terminate: the worker had published no pid \
by the bound and had still spawned nothing {OUTER_GRACE:?} later, so this \
case did not exercise the after-publication path it is written for"
),
Cleanup::CancelledThenCleanedUp(status) => panic!(
"the cancellation was recorded before publication rather than signalled at a \
live tree ({status:?}); this case is the after-publication ordering, and the \
pre-publication ones have their own cases"
),
Cleanup::CancelledCleanupInFlight(pid) => panic!(
"the cancellation was recorded before publication and the spawner's cleanup of \
leader {pid} was still in flight after {OUTER_GRACE:?}; this case is the \
after-publication ordering, and the pre-publication ones have their own cases"
),
Cleanup::AlreadyReaped(status) => panic!(
"the leader was already reaped when the supervisor acted ({status:?}), so the \
supervisor's own termination was not what ended this fixture"
),
},
Supervised::Returned(returned) => {
panic!("a ten-minute inner budget must not have returned on its own: {returned:?}")
}
}
assert!(
elapsed < SAFEGUARD_BUDGET + OUTER_GRACE + SAFEGUARD_BUDGET,
"the supervisor took {elapsed:?} to give up on a {SAFEGUARD_BUDGET:?} bound"
);
thread::sleep((delay + Duration::from_secs(2)).saturating_sub(started.elapsed()));
assert!(
!marker.exists(),
"a descendant of the fixture survived the supervisor's cleanup and performed its \
side effect at {}",
marker.display()
);
}
#[test]
#[cfg(unix)]
fn an_exited_leaders_descendant_on_the_pipes_is_still_terminated() {
let tmp = tempfile::tempdir().unwrap();
let marker = tmp.path().join("delayed-side-effect.txt");
let delay = Duration::from_secs(6);
let mut cmd = Command::new("/bin/sh");
cmd.arg("-c").arg(format!(
"( sleep {}; printf x > \"{}\" ) &\nprintf '{TRANSCRIPT}fixture\\n' >&2\nexit 0\n",
delay.as_secs(),
marker.display()
));
let tree = Tree::new();
let started = Instant::now();
let outcome = within(OUTER_BOUND, &tree, {
let tree = Arc::clone(&tree);
move || try_run_fixture(cmd, true, SAFEGUARD_BUDGET, tree)
});
let elapsed = started.elapsed();
match outcome {
Err(FixtureFailure::ReaderStuck { stream, .. }) => assert_eq!(stream, "stdout"),
other => panic!("expected a reader held open by a descendant, got {other:?}"),
}
assert!(
elapsed < SAFEGUARD_BUDGET * 4,
"giving up took {elapsed:?}, which is not inside the budget of {SAFEGUARD_BUDGET:?}"
);
assert!(
tree.reaped_status().is_some(),
"the leader must be reaped by the time the harness reports"
);
thread::sleep((delay + Duration::from_secs(2)).saturating_sub(started.elapsed()));
assert!(
!marker.exists(),
"a descendant of an exited leader survived cleanup and performed its side effect at {}",
marker.display()
);
}
#[cfg(unix)]
const SYNC_BOUND: Duration = Duration::from_secs(20);
#[cfg(unix)]
fn rendezvous() -> (
Box<dyn FnOnce() + Send>,
mpsc::Receiver<()>,
mpsc::Sender<()>,
) {
let (arrived_tx, arrived_rx) = mpsc::channel();
let (release_tx, release_rx) = mpsc::channel::<()>();
let gate = Box::new(move || {
let _ = arrived_tx.send(());
let _ = release_rx.recv();
}) as Box<dyn FnOnce() + Send>;
(gate, arrived_rx, release_tx)
}
#[cfg(unix)]
struct GatedRun<T> {
tree: Arc<Tree>,
arrived: Option<mpsc::Receiver<()>>,
release: Option<mpsc::Sender<()>>,
result: Option<Receiver<T>>,
}
#[cfg(unix)]
impl<T> GatedRun<T> {
fn start(
tree: &Arc<Tree>,
gate: Option<(mpsc::Receiver<()>, mpsc::Sender<()>)>,
f: impl FnOnce() -> T + Send + 'static,
) -> GatedRun<T>
where
T: Send + 'static,
{
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let _ = tx.send(f());
});
let (arrived, release) = match gate {
Some((a, r)) => (Some(a), Some(r)),
None => (None, None),
};
GatedRun {
tree: Arc::clone(tree),
arrived,
release,
result: Some(rx),
}
}
fn await_gate(&self) {
self.arrived
.as_ref()
.expect("this case was started with a rendezvous")
.recv_timeout(SYNC_BOUND)
.expect("the worker must reach its rendezvous");
}
fn release(&mut self) {
drop(self.release.take());
}
fn collect(&mut self) -> T {
self.result
.take()
.expect("the worker's result is collected once")
.recv_timeout(SYNC_BOUND)
.expect("the worker must return once it is released")
}
}
#[cfg(unix)]
impl<T> Drop for GatedRun<T> {
fn drop(&mut self) {
self.tree.cancel();
drop(self.release.take());
if let Some(rx) = self.result.take() {
let _ = rx.recv_timeout(SYNC_BOUND);
}
}
}
#[cfg(unix)]
fn wait_until(what: &str, mut cond: impl FnMut() -> bool) {
let started = Instant::now();
while !cond() {
assert!(
started.elapsed() < SYNC_BOUND,
"waited {SYNC_BOUND:?} for {what}"
);
thread::sleep(Duration::from_millis(2));
}
}
#[cfg(unix)]
fn descendant_fixture(marker: &Path, ready: &Path, delay: Duration) -> Command {
let mut cmd = Command::new("/bin/sh");
cmd.arg("-c").arg(format!(
"( sleep {}; printf x > \"{}\" ) &\nprintf r > \"{}\"\nprintf '{TRANSCRIPT}fixture\\n' >&2\nsleep 600\n",
delay.as_secs(),
marker.display(),
ready.display()
));
cmd
}
#[cfg(unix)]
fn past_the_side_effect(started: Instant, delay: Duration) {
thread::sleep((delay + Duration::from_secs(2)).saturating_sub(started.elapsed()));
}
#[test]
#[cfg(unix)]
fn a_cancellation_before_the_spawn_starts_no_fixture() {
let tmp = tempfile::tempdir().unwrap();
let marker = tmp.path().join("delayed-side-effect.txt");
let ready = tmp.path().join("fixture-ran.txt");
let delay = Duration::from_secs(6);
let cmd = descendant_fixture(&marker, &ready, delay);
let tree = Tree::new();
let (gate, arrived, release) = rendezvous();
let started = Instant::now();
let mut run = GatedRun::start(&tree, Some((arrived, release)), {
let tree = Arc::clone(&tree);
move || {
try_run_fixture_gated(
cmd,
true,
Duration::from_secs(600),
tree,
SpawnGates {
before_spawn: Some(gate),
..SpawnGates::default()
},
)
}
});
run.await_gate();
match tree.cancel() {
CancelOutcome::Recorded => {}
other => panic!("a cancellation before any spawn must be recorded, got {other:?}"),
}
run.release();
match run.collect() {
Err(FixtureFailure::Cancelled { leader: None }) => {}
other => panic!("expected the spawn to be refused outright, got {other:?}"),
}
match tree.cleanup() {
Cleanup::NeverStarted => {}
other => panic!("nothing was spawned, so there is nothing to have reaped: {other:?}"),
}
assert!(
!ready.exists(),
"the fixture ran under a standing cancellation: {}",
ready.display()
);
past_the_side_effect(started, delay);
assert!(
!marker.exists(),
"a fixture spawned under a standing cancellation performed its delayed side effect at {}",
marker.display()
);
}
#[test]
#[cfg(unix)]
fn a_cancellation_between_the_spawn_and_the_publication_cleans_up() {
let tmp = tempfile::tempdir().unwrap();
let marker = tmp.path().join("delayed-side-effect.txt");
let ready = tmp.path().join("fixture-ran.txt");
let delay = Duration::from_secs(6);
let cmd = descendant_fixture(&marker, &ready, delay);
let tree = Tree::new();
let (gate, arrived, release) = rendezvous();
let started = Instant::now();
let mut run = GatedRun::start(&tree, Some((arrived, release)), {
let tree = Arc::clone(&tree);
move || {
try_run_fixture_gated(
cmd,
true,
Duration::from_secs(600),
tree,
SpawnGates {
before_publish: Some(gate),
..SpawnGates::default()
},
)
}
});
run.await_gate();
assert!(
tree.live_pid().is_none(),
"the rendezvous sits before publication, so no pid can be on the tree yet"
);
wait_until("the fixture to fork its descendant", || ready.exists());
match tree.cancel() {
CancelOutcome::Recorded => {}
other => panic!("the pid is unpublished, so the cancellation must be recorded: {other:?}"),
}
run.release();
let status = match run.collect() {
Err(FixtureFailure::Cancelled {
leader: Some(status),
}) => status,
other => panic!("expected the spawner to clean up the leader it created, got {other:?}"),
};
assert!(
looks_killed_by_the_harness(&status),
"the leader must have been terminated rather than have exited on its own, got {status:?}"
);
match tree.cleanup() {
Cleanup::CancelledThenCleanedUp(recorded) => assert_eq!(
recorded, status,
"the reap the spawner performed is the one recorded on the tree"
),
other => panic!("the leader must be reaped by the thread that created it: {other:?}"),
}
assert_eq!(
tree.cleanup().reaped(),
Some(status),
"a cleanup that reaped a leader reports its status"
);
past_the_side_effect(started, delay);
assert!(
!marker.exists(),
"a descendant of a leader cancelled before publication survived and performed its \
side effect at {}",
marker.display()
);
}
#[test]
#[cfg(unix)]
fn a_cancellation_after_publication_is_signalled_and_reaped_by_the_worker() {
let tmp = tempfile::tempdir().unwrap();
let marker = tmp.path().join("delayed-side-effect.txt");
let ready = tmp.path().join("fixture-ran.txt");
let delay = Duration::from_secs(6);
let cmd = descendant_fixture(&marker, &ready, delay);
let tree = Tree::new();
let started = Instant::now();
let mut run = GatedRun::start(&tree, None, {
let tree = Arc::clone(&tree);
move || try_run_fixture(cmd, true, Duration::from_secs(600), tree)
});
wait_until("the worker to publish the leader's pid", || {
tree.live_pid().is_some()
});
wait_until("the fixture to fork its descendant", || ready.exists());
match tree.cancel() {
CancelOutcome::Signalled => {}
other => panic!("a published, unreaped leader must be signalled, got {other:?}"),
}
let _ = run.collect();
let status = match tree.cleanup() {
Cleanup::Reaped(status) => status,
other => panic!("the worker must reap the leader the supervisor signalled: {other:?}"),
};
assert!(
looks_killed_by_the_harness(&status),
"the leader was terminated by the cancellation, got {status:?}"
);
past_the_side_effect(started, delay);
assert!(
!marker.exists(),
"a descendant of a leader cancelled after publication survived and performed its \
side effect at {}",
marker.display()
);
}
#[test]
#[cfg(unix)]
fn a_spawners_cleanup_in_flight_does_not_read_as_nothing_started() {
let tree = Tree::new();
assert!(matches!(tree.cleanup(), Cleanup::NeverStarted));
assert!(matches!(tree.cancel(), CancelOutcome::Recorded));
assert!(
matches!(tree.cleanup(), Cleanup::NeverStarted),
"a cancellation with nothing spawned under it is still nothing started"
);
assert_eq!(tree.publish(4242), Permit::Cancelled);
match tree.cleanup() {
Cleanup::CancelledCleanupInFlight(pid) => assert_eq!(pid, 4242),
other => panic!("a spawner's cleanup in flight must not read as {other:?}"),
}
assert!(matches!(tree.cancel(), CancelOutcome::Recorded));
assert_eq!(
tree.cleanup().reaped(),
None,
"nothing has been reaped while the cleanup is still in flight"
);
let reaped = Command::new("/bin/sh")
.arg("-c")
.arg("exit 3")
.status()
.unwrap();
tree.cancelled_reap(reaped);
match tree.cleanup() {
Cleanup::CancelledThenCleanedUp(status) => assert_eq!(status.code(), Some(3)),
other => panic!("the spawner's reap must close the in-flight state, got {other:?}"),
}
}