From c4a88ee64a1f459db35181e082273eb6ed310c38 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sat, 10 Oct 2026 16:23:43 +1100 Subject: [PATCH] Keep going when the CLI's stdout is closed. outln! treated a failed write as success and returned immediately, so run and script stopped before --bounce or --save whenever a pipe reader had gone away. A write error is now ignored and the exit code comes from the work itself. --- apps/soundcraft-cli/src/main.rs | 5 +- apps/soundcraft-cli/tests/closed_stdout.rs | 124 +++++++++++++++++++++ 2 files changed, 127 insertions(+), 2 deletions(-) create mode 100644 apps/soundcraft-cli/tests/closed_stdout.rs diff --git a/apps/soundcraft-cli/src/main.rs b/apps/soundcraft-cli/src/main.rs index 2ba9112..9c69978 100644 --- a/apps/soundcraft-cli/src/main.rs +++ b/apps/soundcraft-cli/src/main.rs @@ -20,11 +20,12 @@ use soundcraft_engine::Engine; use std::io::Write; use std::process::ExitCode; -/// Print without panicking on a closed pipe. +/// Print without panicking on a closed pipe. A write error must not skip later work +/// such as `--bounce` or `--save`, and must not by itself report success. macro_rules! outln { ($($t:tt)*) => {{ let mut o = std::io::stdout().lock(); - if writeln!(o, $($t)*).is_err() { return ExitCode::SUCCESS; } + let _ = writeln!(o, $($t)*); }}; } diff --git a/apps/soundcraft-cli/tests/closed_stdout.rs b/apps/soundcraft-cli/tests/closed_stdout.rs new file mode 100644 index 0000000..9807423 --- /dev/null +++ b/apps/soundcraft-cli/tests/closed_stdout.rs @@ -0,0 +1,124 @@ +//! A closed stdout must not abort `run` or `script` before `--bounce` and `--save`. + +use std::process::{Command, Stdio}; + +fn bin() -> Command { + let mut cmd = Command::new(env!("CARGO_BIN_EXE_soundcraft-cli")); + cmd.env("SOUNDCRAFT_NO_PREFS", "1"); + cmd +} + +/// Drop the read end so the child's stdout writes fail with `EPIPE`. +fn closed_stdout() -> std::process::Stdio { + let (reader, writer) = std::io::pipe().expect("pipe"); + drop(reader); + Stdio::from(writer) +} + +fn temp_dir(name: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("sc-cli-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +#[test] +fn run_with_closed_stdout_still_bounces_and_saves() { + let dir = temp_dir("run"); + let wav = dir.join("mix.wav"); + let session = dir.join("Synthetic.scraft"); + let output = bin() + .arg("run") + .arg("--cmd") + .arg(r#"track.new={"name":"Synthetic","count":1,"format":"stereo"}"#) + .arg("--bounce") + .arg(&wav) + .arg("--save") + .arg(&session) + .arg("--start") + .arg("0") + .arg("--end") + .arg("0.01") + .stdout(closed_stdout()) + .stderr(Stdio::piped()) + .output() + .unwrap(); + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); + assert!(wav.is_file(), "bounce was skipped"); + assert!(session.is_file(), "save was skipped"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn script_with_closed_stdout_still_bounces_and_saves() { + let dir = temp_dir("script"); + let script = dir.join("edit.scraft.txt"); + std::fs::write(&script, "track.new {\"name\":\"Synthetic\",\"count\":1,\"format\":\"stereo\"}\n").unwrap(); + let wav = dir.join("mix.wav"); + let session = dir.join("Synthetic.scraft"); + let output = bin() + .arg("script") + .arg(&script) + .arg("--bounce") + .arg(&wav) + .arg("--save") + .arg(&session) + .arg("--start") + .arg("0") + .arg("--end") + .arg("0.01") + .stdout(closed_stdout()) + .stderr(Stdio::piped()) + .output() + .unwrap(); + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); + assert!(wav.is_file()); + assert!(session.is_file()); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn bounce_then_save_with_closed_stdout_writes_the_wav() { + let dir = temp_dir("bounce"); + let wav = dir.join("mix.wav"); + let session = dir.join("Empty.scraft"); + let output = bin() + .arg("run") + .arg("--bounce") + .arg(&wav) + .arg("--save") + .arg(&session) + .arg("--start") + .arg("0") + .arg("--end") + .arg("0.01") + .stdout(closed_stdout()) + .stderr(Stdio::piped()) + .output() + .unwrap(); + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); + assert!(wav.is_file()); + assert!(session.is_file()); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn invalid_bounce_destination_still_fails() { + let dir = temp_dir("bad-bounce"); + let missing = dir.join("no-such-dir").join("mix.wav"); + let output = bin() + .arg("run") + .arg("--bounce") + .arg(&missing) + .arg("--start") + .arg("0") + .arg("--end") + .arg("0.01") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .unwrap(); + assert!(!output.status.success(), "a bounce that cannot be written must fail"); + assert!(!missing.exists()); + let _ = std::fs::remove_dir_all(&dir); +}