Skip to main content

riff_core/
pipe.rs

1//! The end of a command at a closed pipe (01M5MRG35BYRGZYD4VQVV8ZTYX).
2//!
3//! Rust ignores SIGPIPE. A write to a pipe that its reader closed fails
4//! with EPIPE, and `println!` panics on that error. So `riff daemon status
5//! | head -1` showed a panic after `head` read its line.
6//!
7//! The usual fix sets SIGPIPE back to its default. riff does not do
8//! that: the signal would also come from a write to a socket or to the
9//! pipe of a child process. `riff run` reconnects when the daemon closes
10//! its socket, and the daemon and the relay serve sockets. A SIGPIPE
11//! would kill them.
12//!
13//! [`end_quietly`] adds a panic hook in place of the signal. The panic of
14//! `println!` or `eprintln!` (also of `anstream`) on a closed pipe ends
15//! the process with the exit 0 and prints nothing. Each other panic goes
16//! to the hook that was there before.
17//!
18//! ```mermaid
19//! flowchart LR
20//!     P["println! at a closed pipe"] --> E[EPIPE] --> H{panic hook}
21//!     H -- "failed printing to stdout or stderr" --> X[exit 0]
22//!     H -- "another panic" --> D[the hook before]
23//!     S["write to a socket"] --> Err[an error, no signal]
24//! ```
25//!
26//! ```
27//! use riff_core::pipe::closed_pipe;
28//!
29//! assert!(closed_pipe("failed printing to stdout: Broken pipe (os error 32)"));
30//! assert!(!closed_pipe("failed printing to stdout: No space left on device (os error 28)"));
31//! ```
32
33/// EPIPE: the same number on Linux, macOS and the BSDs.
34const EPIPE: i32 = 32;
35
36/// True when `message` is the panic of a print to stdout or stderr that
37/// failed because the reader closed the pipe. `println!`, `eprintln!`
38/// and the macros of `anstream` panic with this message.
39///
40/// ```
41/// use riff_core::pipe::closed_pipe;
42///
43/// assert!(closed_pipe("failed printing to stderr: Broken pipe (os error 32)"));
44/// assert!(!closed_pipe("Broken pipe (os error 32)"));
45/// ```
46pub fn closed_pipe(message: &str) -> bool {
47    let error = std::io::Error::from_raw_os_error(EPIPE).to_string();
48    ["failed printing to stdout: ", "failed printing to stderr: "]
49        .iter()
50        .any(|start| message.strip_prefix(start) == Some(error.as_str()))
51}
52
53/// Adds the panic hook: a print to a closed pipe ends the process with
54/// the exit 0, and prints nothing. Call it at the start of `main` of a
55/// command that ends by itself, never of a server: the hook ends the whole
56/// process from each thread.
57///
58/// ```no_run
59/// riff_core::pipe::end_quietly();
60/// for n in 0..1000 {
61///     println!("{n}"); // `| head -1` ends the process quietly here.
62/// }
63/// ```
64pub fn end_quietly() {
65    let before = std::panic::take_hook();
66    std::panic::set_hook(Box::new(move |info| {
67        if info.payload_as_str().is_some_and(closed_pipe) {
68            std::process::exit(0);
69        }
70        before(info);
71    }));
72}
73
74#[cfg(test)]
75mod tests {
76    use super::closed_pipe;
77
78    /// The message of `println!` and of `anstream::println!` on EPIPE.
79    #[test]
80    fn a_print_to_a_closed_pipe_is_a_closed_pipe() {
81        let error = std::io::Error::from(std::io::ErrorKind::BrokenPipe);
82        let os = std::io::Error::from_raw_os_error(32);
83        assert_eq!(os.kind(), error.kind());
84        for label in ["stdout", "stderr"] {
85            assert!(closed_pipe(&format!("failed printing to {label}: {os}")));
86        }
87    }
88
89    /// Another error of a print, or another panic, is not a closed pipe.
90    #[test]
91    fn another_panic_is_not_a_closed_pipe() {
92        let full = std::io::Error::from_raw_os_error(28);
93        assert!(!closed_pipe(&format!("failed printing to stdout: {full}")));
94        assert!(!closed_pipe(
95            "failed printing to a file: Broken pipe (os error 32)"
96        ));
97        assert!(!closed_pipe("called `Option::unwrap()` on a `None` value"));
98        assert!(!closed_pipe(""));
99    }
100}