From d395a7f68bfcabb839c87a5b94f0655d53d389c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=9C=BF=20Fleur=20de=20Blue?= <135421389+Xylphy@users.noreply.github.com> Date: Fri, 16 Jan 2026 06:15:12 +0800 Subject: [PATCH 1/6] sort: ignore broken pipe when writing to stdout --- src/uu/sort/src/sort.rs | 25 +++++++++++++++++++++---- tests/by-util/test_sort.rs | 19 +++++++++++++++++++ 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/src/uu/sort/src/sort.rs b/src/uu/sort/src/sort.rs index ddbf225762c..d1904196326 100644 --- a/src/uu/sort/src/sort.rs +++ b/src/uu/sort/src/sort.rs @@ -33,7 +33,7 @@ use std::env; use std::ffi::{OsStr, OsString}; use std::fs::{File, OpenOptions}; use std::hash::{Hash, Hasher}; -use std::io::{BufRead, BufReader, BufWriter, Read, Write, stdin, stdout}; +use std::io::{BufRead, BufReader, BufWriter, ErrorKind, Read, Write, stdin, stdout}; use std::num::IntErrorKind; use std::ops::Range; #[cfg(unix)] @@ -2692,17 +2692,34 @@ fn print_sorted<'a, T: Iterator>>( settings: &GlobalSettings, output: Output, ) -> UResult<()> { + let is_stdout = output.as_output_name().is_none(); let output_name = output .as_output_name() - .unwrap_or(OsStr::new("standard output")) + .unwrap_or_else(|| OsStr::new("standard output")) .to_owned(); let ctx = || translate!("sort-error-write-failed", "output" => output_name.maybe_quote()); let mut writer = output.into_write(); + + // Print each sorted line for line in iter { - line.print(&mut writer, settings).map_err_context(ctx)?; + // Try to print the line, handle BrokenPipe gracefully if writing to stdout + if let Err(e) = line.print(&mut writer, settings) { + if is_stdout && e.kind() == ErrorKind::BrokenPipe { + return Ok(()); + } + return Err(e).map_err_context(ctx); + } } - writer.flush().map_err_context(ctx)?; + + // Flush the writer, handle BrokenPipe gracefully if writing to stdout + if let Err(e) = writer.flush() { + if is_stdout && e.kind() == ErrorKind::BrokenPipe { + return Ok(()); + } + return Err(e).map_err_context(ctx); + } + Ok(()) } diff --git a/tests/by-util/test_sort.rs b/tests/by-util/test_sort.rs index b478912dd19..63c59057fed 100644 --- a/tests/by-util/test_sort.rs +++ b/tests/by-util/test_sort.rs @@ -1218,6 +1218,25 @@ fn test_sigpipe_panic() { child.wait().unwrap().no_stderr(); } +#[test] +#[cfg(unix)] +fn test_broken_pipe_to_stdout_is_silent_and_success() { + let (at, mut ucmd) = at_and_ucmd!(); + + // Ensure sort will attempt to write at least one line. + at.write("in.txt", "1\n"); + + ucmd.arg("in.txt"); + + let mut child = ucmd.run_no_wait(); + + // Simulate `... | head -n 0` by closing the reader end immediately. + child.close_stdout(); + + // Expected GNU-like behavior: no diagnostic (and it should not error out). + child.wait().unwrap().no_stderr(); +} + #[test] fn test_conflict_check_out() { let cases = [ From 65f90017a9d678800af77dad7c31774d69c13693 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=9C=BF=20Fleur=20de=20Blue?= <135421389+Xylphy@users.noreply.github.com> Date: Sat, 17 Jan 2026 09:51:45 +0800 Subject: [PATCH 2/6] sort: use uucore SIGPIPE handling for broken pipes Enable uucore SIGPIPE capture + conditional pipe error handling so `sort | head -n 0` does not emit "Broken pipe" diagnostics and matches GNU exit status behavior. Add a shell-pipeline regression test that asserts no stderr and exit code 141 when the downstream closes early. Also apply small idiomatic cleanups (e.g. `.map(Into::into)`). --- src/uu/sort/Cargo.toml | 1 + src/uu/sort/src/sort.rs | 37 ++++++++++++++----------------------- tests/by-util/test_sort.rs | 29 ++++++++++++++--------------- 3 files changed, 29 insertions(+), 38 deletions(-) diff --git a/src/uu/sort/Cargo.toml b/src/uu/sort/Cargo.toml index 4763755160e..e99b992aeba 100644 --- a/src/uu/sort/Cargo.toml +++ b/src/uu/sort/Cargo.toml @@ -39,6 +39,7 @@ uucore = { workspace = true, features = [ "parser-size", "version-cmp", "i18n-decimal", + "signals" ] } fluent = { workspace = true } diff --git a/src/uu/sort/src/sort.rs b/src/uu/sort/src/sort.rs index d1904196326..805a6e989af 100644 --- a/src/uu/sort/src/sort.rs +++ b/src/uu/sort/src/sort.rs @@ -33,7 +33,7 @@ use std::env; use std::ffi::{OsStr, OsString}; use std::fs::{File, OpenOptions}; use std::hash::{Hash, Hasher}; -use std::io::{BufRead, BufReader, BufWriter, ErrorKind, Read, Write, stdin, stdout}; +use std::io::{BufRead, BufReader, BufWriter, Read, Write, stdin, stdout}; use std::num::IntErrorKind; use std::ops::Range; #[cfg(unix)] @@ -1745,9 +1745,17 @@ fn emit_debug_warnings( } } +#[cfg(unix)] +uucore::init_sigpipe_capture!(); + #[uucore::main] #[allow(clippy::cognitive_complexity)] pub fn uumain(args: impl uucore::Args) -> UResult<()> { + #[cfg(unix)] + if !uucore::signals::sigpipe_was_ignored() { + let _ = uucore::signals::enable_pipe_errors(); + } + let mut settings = GlobalSettings::default(); let (processed_args, mut legacy_warnings) = preprocess_legacy_args(args); @@ -1771,7 +1779,7 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { let mut files: Vec = if matches.contains_id(options::FILES0_FROM) { let files0_from: PathBuf = matches .get_one::(options::FILES0_FROM) - .map(|v| v.into()) + .map(std::convert::Into::into) .unwrap_or_default(); // Cannot combine FILES with FILES0_FROM @@ -1972,7 +1980,7 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { || matches!( matches .get_one::(options::check::CHECK) - .map(|s| s.as_str()), + .map(String::as_str), Some(options::check::SILENT | options::check::QUIET) ) { @@ -2663,7 +2671,7 @@ enum Month { fn month_parse(line: &[u8]) -> Month { let line = line.trim_ascii_start(); - match line.get(..3).map(|x| x.to_ascii_uppercase()).as_deref() { + match line.get(..3).map(<[u8]>::to_ascii_uppercase).as_deref() { Some(b"JAN") => Month::January, Some(b"FEB") => Month::February, Some(b"MAR") => Month::March, @@ -2692,7 +2700,6 @@ fn print_sorted<'a, T: Iterator>>( settings: &GlobalSettings, output: Output, ) -> UResult<()> { - let is_stdout = output.as_output_name().is_none(); let output_name = output .as_output_name() .unwrap_or_else(|| OsStr::new("standard output")) @@ -2700,26 +2707,10 @@ fn print_sorted<'a, T: Iterator>>( let ctx = || translate!("sort-error-write-failed", "output" => output_name.maybe_quote()); let mut writer = output.into_write(); - - // Print each sorted line for line in iter { - // Try to print the line, handle BrokenPipe gracefully if writing to stdout - if let Err(e) = line.print(&mut writer, settings) { - if is_stdout && e.kind() == ErrorKind::BrokenPipe { - return Ok(()); - } - return Err(e).map_err_context(ctx); - } - } - - // Flush the writer, handle BrokenPipe gracefully if writing to stdout - if let Err(e) = writer.flush() { - if is_stdout && e.kind() == ErrorKind::BrokenPipe { - return Ok(()); - } - return Err(e).map_err_context(ctx); + line.print(&mut writer, settings).map_err_context(ctx)?; } - + writer.flush().map_err_context(ctx)?; Ok(()) } diff --git a/tests/by-util/test_sort.rs b/tests/by-util/test_sort.rs index 63c59057fed..66879a3a555 100644 --- a/tests/by-util/test_sort.rs +++ b/tests/by-util/test_sort.rs @@ -12,6 +12,7 @@ use std::time::Duration; use uutests::at_and_ucmd; use uutests::new_ucmd; +use uutests::util_name; use uutests::util::TestScenario; fn test_helper(file_name: &str, possible_args: &[&str]) { @@ -1218,23 +1219,21 @@ fn test_sigpipe_panic() { child.wait().unwrap().no_stderr(); } +// TODO: When SIGPIPE is trapped/ignored, GNU returns exit code 2 for IO failures, +// but uutils currently returns 1 in that mode. #[test] #[cfg(unix)] -fn test_broken_pipe_to_stdout_is_silent_and_success() { - let (at, mut ucmd) = at_and_ucmd!(); - - // Ensure sort will attempt to write at least one line. - at.write("in.txt", "1\n"); - - ucmd.arg("in.txt"); - - let mut child = ucmd.run_no_wait(); - - // Simulate `... | head -n 0` by closing the reader end immediately. - child.close_stdout(); - - // Expected GNU-like behavior: no diagnostic (and it should not error out). - child.wait().unwrap().no_stderr(); +fn test_broken_pipe_exits_141_no_stderr() { + let scene = TestScenario::new(util_name!()); + let bin = scene.bin_path.clone().into_os_string(); + scene + .cmd_shell( + r#"{ seq 1 10000 | "$BIN" sort -n 2>err | head -n1; }; echo ${PIPESTATUS[1]} >code"#, + ) + .env("BIN", &bin) + .succeeds(); + assert!(scene.fixtures.read("err").is_empty()); + assert_eq!(scene.fixtures.read("code").trim(), "141"); } #[test] From 3aa889dd23e5e06ad6f5de5dd94f1e114827139c Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Sat, 17 Jan 2026 09:29:08 +0100 Subject: [PATCH 3/6] Add PIPESTATUS to spell-checker ignore list --- tests/by-util/test_sort.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/by-util/test_sort.rs b/tests/by-util/test_sort.rs index 66879a3a555..d6006ddd99c 100644 --- a/tests/by-util/test_sort.rs +++ b/tests/by-util/test_sort.rs @@ -3,7 +3,7 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// spell-checker:ignore (words) ints (linux) NOFILE dfgi +// spell-checker:ignore (words) ints (linux) NOFILE dfgi PIPESTATUS #![allow(clippy::cast_possible_wrap)] use std::env; From 5c78e4e48d703b5711874055c0fd3d0f3782e514 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=9C=BF=20Fleur=20de=20Blue?= <135421389+Xylphy@users.noreply.github.com> Date: Sat, 17 Jan 2026 21:42:17 +0800 Subject: [PATCH 4/6] tests/sort: run broken-pipe pipeline test under bash --- tests/by-util/test_sort.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/by-util/test_sort.rs b/tests/by-util/test_sort.rs index d6006ddd99c..e2a7c418917 100644 --- a/tests/by-util/test_sort.rs +++ b/tests/by-util/test_sort.rs @@ -12,8 +12,8 @@ use std::time::Duration; use uutests::at_and_ucmd; use uutests::new_ucmd; -use uutests::util_name; use uutests::util::TestScenario; +use uutests::util_name; fn test_helper(file_name: &str, possible_args: &[&str]) { for args in possible_args { @@ -1227,9 +1227,9 @@ fn test_broken_pipe_exits_141_no_stderr() { let scene = TestScenario::new(util_name!()); let bin = scene.bin_path.clone().into_os_string(); scene - .cmd_shell( - r#"{ seq 1 10000 | "$BIN" sort -n 2>err | head -n1; }; echo ${PIPESTATUS[1]} >code"#, - ) + .cmd("bash") + .arg("-c") + .arg(r#"{ seq 1 10000 | "$BIN" sort -n 2>err | head -n1; }; echo ${PIPESTATUS[1]} >code"#) .env("BIN", &bin) .succeeds(); assert!(scene.fixtures.read("err").is_empty()); From b9b61f8f497e1964d5426d5c58738d5decae1a00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=9C=BF=20Fleur=20de=20Blue?= <135421389+Xylphy@users.noreply.github.com> Date: Sat, 17 Jan 2026 22:28:05 +0800 Subject: [PATCH 5/6] tests/sort: avoid bash by simulating broken pipe in Rust --- tests/by-util/test_sort.rs | 62 ++++++++++++++++++++++++++++++++------ 1 file changed, 52 insertions(+), 10 deletions(-) diff --git a/tests/by-util/test_sort.rs b/tests/by-util/test_sort.rs index e2a7c418917..45d8225f0a2 100644 --- a/tests/by-util/test_sort.rs +++ b/tests/by-util/test_sort.rs @@ -1223,17 +1223,59 @@ fn test_sigpipe_panic() { // but uutils currently returns 1 in that mode. #[test] #[cfg(unix)] -fn test_broken_pipe_exits_141_no_stderr() { +fn test_broken_pipe_exits_sigpipe_no_stderr() { + use std::io::{BufRead, BufReader, Write}; + use std::os::unix::process::ExitStatusExt; + use std::process::{Command, Stdio}; + let scene = TestScenario::new(util_name!()); - let bin = scene.bin_path.clone().into_os_string(); - scene - .cmd("bash") - .arg("-c") - .arg(r#"{ seq 1 10000 | "$BIN" sort -n 2>err | head -n1; }; echo ${PIPESTATUS[1]} >code"#) - .env("BIN", &bin) - .succeeds(); - assert!(scene.fixtures.read("err").is_empty()); - assert_eq!(scene.fixtures.read("code").trim(), "141"); + let bin = scene.bin_path.clone(); + + // Run multicall: coreutils sort -n + let mut child = Command::new(bin) + .arg("sort") + .arg("-n") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn sort"); + + // Feed enough input that sort will try to write output. + { + let mut stdin = child.stdin.take().expect("take stdin"); + for i in 1..=10000 { + writeln!(stdin, "{i}").expect("write stdin"); + } + // drop(stdin) closes stdin + } + + // Read a single output line, then close stdout to trigger SIGPIPE on the child + // the next time it writes (like `| head -n1`). + { + let stdout = child.stdout.take().expect("take stdout"); + let mut r = BufReader::new(stdout); + let mut line = String::new(); + let _ = r.read_line(&mut line).expect("read first line"); + // drop(r) closes the read end + } + + let output = child.wait_with_output().expect("wait"); + + // No "Broken pipe" diagnostic. + assert!( + output.stderr.is_empty(), + "expected empty stderr, got: {:?}", + String::from_utf8_lossy(&output.stderr) + ); + + // Shells report SIGPIPE as 128+13=141, but Rust exposes it as a signal. + assert_eq!( + output.status.signal(), + Some(libc::SIGPIPE), + "expected SIGPIPE; status={:?}", + output.status + ); } #[test] From ed711d6e8580d955e5376f25b04e20c9dded9ad6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=9C=BF=20Fleur=20de=20Blue?= <135421389+Xylphy@users.noreply.github.com> Date: Sun, 18 Jan 2026 01:10:17 +0800 Subject: [PATCH 6/6] tests/sort: make broken-pipe test reliable on macOS CI --- tests/by-util/test_sort.rs | 41 +++++++++++--------------------------- 1 file changed, 12 insertions(+), 29 deletions(-) diff --git a/tests/by-util/test_sort.rs b/tests/by-util/test_sort.rs index 45d8225f0a2..a08dc637f64 100644 --- a/tests/by-util/test_sort.rs +++ b/tests/by-util/test_sort.rs @@ -1223,15 +1223,16 @@ fn test_sigpipe_panic() { // but uutils currently returns 1 in that mode. #[test] #[cfg(unix)] -fn test_broken_pipe_exits_sigpipe_no_stderr() { - use std::io::{BufRead, BufReader, Write}; - use std::os::unix::process::ExitStatusExt; +fn test_broken_pipe_no_stderr_and_expected_status() { + use std::io::Write; use std::process::{Command, Stdio}; + #[cfg(not(target_os = "macos"))] + use std::os::unix::process::ExitStatusExt; + let scene = TestScenario::new(util_name!()); let bin = scene.bin_path.clone(); - // Run multicall: coreutils sort -n let mut child = Command::new(bin) .arg("sort") .arg("-n") @@ -1241,41 +1242,23 @@ fn test_broken_pipe_exits_sigpipe_no_stderr() { .spawn() .expect("spawn sort"); - // Feed enough input that sort will try to write output. + drop(child.stdout.take().expect("take stdout")); + { let mut stdin = child.stdin.take().expect("take stdin"); for i in 1..=10000 { writeln!(stdin, "{i}").expect("write stdin"); } - // drop(stdin) closes stdin - } - - // Read a single output line, then close stdout to trigger SIGPIPE on the child - // the next time it writes (like `| head -n1`). - { - let stdout = child.stdout.take().expect("take stdout"); - let mut r = BufReader::new(stdout); - let mut line = String::new(); - let _ = r.read_line(&mut line).expect("read first line"); - // drop(r) closes the read end } let output = child.wait_with_output().expect("wait"); - // No "Broken pipe" diagnostic. - assert!( - output.stderr.is_empty(), - "expected empty stderr, got: {:?}", - String::from_utf8_lossy(&output.stderr) - ); + assert!(output.stderr.is_empty()); - // Shells report SIGPIPE as 128+13=141, but Rust exposes it as a signal. - assert_eq!( - output.status.signal(), - Some(libc::SIGPIPE), - "expected SIGPIPE; status={:?}", - output.status - ); + // NOTE: On macOS CI this is flaky to assert because `sort` can complete + // and exit 0 before the closed-pipe condition is observed (timing/buffering). + #[cfg(not(target_os = "macos"))] + assert_eq!(output.status.signal(), Some(libc::SIGPIPE)); } #[test]