diff --git a/apps/designcraft-cli/src/main.rs b/apps/designcraft-cli/src/main.rs index b14125f1..9e9eba25 100644 --- a/apps/designcraft-cli/src/main.rs +++ b/apps/designcraft-cli/src/main.rs @@ -17,16 +17,37 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] use std::process::ExitCode; +/// `println!` that ends the program quietly when stdout is closed (`designcraft-cli commands | +/// head`) instead of panicking with "failed printing to stdout: Broken pipe (os error 32)". +macro_rules! outln { + ($($arg:tt)*) => {{ + use std::io::Write as _; + if let Err(e) = writeln!(std::io::stdout(), $($arg)*) { + $crate::stdout_failed(e); + } + }}; +} + mod perf; use designcraft_engine::Session; use serde_json::{Value, json}; +/// stdout went away. A reader that stopped early (a closed pipe) ends the program quietly, as +/// ripgrep does; any other write error is reported. +fn stdout_failed(e: std::io::Error) -> ! { + if e.kind() == std::io::ErrorKind::BrokenPipe { + std::process::exit(0); + } + eprintln!("designcraft-cli: can't write to stdout: {e}"); + std::process::exit(1); +} + fn main() -> ExitCode { let args: Vec = std::env::args().skip(1).collect(); match args.first().map(String::as_str) { Some("--version" | "-V" | "version") => { - println!("designcraft-cli {}", env!("CARGO_PKG_VERSION")); + outln!("designcraft-cli {}", env!("CARGO_PKG_VERSION")); ExitCode::SUCCESS } Some("run") => report(run(&args[1..])), @@ -40,7 +61,7 @@ fn main() -> ExitCode { .into_iter() .filter(|c| filter.as_ref().is_none_or(|f| c.to_string().to_lowercase().contains(f.as_str()))) .collect(); - println!("{}", serde_json::to_string_pretty(&list).unwrap_or_default()); + outln!("{}", serde_json::to_string_pretty(&list).unwrap_or_default()); ExitCode::SUCCESS } Some("describe") => report(describe(args.get(1).map(String::as_str))), @@ -51,7 +72,7 @@ fn main() -> ExitCode { Some("bench") => report(perf::bench(&args[1..])), Some("links") => { use designcraft_engine::links::*; - println!("Discord {DISCORD}\nWebsite {WEBSITE}\nApp page {APP_PAGE}\nGitHub {GITHUB}\nIssues {ISSUES}"); + outln!("Discord {DISCORD}\nWebsite {WEBSITE}\nApp page {APP_PAGE}\nGitHub {GITHUB}\nIssues {ISSUES}"); ExitCode::SUCCESS } _ => { @@ -148,7 +169,7 @@ fn run(args: &[String]) -> Result<(), String> { let r = s.execute(id, &p).map_err(|e| e.to_string())?; results.push(r.clone()); if !r.is_null() { - println!("{}", serde_json::to_string(&r).unwrap_or_default()); + outln!("{}", serde_json::to_string(&r).unwrap_or_default()); } } "--page" => page = val()?.parse().map_err(|_| "bad --page")?, @@ -232,7 +253,7 @@ fn describe(id: Option<&str>) -> Result<(), String> { o.remove("enabled"); o.remove("disabled_reason"); } - println!("{}", serde_json::to_string_pretty(&c).unwrap_or_default()); + outln!("{}", serde_json::to_string_pretty(&c).unwrap_or_default()); Ok(()) } @@ -317,7 +338,7 @@ fn script(args: &[String]) -> Result<(), String> { } } } - println!("{}", serde_json::to_string_pretty(&report.to_json()).unwrap_or_default()); + outln!("{}", serde_json::to_string_pretty(&report.to_json()).unwrap_or_default()); match report.failed { Some((i, c, e)) => Err(format!("step {i} ({c}) failed: {e}")), None => Ok(()), @@ -350,6 +371,6 @@ fn app(args: &[String]) -> Result<(), String> { r.call("engine.execute", json!({"command": cmd, "params": json_arg(rest.get(1))?}))? } }; - println!("{}", serde_json::to_string_pretty(&out).unwrap_or_default()); + outln!("{}", serde_json::to_string_pretty(&out).unwrap_or_default()); Ok(()) } diff --git a/apps/designcraft-cli/src/perf.rs b/apps/designcraft-cli/src/perf.rs index f3217cd4..7749f644 100644 --- a/apps/designcraft-cli/src/perf.rs +++ b/apps/designcraft-cli/src/perf.rs @@ -502,9 +502,9 @@ fn measure(doc: &Document, runs: usize) -> Result, String> { } fn print(title: &str, rows: &[Row], noisy: bool) -> Result<(), String> { - println!("{title}"); + outln!("{title}"); let mut over = 0; - println!(" {:<60} {:>11} {:>10}", "", "measured", "budget"); + outln!(" {:<60} {:>11} {:>10}", "", "measured", "budget"); for r in rows { let (b, flag) = match r.budget { Some(b) if r.ms > b => { @@ -514,16 +514,16 @@ fn print(title: &str, rows: &[Row], noisy: bool) -> Result<(), String> { Some(b) => (format!("{b:>7.0} ms"), "ok"), None => (" — ".into(), ""), }; - println!(" {:<60} {:>8.2} ms {b} {flag}", r.name, r.ms); + outln!(" {:<60} {:>8.2} ms {b} {flag}", r.name, r.ms); } match (over, noisy) { (0, _) => Ok(()), (_, true) => { - println!("{over} over budget, but the machine is busy: re-run when idle."); + outln!("{over} over budget, but the machine is busy: re-run when idle."); Ok(()) } _ => { - println!("{over} budget(s) exceeded"); + outln!("{over} budget(s) exceeded"); Ok(()) } } @@ -533,13 +533,13 @@ fn header() -> (usize, bool) { let cores = std::thread::available_parallelism().map_or(1, |c| c.get()); let load = load_average(); let noisy = load.is_some_and(|l| l > cores as f64 * 0.75); - println!( + outln!( "{cores} cores, load average {}, render threads {}", load.map_or("?".into(), |l| format!("{l:.1}")), designcraft_render::default_threads() ); if noisy { - println!("WARNING: the machine is busy (load average above ¾ of the cores); wall-clock timings below are not trustworthy."); + outln!("WARNING: the machine is busy (load average above ¾ of the cores); wall-clock timings below are not trustworthy."); } (cores, noisy) } @@ -566,7 +566,7 @@ pub fn perf(args: &[String]) -> Result<(), String> { let t = Instant::now(); let doc = synthetic(&spec)?; let frames = spec.pages * spec.frames_per_page; - println!( + outln!( "DesignCraft performance budgets — synthetic: {} pages, {frames} text frames in {} threaded stories, {}k chars, {} images ({} assets), {} gradients (built in {:.0} ms)", spec.pages, doc.stories.len(), @@ -601,7 +601,7 @@ pub fn bench(args: &[String]) -> Result<(), String> { s.execute("file.open", &serde_json::json!({"path": file})).map_err(|e| e.to_string())?; let open = t.elapsed().as_secs_f64() * 1000.0; let doc = s.doc().map_err(|e| e.to_string())?.doc.clone(); - println!("{file}: {} pages, {} stories, {} assets (opened in {open:.0} ms)", doc.page_count(), doc.stories.len(), doc.assets.len()); + outln!("{file}: {} pages, {} stories, {} assets (opened in {open:.0} ms)", doc.page_count(), doc.stories.len(), doc.assets.len()); let rows = measure(&doc, runs)?; print("", &rows, noisy) } diff --git a/apps/designcraft-cli/tests/cli.rs b/apps/designcraft-cli/tests/cli.rs index aa118de2..75f6d90b 100644 --- a/apps/designcraft-cli/tests/cli.rs +++ b/apps/designcraft-cli/tests/cli.rs @@ -66,3 +66,17 @@ fn run_cmd_references_and_describe() { let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); assert!(v.as_array().unwrap().iter().all(|c| c.to_string().contains("footnote"))); } + +/// `designcraft-cli commands | head -1` panicked with "failed printing to +/// stdout: Broken pipe (os error 32)" and exit status 101. +#[test] +fn closed_stdout_ends_quietly() { + use std::io::Read; + let mut child = cli().arg("commands").stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap(); + let mut first = [0u8; 16]; + child.stdout.as_mut().unwrap().read_exact(&mut first).unwrap(); + drop(child.stdout.take()); + let out = child.wait_with_output().unwrap(); + let err = String::from_utf8_lossy(&out.stderr); + assert!(out.status.success() && !err.contains("panicked"), "{:?}: {err}", out.status); +}