collective

Browse / github.com/storytold/gridcraft / patch

CLI: finish workbook saves after stdout closes

CLI: finish workbook saves after stdout closes When a reader closes stdout during `gridcraft-cli run`, the CLI exits successfully before applying later commands or writing `--out`. Suppress further output after a broken pipe so the requested work finishes, and return failure if a later command or save fails. Other stdout errors still fail. The help text explains that closing stdout no longer cancels work. Handle a closed stderr pipe as well: when stdout and stderr share the closed reader, diagnostics no longer panic after a successful save or mask the exit status of failed work. Buffer each result line before writing it so short lines ret

Status
open
Score
0.00
Adopters
0 other operators
Author
DAnaeto (imported from pull request #2723, open)
Base
45f5ab18e4e57bf50ee88d2526deb4b87c5b24cb
Size
+153 −19 in 4 files
Review
pending
Shared
2026-10-28
Id
18b5f5b922dccba4b5b76d13

How it went

No other operator has reported using it yet.

Diff

raw · apps/gridcraft-cli/src/check.rs apps/gridcraft-cli/src/main.rs apps/gridcraft-cli/tests/it/cli.rs docs/cli.md

diff --git a/apps/gridcraft-cli/src/check.rs b/apps/gridcraft-cli/src/check.rs--- a/apps/gridcraft-cli/src/check.rs+++ b/apps/gridcraft-cli/src/check.rs@@ -191,7 +191,7 @@ impl Diffs { /// every checked formula matches and every round trip is clean; 1 otherwise; 2 on a usage error. pub fn main(args: &[String]) -> ExitCode {     let usage = |e: String| {-        eprintln!("gridcraft-cli: {e}\n{USAGE}");+        errln!("gridcraft-cli: {e}\n{USAGE}");         ExitCode::from(2)     };     let a = match Args::parse(args, &["tolerance", "max-report"], &["json"]) {diff --git a/apps/gridcraft-cli/src/main.rs b/apps/gridcraft-cli/src/main.rs--- a/apps/gridcraft-cli/src/main.rs+++ b/apps/gridcraft-cli/src/main.rs@@ -17,18 +17,32 @@ #![forbid(unsafe_code)]  use std::process::ExitCode;+use std::sync::atomic::{AtomicBool, Ordering}; -/// `println!` that ends the program quietly when stdout is closed (`gridcraft-cli commands |-/// head`) instead of panicking on a broken pipe.+static STDOUT_CLOSED: AtomicBool = AtomicBool::new(false);++/// `println!` that suppresses further output when a reader closes stdout, while allowing work to finish. macro_rules! outln {     ($($arg:tt)*) => {{-        use std::io::Write as _;-        if let Err(e) = writeln!(std::io::stdout(), $($arg)*) {+        if !$crate::STDOUT_CLOSED.load(std::sync::atomic::Ordering::Relaxed)+            && let Err(e) = $crate::write_stdout(format_args!("{}\n", format_args!($($arg)*)))+        {             $crate::stdout_failed(e);         }     }}; } +macro_rules! errln {+    ($($arg:tt)*) => {{+        use std::io::Write as _;+        if let Err(e) = writeln!(std::io::stderr().lock(), $($arg)*)+            && e.kind() != std::io::ErrorKind::BrokenPipe+        {+            std::process::exit(1);+        }+    }};+}+ mod check;  use gridcraft_engine::Session;@@ -45,20 +59,38 @@ const USAGE: &str = "usage:                      [--out FILE] [--print RANGE] [--quiet]                      a script line is id={json} or {\"command\": \"id\", \"params\": {…}};                      blank lines and lines starting with # or // are skipped+                     closing stdout does not cancel work; cancel the process to stop it   gridcraft-cli commands [--json] [--search X]   gridcraft-cli functions [--json] [--search X] [--category C]   gridcraft-cli mcp [--connect PORT] [--in FILE | --sample NAME]   gridcraft-cli send <port> <method> [json]   gridcraft-cli version"; -/// stdout went away. A reader that stopped early (a closed pipe) ends the program quietly; any-/// other write error is reported.-fn stdout_failed(e: std::io::Error) -> ! {+fn write_stdout(args: std::fmt::Arguments<'_>) -> std::io::Result<()> {+    use std::io::Write as _;+    #[cfg(unix)]+    {+        // Stdout suppresses EBADF, so write through a duplicate to report invalid destinations.+        use std::os::fd::AsFd as _;+        let stdout = std::io::stdout();+        std::fs::File::from(stdout.as_fd().try_clone_to_owned()?).write_all(args.to_string().as_bytes())+    }+    #[cfg(not(unix))]+    {+        let stdout = std::io::stdout();+        let mut out = stdout.lock();+        out.write_fmt(args).and_then(|()| out.flush())+    }+}++/// Stop writing after a reader closes the pipe; report any other write error.+fn stdout_failed(e: std::io::Error) {     if e.kind() == std::io::ErrorKind::BrokenPipe {-        std::process::exit(0);+        STDOUT_CLOSED.store(true, Ordering::Relaxed);+    } else {+        errln!("gridcraft-cli: can't write to stdout: {e}");+        std::process::exit(1);     }-    eprintln!("gridcraft-cli: can't write to stdout: {e}");-    std::process::exit(1); }  fn main() -> ExitCode {@@ -89,7 +121,7 @@ fn main() -> ExitCode {     match r {         Ok(()) => ExitCode::SUCCESS,         Err(e) => {-            eprintln!("gridcraft-cli: {e}");+            errln!("gridcraft-cli: {e}");             ExitCode::FAILURE         }     }@@ -159,7 +191,7 @@ fn exec(s: &mut Session, id: &str, params: Value) -> Result<Value, String> {         && let Some(warnings) = value.get("warnings").and_then(Value::as_array)     {         for warning in warnings.iter().filter_map(Value::as_str) {-            eprintln!("Warning: {warning}");+            errln!("Warning: {warning}");         }     }     r@@ -292,13 +324,13 @@ fn convert(args: &[String]) -> Result<(), String> {     activate_sheet(&mut s, a.opt("sheet"))?;     if pdf {         let result = exec(&mut s, "file.exportPdf", json!({"path": out}))?;-        eprintln!("wrote {out} ({} bytes, {} pages)", result["bytes"], result["pages"]);+        errln!("wrote {out} ({} bytes, {} pages)", result["bytes"], result["pages"]);         return Ok(());     }     let d = s.doc().map_err(|e| e.to_string())?;     let bytes = gridcraft_engine::io::save_file_bytes(&d.wb, out).map_err(|e| e.to_string())?;     gridcraft_engine::io::write_file(out, &bytes).map_err(|e| e.to_string())?;-    eprintln!("wrote {out} ({} bytes)", bytes.len());+    errln!("wrote {out} ({} bytes)", bytes.len());     Ok(()) } @@ -451,7 +483,7 @@ fn run(args: &[String]) -> Result<(), String> {     }     if let Some(out) = a.opt("out") {         let r = exec(&mut s, "file.save", json!({"path": out}))?;-        eprintln!("wrote {out} ({} bytes)", r["bytes"]);+        errln!("wrote {out} ({} bytes)", r["bytes"]);     }     for (name, range) in &a.opts {         if name == "print" {@@ -491,7 +523,7 @@ fn commands(args: &[String]) -> Result<(), String> {         outln!("{:<w$}  {}{menu}{sc}", c.id, c.label.trim_end_matches('…'));         outln!("{:<w$}    {}", "", c.params);     }-    eprintln!("{} commands", list.len());+    errln!("{} commands", list.len());     Ok(()) } @@ -518,7 +550,7 @@ fn functions(args: &[String]) -> Result<(), String> {         let desc = f["description"].as_str().unwrap_or("");         outln!("    {cat}: {desc}");     }-    eprintln!("{} functions", list.len());+    errln!("{} functions", list.len());     Ok(()) } @@ -549,7 +581,7 @@ fn mcp(args: &[String]) -> Result<(), String> {             Box::new(Headless::with_session(session))         }     };-    eprintln!("gridcraft-cli: MCP server on stdio ({})", backend.describe());+    errln!("gridcraft-cli: MCP server on stdio ({})", backend.describe());     let stdin = std::io::stdin();     let stdout = std::io::stdout();     Server::new(backend).serve(stdin.lock(), stdout.lock()).map_err(|e| e.to_string())diff --git a/apps/gridcraft-cli/tests/it/cli.rs b/apps/gridcraft-cli/tests/it/cli.rs--- a/apps/gridcraft-cli/tests/it/cli.rs+++ b/apps/gridcraft-cli/tests/it/cli.rs@@ -22,6 +22,105 @@ fn tmpdir(name: &str) -> PathBuf {     d } +#[cfg(unix)]+fn run_with_closed_stdout(args: &[&str]) -> Output {+    // Hold the shell until the read end is closed, so the first result always sees EPIPE.+    let mut child = Command::new("sh")+        .args(["-c", "IFS= read -r ready; exec \"$@\"", "sh", env!("CARGO_BIN_EXE_gridcraft-cli")])+        .args(args)+        .stdin(Stdio::piped())+        .stdout(Stdio::piped())+        .stderr(Stdio::piped())+        .spawn()+        .unwrap();+    drop(child.stdout.take());+    writeln!(child.stdin.take().unwrap(), "go").unwrap();+    child.wait_with_output().unwrap()+}++#[cfg(unix)]+#[test]+fn broken_stdout_still_runs_later_commands_and_saves_workbook() {+    let dir = tmpdir("broken-stdout-save");+    let book = dir.join("out.xlsx");+    let out = run_with_closed_stdout(&[+        "run",+        "--cmd",+        r#"cell.set={"cell":"A1","input":"first"}"#,+        "--cmd",+        r#"cell.set={"cell":"A2","input":"saved"}"#,+        "--out",+        book.to_str().unwrap(),+    ]);+    assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));+    assert!(book.exists(), "workbook was not saved");+    assert_eq!(ok(&["cat", book.to_str().unwrap(), "--range", "A1:A2"]).split_whitespace().collect::<Vec<_>>(), ["first", "saved"]);+    let _ = std::fs::remove_dir_all(dir);+}++#[cfg(unix)]+#[test]+fn broken_stdout_does_not_hide_later_work_or_save_errors() {+    let dir = tmpdir("broken-stdout-errors");+    let book = dir.join("missing").join("out.xlsx");+    let fail_save = run_with_closed_stdout(&["run", "--cmd", r#"cell.set={"cell":"A1","input":"first"}"#, "--out", book.to_str().unwrap()]);+    assert_eq!(fail_save.status.code(), Some(1), "save failure must return 1 without panicking");+    assert!(!fail_save.stderr.is_empty(), "save failure must include a diagnostic");+    let fail_command = run_with_closed_stdout(&["run", "--cmd", r#"cell.set={"cell":"A1","input":"first"}"#, "--cmd", "no.such.command"]);+    assert_eq!(fail_command.status.code(), Some(1), "later command failure must return 1 without panicking");+    assert!(String::from_utf8_lossy(&fail_command.stderr).contains("no.such.command"));+    let _ = std::fs::remove_dir_all(dir);+}++#[cfg(unix)]+#[test]+fn closed_stdout_and_stderr_do_not_hide_successful_save() {+    use std::os::fd::OwnedFd;+    use std::os::unix::net::UnixStream;++    let dir = tmpdir("closed-both-save");+    let book = dir.join("out.xlsx");+    let (writer, reader) = UnixStream::pair().unwrap();+    let error_writer = writer.try_clone().unwrap();+    drop(reader);+    let status = bin()+        .args(["run", "--cmd", r#"cell.set={"cell":"A1","input":"saved"}"#, "--out", book.to_str().unwrap()])+        .stdout(Stdio::from(OwnedFd::from(writer)))+        .stderr(Stdio::from(OwnedFd::from(error_writer)))+        .status()+        .unwrap();+    assert!(status.success(), "successful save must not fail after the reader closes: {status}");+    assert_eq!(ok(&["cat", book.to_str().unwrap(), "--range", "A1"]).trim(), "saved");+    let _ = std::fs::remove_dir_all(dir);+}++#[cfg(unix)]+#[test]+fn closed_stdout_and_stderr_still_report_work_failure() {+    use std::os::fd::OwnedFd;+    use std::os::unix::net::UnixStream;++    let (writer, reader) = UnixStream::pair().unwrap();+    let error_writer = writer.try_clone().unwrap();+    drop(reader);+    let status = bin()+        .args(["run", "--cmd", r#"cell.set={"cell":"A1","input":"first"}"#, "--cmd", "no.such.command"])+        .stdout(Stdio::from(OwnedFd::from(writer)))+        .stderr(Stdio::from(OwnedFd::from(error_writer)))+        .status()+        .unwrap();+    assert_eq!(status.code(), Some(1), "failed work should exit 1 without panicking");+}++#[cfg(unix)]+#[test]+fn stdout_errors_other_than_broken_pipe_fail() {+    let read_only = std::fs::File::open(env!("CARGO_BIN_EXE_gridcraft-cli")).unwrap();+    let out = bin().arg("version").stdout(Stdio::from(read_only)).output().unwrap();+    assert!(!out.status.success());+    assert!(String::from_utf8_lossy(&out.stderr).contains("can't write to stdout"));+}+ #[test] fn version_and_usage() {     assert_eq!(ok(&["--version"]).trim(), format!("gridcraft-cli {}", env!("CARGO_PKG_VERSION")));diff --git a/docs/cli.md b/docs/cli.md--- a/docs/cli.md+++ b/docs/cli.md@@ -45,6 +45,9 @@ gridcraft-cli run [--in FILE | --sample budget|sales|grades] \ Opens a file (or a sample, or a blank workbook), runs `--cmd` and `--script` steps in the order given, then saves `--out` and prints each `--print` range. Each non-null command result is printed as a JSON line unless `--quiet`. The first failing step stops the run with a non-zero exit.+If the reader of a stdout pipe closes early, printed output stops but the run continues, including later steps and a+requested `--out` save. The exit status is 0 if the work succeeds, or non-zero if it fails. Other stdout write errors+stop execution with a non-zero exit. Stop the process to cancel the run.  A `--cmd` is `id`, `id={json}` or `id {json}`. A script has one command per line in the same forms, or as JSON objects `{"command": "cell.set", "params": {...}}`; blank lines and `#` comments are skipped.