SIGPIPE handling is missing in many utilities
I noticed that when you pipe output and close it early (like seq inf | head -n 1), many utilities panic with error messages instead of exiting silently like GNU coreutils does.
Looks like we've known about this since 2014 (#374) but only 7 out of 102 utilities handle it properly.
Why it matters
POSIX 1003.1-2001 says:
SIGPIPE shall be sent to a process that attempts to write to a pipe when there are no readers. Default action: Terminate the process.
GNU Coreutils does this:
'PIPE': Write on a pipe with no one to read it.
They both say utilities should just quit silently. We should probably do the same.
Why this hasn't been fixed yet
I did some digging. Here's what I think happened:
- Rust ignores SIGPIPE by default (rust-lang/rust#62569)
- The bug only shows up in specific cases, so not many people report it
- Since it affects all utilities, nobody took ownership
- Different people tried different fixes over the years
- There was no documentation on how to handle it (until recently)
Good news: cat (4406b403), tail (f04ed45a), and tr (c1eeed61) were fixed in 2025, so there's momentum now.
What's working and what's not
Already fixed: cat, env, tail, tee, timeout, tr, yes (7 utilities)
Still broken: seq, head, echo, ls, wc, cut, sort, uniq, nl, and more (>17+ utilities)
Suggested fix
Looking at how other utilities handle this, most use the signal handler approach from uucore::signals:
Examples from the codebase:
tee(src/uu/tee/src/tee.rs:166)tail(src/uu/tail/src/tail.rs:49)tr(src/uu/tr/src/tr.rs:42)
They all do this:
use uucore::signals::enable_pipe_errors;
#[uucore::main]
pub fn uumain(args: impl uucore::Args) -> UResult<()> {
#[cfg(unix)]
enable_pipe_errors()?;
// rest of the code
}
note: cat uses a different approach with direct libc calls 4406b403, but the enable_pipe_errors() method seems to be the pattern most utilities follow.
For testing, cat has a good example: (tests/by-util/test_cat.rs:122-135)
#[test]
fn test_broken_pipe() {
let mut child = new_ucmd!()
.args(&["alpha.txt"])
.set_stdout(Stdio::piped())
.run_no_wait();
child.close_stdout();
child.wait().unwrap().fails_silently();
}
From what I can appreciate, the panicking should have been implemented already. Then it was moved from mkmain.rs to the src/uucore/lib/src/lib.rs, more precisely to the bin! macro. However, I don't think anything broke there.
I think the first thing here is trying to add a test for each util (most of which will fail, initially). Should all utils handle SIGPIPE in the same way? (i.e. map SIGPIPE to SIG_DFL)? I think it is for all coreutils, but I am not sure.
If that is the case, then enable_pipe_errors()? should be added to bin!. If most but not all utilities fall under this scenario, we control the exceptions via an optional argument to the bin! call within each utility main.rs?
I would like to tackle this, but I need to learn more about the architecture of the solution first.
@mfontanaar Hmm, so I was looking into this SIGPIPE thing and found some interesting docs. Basically Rust is ignoring the signal by default, which breaks the whole "silent exit when pipe closes" thing that POSIX expects. Right now we're just muting the panic, which is kinda putting a band-aid on the real problem.
So there's basically three ways to fix this:
Option 1: Rust's new attribute thingy. There's this unix_sigpipe feature in Rust 1.84+ where you can just mark each binary with:
#![feature(unix_sigpipe)]
#[unix_sigpipe = "sig_dfl"]
fn main() { ... }
Cool, very explicit, but you'd have to go change like 102+ files. Plus it's nightly-only, so not ideal for stable.
Option 2: Throw it in the bin! macro (this is probably the move). Just add like 4 lines to the macro in src/uucore/src/lib/lib.rs to call enable_pipe_errors():
#[cfg(unix)]
{
let _ = uucore::signals::enable_pipe_errors();
}
One change, fixes everything at once. Done. It's what POSIX expects anyway—all utilities should handle SIGPIPE the same way.
Option 3: Do it per-utility. Add the signal handler call to each uumain() function individually:
use nix::sys::signal::{signal, SigHandler::SigDfl, Signal::SIGPIPE};
unsafe { signal(SIGPIPE, SigDfl) }
Works, but... you're modifying 95+ files for something that should be uniform. Not great.
Why option 2 wins: POSIX literally says all coreutils should handle SIGPIPE identically. So making it the default in the macro makes sense, it's not something that should vary per-utility. Even tee which already messes with signals will be fine, the macro just sets the baseline and it can override if needed.
Some references:
- POSIX spec: https://pubs.opengroup.org/onlinepubs/009695399/functions/xsh_chap02_04.html
- Rust issue about why they ignore it: https://github.com/rust-lang/rust/issues/62569
- Stack Overflow discussion: https://stackoverflow.com/questions/108183/
- GNU docs: https://www.gnu.org/software/coreutils/manual/html_node/Signal-specifications.html
- unix_sigpipe feature - https://dev-doc.rust-lang.org/beta/unstable-book/language-features/unix-sigpipe.html
- Signal handling in Rust - https://blog.logrocket.com/guide-signal-handling-rust/
I think we should be careful here about the universality thing. I can think of tee as an special example of a utility that SHOULDN'T have SIGPIPE mapped to SIG_DFL, as it usually has more than one output stream, and hence it has to implement custom logic for handling SIGPIPE. I realized this when reading tests for broken pipes in test_tee.rs.
I cannot think of other examples, but there may be. I still think adding this to bin! with an optional argument to disable SIGPIPE -> SIG_DFL behavior is a decent path forward.
And for testing, I think having a very similar test across 102 utilities could be a lot of senseless repetition. On a principle basis, I would heavily oppose writing tests via macros. However, the fact of having 100 nearly identical tests scattered across 100 files makes me doubt my convictions.
What do project maintainers think about this? @sylvestre @cakebaker
Okay, I learned several things:
- I don't understand what role does the
bin!macro in themain.rsfiles has. Is it only used when compiling standalone utilities? (instead of multibin) - The place to implement this would be the
uucore_procsmacro. However, that being a procedural macro, it brings several issues with dependencies, given the current structure, as we cannot reexport anything from it. Most utility crates don't depend on thesignalsfeature of theuucorecrate. Alternatively, it could output an unsafe block callinglibcdirectly (it's a simple function call). Yet again, for several utilities,uucore::libcis outside of their scope.
Of course, this being a mostly project wide desired behavior, I don't want to be so in the middle of each crate, requiring a particular dependency or feature. Even less, given that this should be Rust's default behavior, it isn't only because of historical reasons. Depending on uucore should suffice. One possibility would be to unconditionally rexport libc in uucore, with plans for removal when #[unix_sigpipe] gets stabilized (whenever that happens).
Finally, regarding bin! vs #[uucore_procs::main], should functionality be implemented in both so that both the standalone and the multibin follow normal SIGPIPE?
I have started a draft PR restoring the default SIGPIPE signal handler in lib.rs: https://github.com/uutils/coreutils/pull/9620 . Can you please have a look?
@Ecordonnier Your patch might be better than the current behavior; I haven't thought about it enough yet.
However, I just wanted to clarify that the behavior of SIGPIPE should be inherited from the parent process (assuming it is set to SIG_DFL or SIG_IGN). Copying text from the POSIX page for exec [1]:
Signals set to the default action (SIG_DFL) in the calling process image shall be set to the default action in the new process image. Except for SIGCHLD, signals set to be ignored (SIG_IGN) by the calling process image shall be set to be ignored by the new process image.
If the parent process ignores SIGPIPE then it shouldn't be unignored in the child, i.e., by using signal (SIGPIPE, SIG_DFL).
I don't really see a reasonable way to handle this without Rust stabilizing the feature you linked earlier [2]. The proposal is years old without much activity, which is unfortunate.
[1] https://pubs.opengroup.org/onlinepubs/9799919799/functions/exec.html [2] https://dev-doc.rust-lang.org/beta/unstable-book/language-features/unix-sigpipe.html
@collinfunk I agree that the behavior or SIGPIPE should be inherited from the parent process, but it is not possible as long as the Rust startup code resets the SIGPIPE signal handler.
Thus I think my PR is improving the situation, but we'll need some changes in rust long-term to fix this properly.
This scenario where a signal handler is set to "ignored" before starting a utility is currently broken, and will still be broken, even after my change:
ecordonnier@lj8k2dq3:~/dev$ yes --version
yes (GNU coreutils) 9.4
Copyright (C) 2023 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <https://gnu.org/licenses/gpl.html>.
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.
Written by David MacKenzie.
ecordonnier@lj8k2dq3:~/dev$ trap '' SIGPIPE
ecordonnier@lj8k2dq3:~/dev$ yes | head -1
y
yes: standard output: Broken pipe
ecordonnier@lj8k2dq3:~/dev$ echo ${PIPESTATUS[0]} ${PIPESTATUS[1]}
1 0
ecordonnier@lj8k2dq3:~/dev/coreutils$ ./target/debug/coreutils --version
coreutils 0.5.0 (multi-call binary)
ecordonnier@lj8k2dq3:~/dev$ trap '' SIGPIPE
ecordonnier@lj8k2dq3:~/dev/coreutils$ ./target/debug/coreutils yes | ./target/debug/coreutils head -1
y
ecordonnier@lj8k2dq3:~/dev/coreutils$ echo ${PIPESTATUS[0]} ${PIPESTATUS[1]}
141 0
See also https://github.com/uutils/coreutils/pull/9184 which uses a static variable in order to read the SIGPIPE signal handler before the rust runtime resets it (I haven't tested whether this works).
I was able to validate that this approach works for all of the GNU signals tests and is also really handy for finding out whether /dev/null is set as an input of whether the fd was closed (Since rust converts all closed fd into /dev/null and we need to treat that seperately)
Going to work on splitting these changes up so that we can start using this signals library across all of our utilities