diff --git a/apps/vectorcraft-cli/src/main.rs b/apps/vectorcraft-cli/src/main.rs index 6c6c2f26..eb6f472c 100644 --- a/apps/vectorcraft-cli/src/main.rs +++ b/apps/vectorcraft-cli/src/main.rs @@ -14,11 +14,41 @@ use std::io::Write; use std::process::ExitCode; +/// `println!` / `print!` that end the program quietly when stdout is closed +/// (`vectorcraft-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); + } + }}; +} +macro_rules! out { + ($($arg:tt)*) => {{ + use std::io::Write as _; + if let Err(e) = write!(std::io::stdout(), $($arg)*) { + $crate::stdout_failed(e); + } + }}; +} + mod perf; use serde_json::{Value, json}; use vectorcraft_mcp::{Backend, DEFAULT_ADDR, Headless, Remote, Server}; +/// 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!("vectorcraft-cli: can't write to stdout: {e}"); + std::process::exit(1); +} + const USAGE: &str = "\ vectorcraft-cli — VectorCraft automation @@ -68,11 +98,11 @@ fn main() -> ExitCode { Some("bench") => bench(&args[1..]), Some("perf") => perf::run(&args[1..]), Some("-h" | "--help" | "help") | None => { - print!("{}", usage()); + out!("{}", usage()); Ok(()) } Some("-V" | "--version") => { - println!("vectorcraft-cli {}", env!("CARGO_PKG_VERSION")); + outln!("vectorcraft-cli {}", env!("CARGO_PKG_VERSION")); Ok(()) } Some(other) => Err(format!("unknown subcommand `{other}`\n\n{}", usage())), @@ -120,7 +150,7 @@ fn mcp(args: &[String]) -> Result<(), String> { fn commands() -> Result<(), String> { let mut h = Headless::new(); let v = h.call("engine.commands", json!({}))?; - println!("{}", serde_json::to_string_pretty(&v).unwrap_or_default()); + outln!("{}", serde_json::to_string_pretty(&v).unwrap_or_default()); Ok(()) } @@ -146,7 +176,7 @@ fn convert(args: &[String]) -> Result<(), String> { json!({"command": "document.export", "params": {"path": output, "scale": scale, "artboard": artboard, "range": range, "outlineText": outline_text}}), ) .map_err(|e| format!("export {output}: {e}"))?; - println!("{r}"); + outln!("{r}"); Ok(()) } @@ -162,7 +192,7 @@ fn info(args: &[String]) -> Result<(), String> { let artboards: Vec = doc.artboards.iter().map(|a| json!({"name": a.name, "rect": [a.rect.x0, a.rect.y0, a.rect.width(), a.rect.height()]})).collect(); let v = json!({"file": file, "info": base, "artboards": artboards, "kinds": kinds, "fonts": fonts}); - println!("{}", serde_json::to_string_pretty(&v).unwrap_or_default()); + outln!("{}", serde_json::to_string_pretty(&v).unwrap_or_default()); Ok(()) } @@ -252,7 +282,7 @@ fn bench(args: &[String]) -> Result<(), String> { * vectorcraft_geom::Affine::scale(z) * vectorcraft_geom::Affine::translate(-b.center().to_vec2()); let opts = vectorcraft_render::RenderOptions::default(); - println!("{file}: {} nodes, {w}x{h}", doc.layers.iter().map(|l| l.count()).sum::()); + outln!("{file}: {} nodes, {w}x{h}", doc.layers.iter().map(|l| l.count()).sum::()); for threads in [vectorcraft_render::default_threads(), 0] { let mut r = vectorcraft_render::Renderer::new(); r.threads = threads; @@ -261,7 +291,7 @@ fn bench(args: &[String]) -> Result<(), String> { for _ in 0..iters { r.render(&doc, w, h, view, &opts); } - println!( + outln!( " threads {threads}: {:.1} ms/frame (drawn {}, culled {})", t.elapsed().as_secs_f64() * 1000.0 / iters as f64, r.stats.drawn, diff --git a/apps/vectorcraft-cli/src/perf.rs b/apps/vectorcraft-cli/src/perf.rs index 2f4e5c11..f13b0cbe 100644 --- a/apps/vectorcraft-cli/src/perf.rs +++ b/apps/vectorcraft-cli/src/perf.rs @@ -81,14 +81,14 @@ pub fn run(args: &[String]) -> Result<(), String> { } let cores = std::thread::available_parallelism().map_or(1, |c| c.get()); let load = load_average(); - println!("VectorCraft performance budgets — {n} paths, {cores} cores, load average {}", load.map_or("?".into(), |l| format!("{l:.1}"))); + outln!("VectorCraft performance budgets — {n} paths, {cores} cores, load average {}", load.map_or("?".into(), |l| format!("{l:.1}"))); let noisy = load.is_some_and(|l| l > cores as f64 * 0.75); if noisy { - println!("WARNING: the machine is busy; wall-clock timings below are not trustworthy."); + outln!("WARNING: the machine is busy; wall-clock timings below are not trustworthy."); } let t = Instant::now(); let doc = synthetic(n)?; - println!(" (built in {:.0} ms)", t.elapsed().as_secs_f64() * 1000.0); + outln!(" (built in {:.0} ms)", t.elapsed().as_secs_f64() * 1000.0); let mut rows: Vec<(&str, f64, f64)> = vec![]; let opts = RenderOptions::default(); @@ -150,16 +150,16 @@ pub fn run(args: &[String]) -> Result<(), String> { } let mut over = 0; - println!(" {:<40} {:>10} {:>10}", "", "measured", "budget"); + outln!(" {:<40} {:>10} {:>10}", "", "measured", "budget"); for (name, ms, budget) in &rows { let ok = ms <= budget; over += usize::from(!ok); - println!(" {name:<40} {ms:>8.2} ms {budget:>7.0} ms {}", if ok { "ok" } else { "OVER" }); + outln!(" {name:<40} {ms:>8.2} ms {budget:>7.0} ms {}", if ok { "ok" } else { "OVER" }); } 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(()) } _ => Err(format!("{over} budget(s) exceeded")), diff --git a/apps/vectorcraft-cli/tests/cli.rs b/apps/vectorcraft-cli/tests/cli.rs index 398f2880..1dfebe64 100644 --- a/apps/vectorcraft-cli/tests/cli.rs +++ b/apps/vectorcraft-cli/tests/cli.rs @@ -82,3 +82,17 @@ fn mcp_headless_over_stdio() { assert_eq!(replies[2]["result"]["isError"], false); assert_eq!(replies[3]["result"]["content"][0]["type"], "image"); } + +/// `vectorcraft-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 = Command::new(BIN).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); +}