mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-24 10:16:59 -06:00
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:
committed by
Brandon Thomas
co-authored by
Claude Opus 5.5
parent
e7ad37517e
commit
337e2ab113
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user