diff --git a/.vscode/cspell.dictionaries/jargon.wordlist.txt b/.vscode/cspell.dictionaries/jargon.wordlist.txt index a1bda0e7690..2b5171bfb2f 100644 --- a/.vscode/cspell.dictionaries/jargon.wordlist.txt +++ b/.vscode/cspell.dictionaries/jargon.wordlist.txt @@ -1,4 +1,7 @@ AFAICT +alrm +sigalrm +SIGALRM asimd ASIMD alloc @@ -31,6 +34,7 @@ denoland deque dequeue dev +EAGAIN EINTR eintr nextest @@ -98,6 +102,7 @@ multicall nmerge noatime nocache +NOCLDSTOP nocreat noctty noerror @@ -122,8 +127,10 @@ preload prepend prepended primality +pselect pseudoprime pseudoprimes +pthread quantiles readonly reparse @@ -135,21 +142,31 @@ semiprimes setcap setfacl setfattr +setmask setlocale shortcode shortcodes +sigaction +sigaddset siginfo +sigmask sigusr +sigprocmask +SIGRTMAX +SIGRTMIN +sigset strcasecmp subcommand subexpression submodule +suseconds sync symlink symlinks syscall syscalls sysconf +timeval tokenize toolchain totalram diff --git a/src/uu/timeout/Cargo.toml b/src/uu/timeout/Cargo.toml index c6b795628f7..f0da39a9a99 100644 --- a/src/uu/timeout/Cargo.toml +++ b/src/uu/timeout/Cargo.toml @@ -20,8 +20,13 @@ path = "src/timeout.rs" [dependencies] clap = { workspace = true } libc = { workspace = true } -nix = { workspace = true, features = ["signal"] } -uucore = { workspace = true, features = ["parser", "process", "signals"] } +nix = { workspace = true, features = ["signal", "poll"] } +uucore = { workspace = true, features = [ + "parser", + "process", + "signals", + "pipes", +] } fluent = { workspace = true } [[bin]] diff --git a/src/uu/timeout/src/timeout.rs b/src/uu/timeout/src/timeout.rs index 3e1a35c45b2..8de4623add0 100644 --- a/src/uu/timeout/src/timeout.rs +++ b/src/uu/timeout/src/timeout.rs @@ -8,19 +8,22 @@ mod status; use crate::status::ExitStatus; use clap::{Arg, ArgAction, Command}; +use nix::sys::signal::Signal; use std::io::ErrorKind; use std::os::unix::process::ExitStatusExt; use std::process::{self, Child, Stdio}; -use std::sync::atomic::{self, AtomicBool}; use std::time::Duration; use uucore::display::Quotable; use uucore::error::{UResult, USimpleError, UUsageError}; use uucore::parser::parse_time; -use uucore::process::ChildExt; +use uucore::process::{ChildExt, CommandExt, SelfPipe, WaitOrTimeoutRet}; use uucore::translate; #[cfg(unix)] -use uucore::signals::enable_pipe_errors; +use ::{ + nix::sys::signal::{SigSet, SigmaskHow, pthread_sigmask}, + uucore::signals::{enable_pipe_errors, is_ignored}, +}; use uucore::{ format_usage, show_error, @@ -176,34 +179,6 @@ pub fn uu_app() -> Command { .after_help(translate!("timeout-after-help")) } -/// Remove pre-existing SIGCHLD handlers that would make waiting for the child's exit code fail. -fn unblock_sigchld() { - unsafe { - nix::sys::signal::signal( - nix::sys::signal::Signal::SIGCHLD, - nix::sys::signal::SigHandler::SigDfl, - ) - .unwrap(); - } -} - -/// We should terminate child process when receiving TERM signal. -static SIGNALED: AtomicBool = AtomicBool::new(false); - -fn catch_sigterm() { - use nix::sys::signal; - - extern "C" fn handle_sigterm(signal: libc::c_int) { - let signal = signal::Signal::try_from(signal).unwrap(); - if signal == signal::Signal::SIGTERM { - SIGNALED.store(true, atomic::Ordering::Relaxed); - } - } - - let handler = signal::SigHandler::Handler(handle_sigterm); - unsafe { signal::signal(signal::Signal::SIGTERM, handler) }.unwrap(); -} - /// Report that a signal is being sent if the verbose flag is set. fn report_if_verbose(signal: usize, cmd: &str, verbose: bool) { if verbose { @@ -258,24 +233,28 @@ fn wait_or_kill_process( preserve_status: bool, foreground: bool, verbose: bool, + self_pipe: &mut SelfPipe, ) -> std::io::Result { // ignore `SIGTERM` here - match process.wait_or_timeout(duration, None) { - Ok(Some(status)) => { + self_pipe.unset_other(Signal::SIGTERM)?; + + match process.wait_or_timeout(duration, self_pipe) { + Ok(WaitOrTimeoutRet::InTime(status)) => { if preserve_status { Ok(status.code().unwrap_or_else(|| status.signal().unwrap())) } else { Ok(ExitStatus::TimeoutFailed.into()) } } - Ok(None) => { + Ok(WaitOrTimeoutRet::TimedOut) => { let signal = signal_by_name_or_value("KILL").unwrap(); report_if_verbose(signal, cmd, verbose); send_signal(process, signal, foreground); process.wait()?; Ok(ExitStatus::SignalSent(signal).into()) } - Err(_) => Ok(ExitStatus::CommandTimedOut.into()), + Ok(WaitOrTimeoutRet::CustomSignaled(n)) => Ok(ExitStatus::SignalSent(n as _).into()), + Err(_) => Ok(ExitStatus::TimeoutFailed.into()), } } @@ -305,6 +284,37 @@ fn preserve_signal_info(signal: libc::c_int) -> libc::c_int { signal } +#[cfg(unix)] +fn block_ignored_signals() -> nix::Result<()> { + #[cfg(target_os = "linux")] + fn rt_signals() -> impl Iterator { + libc::SIGRTMIN()..=libc::SIGRTMAX() + } + #[cfg(not(target_os = "linux"))] + fn rt_signals() -> impl Iterator { + std::iter::empty() + } + + let mut set = SigSet::empty(); + for s in Signal::iterator() + .filter_map(|s| { + if matches!(s, Signal::SIGSTOP | Signal::SIGKILL | Signal::SIGTERM) { + None + } else { + Some(s as i32) + } + }) + .chain(rt_signals()) + { + if is_ignored(s)? { + // We use raw libc bindings because [`nix`] does not support RT signals. + // SAFETY: SigSet is repr(transparent) over sigset_t. + unsafe { libc::sigaddset((&raw mut set).cast(), s) }; + } + } + pthread_sigmask(SigmaskHow::SIG_BLOCK, Some(&set), None) +} + fn timeout( cmd: &[String], duration: Duration, @@ -318,52 +328,61 @@ fn timeout( unsafe { libc::setpgid(0, 0) }; } #[cfg(unix)] - enable_pipe_errors()?; + // We keep the inherited SIGPIPE disposition if ignored. + if !is_ignored(Signal::SIGPIPE as _)? { + enable_pipe_errors()?; + } - let process = &mut process::Command::new(&cmd[0]) + let mut command = process::Command::new(&cmd[0]); + command .args(&cmd[1..]) .stdin(Stdio::inherit()) .stdout(Stdio::inherit()) - .stderr(Stdio::inherit()) - .spawn() - .map_err(|err| { - let status_code = match err.kind() { - ErrorKind::NotFound => ExitStatus::CommandNotFound.into(), - ErrorKind::PermissionDenied => ExitStatus::CannotInvoke.into(), - _ => ExitStatus::CannotInvoke.into(), - }; - USimpleError::new( - status_code, - translate!("timeout-error-failed-to-execute-process", "error" => err), - ) - })?; - unblock_sigchld(); - catch_sigterm(); + .stderr(Stdio::inherit()); + #[cfg(unix)] + block_ignored_signals()?; + let mut set = SigSet::empty(); + set.add(Signal::SIGTERM); + set.add(Signal::SIGUSR1); + let mut self_pipe = command.set_up_timeout(set)?; + let process = &mut command.spawn().map_err(|err| { + let status_code = match err.kind() { + ErrorKind::NotFound => ExitStatus::CommandNotFound.into(), + ErrorKind::PermissionDenied => ExitStatus::CannotInvoke.into(), + _ => ExitStatus::CannotInvoke.into(), + }; + USimpleError::new( + status_code, + translate!("timeout-error-failed-to-execute-process", "error" => err), + ) + })?; // Wait for the child process for the specified time period. // - // If the process exits within the specified time period (the - // `Ok(Some(_))` arm), then return the appropriate status code. - // - // If the process does not exit within that time (the `Ok(None)` - // arm) and `kill_after` is specified, then try sending `SIGKILL`. - // // TODO The structure of this block is extremely similar to the // structure of `wait_or_kill_process()`. They can probably be // refactored into some common function. - match process.wait_or_timeout(duration, Some(&SIGNALED)) { - Ok(Some(status)) => Err(status + match process.wait_or_timeout(duration, &mut self_pipe) { + Ok(WaitOrTimeoutRet::InTime(status)) => Err(status .code() .unwrap_or_else(|| preserve_signal_info(status.signal().unwrap())) .into()), - Ok(None) => { + Ok(WaitOrTimeoutRet::CustomSignaled(n)) => { + report_if_verbose(signal, &cmd[0], verbose); + send_signal(process, signal, foreground); + process.wait()?; + if n == Signal::SIGTERM as i32 { + Err(ExitStatus::Terminated.into()) + } else { + Err(ExitStatus::SignalSent(n as _).into()) + } + } + Ok(WaitOrTimeoutRet::TimedOut) => { report_if_verbose(signal, &cmd[0], verbose); send_signal(process, signal, foreground); match kill_after { None => { let status = process.wait()?; - if SIGNALED.load(atomic::Ordering::Relaxed) { - Err(ExitStatus::Terminated.into()) - } else if preserve_status { + if preserve_status { if let Some(ec) = status.code() { Err(ec.into()) } else if let Some(sc) = status.signal() { @@ -383,6 +402,7 @@ fn timeout( preserve_status, foreground, verbose, + &mut self_pipe, ) { Ok(status) => Err(status.into()), Err(e) => Err(USimpleError::new( diff --git a/src/uucore/Cargo.toml b/src/uucore/Cargo.toml index 0362dc09793..708f098aff2 100644 --- a/src/uucore/Cargo.toml +++ b/src/uucore/Cargo.toml @@ -94,6 +94,7 @@ nix = { workspace = true, features = [ "signal", "dir", "user", + "poll", ] } xattr = { workspace = true, optional = true } @@ -163,7 +164,7 @@ ringbuffer = [] safe-traversal = ["libc"] selinux = ["dep:selinux"] smack = ["xattr"] -signals = [] +signals = ["libc"] sum = [ "digest", "hex", diff --git a/src/uucore/src/lib/features/process.rs b/src/uucore/src/lib/features/process.rs index 55e8c36482b..0df3f26da5c 100644 --- a/src/uucore/src/lib/features/process.rs +++ b/src/uucore/src/lib/features/process.rs @@ -7,16 +7,43 @@ // spell-checker:ignore (sys/unix) WIFSIGNALED ESRCH // spell-checker:ignore pgrep pwait snice getpgrp -use libc::{gid_t, pid_t, uid_t}; +#[cfg(feature = "pipes")] +use std::marker::PhantomData; + +#[cfg(feature = "pipes")] +use crate::pipes::pipe; +#[cfg(feature = "pipes")] +use ::{ + nix::sys::select::FdSet, + nix::sys::select::select, + nix::sys::signal::{SaFlags, SigAction, SigHandler, SigSet, sigaction}, + nix::sys::time::TimeVal, + std::fs::File, + std::io::{Read, Write}, + std::os::fd::AsFd, + std::process::Command, + std::process::ExitStatus, + std::sync::Mutex, + std::time::Duration, + std::time::Instant, +}; +use libc::{c_int, gid_t, pid_t, uid_t}; #[cfg(not(target_os = "redox"))] use nix::errno::Errno; -use std::io; -use std::process::Child; -use std::process::ExitStatus; -use std::sync::atomic; -use std::sync::atomic::AtomicBool; -use std::thread; -use std::time::{Duration, Instant}; +use nix::sys::signal::Signal; +use std::{io, process::Child}; + +/// Not all platforms support uncapped times (read: macOS). However, +/// we will conform to POSIX for portability. +/// +#[cfg(feature = "pipes")] +const TIME_T_POSIX_MAX: u64 = 100_000_000; + +/// Not all platforms support uncapped times (read: macOS). However, +/// we will conform to POSIX for portability. +/// +#[cfg(feature = "pipes")] +const SUSECONDS_T_POSIX_MAX: u32 = 1_000_000; // SAFETY: These functions always succeed and return simple integers. @@ -90,67 +117,212 @@ pub trait ChildExt { /// Wait for a process to finish or return after the specified duration. /// A `timeout` of zero disables the timeout. + #[cfg(feature = "pipes")] fn wait_or_timeout( &mut self, timeout: Duration, - signaled: Option<&AtomicBool>, - ) -> io::Result>; + self_pipe: &mut SelfPipe, + ) -> io::Result; +} + +#[cfg(feature = "pipes")] +pub struct SelfPipe(File, SigSet, PhantomData<*mut ()>); + +#[cfg(feature = "pipes")] +pub trait CommandExt { + fn set_up_timeout(&mut self, others: SigSet) -> io::Result; +} + +/// Concise enum of [`ChildExt::wait_or_timeout`] possible returns. +#[derive(Debug)] +#[cfg(feature = "pipes")] +pub enum WaitOrTimeoutRet { + InTime(ExitStatus), + CustomSignaled(i32), + TimedOut, } impl ChildExt for Child { fn send_signal(&mut self, signal: usize) -> io::Result<()> { - if unsafe { libc::kill(self.id() as pid_t, signal as i32) } == 0 { - Ok(()) - } else { - Err(io::Error::last_os_error()) - } + nix::Error::result(unsafe { libc::kill(self.id() as pid_t, signal as i32) })?; + Ok(()) } fn send_signal_group(&mut self, signal: usize) -> io::Result<()> { - // Ignore the signal, so we don't go into a signal loop. - if unsafe { libc::signal(signal as i32, libc::SIG_IGN) } == usize::MAX { - return Err(io::Error::last_os_error()); - } - if unsafe { libc::kill(0, signal as i32) } == 0 { - Ok(()) - } else { - Err(io::Error::last_os_error()) + // Ignore the signal, so we don't go into a signal loop. Some signals will fail + // the call because they cannot be ignored, but they insta-kill so it's fine. + if signal != Signal::SIGSTOP as _ && signal != Signal::SIGKILL as _ { + let err = unsafe { libc::signal(signal as i32, libc::SIG_IGN) } == usize::MAX; + if err { + return Err(io::Error::last_os_error()); + } } + nix::Error::result(unsafe { libc::kill(0, signal as i32) })?; + Ok(()) } + #[cfg(feature = "pipes")] fn wait_or_timeout( &mut self, timeout: Duration, - signaled: Option<&AtomicBool>, - ) -> io::Result> { - if timeout == Duration::from_micros(0) { - return self.wait().map(Some); - } - // .try_wait() doesn't drop stdin, so we do it manually + self_pipe: &mut SelfPipe, + ) -> io::Result { + // Manually drop stdin drop(self.stdin.take()); let start = Instant::now(); + // This is not a hot loop, it runs exactly once if the process + // times out, and otherwise will most likely run twice, so that + // select() ensures we are selecting on the signals we care about. + // It would only run more than twice if we receive an external + // signal we are not selecting, select() returns EAGAIN or there is + // a read error on the pipes (bug on some platforms), but there is no + // way this creates hot-loop issues anyway. loop { - if let Some(status) = self.try_wait()? { - return Ok(Some(status)); - } + let mut fd_set = FdSet::new(); + fd_set.insert(self_pipe.0.as_fd()); + let mut timeout_v = duration_to_timeval_elapsed(timeout, start); - if start.elapsed() >= timeout - || signaled.is_some_and(|signaled| signaled.load(atomic::Ordering::Relaxed)) + // Perform signal selection. + match select(None, Some(&mut fd_set), None, None, timeout_v.as_mut()) + .map_err(|x| x as c_int) // Transparent conversion. { - break; + Err(errno::EINTR | errno::EAGAIN) => continue, // Signal interrupted it. + Err(_) => return Err(io::Error::last_os_error()), // Propagate error. + Ok(_) => { + if start.elapsed() >= timeout && !timeout.is_zero() { + return Ok(WaitOrTimeoutRet::TimedOut); + } + // The set is modified to contain the readable ones; + // if empty, we'd stall on the read. However, this may + // happen spuriously, so we try to select again. + if fd_set.contains(self_pipe.0.as_fd()) { + let mut buf = [0; std::mem::size_of::()]; + self_pipe.0.read_exact(&mut buf)?; + let sig = i32::from_ne_bytes(buf); + return match sig { + // SIGCHLD + libc::SIGCHLD => match self.try_wait()? { + Some(e) => Ok(WaitOrTimeoutRet::InTime(e)), + None => Ok(WaitOrTimeoutRet::InTime(ExitStatus::default())), + }, + // Received SIGALRM externally, for compat with + // GNU timeout we act as if it had timed out. + libc::SIGALRM => Ok(WaitOrTimeoutRet::TimedOut), + // Custom signals on zero timeout still succeed. + _ if timeout.is_zero() => { + Ok(WaitOrTimeoutRet::InTime(ExitStatus::default())) + } + // We received a custom signal and fail. + x => Ok(WaitOrTimeoutRet::CustomSignaled(x)), + }; + } + } + } + } + } +} + +#[cfg(feature = "pipes")] +#[allow(clippy::unnecessary_fallible_conversions, clippy::useless_conversion)] +fn duration_to_timeval_elapsed(time: Duration, start: Instant) -> Option { + if time.is_zero() { + None + } else { + let elapsed = start.elapsed(); + // This code ensures we do not overflow on any platform and we keep + // POSIX conformance. As-casts here are either no-ops or impossible + // to under/overflow because values are clamped to range or of the + // same size. If there is underflow, a minimum microsecond is added. + let seconds = time + .as_secs() + .saturating_sub(elapsed.as_secs()) + .clamp(0, TIME_T_POSIX_MAX) as libc::time_t; + let microseconds = time + .subsec_micros() + .saturating_sub(elapsed.subsec_micros()) + .clamp((seconds == 0) as u32, SUSECONDS_T_POSIX_MAX) + as libc::suseconds_t; + + Some(TimeVal::new(seconds, microseconds)) + } +} + +#[cfg(feature = "pipes")] +impl CommandExt for Command { + fn set_up_timeout(&mut self, others: SigSet) -> io::Result { + static SELF_PIPE_W: Mutex> = Mutex::new(None); + let (r, w) = pipe()?; + *SELF_PIPE_W.lock().unwrap() = Some(w); + extern "C" fn sig_handler(signal: c_int) { + let mut lock = SELF_PIPE_W.lock(); + let Ok(&mut Some(ref mut writer)) = lock.as_deref_mut() else { + return; + }; + let _ = writer.write(&signal.to_ne_bytes()); + } + let action = SigAction::new( + SigHandler::Handler(sig_handler), + SaFlags::SA_NOCLDSTOP, + SigSet::all(), + ); + unsafe { + sigaction(Signal::SIGCHLD, &action)?; + sigaction(Signal::SIGALRM, &action)?; + for signal in &others { + sigaction(signal, &action)?; } + }; + Ok(SelfPipe(r, others, PhantomData)) + } +} - // XXX: this is kinda gross, but it's cleaner than starting a thread just to wait - // (which was the previous solution). We might want to use a different duration - // here as well - thread::sleep(Duration::from_millis(100)); +#[cfg(feature = "pipes")] +impl SelfPipe { + pub fn unset_other(&self, signal: Signal) -> io::Result<()> { + if self.1.contains(signal) { + unsafe { + sigaction( + signal, + &SigAction::new(SigHandler::SigDfl, SaFlags::empty(), SigSet::empty()), + )?; + } } + Ok(()) + } +} - Ok(None) +#[cfg(feature = "pipes")] +impl Drop for SelfPipe { + fn drop(&mut self) { + let action = SigAction::new(SigHandler::SigDfl, SaFlags::empty(), SigSet::empty()); + let _ = unsafe { sigaction(Signal::SIGCHLD, &action) }; + for signal in &self.1 { + let _ = unsafe { sigaction(signal, &action) }; + } } } +// The libc/nix crate appear to not have caught up to on Redox's libc, so +// we will just do this manually, which should be fine. +// FIXME: import Errno and try on Redox at some point, then enable them +// throughout uutils. Maybe we could just link to it ourselves, though. +#[cfg(all(not(target_os = "redox"), feature = "pipes"))] +mod errno { + use super::{Errno, c_int}; + + pub const EINTR: c_int = Errno::EINTR as c_int; + pub const EAGAIN: c_int = Errno::EAGAIN as c_int; +} + +#[cfg(all(target_os = "redox", feature = "pipes"))] +mod errno { + use super::c_int; + + pub const EINTR: c_int = 4; + pub const EAGAIN: c_int = 11; +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/uucore/src/lib/features/signals.rs b/src/uucore/src/lib/features/signals.rs index 0bccb2173f6..79daaf136bc 100644 --- a/src/uucore/src/lib/features/signals.rs +++ b/src/uucore/src/lib/features/signals.rs @@ -10,6 +10,9 @@ //! It provides a way to convert signal names to their corresponding values and vice versa. //! It also provides a way to ignore the SIGINT signal and enable pipe errors. +use std::mem::MaybeUninit; +use std::ptr::null; + #[cfg(unix)] use nix::errno::Errno; #[cfg(unix)] @@ -410,6 +413,17 @@ pub fn signal_name_by_value(signal_value: usize) -> Option<&'static str> { ALL_SIGNALS.get(signal_value).copied() } +/// Returns whether signal disposition is to ignore. We use raw i32 because [`nix`] does not currently +/// support RT signals. +#[cfg(unix)] +pub fn is_ignored(signal: i32) -> Result { + let mut prev_handler = MaybeUninit::uninit(); + // We use libc functions here because nix does not properly + // support real-time signals nor null sigaction handlers. + Errno::result(unsafe { libc::sigaction(signal, null(), prev_handler.as_mut_ptr()) })?; + Ok(unsafe { prev_handler.assume_init() }.sa_sigaction == libc::SIG_IGN) +} + /// Returns the default signal value. #[cfg(unix)] pub fn enable_pipe_errors() -> Result<(), Errno> { diff --git a/tests/by-util/test_timeout.rs b/tests/by-util/test_timeout.rs index 9c5c6c1a465..0dc65f4be72 100644 --- a/tests/by-util/test_timeout.rs +++ b/tests/by-util/test_timeout.rs @@ -1,4 +1,6 @@ -use std::time::Duration; +use std::process::Command; +use std::time::{Duration, Instant}; +use uutests::util::TestScenario; // This file is part of the uutils coreutils package. // @@ -52,11 +54,13 @@ fn test_command_with_args() { fn test_verbose() { for verbose_flag in ["-v", "--verbose"] { new_ucmd!() - .args(&[verbose_flag, ".1", "sleep", "1"]) + .args(&[verbose_flag, ".1", "sleep", "10"]) + .timeout(Duration::from_secs(1)) .fails() .stderr_only("timeout: sending signal TERM to command 'sleep'\n"); new_ucmd!() - .args(&[verbose_flag, "-s0", "-k.1", ".1", "sleep", "1"]) + .args(&[verbose_flag, "-s0", "-k.1", ".1", "sleep", "10"]) + .timeout(Duration::from_secs(1)) .fails() .stderr_only("timeout: sending signal EXIT to command 'sleep'\ntimeout: sending signal KILL to command 'sleep'\n"); } @@ -107,22 +111,26 @@ fn test_preserve_status() { fn test_preserve_status_even_when_send_signal() { // When sending CONT signal, process doesn't get killed or stopped. // So, expected result is success and code 0. + let time = Instant::now(); for cont_spelling in ["CONT", "cOnT", "SIGcont"] { new_ucmd!() - .args(&["-s", cont_spelling, "--preserve-status", ".1", "sleep", "1"]) + .args(&["-s", cont_spelling, "--preserve-status", ".1", "sleep", "2"]) .succeeds() .no_output(); } + assert!(time.elapsed().as_secs() >= 6); // Assert they run for one second each. } #[test] fn test_dont_overflow() { new_ucmd!() .args(&["9223372036854775808d", "sleep", "0"]) + .timeout(Duration::from_secs(2)) .succeeds() .no_output(); new_ucmd!() .args(&["-k", "9223372036854775808d", "10", "sleep", "0"]) + .timeout(Duration::from_secs(2)) .succeeds() .no_output(); } @@ -183,11 +191,12 @@ fn test_kill_subprocess() { new_ucmd!() .args(&[ // Make sure the CI can spawn the subprocess. - "1", + "5", "sh", "-c", - "trap 'echo inside_trap' TERM; sleep 5", + "trap 'echo inside_trap' TERM; sleep 30", ]) + .timeout(Duration::from_secs(6)) // assert it exits when it times out. .fails_with_code(124) .stdout_contains("inside_trap"); } @@ -202,6 +211,14 @@ fn test_hex_timeout_ending_with_d() { } #[test] +// This is because of a bug fixed on newer macOS versions, which has not +// been backported to now-obsolete macOS on x64. It's about it not picking +// up correctly the child's exit code due to random shenanigans. It's +// unclear whether it should be fixed, but I will try to work around it at +// a later time if I can get macOS running on my ThinkPad T480s. To clarify, +// this most likely not an issue in real-world scenarios, only on how this +// test (Rust's std and our utils module) process the exit code. +#[cfg_attr(all(target_os = "macos", target_arch = "x86_64"), ignore)] fn test_terminate_child_on_receiving_terminate() { let mut timeout_cmd = new_ucmd!() .args(&[ @@ -235,3 +252,69 @@ fn test_command_cannot_invoke() { // Try to execute a directory (should give permission denied or similar) new_ucmd!().args(&["1", "/"]).fails_with_code(126); } + +#[test] +fn test_cascaded_timeout_with_bash_trap() { + // Use bash if available, otherwise skip + if Command::new("bash").arg("--version").output().is_err() { + // Skip test if bash is not available + return; + } + + // Test with bash explicitly to ensure SIGINT handlers work + let script = r" + trap 'echo bash_trap_fired; exit 0' INT + sleep 10 + "; + + let ts = TestScenario::new("timeout"); + let timeout_bin = ts.bin_path.to_str().unwrap(); + + ts.ucmd() + .args(&[ + "-s", + "ALRM", + "0.3", + timeout_bin, + "timeout", + "-s", + "INT", + "5", + "bash", + "-c", + script, + ]) + .timeout(Duration::from_secs(6)) + .fails_with_code(124) + .stdout_contains("bash_trap_fired"); +} + +#[test] +// We have to work around a bug in BSD `sh`. The GNU Test Suite also skips +// this kind of test on the platform for this reason. +#[cfg(not(any(target_os = "freebsd", target_os = "openbsd")))] +fn test_signal_block_on_ignore() { + let ts = TestScenario::new("timeout"); + let res = ts + .cmd("/bin/sh") + .arg("-c") + .arg(format!( + "(trap '' PIPE; {} timeout -v 10 yes | :)", + ts.bin_path.to_str().unwrap() + )) + .timeout(Duration::from_secs(2)) + .succeeds(); + // If the signal disposition is correct, instead of being silently killed + // by SIGPIPE, `yes` receives an EPIPE error and outputs it. + assert_eq!( + res.stderr_str() + .to_string() + .trim_end_matches('\n') + .trim_end_matches('\r'), + if cfg!(any(target_os = "macos", target_os = "ios")) { + "yes: stdout: Broken pipe" + } else { + "yes: standard output: Broken pipe" + } + ); +}