diff --git a/.vscode/cspell.dictionaries/jargon.wordlist.txt b/.vscode/cspell.dictionaries/jargon.wordlist.txt index a757953b44f..03ba47db8c3 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 @@ -190,6 +191,21 @@ nofield uninlined nonminimal +# * poll/signal constants +GETFD +iopoll +isapipe +pollfd +POLLERR +POLLHUP +POLLNVAL +POLLRDBAND +revents +Sigmask +sigprocmask +sigset +SIGTTIN + # * CPU/hardware features ASIMD asimd diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index ccb71eaffbe..62361a2606c 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -196,9 +196,9 @@ checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" [[package]] name = "cc" -version = "1.2.48" +version = "1.2.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c481bdbf0ed3b892f6f806287d72acd515b352a4ec27a208489b8c1bc839633a" +checksum = "90583009037521a116abf44494efecd645ba48b6622457080f080b85544e2215" dependencies = [ "find-msvc-tools", "jobserver", @@ -753,9 +753,9 @@ checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" [[package]] name = "icu_properties" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e93fcd3157766c0c8da2f8cff6ce651a31f0810eaa1c51ec363ef790bbb5fb99" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" dependencies = [ "icu_collections", "icu_locale_core", @@ -767,9 +767,9 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02845b3647bb045f1100ecd6480ff52f34c35f82d9880e029d329c21d1054899" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" [[package]] name = "icu_provider" @@ -850,9 +850,9 @@ dependencies = [ [[package]] name = "jiff-tzdb" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1283705eb0a21404d2bfd6eef2a7593d240bc42a0bdb39db0ad6fa2ec026524" +checksum = "68971ebff725b9e2ca27a601c5eb38a4c5d64422c4cbab0c535f248087eda5c2" [[package]] name = "jiff-tzdb-platform" @@ -1366,9 +1366,9 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "simd-adler32" -version = "0.3.7" +version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" [[package]] name = "similar" diff --git a/src/uu/env/src/env.rs b/src/uu/env/src/env.rs index 72f5aa7923e..dd4da411f97 100644 --- a/src/uu/env/src/env.rs +++ b/src/uu/env/src/env.rs @@ -84,6 +84,9 @@ mod options { pub const SPLIT_STRING: &str = "split-string"; pub const ARGV0: &str = "argv0"; pub const IGNORE_SIGNAL: &str = "ignore-signal"; + pub const DEFAULT_SIGNAL: &str = "default-signal"; + pub const BLOCK_SIGNAL: &str = "block-signal"; + pub const LIST_SIGNAL_HANDLING: &str = "list-signal-handling"; } struct Options<'a> { @@ -97,6 +100,12 @@ struct Options<'a> { argv0: Option<&'a OsStr>, #[cfg(unix)] ignore_signal: Vec, + #[cfg(unix)] + default_signal: Vec, + #[cfg(unix)] + block_signal: Vec, + #[cfg(unix)] + list_signal_handling: bool, } /// print `name=value` env pairs on screen @@ -155,11 +164,11 @@ fn parse_signal_value(signal_name: &str) -> UResult { } #[cfg(unix)] -fn parse_signal_opt<'a>(opts: &mut Options<'a>, opt: &'a OsStr) -> UResult<()> { +fn parse_signal_opt(signal_vec: &mut Vec, opt: &OsStr) -> UResult<()> { if opt.is_empty() { return Ok(()); } - let signals: Vec<&'a OsStr> = opt + let signals: Vec<&OsStr> = opt .as_bytes() .split(|&b| b == b',') .map(OsStr::from_bytes) @@ -179,14 +188,33 @@ fn parse_signal_opt<'a>(opts: &mut Options<'a>, opt: &'a OsStr) -> UResult<()> { )); }; let sig_val = parse_signal_value(sig_str)?; - if !opts.ignore_signal.contains(&sig_val) { - opts.ignore_signal.push(sig_val); + if !signal_vec.contains(&sig_val) { + signal_vec.push(sig_val); } } Ok(()) } +/// Parse signal option that can be empty (meaning all signals) +#[cfg(unix)] +fn parse_signal_opt_or_all(signal_vec: &mut Vec, opt: &OsStr) -> UResult<()> { + if opt.is_empty() { + // Empty means all signals - add all valid signal numbers (1-31 typically) + // Skip SIGKILL (9) and SIGSTOP (19) which cannot be caught or ignored + for sig_val in 1..32 { + if sig_val == 9 || sig_val == 19 { + continue; // SIGKILL and SIGSTOP cannot be modified + } + if !signal_vec.contains(&sig_val) { + signal_vec.push(sig_val); + } + } + return Ok(()); + } + parse_signal_opt(signal_vec, opt) +} + fn load_config_file(opts: &mut Options) -> UResult<()> { // NOTE: config files are parsed using an INI parser b/c it's available and compatible with ".env"-style files // ... * but support for actual INI files, although working, is not intended, nor claimed @@ -307,9 +335,40 @@ pub fn uu_app() -> Command { .long(options::IGNORE_SIGNAL) .value_name("SIG") .action(ArgAction::Append) + .num_args(0..=1) + .default_missing_value("") + .require_equals(true) .value_parser(ValueParser::os_string()) .help(translate!("env-help-ignore-signal")), ) + .arg( + Arg::new(options::DEFAULT_SIGNAL) + .long(options::DEFAULT_SIGNAL) + .value_name("SIG") + .action(ArgAction::Append) + .num_args(0..=1) + .default_missing_value("") + .require_equals(true) + .value_parser(ValueParser::os_string()) + .help("set handling of SIG signal(s) to the default"), + ) + .arg( + Arg::new(options::BLOCK_SIGNAL) + .long(options::BLOCK_SIGNAL) + .value_name("SIG") + .action(ArgAction::Append) + .num_args(0..=1) + .default_missing_value("") + .require_equals(true) + .value_parser(ValueParser::os_string()) + .help("block delivery of SIG signal(s) to COMMAND"), + ) + .arg( + Arg::new(options::LIST_SIGNAL_HANDLING) + .long(options::LIST_SIGNAL_HANDLING) + .action(ArgAction::SetTrue) + .help("list non default signal handling to stderr"), + ) } pub fn parse_args_from_str(text: &NativeIntStr) -> UResult> { @@ -543,9 +602,23 @@ impl EnvAppData { apply_specified_env_vars(&opts); + // Apply signal handling in the correct order: + // 1. Reset signals to default + // 2. Set signals to ignore + // 3. Block signals + // 4. List signal handling (if requested) + #[cfg(unix)] + apply_default_signal(&opts)?; + #[cfg(unix)] apply_ignore_signal(&opts)?; + #[cfg(unix)] + apply_block_signal(&opts)?; + + #[cfg(unix)] + list_signal_handling(&opts)?; + if opts.program.is_empty() { // no program provided, so just dump all env vars to stdout print_env(opts.line_ending); @@ -705,12 +778,32 @@ fn make_options(matches: &clap::ArgMatches) -> UResult> { argv0, #[cfg(unix)] ignore_signal: vec![], + #[cfg(unix)] + default_signal: vec![], + #[cfg(unix)] + block_signal: vec![], + #[cfg(unix)] + list_signal_handling: matches.get_flag(options::LIST_SIGNAL_HANDLING), }; #[cfg(unix)] - if let Some(iter) = matches.get_many::("ignore-signal") { + if let Some(iter) = matches.get_many::(options::IGNORE_SIGNAL) { for opt in iter { - parse_signal_opt(&mut opts, opt)?; + parse_signal_opt_or_all(&mut opts.ignore_signal, opt)?; + } + } + + #[cfg(unix)] + if let Some(iter) = matches.get_many::(options::DEFAULT_SIGNAL) { + for opt in iter { + parse_signal_opt_or_all(&mut opts.default_signal, opt)?; + } + } + + #[cfg(unix)] + if let Some(iter) = matches.get_many::(options::BLOCK_SIGNAL) { + for opt in iter { + parse_signal_opt_or_all(&mut opts.block_signal, opt)?; } } @@ -843,6 +936,86 @@ fn ignore_signal(sig: Signal) -> UResult<()> { Ok(()) } +#[cfg(unix)] +fn apply_default_signal(opts: &Options<'_>) -> UResult<()> { + use nix::sys::signal::{SigHandler::SigDfl, signal}; + + for &sig_value in &opts.default_signal { + let sig: Signal = (sig_value as i32) + .try_into() + .map_err(|e| io::Error::from_raw_os_error(e as i32))?; + + // SAFETY: Setting signal handler to default is safe + let result = unsafe { signal(sig, SigDfl) }; + if let Err(err) = result { + return Err(USimpleError::new( + 125, + translate!("env-error-failed-set-signal-action", "signal" => (sig as i32), "error" => err.desc()), + )); + } + } + Ok(()) +} + +#[cfg(unix)] +fn apply_block_signal(opts: &Options<'_>) -> UResult<()> { + use nix::sys::signal::{SigSet, SigmaskHow, sigprocmask}; + + if opts.block_signal.is_empty() { + return Ok(()); + } + + let mut sigset = SigSet::empty(); + for &sig_value in &opts.block_signal { + let sig: Signal = (sig_value as i32) + .try_into() + .map_err(|e| io::Error::from_raw_os_error(e as i32))?; + sigset.add(sig); + } + + sigprocmask(SigmaskHow::SIG_BLOCK, Some(&sigset), None).map_err(|err| { + USimpleError::new(125, format!("failed to block signals: {}", err.desc())) + })?; + + Ok(()) +} + +#[cfg(unix)] +fn list_signal_handling(opts: &Options<'_>) -> UResult<()> { + use std::mem::MaybeUninit; + use uucore::signals::signal_name_by_value; + + if !opts.list_signal_handling { + return Ok(()); + } + + let stderr = io::stderr(); + let mut stderr = stderr.lock(); + + // Check each signal that was modified + for &sig_value in &opts.ignore_signal { + if let Some(name) = signal_name_by_value(sig_value) { + // Get current signal handler + let mut current = MaybeUninit::::uninit(); + if unsafe { libc::sigaction(sig_value as i32, std::ptr::null(), current.as_mut_ptr()) } + == 0 + { + let handler = unsafe { current.assume_init() }.sa_sigaction; + let handler_str = if handler == libc::SIG_IGN { + "IGNORE" + } else if handler == libc::SIG_DFL { + "DEFAULT" + } else { + "HANDLER" + }; + writeln!(stderr, "{name:<10} (): {handler_str}").ok(); + } + } + } + + Ok(()) +} + #[uucore::main] pub fn uumain(args: impl uucore::Args) -> UResult<()> { // Rust ignores SIGPIPE (see https://github.com/rust-lang/rust/issues/62569). diff --git a/src/uu/mktemp/Cargo.toml b/src/uu/mktemp/Cargo.toml index 651db9e21b0..c871ef67113 100644 --- a/src/uu/mktemp/Cargo.toml +++ b/src/uu/mktemp/Cargo.toml @@ -22,7 +22,7 @@ clap = { workspace = true } rand = { workspace = true } tempfile = { workspace = true } thiserror = { workspace = true } -uucore = { workspace = true } +uucore = { workspace = true, features = ["signals"] } fluent = { workspace = true } [[bin]] diff --git a/src/uu/mktemp/src/mktemp.rs b/src/uu/mktemp/src/mktemp.rs index a0eaa32d451..5dd4608a2f7 100644 --- a/src/uu/mktemp/src/mktemp.rs +++ b/src/uu/mktemp/src/mktemp.rs @@ -8,10 +8,18 @@ use clap::builder::{TypedValueParser, ValueParserFactory}; use clap::{Arg, ArgAction, ArgMatches, Command}; use uucore::display::{Quotable, println_verbatim}; -use uucore::error::{FromIo, UError, UResult, UUsageError}; +use uucore::error::{FromIo, UError, UResult, USimpleError, UUsageError}; use uucore::format_usage; +#[cfg(unix)] +use uucore::signals::stdout_was_closed; use uucore::translate; +// Capture stdout state at process startup (before Rust's runtime may reopen closed fds) +#[cfg(unix)] +uucore::init_sigpipe_capture!(); + +use std::io::{Write, stdout}; + use std::env; use std::ffi::{OsStr, OsString}; use std::io::ErrorKind; @@ -335,6 +343,25 @@ impl ValueParserFactory for OptionalPathBufParser { } } +/// Check if stdout is valid (not closed and reopened as /dev/null by Rust's runtime). +/// Returns an error if stdout is not writable. +/// Uses the early-capture mechanism from init_sigpipe_capture!() to detect this +/// at process startup, not at runtime (which would incorrectly trigger on +/// legitimate redirects to /dev/null). +/// On Windows, this check is not applicable as the signals module is Unix-only. +#[cfg(unix)] +fn check_stdout_valid() -> UResult<()> { + if stdout_was_closed() { + return Err(USimpleError::new(1, "write error")); + } + Ok(()) +} + +#[cfg(not(unix))] +fn check_stdout_valid() -> UResult<()> { + Ok(()) +} + #[uucore::main] pub fn uumain(args: impl uucore::Args) -> UResult<()> { let args: Vec<_> = args.collect(); @@ -386,6 +413,11 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { suffix, } = Params::from(options)?; + // Check stdout is valid before creating the temp file. + // If stdout is closed (reopened as /dev/null by Rust), we should fail early + // so that no temp file is created that would be orphaned. + check_stdout_valid()?; + // Create the temporary file or directory, or simulate creating it. let res = if dry_run { dry_exec(&tmpdir, &prefix, rand, &suffix) @@ -399,7 +431,14 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { } else { res }; - println_verbatim(res?).map_err_context(|| translate!("mktemp-error-failed-print")) + println_verbatim(res?).map_err_context(|| translate!("mktemp-error-failed-print"))?; + + // Check for stdout write errors (e.g., /dev/full) + if let Err(e) = stdout().flush() { + return Err(USimpleError::new(1, e.to_string())); + } + + Ok(()) } pub fn uu_app() -> Command { diff --git a/src/uu/printf/Cargo.toml b/src/uu/printf/Cargo.toml index 05d033fc681..f27d4146cf3 100644 --- a/src/uu/printf/Cargo.toml +++ b/src/uu/printf/Cargo.toml @@ -19,7 +19,7 @@ path = "src/printf.rs" [dependencies] clap = { workspace = true } -uucore = { workspace = true, features = ["format", "quoting-style"] } +uucore = { workspace = true, features = ["format", "quoting-style", "signals"] } fluent = { workspace = true } [[bin]] diff --git a/src/uu/printf/src/printf.rs b/src/uu/printf/src/printf.rs index d313c8acec5..a109545dd8c 100644 --- a/src/uu/printf/src/printf.rs +++ b/src/uu/printf/src/printf.rs @@ -4,16 +4,39 @@ // file that was distributed with this source code. use clap::{Arg, ArgAction, Command}; use std::ffi::OsString; -use std::io::stdout; +use std::io::{Write, stdout}; use std::ops::ControlFlow; -use uucore::error::{UResult, UUsageError}; +use uucore::error::{UResult, USimpleError, UUsageError}; use uucore::format::{FormatArgument, FormatArguments, FormatItem, parse_spec_and_escape}; +use uucore::signals::stdout_was_closed; use uucore::translate; use uucore::{format_usage, os_str_as_bytes, show_warning}; +// Capture stdout state at process startup (before Rust's runtime may reopen closed fds) +uucore::init_sigpipe_capture!(); + const VERSION: &str = "version"; const HELP: &str = "help"; +/// Check for stdout write errors after output has been written. +/// This handles both /dev/full (flush error) and closed stdout (reopened as /dev/null). +fn check_stdout_errors() -> UResult<()> { + // Check for stdout write errors (e.g., /dev/full) + if let Err(e) = stdout().flush() { + return Err(USimpleError::new(1, e.to_string())); + } + + // Check if stdout was closed before Rust's runtime reopened it as /dev/null. + // Uses the early-capture mechanism from init_sigpipe_capture!() to detect this + // at process startup, not at runtime (which would incorrectly trigger on + // legitimate redirects to /dev/null). + if stdout_was_closed() { + return Err(USimpleError::new(1, "write error")); + } + + Ok(()) +} + mod options { pub const FORMAT: &str = "FORMAT"; pub const ARGUMENT: &str = "ARGUMENT"; @@ -64,6 +87,10 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { ) ); } + // Check for write errors if we wrote any output + if !format.is_empty() { + check_stdout_errors()?; + } return Ok(()); } @@ -77,7 +104,9 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { args.start_next_batch(); } - Ok(()) + // Check for write errors (format is always non-empty here since we would have + // returned early above if !format_seen and there were no format specs) + check_stdout_errors() } pub fn uu_app() -> Command { 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..92257c00c16 100644 --- a/src/uu/seq/src/seq.rs +++ b/src/uu/seq/src/seq.rs @@ -7,6 +7,9 @@ use std::ffi::{OsStr, OsString}; use std::io::{BufWriter, Write, stdout}; use clap::{Arg, ArgAction, Command}; + +// Initialize SIGPIPE state capture at process startup +uucore::init_sigpipe_capture!(); use num_bigint::BigUint; use num_traits::ToPrimitive; use num_traits::Zero; @@ -92,6 +95,14 @@ fn select_precision( #[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 !uucore::signals::sigpipe_was_ignored() { + let _ = uucore::signals::enable_pipe_errors(); + } + let matches = uucore::clap_localization::handle_clap_result(uu_app(), split_short_args_with_value(args))?; @@ -209,16 +220,21 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { padding, ); - match result { - Ok(()) => Ok(()), - Err(err) if err.kind() == std::io::ErrorKind::BrokenPipe => { + let sigpipe_ignored = uucore::signals::sigpipe_was_ignored(); + 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(()) + if sigpipe_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/uu/tail/Cargo.toml b/src/uu/tail/Cargo.toml index 7d7b57a74a3..732278732f0 100644 --- a/src/uu/tail/Cargo.toml +++ b/src/uu/tail/Cargo.toml @@ -23,7 +23,7 @@ clap = { workspace = true } libc = { workspace = true } memchr = { workspace = true } notify = { workspace = true } -uucore = { workspace = true, features = ["fs", "parser-size"] } +uucore = { workspace = true, features = ["fs", "parser-size", "signals"] } same-file = { workspace = true } fluent = { workspace = true } diff --git a/src/uu/tail/locales/en-US.ftl b/src/uu/tail/locales/en-US.ftl index d4b670c49ff..094d13ed5b7 100644 --- a/src/uu/tail/locales/en-US.ftl +++ b/src/uu/tail/locales/en-US.ftl @@ -42,6 +42,7 @@ tail-error-backend-cannot-be-used-too-many-files = { $backend } cannot be used, tail-error-backend-resources-exhausted = { $backend } resources exhausted tail-error-notify-error = NotifyError: { $error } tail-error-recv-timeout-error = RecvTimeoutError: { $error } +tail-error-stdout = standard output: { $error } # Warning messages tail-warning-retry-ignored = --retry ignored; --retry is useful only when following diff --git a/src/uu/tail/src/follow/files.rs b/src/uu/tail/src/follow/files.rs index af9ed39d4eb..a8a88ae23b0 100644 --- a/src/uu/tail/src/follow/files.rs +++ b/src/uu/tail/src/follow/files.rs @@ -91,6 +91,11 @@ impl FileHandling { self.map.len() == 1 && (self.map.contains_key(Path::new(text::DASH))) } + /// Return true if the files map is empty + pub fn is_empty(&self) -> bool { + self.map.is_empty() + } + /// Return true if there is at least one "tailable" path (or stdin) remaining pub fn files_remaining(&self) -> bool { for path in self.map.keys() { @@ -138,6 +143,24 @@ impl FileHandling { pub fn tail_file(&mut self, path: &Path, verbose: bool) -> UResult { let mut chunks = BytesChunkBuffer::new(u64::MAX); if let Some(reader) = self.get_mut(path).reader.as_mut() { + // First, try a peek to check for EIO (happens when SIGTTIN is ignored + // and we're a background process trying to read from controlling terminal). + #[cfg(unix)] + { + use std::io::ErrorKind; + match reader.fill_buf() { + Ok(_) => {} + Err(e) if e.raw_os_error() == Some(libc::EIO) => { + // Treat EIO as "no data available" (GNU compatibility) + return Ok(false); + } + Err(e) if e.kind() == ErrorKind::Interrupted => { + // Interrupted, try again later + return Ok(false); + } + Err(e) => return Err(e.into()), + } + } chunks.fill(reader)?; } if chunks.has_data() { diff --git a/src/uu/tail/src/follow/watch.rs b/src/uu/tail/src/follow/watch.rs index 11e36791814..4dc37c51086 100644 --- a/src/uu/tail/src/follow/watch.rs +++ b/src/uu/tail/src/follow/watch.rs @@ -10,7 +10,7 @@ use crate::follow::files::{FileHandling, PathData}; use crate::paths::{Input, InputKind, MetadataExtTail, PathExtTail}; use crate::{platform, text}; use notify::{RecommendedWatcher, RecursiveMode, Watcher, WatcherKind}; -use std::io::BufRead; +use std::io::{BufRead, stdout}; use std::path::{Path, PathBuf}; use std::sync::mpsc::{self, Receiver, channel}; use uucore::display::Quotable; @@ -96,6 +96,24 @@ pub struct Observer { /// change during runtime it is moved out of [`Settings`]. pub use_polling: bool, + /// If true, monitor stdout so we exit if pipe reader terminates. + /// This is set when stdout is a pipe or FIFO. + pub monitor_output: bool, + + /// Set to true when stdin was a pipe that we processed. + /// Pipes can't be followed, so we read them but don't add to observer. + /// This is used to avoid printing "no files remaining" after processing + /// a stdin pipe when there are no other valid files. + pub stdin_pipe_processed: bool, + + /// Set to true when stdin was detected as bad (closed/invalid fd). + /// This is used to ensure follow() is called to print "no files remaining". + pub stdin_bad: bool, + + /// Set to true when we had a bad path (permanent error, e.g., directory without --retry). + /// This is used to ensure follow() is called to print "no files remaining". + pub had_bad_path: bool, + pub watcher_rx: Option, pub orphans: Vec, pub files: FileHandling, @@ -117,10 +135,33 @@ impl Observer { 0 }; + // If stdout is a pipe or FIFO, monitor it so we exit if reader terminates. + // This matches GNU coreutils behavior (see isapipe.c). + // On some systems (like Darwin), pipes are sockets, so check both. + #[cfg(unix)] + let monitor_output = { + use std::os::unix::io::AsRawFd; + let stdout_fd = stdout().as_raw_fd(); + let mut stat: libc::stat = unsafe { std::mem::zeroed() }; + if unsafe { libc::fstat(stdout_fd, &mut stat) } == 0 { + let mode = stat.st_mode & libc::S_IFMT; + // Check for FIFO/pipe or socket (pipes are sockets on some systems) + mode == libc::S_IFIFO || mode == libc::S_IFSOCK + } else { + false + } + }; + #[cfg(not(unix))] + let monitor_output = false; + Self { retry, follow, use_polling, + monitor_output, + stdin_pipe_processed: false, + stdin_bad: false, + had_bad_path: false, watcher_rx: None, orphans: Vec::new(), files, @@ -162,24 +203,6 @@ impl Observer { Ok(()) } - pub fn add_stdin( - &mut self, - display_name: &str, - reader: Option>, - update_last: bool, - ) -> UResult<()> { - if self.follow == Some(FollowMode::Descriptor) { - return self.add_path( - &PathBuf::from(text::DEV_STDIN), - display_name, - reader, - update_last, - ); - } - - Ok(()) - } - pub fn add_bad_path( &mut self, path: &Path, @@ -190,6 +213,12 @@ impl Observer { return self.add_path(path, display_name, None, update_last); } + // Track that we had a bad path so follow() gets called to print + // "no files remaining" if all paths failed. + if self.follow.is_some() { + self.had_bad_path = true; + } + Ok(()) } @@ -488,7 +517,14 @@ impl Observer { #[allow(clippy::cognitive_complexity)] pub fn follow(mut observer: Observer, settings: &Settings) -> UResult<()> { + // Check if there are no files to follow if observer.files.no_files_remaining(settings) && !observer.files.only_stdin_remaining() { + // If stdin was a pipe that we processed, just exit silently. + // Pipes can't be followed, so there's nothing more to do. + // Any errors about missing files were already printed. + if observer.stdin_pipe_processed { + return Ok(()); + } return Err(USimpleError::new(1, translate!("tail-no-files-remaining"))); } @@ -629,6 +665,35 @@ pub fn follow(mut observer: Observer, settings: &Settings) -> UResult<()> { paths = observer.files.keys().cloned().collect::>(); } + // Check if stdout is still connected (broken pipe detection). + // This is needed to detect when the pipe reader (e.g., head) has exited. + // When SIGPIPE is ignored, we won't get a signal, so we need to detect + // the broken pipe via poll(). GNU coreutils uses POLLRDBAND and checks + // for POLLERR/POLLHUP/POLLNVAL in revents. + #[cfg(unix)] + if observer.monitor_output { + use std::os::unix::io::AsRawFd; + let stdout_fd = std::io::stdout().as_raw_fd(); + // Use POLLRDBAND like GNU coreutils iopoll.c + let mut pollfd = libc::pollfd { + fd: stdout_fd, + events: libc::POLLRDBAND, + revents: 0, + }; + // Poll with 0 timeout - just check the status + let ret = unsafe { libc::poll(&mut pollfd, 1, 0) }; + if ret >= 0 && (pollfd.revents & (libc::POLLERR | libc::POLLHUP | libc::POLLNVAL)) != 0 + { + // Pipe is broken - reader has exited + set_exit_code(1); + show_error!( + "{}", + translate!("tail-error-stdout", "error" => translate!("tail-bad-fd")) + ); + break; + } + } + // main print loop for path in &paths { _read_some = observer.files.tail_file(path, settings.verbose)?; diff --git a/src/uu/tail/src/paths.rs b/src/uu/tail/src/paths.rs index 340a0b29dec..c459750aaff 100644 --- a/src/uu/tail/src/paths.rs +++ b/src/uu/tail/src/paths.rs @@ -230,13 +230,15 @@ pub fn path_is_tailable(path: &Path) -> bool { #[inline] pub fn stdin_is_bad_fd() -> bool { - // FIXME : Rust's stdlib is reopening fds as /dev/null - // see also: https://github.com/uutils/coreutils/issues/2873 - // (gnu/tests/tail-2/follow-stdin.sh fails because of this) - //#[cfg(unix)] + // Use the early-capture mechanism from uucore to detect if stdin was closed + // before Rust's runtime reopened it as /dev/null. + // See: https://github.com/uutils/coreutils/issues/2873 + #[cfg(unix)] { - //platform::stdin_is_bad_fd() + uucore::signals::stdin_was_closed() + } + #[cfg(not(unix))] + { + false } - //#[cfg(not(unix))] - false } diff --git a/src/uu/tail/src/tail.rs b/src/uu/tail/src/tail.rs index cd10203b3f7..c55581aab13 100644 --- a/src/uu/tail/src/tail.rs +++ b/src/uu/tail/src/tail.rs @@ -13,6 +13,10 @@ // spell-checker:ignore (shell/tools) // spell-checker:ignore (misc) +// Initialize SIGPIPE state capture at process startup +#[cfg(unix)] +uucore::init_sigpipe_capture!(); + pub mod args; pub mod chunks; mod follow; @@ -33,20 +37,26 @@ use std::fs::File; use std::io::{self, BufReader, BufWriter, ErrorKind, Read, Seek, SeekFrom, Write, stdin, stdout}; use std::path::{Path, PathBuf}; use uucore::display::Quotable; -use uucore::error::{FromIo, UResult, USimpleError, get_exit_code, set_exit_code}; +use uucore::error::{FromIo, UResult, USimpleError, set_exit_code}; use uucore::translate; use uucore::{show, show_error}; #[uucore::main] pub fn uumain(args: impl uucore::Args) -> UResult<()> { - // When we receive a SIGPIPE signal, we want to terminate the process so - // that we don't print any error messages to stderr. Rust ignores SIGPIPE - // (see https://github.com/rust-lang/rust/issues/62569), so we restore it's - // default action here. - #[cfg(not(target_os = "windows"))] + // 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 !uucore::signals::sigpipe_was_ignored() { + let _ = uucore::signals::enable_pipe_errors(); + } + + // Ignore SIGTTIN to prevent tail from being stopped when running in + // background and reading from /dev/tty (GNU compatibility). + #[cfg(unix)] unsafe { - libc::signal(libc::SIGPIPE, libc::SIG_DFL); + libc::signal(libc::SIGTTIN, libc::SIG_IGN); } let settings = parse_args(args)?; @@ -100,13 +110,33 @@ fn uu_tail(settings: &Settings) -> UResult<()> { the input file is not a FIFO, pipe, or regular file, it is unspecified whether or not the -f option shall be ignored. */ - if !settings.has_only_stdin() || settings.pid != 0 { - follow::follow(observer, settings)?; + + // Check if stdout was closed at process startup (e.g., `>&-` redirection). + // We use stdout_was_closed() because Rust's runtime reopens closed fds + // as /dev/null before our code runs, so we need to capture the state + // in .init_array before Rust's runtime. + // If stdout was closed, we can't follow, so exit with error. + #[cfg(unix)] + if uucore::signals::stdout_was_closed() { + set_exit_code(1); + return Ok(()); } - } - if get_exit_code() > 0 && paths::stdin_is_bad_fd() { - show_error!("{}: {}", text::DASH, translate!("tail-bad-fd")); + // Call follow() when: + // - There are files to follow (observer has files), OR + // - There's a --pid option, OR + // - Stdin was bad (to print "no files remaining"), OR + // - We had a bad path (e.g., directory without --retry, to print "no files remaining") + // Note: When stdin is a regular file (not a pipe), it gets added to observer.files + // via tail_file(), so we check if the observer has files rather than checking + // has_only_stdin(). + if !observer.files.is_empty() + || settings.pid != 0 + || observer.stdin_bad + || observer.had_bad_path + { + follow::follow(observer, settings)?; + } } Ok(()) @@ -148,13 +178,34 @@ fn tail_file( translate!("tail-error-cannot-follow-file-type", "file" => input.display_name.clone(), "msg" => msg) ); } + // Always add_bad_path to track that we had a bad path for "no files remaining" + observer.add_bad_path(path, input.display_name.as_str(), false)?; if !observer.follow_name_retry() { // skip directory if not retry return Ok(()); } - observer.add_bad_path(path, input.display_name.as_str(), false)?; } else { - match File::open(path) { + // Open fifos with O_NONBLOCK to avoid blocking when there's no writer. + // This allows tail to check --pid status while waiting for a writer. + #[cfg(unix)] + let open_result = { + use std::fs::OpenOptions; + use std::os::unix::fs::{FileTypeExt, OpenOptionsExt}; + let meta = path.metadata().ok(); + let is_fifo = meta.as_ref().is_some_and(|m| m.file_type().is_fifo()); + if is_fifo { + OpenOptions::new() + .read(true) + .custom_flags(libc::O_NONBLOCK) + .open(path) + } else { + File::open(path) + } + }; + #[cfg(not(unix))] + let open_result = File::open(path); + + match open_result { Ok(mut file) => { let st = file.metadata()?; let blksize_limit = uucore::fs::sane_blksize::sane_blksize_from_metadata(&st); @@ -168,6 +219,28 @@ fn tail_file( reader = BufReader::new(file); } else { reader = BufReader::new(file); + // Check for EIO before reading - happens when SIGTTIN is ignored + // and we're a background process reading from controlling terminal. + #[cfg(unix)] + { + use std::io::BufRead; + match reader.fill_buf() { + Ok(_) => {} + Err(e) if e.raw_os_error() == Some(libc::EIO) => { + // Skip initial read, just proceed to follow mode + if input.is_tailable() { + observer.add_path( + path, + input.display_name.as_str(), + Some(Box::new(reader)), + true, + )?; + } + return Ok(()); + } + Err(e) => return Err(e.into()), + } + } unbounded_tail(&mut reader, settings)?; } if input.is_tailable() { @@ -205,6 +278,29 @@ fn tail_stdin( input: &Input, observer: &mut Observer, ) -> UResult<()> { + // Check if stdin was closed before Rust's runtime reopened it as /dev/null. + #[cfg(unix)] + let stdin_bad = paths::stdin_is_bad_fd() + || std::fs::read_link("/proc/self/fd/0") + .is_ok_and(|p| p == std::path::Path::new("/dev/null")); + #[cfg(not(unix))] + let stdin_bad = paths::stdin_is_bad_fd(); + + if stdin_bad { + set_exit_code(1); + let stdin_name = translate!("tail-stdin-header"); + show_error!( + "{}", + translate!("tail-error-cannot-fstat", "file" => stdin_name.quote().to_string(), "error" => translate!("tail-bad-fd")) + ); + // Mark stdin as bad so follow() will be called to print "no files remaining". + // Don't set stdin_pipe_processed here - a bad stdin is different from + // a valid pipe. For a bad stdin, we DO want "no files remaining" if + // there are no other valid files to follow. + observer.stdin_bad = true; + return Ok(()); + } + // on macOS, resolve() will always return None for stdin, // we need to detect if stdin is a directory ourselves. // fstat-ing certain descriptors under /dev/fd fails with @@ -249,7 +345,11 @@ fn tail_stdin( stdin_offset, )?; } - // pipe + // pipe - stdin is a pipe, not a FIFO or regular file + // Pipes can't be followed because they can't have data appended after EOF. + // Per POSIX: "If no file operand is specified and standard input is a + // pipe or FIFO, the -f option shall be ignored." + // We read the data but don't add stdin to the follow observer. None => { header_printer.print_input(input); if paths::stdin_is_bad_fd() { @@ -267,7 +367,11 @@ fn tail_stdin( } else { let mut reader = BufReader::new(stdin()); unbounded_tail(&mut reader, settings)?; - observer.add_stdin(input.display_name.as_str(), Some(Box::new(reader)), true)?; + // Don't add stdin to the follow observer when it's a pipe. + // Pipes can't be followed - once EOF is reached, there's no more data. + // Mark that we processed stdin as a pipe so follow() knows not + // to print "no files remaining" if there are no other files. + observer.stdin_pipe_processed = true; } } } @@ -527,6 +631,9 @@ where } else { io::copy(file, &mut stdout).unwrap(); } + // Flush to ensure output is visible immediately (important when stdout + // is redirected to a file and tail will enter follow mode). + let _ = stdout.flush(); } #[cfg(test)] diff --git a/src/uucore/src/lib/features/signals.rs b/src/uucore/src/lib/features/signals.rs index 0bccb2173f6..b18b97559a7 100644 --- a/src/uucore/src/lib/features/signals.rs +++ b/src/uucore/src/lib/features/signals.rs @@ -426,6 +426,132 @@ pub fn ignore_interrupts() -> Result<(), Errno> { unsafe { signal(SIGINT, SigIgn) }.map(|_| ()) } +// ============================================================================ +// SIGPIPE state capture functionality +// ============================================================================ + +#[cfg(unix)] +use std::sync::atomic::{AtomicBool, Ordering}; + +#[cfg(unix)] +static SIGPIPE_WAS_IGNORED: AtomicBool = AtomicBool::new(false); + +#[cfg(unix)] +static STDOUT_WAS_CLOSED: AtomicBool = AtomicBool::new(false); + +#[cfg(unix)] +static STDIN_WAS_CLOSED: AtomicBool = AtomicBool::new(false); + +#[cfg(unix)] +/// # Safety +/// This function runs once at process initialization and only observes the +/// current `SIGPIPE` handler and stdout state, so there are no extra safety +/// requirements for callers. +pub unsafe extern "C" fn capture_sigpipe_state() { + use nix::libc; + use std::mem::MaybeUninit; + use std::ptr; + + // Capture SIGPIPE state + let mut current = MaybeUninit::::uninit(); + if unsafe { libc::sigaction(libc::SIGPIPE, ptr::null(), current.as_mut_ptr()) } == 0 { + let ignored = unsafe { current.assume_init() }.sa_sigaction == libc::SIG_IGN; + SIGPIPE_WAS_IGNORED.store(ignored, Ordering::Relaxed); + } + + // Capture stdout state (before Rust's runtime potentially reopens it) + // F_GETFD returns -1 with EBADF if fd is not valid + let stdout_valid = unsafe { libc::fcntl(libc::STDOUT_FILENO, libc::F_GETFD) } != -1; + STDOUT_WAS_CLOSED.store(!stdout_valid, Ordering::Relaxed); + + // Capture stdin state (before Rust's runtime potentially reopens it) + let stdin_valid = unsafe { libc::fcntl(libc::STDIN_FILENO, libc::F_GETFD) } != -1; + STDIN_WAS_CLOSED.store(!stdin_valid, Ordering::Relaxed); +} + +/// Initializes SIGPIPE state capture for the calling binary. +/// +/// This macro sets up the necessary link section attributes to ensure +/// `capture_sigpipe_state` runs at process initialization, before `main()`. +/// +/// # Usage +/// +/// Call this macro once at the crate root level of any utility that needs +/// to detect whether SIGPIPE was ignored: +/// +/// ```ignore +/// uucore::init_sigpipe_capture!(); +/// ``` +#[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. +/// +/// This requires [`init_sigpipe_capture!`] to have been invoked at the crate root. +#[cfg(unix)] +pub fn sigpipe_was_ignored() -> bool { + SIGPIPE_WAS_IGNORED.load(Ordering::Relaxed) +} + +/// Non-Unix stub: Always returns `false` since SIGPIPE doesn't exist on non-Unix platforms. +#[cfg(not(unix))] +pub const fn sigpipe_was_ignored() -> bool { + false +} + +/// Returns whether stdout was closed at process startup. +/// +/// This requires [`init_sigpipe_capture!`] to have been invoked at the crate root. +/// This is useful for detecting `>&-` redirection before Rust's runtime +/// potentially reopens closed fds as /dev/null. +#[cfg(unix)] +pub fn stdout_was_closed() -> bool { + STDOUT_WAS_CLOSED.load(Ordering::Relaxed) +} + +/// Non-Unix stub: Always returns `false`. +#[cfg(not(unix))] +pub const fn stdout_was_closed() -> bool { + false +} + +/// Returns whether stdin was closed at process startup. +/// +/// This requires [`init_sigpipe_capture!`] to have been invoked at the crate root. +/// This is useful for detecting `<&-` redirection before Rust's runtime +/// potentially reopens closed fds as /dev/null. +#[cfg(unix)] +pub fn stdin_was_closed() -> bool { + STDIN_WAS_CLOSED.load(Ordering::Relaxed) +} + +/// Non-Unix stub: Always returns `false`. +#[cfg(not(unix))] +pub const fn stdin_was_closed() -> bool { + false +} + #[test] fn signal_by_value() { assert_eq!(signal_by_name_or_value("0"), Some(0)); diff --git a/src/uucore/src/lib/lib.rs b/src/uucore/src/lib/lib.rs index 40632ae983b..fe3ac91e403 100644 --- a/src/uucore/src/lib/lib.rs +++ b/src/uucore/src/lib/lib.rs @@ -209,7 +209,9 @@ macro_rules! bin { let code = $util::uumain(uucore::args_os()); // (defensively) flush stdout for utility prior to exit; see if let Err(e) = std::io::stdout().flush() { - eprintln!("Error flushing stdout: {e}"); + if e.kind() != std::io::ErrorKind::BrokenPipe { + eprintln!("Error flushing stdout: {e}"); + } } std::process::exit(code); diff --git a/src/uucore/src/lib/mods/error.rs b/src/uucore/src/lib/mods/error.rs index 0b88e389b65..89dced69f52 100644 --- a/src/uucore/src/lib/mods/error.rs +++ b/src/uucore/src/lib/mods/error.rs @@ -210,6 +210,15 @@ pub trait UError: Error + Send { 1 } + /// Check if this error is caused by a broken pipe (EPIPE). + /// + /// When writing to a closed pipe, programs should exit silently with status 0 + /// rather than printing an error message. This method allows utilities to + /// detect BrokenPipe errors and handle them appropriately. + fn is_broken_pipe(&self) -> bool { + false + } + /// Print usage help to a custom error. /// /// Return true or false to control whether a short usage help is printed @@ -399,7 +408,11 @@ impl UIoError { } } -impl UError for UIoError {} +impl UError for UIoError { + fn is_broken_pipe(&self) -> bool { + self.inner.kind() == std::io::ErrorKind::BrokenPipe + } +} impl Error for UIoError {} @@ -495,6 +508,25 @@ impl FromIo> for std::io::ErrorKind { } } +/// Check if stdout was closed before Rust's runtime reopened it as /dev/null. +/// +/// When a process is started with stdout closed (e.g., `cmd >&-`), Rust's runtime +/// will reopen file descriptor 1 as /dev/null to avoid issues with libraries that +/// assume stdout is always valid. This function detects this condition by checking +/// if /proc/self/fd/1 points to /dev/null. +/// +/// See +#[cfg(unix)] +pub fn is_stdout_closed() -> bool { + std::fs::read_link("/proc/self/fd/1").is_ok_and(|p| p == std::path::Path::new("/dev/null")) +} + +/// Non-Unix stub that always returns false. +#[cfg(not(unix))] +pub fn is_stdout_closed() -> bool { + false +} + impl From for UIoError { fn from(f: std::io::Error) -> Self { Self { diff --git a/tests/by-util/test_seq.rs b/tests/by-util/test_seq.rs index d5dd526aa6b..617601cfc38 100644 --- a/tests/by-util/test_seq.rs +++ b/tests/by-util/test_seq.rs @@ -4,33 +4,16 @@ // file that was distributed with this source code. // spell-checker:ignore lmnop xlmnop 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 +186,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] @@ -653,7 +654,7 @@ fn test_neg_inf() { new_ucmd!() .args(&["--", "-inf", "0"]) .run_stdout_starts_with(b"-inf\n-inf\n-inf\n") - .success(); + .signal_name_is("PIPE"); } #[test] @@ -661,7 +662,7 @@ fn test_neg_infinity() { new_ucmd!() .args(&["--", "-infinity", "0"]) .run_stdout_starts_with(b"-inf\n-inf\n-inf\n") - .success(); + .signal_name_is("PIPE"); } #[test] @@ -669,7 +670,7 @@ fn test_inf() { new_ucmd!() .args(&["inf"]) .run_stdout_starts_with(b"1\n2\n3\n") - .success(); + .signal_name_is("PIPE"); } #[test] @@ -677,7 +678,7 @@ fn test_infinity() { new_ucmd!() .args(&["infinity"]) .run_stdout_starts_with(b"1\n2\n3\n") - .success(); + .signal_name_is("PIPE"); } #[test] @@ -685,7 +686,7 @@ 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(); + .signal_name_is("PIPE"); } #[test] @@ -693,7 +694,7 @@ 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(); + .signal_name_is("PIPE"); } #[test] @@ -1078,7 +1079,7 @@ fn test_precision_corner_cases() { new_ucmd!() .args(&["1", "1.2", "inf"]) .run_stdout_starts_with(b"1.0\n2.2\n3.4\n") - .success(); + .signal_name_is("PIPE"); } // GNU `seq` manual only makes guarantees about `-w` working if the @@ -1141,5 +1142,5 @@ fn test_equalize_widths_corner_cases() { new_ucmd!() .args(&["-w", "1", "1.2", "inf"]) .run_stdout_starts_with(b"1.0\n2.2\n3.4\n") - .success(); + .signal_name_is("PIPE"); }