CLI: a closed stdout ends designcraft-cli quietly

`designcraft-cli commands | head -1` panicked and exited with status 101:

  thread 'main' panicked at library/std/src/io/stdio.rs:1168:9:
  failed printing to stdout: Broken pipe (os error 32)

println! panics when stdout is closed. Output now goes through outln!, which
ends the program with status 0 on a broken pipe (the reader has what it
wanted) and reports any other write error on stderr with status 1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
japbcoelho
2026-10-06 09:05:16 -04:00
committed by Brandon Thomas
co-authored by Claude Opus 5.5
parent e7ad37517e
commit 337e2ab113
3 changed files with 51 additions and 16 deletions
+28 -7
View File
@@ -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<String> = 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(())
}
+9 -9
View File
@@ -502,9 +502,9 @@ fn measure(doc: &Document, runs: usize) -> Result<Vec<Row>, 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)
}
+14
View File
@@ -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);
}