diff --git a/.vscode/cspell.dictionaries/jargon.wordlist.txt b/.vscode/cspell.dictionaries/jargon.wordlist.txt index a757953b44f..564e29d7a01 100644 --- a/.vscode/cspell.dictionaries/jargon.wordlist.txt +++ b/.vscode/cspell.dictionaries/jargon.wordlist.txt @@ -131,6 +131,7 @@ setfacl setfattr shortcode shortcodes +sigaction siginfo sigusr strcasecmp diff --git a/src/uu/seq/Cargo.toml b/src/uu/seq/Cargo.toml index 6f74ce37a9e..534b675e126 100644 --- a/src/uu/seq/Cargo.toml +++ b/src/uu/seq/Cargo.toml @@ -30,6 +30,7 @@ uucore = { workspace = true, features = [ "format", "parser", "quoting-style", + "signals", ] } fluent = { workspace = true } diff --git a/src/uu/seq/src/seq.rs b/src/uu/seq/src/seq.rs index 7b56c26f574..32a2b08c091 100644 --- a/src/uu/seq/src/seq.rs +++ b/src/uu/seq/src/seq.rs @@ -28,6 +28,8 @@ mod numberparse; use crate::error::SeqError; use crate::number::PreciseNumber; +#[cfg(unix)] +use uucore::signals; use uucore::translate; const OPT_SEPARATOR: &str = "separator"; @@ -90,8 +92,20 @@ fn select_precision( } } +// Initialize SIGPIPE state capture at process startup (Unix only) +#[cfg(unix)] +uucore::init_sigpipe_capture!(); + #[uucore::main] pub fn uumain(args: impl uucore::Args) -> UResult<()> { + // Restore SIGPIPE to default if it wasn't explicitly ignored by parent. + // The Rust runtime ignores SIGPIPE, but we need to respect the parent's + // signal disposition for proper pipeline behavior (GNU compatibility). + #[cfg(unix)] + if !signals::sigpipe_was_ignored() { + let _ = signals::enable_pipe_errors(); + } + let matches = uucore::clap_localization::handle_clap_result(uu_app(), split_short_args_with_value(args))?; @@ -209,16 +223,21 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { padding, ); - match result { - Ok(()) => Ok(()), - Err(err) if err.kind() == std::io::ErrorKind::BrokenPipe => { + if let Err(err) = result { + if err.kind() == std::io::ErrorKind::BrokenPipe { // GNU seq prints the Broken pipe message but still exits with status 0 + // unless SIGPIPE was explicitly ignored, in which case it should fail. let err = err.map_err_context(|| "write error".into()); uucore::show_error!("{err}"); - Ok(()) + #[cfg(unix)] + if signals::sigpipe_was_ignored() { + uucore::error::set_exit_code(1); + } + return Ok(()); } - Err(err) => Err(err.map_err_context(|| "write error".into())), + return Err(err.map_err_context(|| "write error".into())); } + Ok(()) } pub fn uu_app() -> Command { diff --git a/src/uucore/src/lib/features/signals.rs b/src/uucore/src/lib/features/signals.rs index 0bccb2173f6..2cc94c88913 100644 --- a/src/uucore/src/lib/features/signals.rs +++ b/src/uucore/src/lib/features/signals.rs @@ -426,6 +426,68 @@ pub fn ignore_interrupts() -> Result<(), Errno> { unsafe { signal(SIGINT, SigIgn) }.map(|_| ()) } +// SIGPIPE state capture - captures whether SIGPIPE was ignored at process startup +#[cfg(unix)] +use std::sync::atomic::{AtomicBool, Ordering}; + +#[cfg(unix)] +static SIGPIPE_WAS_IGNORED: AtomicBool = AtomicBool::new(false); + +/// Captures SIGPIPE state at process initialization, before main() runs. +/// +/// # Safety +/// Called from `.init_array` before main(). Only reads current SIGPIPE handler state. +#[cfg(unix)] +pub unsafe extern "C" fn capture_sigpipe_state() { + use nix::libc; + use std::mem::MaybeUninit; + use std::ptr; + + let mut current = MaybeUninit::::uninit(); + // SAFETY: sigaction with null new-action just queries current state + if unsafe { libc::sigaction(libc::SIGPIPE, ptr::null(), current.as_mut_ptr()) } == 0 { + // SAFETY: sigaction succeeded, so current is initialized + let ignored = unsafe { current.assume_init() }.sa_sigaction == libc::SIG_IGN; + SIGPIPE_WAS_IGNORED.store(ignored, Ordering::Relaxed); + } +} + +/// Initializes SIGPIPE state capture. Call once at crate root level. +#[macro_export] +#[cfg(unix)] +macro_rules! init_sigpipe_capture { + () => { + #[cfg(all(unix, not(target_os = "macos")))] + #[used] + #[unsafe(link_section = ".init_array")] + static CAPTURE_SIGPIPE_STATE: unsafe extern "C" fn() = + $crate::signals::capture_sigpipe_state; + + #[cfg(all(unix, target_os = "macos"))] + #[used] + #[unsafe(link_section = "__DATA,__mod_init_func")] + static CAPTURE_SIGPIPE_STATE: unsafe extern "C" fn() = + $crate::signals::capture_sigpipe_state; + }; +} + +#[macro_export] +#[cfg(not(unix))] +macro_rules! init_sigpipe_capture { + () => {}; +} + +/// Returns whether SIGPIPE was ignored at process startup. +#[cfg(unix)] +pub fn sigpipe_was_ignored() -> bool { + SIGPIPE_WAS_IGNORED.load(Ordering::Relaxed) +} + +#[cfg(not(unix))] +pub const fn sigpipe_was_ignored() -> bool { + false +} + #[test] fn signal_by_value() { assert_eq!(signal_by_name_or_value("0"), Some(0)); diff --git a/tests/by-util/test_seq.rs b/tests/by-util/test_seq.rs index d5dd526aa6b..f94a9a983fe 100644 --- a/tests/by-util/test_seq.rs +++ b/tests/by-util/test_seq.rs @@ -3,34 +3,18 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. // spell-checker:ignore lmnop xlmnop +use rstest::rstest; use uutests::new_ucmd; +#[cfg(unix)] +use uutests::util::TestScenario; +#[cfg(unix)] +use uutests::util_name; #[test] fn test_invalid_arg() { new_ucmd!().arg("--definitely-invalid").fails_with_code(1); } -#[test] -#[cfg(unix)] -fn test_broken_pipe_still_exits_success() { - use std::process::Stdio; - - let mut child = new_ucmd!() - // Use an infinite sequence so a burst of output happens immediately after spawn. - // With small output the process can finish before stdout is closed and the Broken pipe never occurs. - .args(&["inf"]) - .set_stdout(Stdio::piped()) - .run_no_wait(); - - // Trigger a Broken pipe by writing to a pipe whose reader closed first. - child.close_stdout(); - let result = child.wait().unwrap(); - - result - .code_is(0) - .stderr_contains("write error: Broken pipe"); -} - #[test] fn test_no_args() { new_ucmd!() @@ -203,6 +187,24 @@ fn test_width_invalid_float() { .usage_error("invalid floating point argument: '1e2.3'"); } +#[test] +#[cfg(unix)] +fn test_sigpipe_ignored_reports_write_error() { + let scene = TestScenario::new(util_name!()); + let seq_bin = scene.bin_path.clone().into_os_string(); + let script = "trap '' PIPE; { \"$SEQ_BIN\" seq inf 2>err; echo $? >code; } | head -n1"; + let result = scene.cmd_shell(script).env("SEQ_BIN", &seq_bin).succeeds(); + + assert_eq!(result.stdout_str(), "1\n"); + + let err_contents = scene.fixtures.read("err"); + assert!( + err_contents.contains("seq: write error: Broken pipe"), + "stderr missing write error message: {err_contents:?}" + ); + assert_eq!(scene.fixtures.read("code"), "1\n"); +} + // ---- Tests for the big integer based path ---- #[test] @@ -648,52 +650,49 @@ fn test_width_floats() { .stdout_only("09.0\n10.0\n"); } -#[test] -fn test_neg_inf() { - new_ucmd!() - .args(&["--", "-inf", "0"]) - .run_stdout_starts_with(b"-inf\n-inf\n-inf\n") - .success(); -} - -#[test] -fn test_neg_infinity() { - new_ucmd!() - .args(&["--", "-infinity", "0"]) - .run_stdout_starts_with(b"-inf\n-inf\n-inf\n") - .success(); -} - -#[test] -fn test_inf() { - new_ucmd!() - .args(&["inf"]) - .run_stdout_starts_with(b"1\n2\n3\n") - .success(); -} - -#[test] -fn test_infinity() { - new_ucmd!() - .args(&["infinity"]) - .run_stdout_starts_with(b"1\n2\n3\n") - .success(); -} - -#[test] -fn test_inf_width() { - new_ucmd!() - .args(&["-w", "1.000", "inf", "inf"]) - .run_stdout_starts_with(b"1.000\n inf\n inf\n inf\n") - .success(); -} - -#[test] -fn test_neg_inf_width() { - new_ucmd!() - .args(&["-w", "1.000", "-inf", "-inf"]) - .run_stdout_starts_with(b"1.000\n -inf\n -inf\n -inf\n") - .success(); +/// Test infinite sequences - these produce endless output, so we check they start correctly +/// and terminate with SIGPIPE on Unix (or succeed on non-Unix where pipe behavior differs). +#[rstest] +#[case::neg_inf( + &["--", "-inf", "0"], + b"-inf\n-inf\n-inf\n" +)] +#[case::neg_infinity( + &["--", "-infinity", "0"], + b"-inf\n-inf\n-inf\n" +)] +#[case::inf( + &["inf"], + b"1\n2\n3\n" +)] +#[case::infinity( + &["infinity"], + b"1\n2\n3\n" +)] +#[case::inf_width( + &["-w", "1.000", "inf", "inf"], + b"1.000\n inf\n inf\n inf\n" +)] +#[case::neg_inf_width( + &["-w", "1.000", "-inf", "-inf"], + b"1.000\n -inf\n -inf\n -inf\n" +)] +#[case::precision_inf( + &["1", "1.2", "inf"], + b"1.0\n2.2\n3.4\n" +)] +#[case::equalize_width_inf( + &["-w", "1", "1.2", "inf"], + b"1.0\n2.2\n3.4\n" +)] +fn test_infinite_sequence(#[case] args: &[&str], #[case] expected_start: &[u8]) { + let result = new_ucmd!() + .args(args) + .run_stdout_starts_with(expected_start); + #[cfg(unix)] + result.signal_name_is("PIPE"); + #[cfg(not(unix))] + result.success(); } #[test] @@ -1073,12 +1072,6 @@ fn test_precision_corner_cases() { .args(&["1", "1.20", "3.000000"]) .succeeds() .stdout_is("1.00\n2.20\n"); - - // Infinity is ignored - new_ucmd!() - .args(&["1", "1.2", "inf"]) - .run_stdout_starts_with(b"1.0\n2.2\n3.4\n") - .success(); } // GNU `seq` manual only makes guarantees about `-w` working if the @@ -1135,11 +1128,4 @@ fn test_equalize_widths_corner_cases() { .args(&["-w", "0x1.1", "1.00002", "3"]) .succeeds() .stdout_is("1.0625\n2.06252\n"); - - // We can't really pad with infinite number of zeros, so `-w` is ignored. - // (there is another test with infinity as an increment above) - new_ucmd!() - .args(&["-w", "1", "1.2", "inf"]) - .run_stdout_starts_with(b"1.0\n2.2\n3.4\n") - .success(); }