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
This commit is contained in:
Fintan Halpenny 2026-04-02 16:57:28 +00:00 committed by Lorenz Leutgeb
parent 8f4b90db7b
commit e1f16bee26
3 changed files with 79 additions and 25 deletions

View File

@ -40,7 +40,6 @@ use radicle::profile;
use crate::util::environment::Environment; use crate::util::environment::Environment;
#[ignore = "test fails"]
#[test] #[test]
fn config() { fn config() {
let mut environment = Environment::new(); let mut environment = Environment::new();
@ -121,7 +120,6 @@ fn help() {
} }
/// `rad self` uses `Element::print()` for table output. /// `rad self` uses `Element::print()` for table output.
#[ignore = "test fails"]
#[test] #[test]
fn rad_self() { fn rad_self() {
let mut environment = Environment::new(); let mut environment = Environment::new();

View File

@ -84,8 +84,12 @@ pub trait Element: fmt::Debug + Send + Sync {
/// Print this element to stdout. /// Print this element to stdout.
fn print(&self) { fn print(&self) {
use std::io::Write;
let mut stdout = io::stdout().lock();
for line in self.render(Constraint::from_env().unwrap_or_default()) { 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);
} }
} }

View File

@ -74,7 +74,7 @@ macro_rules! info {
writeln!($writer, $($arg)*).ok(); writeln!($writer, $($arg)*).ok();
}); });
($($arg:tt)*) => ({ ($($arg:tt)*) => ({
println!("{}", format_args!($($arg)*)); $crate::io::print(format_args!($($arg)*));
}) })
} }
@ -118,11 +118,11 @@ pub fn success_args<W: io::Write>(w: &mut W, args: fmt::Arguments) {
} }
pub fn tip_args(args: fmt::Arguments) { pub fn tip_args(args: fmt::Arguments) {
println!( print(format_args!(
"{} {}", "{} {}",
format::yellow("*"), format::yellow("*"),
style(format!("{args}")).italic() style(format!("{args}")).italic()
); ));
} }
pub fn notice_args<W: io::Write>(w: &mut W, args: fmt::Arguments) { pub fn notice_args<W: io::Write>(w: &mut W, args: fmt::Arguments) {
@ -148,27 +148,76 @@ pub fn viewport() -> Option<Size> {
} }
pub fn headline(headline: impl fmt::Display) { pub fn headline(headline: impl fmt::Display) {
println!(); print("");
println!("{}", style(headline).bold()); print(style(headline).bold());
println!(); print("");
} }
pub fn header(header: &str) { pub fn header(header: &str) {
println!(); print("");
println!("{}", style(format::yellow(header)).bold().underline()); print(style(format::yellow(header)).bold().underline());
println!(); print("");
} }
pub fn blob(text: impl fmt::Display) { pub fn blob(text: impl fmt::Display) {
println!("{}", style(text.to_string().trim()).dim()); print(style(text.to_string().trim()).dim());
} }
pub fn blank() { 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) { 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 { 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) { 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<process::ExitStatus> { pub fn manual(name: &str) -> io::Result<process::ExitStatus> {
@ -192,40 +241,43 @@ pub fn manual(name: &str) -> io::Result<process::ExitStatus> {
} }
pub fn usage(name: &str, usage: &str) { pub fn usage(name: &str, usage: &str) {
println!( print(format_args!(
"{} {}\n{}", "{} {}\n{}",
PREFIX_ERROR, PREFIX_ERROR,
Paint::red(format!("Error: rad-{name}: invalid usage")), Paint::red(format!("Error: rad-{name}: invalid usage")),
Paint::red(prefixed(TAB, usage)).dim() Paint::red(prefixed(TAB, usage)).dim()
); ));
} }
pub fn println(prefix: impl fmt::Display, msg: impl fmt::Display) { 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) { pub fn indented(msg: impl fmt::Display) {
println!("{TAB}{msg}"); print(format_args!("{TAB}{msg}"));
} }
pub fn subcommand(msg: impl fmt::Display) { 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) { pub fn warning(warning: impl fmt::Display) {
println!( print(format_args!(
"{} {} {warning}", "{} {} {warning}",
PREFIX_WARNING, PREFIX_WARNING,
Paint::yellow("Warning:").bold(), Paint::yellow("Warning:").bold(),
); ));
} }
pub fn error(error: impl fmt::Display) { 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) { 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<D: fmt::Display>(prompt: D, default: bool) -> bool { pub fn ask<D: fmt::Display>(prompt: D, default: bool) -> bool {