collective

Browse / github.com/storytold/deckcraft / patch

CLI: finish file output after stdout closes

CLI: finish file output after stdout closes When a reader closes stdout during `deckcraft-cli run --print --save`, the CLI reports success before running later commands or saving the presentation. The same early exit also stops `render --all` after its first slide. Continue the requested work after a broken pipe and report any later command or save failure. Regression tests close the reader before the first result, reopen the saved presentation to verify a later command, compare all rendered slides with a normal run, and cover subsequent command, save, and non-pipe write failures on Unix. The help text describes the changed cancellatio

Status
open
Score
0.00
Adopters
0 other operators
Author
DAnaeto (imported from pull request #1451, open)
Base
ee875abffa1a31cb760c843bb29e79391a90a766
Size
+136 −6 in 4 files
Review
pending
Shared
2026-10-28
Id
250a51206759a96fdc465434

How it went

No other operator has reported using it yet.

Diff

raw · apps/deckcraft-cli/src/help.rs apps/deckcraft-cli/src/main.rs apps/deckcraft-cli/tests/it/closed_stdout.rs apps/deckcraft-cli/tests/it/main.rs

diff --git a/apps/deckcraft-cli/src/help.rs b/apps/deckcraft-cli/src/help.rs--- a/apps/deckcraft-cli/src/help.rs+++ b/apps/deckcraft-cli/src/help.rs@@ -180,7 +180,10 @@ A presentation is edited by running commands on it:   deckcraft-cli run --cmd 'slide.new={{\"title\":\"Q3 results\"}}' --save q3.deckcraft  `deckcraft-cli help SUBCOMMAND`, or `deckcraft-cli SUBCOMMAND --help`, explains one subcommand;-`deckcraft-cli help run` explains how to write a command.",+`deckcraft-cli help run` explains how to write a command.++If an output reader closes early, run, render, render-transition and convert continue; their exit+status reflects the work, not output delivery. Stop the process to cancel them.",         list.join("\n")     ) }diff --git a/apps/deckcraft-cli/src/main.rs b/apps/deckcraft-cli/src/main.rs--- a/apps/deckcraft-cli/src/main.rs+++ b/apps/deckcraft-cli/src/main.rs@@ -7,23 +7,38 @@ mod automation_args; mod help;  use std::process::ExitCode;+use std::sync::atomic::{AtomicBool, Ordering};  use deckcraft_engine::Session; use serde_json::{Value, json}; +static STDOUT_CLOSED: AtomicBool = AtomicBool::new(false);+ macro_rules! outln {     ($($arg:tt)*) => {{-        use std::io::Write as _;-        if let Err(e) = writeln!(std::io::stdout(), $($arg)*) {+        if !STDOUT_CLOSED.load(Ordering::Relaxed) && let Err(e) = stdout_line(format_args!($($arg)*)) {             if e.kind() == std::io::ErrorKind::BrokenPipe {-                std::process::exit(0);+                STDOUT_CLOSED.store(true, Ordering::Relaxed);+            } else {+                eprintln!("deckcraft-cli: can't write to stdout: {e}");+                std::process::exit(1);             }-            eprintln!("deckcraft-cli: can't write to stdout: {e}");-            std::process::exit(1);         }     }}; } +fn stdout_line(args: std::fmt::Arguments<'_>) -> std::io::Result<()> {+    use std::io::Write as _;+    #[cfg(unix)]+    let mut stdout = {+        use std::os::fd::AsFd as _;+        std::fs::File::from(std::io::stdout().as_fd().try_clone_to_owned()?)+    };+    #[cfg(not(unix))]+    let mut stdout = std::io::stdout();+    stdout.write_all(format!("{args}\n").as_bytes())+}+ fn main() -> ExitCode {     let args: Vec<String> = std::env::args().skip(1).collect();     let rest = args.get(1..).unwrap_or(&[]);diff --git a/apps/deckcraft-cli/tests/it/closed_stdout.rs b/apps/deckcraft-cli/tests/it/closed_stdout.rsnew file mode 100644--- /dev/null+++ b/apps/deckcraft-cli/tests/it/closed_stdout.rs@@ -0,0 +1,110 @@+use std::fs::{self, File};+use std::os::fd::OwnedFd;+use std::os::unix::net::UnixStream;+use std::path::{Path, PathBuf};+use std::process::{Command, Output, Stdio};+use std::time::{SystemTime, UNIX_EPOCH};++struct Scratch(PathBuf);++impl Scratch {+    fn new(name: &str) -> Self {+        let nonce = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos();+        let path = std::env::temp_dir().join(format!("deckcraft-closed-stdout-{name}-{}-{nonce}", std::process::id()));+        fs::create_dir(&path).unwrap();+        Self(path)+    }+}++impl Drop for Scratch {+    fn drop(&mut self) {+        let _ = fs::remove_dir_all(&self.0);+    }+}++fn closed_stdout() -> Stdio {+    let (writer, reader) = UnixStream::pair().unwrap();+    drop(reader);+    Stdio::from(OwnedFd::from(writer))+}++fn run(dir: &Path, args: &[&str], stdout: Stdio) -> Output {+    Command::new(env!("CARGO_BIN_EXE_deckcraft-cli")).current_dir(dir).args(args).stdout(stdout).stderr(Stdio::piped()).output().unwrap()+}++#[test]+fn broken_pipe_does_not_skip_later_commands_or_save() {+    let dir = Scratch::new("save");+    let out = run(+        &dir.0,+        &[+            "run",+            "--cmd",+            r#"slide.new={"layout":"titleOnly","title":"First"}"#,+            "--cmd",+            r#"slide.new={"layout":"titleOnly","title":"Later"}"#,+            "--print",+            "--save",+            "out.deckcraft",+        ],+        closed_stdout(),+    );+    assert!(out.status.success(), "exit {:?}: {}", out.status, String::from_utf8_lossy(&out.stderr));+    let saved = dir.0.join("out.deckcraft");+    assert!(saved.is_file(), "requested save was skipped");+    let info = Command::new(env!("CARGO_BIN_EXE_deckcraft-cli")).arg("info").arg(saved).output().unwrap();+    assert!(info.status.success(), "{}", String::from_utf8_lossy(&info.stderr));+    let data: serde_json::Value = serde_json::from_slice(&info.stdout).unwrap();+    let slides = data["slides"].as_array().unwrap();+    assert!(slides.iter().any(|slide| slide["title"] == "Later"), "later command did not persist: {data}");+}++#[test]+fn broken_pipe_does_not_hide_a_later_command_failure() {+    let dir = Scratch::new("command-failure");+    let out = run(+        &dir.0,+        &["run", "--cmd", r#"slide.new={"title":"First"}"#, "--cmd", r#"slide.go={"index":999}"#, "--print", "--save", "out.deckcraft"],+        closed_stdout(),+    );+    assert!(!out.status.success(), "a later command failure returned success");+    assert!(!dir.0.join("out.deckcraft").exists(), "failed run unexpectedly saved");+    assert!(String::from_utf8_lossy(&out.stderr).contains("no slide 999"), "missing command failure diagnostic");+}++#[test]+fn broken_pipe_does_not_hide_a_save_failure() {+    let dir = Scratch::new("save-failure");+    fs::create_dir(dir.0.join("out.deckcraft")).unwrap();+    let out = run(&dir.0, &["run", "--cmd", r#"slide.new={"title":"First"}"#, "--print", "--save", "out.deckcraft"], closed_stdout());+    assert!(!out.status.success(), "a failed save returned success");+    assert!(!out.stderr.is_empty(), "missing save failure diagnostic");+}++#[test]+fn broken_pipe_does_not_skip_later_rendered_slides() {+    let dir = Scratch::new("render-all");+    let baseline = run(&dir.0, &["render", "--sample", "--all", "--scale", "0.1", "normal"], Stdio::piped());+    assert!(baseline.status.success(), "{}", String::from_utf8_lossy(&baseline.stderr));+    let closed = run(&dir.0, &["render", "--sample", "--all", "--scale", "0.1", "closed"], closed_stdout());+    assert!(closed.status.success(), "{}", String::from_utf8_lossy(&closed.stderr));+    let names = |folder: &str| {+        let mut files: Vec<_> = fs::read_dir(dir.0.join(folder)).unwrap().map(|entry| entry.unwrap().file_name()).collect();+        files.sort();+        files+    };+    let expected = names("normal");+    assert!(expected.len() > 1, "sample must have multiple slides");+    assert_eq!(names("closed"), expected, "rendering stopped after stdout closed");+}++#[test]+fn other_stdout_write_errors_fail() {+    let dir = Scratch::new("write-failure");+    let path = dir.0.join("readonly");+    fs::write(&path, b"").unwrap();+    let readonly = File::open(path).unwrap();+    let out = run(&dir.0, &["run", "--cmd", r#"slide.new={"title":"First"}"#, "--print"], Stdio::from(readonly));+    assert!(!out.status.success(), "a non-broken stdout error returned success");+    assert!(String::from_utf8_lossy(&out.stderr).contains("can't write to stdout"));+}diff --git a/apps/deckcraft-cli/tests/it/main.rs b/apps/deckcraft-cli/tests/it/main.rs--- a/apps/deckcraft-cli/tests/it/main.rs+++ b/apps/deckcraft-cli/tests/it/main.rs@@ -1,4 +1,6 @@ mod cli;+#[cfg(unix)]+mod closed_stdout; mod convert; mod damaged; // Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md).