From 47339b0862d608ca23a7a2cb282d084790e22703 Mon Sep 17 00:00:00 2001 From: Mathjk Date: Wed, 30 Sep 2026 13:09:22 +0800 Subject: [PATCH] Handle broken pipe errors on stdout gracefully Replace `println!` calls on the results paths with `writeln!` on `io::stdout()` and propagate the `io::Error` to `main`, where `ErrorKind::BrokenPipe` is turned into a quiet `exit(0)`. This matches the conventional behavior of command-line tools when their output pipe is closed early (e.g. `hyperfine ... | head -n 0`), and fixes panics in all run-output paths: the per-benchmark header and results, the speed comparison summary, and `-`-style exports to stdout. The warnings and "Note" outputs to stderr are converted the same way. The earlier fix for #453 only covered the `-h`/`--help` output. A regression test runs hyperfine with a pre-closed stdout pipe. Assisted-by: AI tooling (patch generated and verified automatically) --- src/benchmark/mod.rs | 27 ++++++++++++++---------- src/benchmark/scheduler.rs | 33 +++++++++++++++++------------ src/export/mod.rs | 6 +++--- src/main.rs | 25 ++++++++++++++++++++-- tests/integration_tests.rs | 43 +++++++++++++++++++++++++++++++++++++- 5 files changed, 104 insertions(+), 30 deletions(-) diff --git a/src/benchmark/mod.rs b/src/benchmark/mod.rs index e3534a7..83d7858 100644 --- a/src/benchmark/mod.rs +++ b/src/benchmark/mod.rs @@ -5,6 +5,7 @@ pub mod scheduler; pub mod timing_result; use std::cmp; +use std::io::{self, Write}; use crate::benchmark::executor::BenchmarkIteration; use crate::command::Command; @@ -140,12 +141,13 @@ impl<'a> Benchmark<'a> { /// Run the benchmark for a single command pub fn run(&self) -> Result { if self.options.output_style != OutputStyleOption::Disabled { - println!( + writeln!( + io::stdout(), "{}{}: {}", "Benchmark ".bold(), (self.number + 1).to_string().bold(), self.command.get_name_with_unused_parameters(), - ); + )?; } let mut times_real: Vec = vec![]; @@ -359,18 +361,20 @@ impl<'a> Benchmark<'a> { if self.options.output_style != OutputStyleOption::Disabled { if times_real.len() == 1 { - println!( + writeln!( + io::stdout(), " Time ({} ≡): {:>8} {:>8} [User: {}, System: {}]", "abs".green().bold(), mean_str.green().bold(), " ", // alignment user_str.blue(), system_str.blue() - ); + )?; } else { let stddev_str = format_duration(t_stddev.unwrap(), Some(time_unit)); - println!( + writeln!( + io::stdout(), " Time ({} ± {}): {:>8} ± {:>8} [User: {}, System: {}]", "mean".green().bold(), "σ".green(), @@ -378,16 +382,17 @@ impl<'a> Benchmark<'a> { stddev_str.green(), user_str.blue(), system_str.blue() - ); + )?; - println!( + writeln!( + io::stdout(), " Range ({} … {}): {:>8} … {:>8} {}", "min".cyan(), "max".purple(), min_str.cyan(), max_str.purple(), num_str.dimmed() - ); + )?; } } @@ -430,15 +435,15 @@ impl<'a> Benchmark<'a> { } if !warnings.is_empty() { - eprintln!(" "); + writeln!(io::stderr(), " ")?; for warning in &warnings { - eprintln!(" {}: {}", "Warning".yellow(), warning); + writeln!(io::stderr(), " {}: {}", "Warning".yellow(), warning)?; } } if self.options.output_style != OutputStyleOption::Disabled { - println!(" "); + writeln!(io::stdout(), " ")?; } self.run_cleanup_command(self.command.get_parameters().iter().cloned(), output_policy)?; diff --git a/src/benchmark/scheduler.rs b/src/benchmark/scheduler.rs index 69c19eb..9dc56b2 100644 --- a/src/benchmark/scheduler.rs +++ b/src/benchmark/scheduler.rs @@ -3,6 +3,7 @@ use super::executor::{Executor, MockExecutor, RawExecutor, ShellExecutor}; use super::{relative_speed, Benchmark}; use colored::*; use std::cmp::Ordering; +use std::io::{self, Write}; use crate::command::{Command, Commands}; use crate::export::ExportManager; @@ -58,13 +59,13 @@ impl<'a> Scheduler<'a> { Ok(()) } - pub fn print_relative_speed_comparison(&self) { + pub fn print_relative_speed_comparison(&self) -> Result<()> { if self.options.output_style == OutputStyleOption::Disabled { - return; + return Ok(()); } if self.results.len() < 2 { - return; + return Ok(()); } let reference = self @@ -81,15 +82,16 @@ impl<'a> Scheduler<'a> { ) { match self.options.sort_order_speed_comparison { SortOrder::MeanTime => { - println!("{}", "Summary".bold()); + writeln!(io::stdout(), "{}", "Summary".bold())?; let reference = annotated_results.iter().find(|r| r.is_reference).unwrap(); let others = annotated_results.iter().filter(|r| !r.is_reference); - println!( + writeln!( + io::stdout(), " {} ran", reference.result.command_with_unused_parameters.cyan() - ); + )?; for item in others { let stddev = if let Some(stddev) = item.relative_speed_stddev { @@ -114,18 +116,20 @@ impl<'a> Scheduler<'a> { stddev ), }; - println!( + writeln!( + io::stdout(), "{} {}", comparator, item.result.command_with_unused_parameters.magenta() - ); + )?; } } SortOrder::Command => { - println!("{}", "Relative speed comparison".bold()); + writeln!(io::stdout(), "{}", "Relative speed comparison".bold())?; for item in annotated_results { - println!( + writeln!( + io::stdout(), " {}{} {}", format!("{:10.2}", item.relative_speed).bold().green(), if item.is_reference { @@ -136,12 +140,13 @@ impl<'a> Scheduler<'a> { " ".into() }, item.result.command_with_unused_parameters, - ); + )?; } } } } else { - eprintln!( + writeln!( + io::stderr(), "{}: The benchmark comparison could not be computed as some benchmark times are zero. \ This could be caused by background interference during the initial calibration phase \ of hyperfine, in combination with very fast commands (faster than a few milliseconds). \ @@ -149,8 +154,10 @@ impl<'a> Scheduler<'a> { --shell=none/-N option. If it does not help either, you command is most likely too fast \ to be accurately benchmarked by hyperfine.", "Note".bold().red() - ); + )?; } + + Ok(()) } pub fn final_export(&self) -> Result<()> { diff --git a/src/export/mod.rs b/src/export/mod.rs index 3947a2e..8957e26 100644 --- a/src/export/mod.rs +++ b/src/export/mod.rs @@ -1,5 +1,5 @@ use std::fs::{File, OpenOptions}; -use std::io::Write; +use std::io::{self, Write}; mod asciidoc; mod csv; @@ -144,8 +144,8 @@ impl ExportManager { } ExportTarget::Stdout => { if !intermediate { - println!(); - println!("{}", String::from_utf8(content()?).unwrap()); + writeln!(io::stdout())?; + writeln!(io::stdout(), "{}", String::from_utf8(content()?).unwrap())?; } } } diff --git a/src/main.rs b/src/main.rs index 2a97505..06c5d9b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,6 +4,7 @@ )] use std::env; +use std::io::{self, Write}; use benchmark::scheduler::Scheduler; use cli::get_cli_arguments; @@ -44,17 +45,37 @@ fn run() -> Result<()> { let mut scheduler = Scheduler::new(&commands, &options, &export_manager); scheduler.run_benchmarks()?; - scheduler.print_relative_speed_comparison(); + scheduler.print_relative_speed_comparison()?; scheduler.final_export()?; Ok(()) } +/// Check whether `error` or one of its causes is an `io::Error` of kind +/// `BrokenPipe`. This happens when the process on the reading end of a +/// pipe (e.g. `hyperfine … | head -n 1`) terminates before hyperfine is +/// done writing its results. +fn caused_by_broken_pipe(error: &anyhow::Error) -> bool { + error.chain().any(|cause| { + cause + .downcast_ref::() + .is_some_and(|e| e.kind() == io::ErrorKind::BrokenPipe) + }) +} + fn main() { match run() { Ok(_) => {} Err(e) => { - eprintln!("{} {:#}", "Error:".red(), e); + // When writing to a closed pipe, exit quietly with a zero exit + // code — the same behavior that non-Rust CLI tools exhibit when + // they are terminated by `SIGPIPE`. + if caused_by_broken_pipe(&e) { + std::process::exit(0); + } + // The write to stderr can itself fail if stderr is closed; the + // error message cannot be shown in that case anyway. + let _ = writeln!(io::stderr(), "{} {:#}", "Error:".red(), e); std::process::exit(1); } } diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index 2de3a50..54e3844 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -1,5 +1,5 @@ mod common; -use common::hyperfine; +use common::{hyperfine, hyperfine_raw_command}; use predicates::prelude::*; @@ -33,6 +33,47 @@ fn one_run_is_supported() { .success(); } +/// Regression test: hyperfine must not panic when writing to a closed +/// stdout pipe (e.g. `hyperfine ... | head -n 1` or a downstream process +/// that terminates early). A `BrokenPipe` error should result in a quiet +/// exit with code 0 instead of a `println!` panic. +#[test] +fn exits_quietly_when_stdout_is_closed() { + use std::process::Stdio; + + for args in [ + vec!["--runs=1", "echo dummy benchmark"], + vec!["--runs=1", "--export-json=-", "echo dummy benchmark"], + vec!["--runs=1", "echo dummy benchmark", "echo second benchmark"], + ] { + // A pipe whose read end has been closed before hyperfine is even + // started: every write to it will fail with `BrokenPipe`. + let (reader, writer) = std::io::pipe().expect("failed to create pipe"); + drop(reader); + + let output = hyperfine_raw_command() + .args(&args) + .stdout(writer) + .stderr(Stdio::piped()) + .spawn() + .expect("failed to spawn hyperfine") + .wait_with_output() + .expect("failed to wait for hyperfine"); + let stderr = String::from_utf8_lossy(&output.stderr); + + assert!( + !stderr.contains("panicked"), + "hyperfine panicked while writing to a closed pipe:\n{}", + stderr + ); + assert!( + output.status.success(), + "hyperfine did not exit cleanly; stderr:\n{}", + stderr + ); + } +} + #[test] fn can_run_commands_without_a_shell() { hyperfine()