From e1f16bee26696128ea1c8bb38088a5217c80c92a Mon Sep 17 00:00:00 2001 From: Fintan Halpenny Date: Thu, 2 Apr 2026 16:57:28 +0000 Subject: [PATCH] term: Catch EPIPE and swallow Rust (since 1.62) ignores EPIPE by default (see [Rust #62569]), causing writes to closed pipes to return `io::ErrorKind::BrokenPipe` errors. The `println!` macro panics on these errors, producing a confusing backtrace instead of the silent exit expected of Unix CLI tools. To avoid the use of `print!` and `println!`, the `radicle-term` helper functions use `write!` and `writeln!`. This lays the groundwork for the `rad` CLI to transition to using only `radicle-term` functions. There was a first attempt made, which is documented here. A process-wide SIGPIPE reset (`SIG_DFL`) was ruled out because `rad` manages child processes via libgit2 pipes and communicates with the radicle node over Unix sockets. Resetting SIGPIPE globally caused `rad` itself to be killed during internal pipe operations (e.g. during `rad remote add --fetch`), producing flaky test failures. [Rust #62569] https://github.com/rust-lang/rust/issues/62569 --- crates/radicle-cli/tests/commands/sigpipe.rs | 2 - crates/radicle-term/src/element.rs | 6 +- crates/radicle-term/src/io.rs | 96 +++++++++++++++----- 3 files changed, 79 insertions(+), 25 deletions(-) diff --git a/crates/radicle-cli/tests/commands/sigpipe.rs b/crates/radicle-cli/tests/commands/sigpipe.rs index 4bd78cf0..595204e7 100644 --- a/crates/radicle-cli/tests/commands/sigpipe.rs +++ b/crates/radicle-cli/tests/commands/sigpipe.rs @@ -40,7 +40,6 @@ use radicle::profile; use crate::util::environment::Environment; -#[ignore = "test fails"] #[test] fn config() { let mut environment = Environment::new(); @@ -121,7 +120,6 @@ fn help() { } /// `rad self` uses `Element::print()` for table output. -#[ignore = "test fails"] #[test] fn rad_self() { let mut environment = Environment::new(); diff --git a/crates/radicle-term/src/element.rs b/crates/radicle-term/src/element.rs index 7e1ebada..b9d29105 100644 --- a/crates/radicle-term/src/element.rs +++ b/crates/radicle-term/src/element.rs @@ -84,8 +84,12 @@ pub trait Element: fmt::Debug + Send + Sync { /// Print this element to stdout. fn print(&self) { + use std::io::Write; + + let mut stdout = io::stdout().lock(); for line in self.render(Constraint::from_env().unwrap_or_default()) { - println!("{}", line.to_string().trim_end()); + let _ = writeln!(stdout, "{}", line.to_string().trim_end()) + .or_else(crate::io::swallow_broken_pipe_stdout); } } diff --git a/crates/radicle-term/src/io.rs b/crates/radicle-term/src/io.rs index 5bf1a985..b36f9324 100644 --- a/crates/radicle-term/src/io.rs +++ b/crates/radicle-term/src/io.rs @@ -74,7 +74,7 @@ macro_rules! info { writeln!($writer, $($arg)*).ok(); }); ($($arg:tt)*) => ({ - println!("{}", format_args!($($arg)*)); + $crate::io::print(format_args!($($arg)*)); }) } @@ -118,11 +118,11 @@ pub fn success_args(w: &mut W, args: fmt::Arguments) { } pub fn tip_args(args: fmt::Arguments) { - println!( + print(format_args!( "{} {}", format::yellow("*"), style(format!("{args}")).italic() - ); + )); } pub fn notice_args(w: &mut W, args: fmt::Arguments) { @@ -148,27 +148,76 @@ pub fn viewport() -> Option { } pub fn headline(headline: impl fmt::Display) { - println!(); - println!("{}", style(headline).bold()); - println!(); + print(""); + print(style(headline).bold()); + print(""); } pub fn header(header: &str) { - println!(); - println!("{}", style(format::yellow(header)).bold().underline()); - println!(); + print(""); + print(style(format::yellow(header)).bold().underline()); + print(""); } pub fn blob(text: impl fmt::Display) { - println!("{}", style(text.to_string().trim()).dim()); + print(style(text.to_string().trim()).dim()); } pub fn blank() { - println!() + print(""); } +/// Print a line to stdout, silently ignoring broken pipe errors. +/// +/// Use this function instead of [`println!`] when you want to print to standard +/// output, but silently ignore broken pipe errors. +/// +/// See also [`self::print`]. +/// +/// # Panics +/// +/// If writing to standard output fails with an error not of kind [`io::ErrorKind::BrokenPipe`]. pub fn print(msg: impl fmt::Display) { - println!("{msg}"); + use io::Write; + + let mut stdout = io::stdout().lock(); + let _ = writeln!(stdout, "{msg}").or_else(swallow_broken_pipe_stdout); +} + +/// Print to stdout without a trailing newline, silently ignoring broken pipe +/// errors. +/// +/// Use this function instead of [`print!`] when you want to print to standard +/// output, but silently ignore broken pipe errors. +/// +/// See also [`self::print`]. +/// +/// # Panics +/// +/// If writing to standard output fails with an error not of kind [`io::ErrorKind::BrokenPipe`]. +pub fn print_inline(msg: impl fmt::Display) { + use io::Write; + + let mut stdout = io::stdout().lock(); + let _ = write!(stdout, "{msg}").or_else(swallow_broken_pipe_stdout); +} + +/// If the given `err` is of kind [`io::ErrorKind::BrokenPipe`], return `Ok(())` +/// to silently ignore it. Otherwise, panic saying "failed printing to stdout", +/// followed by the error message. +/// +/// This may be used with [`Result::or_else`] to ignore broken pipes when +/// writing to standard output. +/// +/// # Panics +/// +/// If `err` is not of kind [`io::ErrorKind::BrokenPipe`]. +pub(crate) fn swallow_broken_pipe_stdout(err: io::Error) -> io::Result<()> { + if err.kind() == io::ErrorKind::BrokenPipe { + Ok(()) + } else { + panic!("failed printing to stdout: {err}") + } } pub fn prefixed(prefix: &str, text: &str) -> String { @@ -179,7 +228,7 @@ pub fn prefixed(prefix: &str, text: &str) -> String { } pub fn help(name: &str, version: &str, description: &str, usage: &str) { - println!("rad-{name} {version}\n{description}\n{usage}"); + print(format_args!("rad-{name} {version}\n{description}\n{usage}")); } pub fn manual(name: &str) -> io::Result { @@ -192,40 +241,43 @@ pub fn manual(name: &str) -> io::Result { } pub fn usage(name: &str, usage: &str) { - println!( + print(format_args!( "{} {}\n{}", PREFIX_ERROR, Paint::red(format!("Error: rad-{name}: invalid usage")), Paint::red(prefixed(TAB, usage)).dim() - ); + )); } pub fn println(prefix: impl fmt::Display, msg: impl fmt::Display) { - println!("{prefix} {msg}"); + print(format_args!("{prefix} {msg}")); } pub fn indented(msg: impl fmt::Display) { - println!("{TAB}{msg}"); + print(format_args!("{TAB}{msg}")); } pub fn subcommand(msg: impl fmt::Display) { - println!("{}", style(format!("Running `{msg}`...")).dim()); + print(style(format!("Running `{msg}`...")).dim()); } pub fn warning(warning: impl fmt::Display) { - println!( + print(format_args!( "{} {} {warning}", PREFIX_WARNING, Paint::yellow("Warning:").bold(), - ); + )); } pub fn error(error: impl fmt::Display) { - println!("{PREFIX_ERROR} {} {error}", Paint::red("Error:")); + print(format_args!( + "{PREFIX_ERROR} {} {error}", + Paint::red("Error:") + )); } pub fn hint(hint: impl fmt::Display) { - println!("{}", format::hint(format!("{SYMBOL_ERROR} Hint: {hint}"))); + print(format::hint(format!("{SYMBOL_ERROR} Hint: {hint}"))); } pub fn ask(prompt: D, default: bool) -> bool {