Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 17 additions & 4 deletions .vscode/cspell.dictionaries/workspace.wordlist.txt
Original file line number Diff line number Diff line change
Expand Up @@ -182,10 +182,6 @@ LINESIZE
NAMESIZE
RTLD_NEXT
RTLD
SIGINT
SIGKILL
SIGSTOP
SIGTERM
SYS_fdatasync
SYS_syncfs
USERSIZE
Expand Down Expand Up @@ -238,7 +234,24 @@ iovec
unistd

# * vars/signals
pthread
sigaction
sigaddset
sigemptyset
sigmask
sigset
SETMASK
ALRM
SIGALRM
SIGHUP
SIGINT
SIGKILL
SIGPIPE
SIGQUIT
SIGSTOP
SIGTERM
SIGTTIN
SIGTTOU

# * vars/std
CString
Expand Down
1 change: 1 addition & 0 deletions src/uu/timeout/src/status.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ pub(crate) enum ExitStatus {
WaitingFailed,

/// When `SIGTERM` signal received.
#[allow(dead_code)]
Terminated,
}

Expand Down
195 changes: 161 additions & 34 deletions src/uu/timeout/src/timeout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -187,21 +187,75 @@ fn unblock_sigchld() {
}
}

/// We should terminate child process when receiving TERM signal.
/// We should terminate child process when receiving signals.
/// This is set to true when we receive a signal that should trigger timeout behavior.
static SIGNALED: AtomicBool = AtomicBool::new(false);
/// The PID of the monitored child process (0 if not set)
static MONITORED_PID: atomic::AtomicI32 = atomic::AtomicI32::new(0);
/// The signal to send to the child process when timeout occurs
static TERM_SIGNAL: atomic::AtomicI32 = atomic::AtomicI32::new(0);
/// Whether we're running in foreground mode
static FOREGROUND: AtomicBool = AtomicBool::new(false);
/// The signal that was received (0 if none, or signal number)
static RECEIVED_SIGNAL: atomic::AtomicI32 = atomic::AtomicI32::new(0);

fn catch_sigterm() {
fn catch_signals() {
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);
extern "C" fn handle_signal(sig: libc::c_int) {
// Avoid any Rust functions that aren't async-signal-safe
SIGNALED.store(true, atomic::Ordering::Relaxed);
RECEIVED_SIGNAL.store(sig, atomic::Ordering::Relaxed);

// Send signal to child process directly from handler (like GNU timeout)
let pid = MONITORED_PID.load(atomic::Ordering::Relaxed);
let term_sig = TERM_SIGNAL.load(atomic::Ordering::Relaxed);
let foreground = FOREGROUND.load(atomic::Ordering::Relaxed);

if pid > 0 {
// Send the signal directly to the monitored child (like GNU lines 215-226)
unsafe { libc::kill(pid, term_sig) };

// The normal case is the job has remained in our newly created process group,
// so send to all processes in that (GNU lines 228-238)
if !foreground {
// Send to child's process group using negative PID
// This avoids sending to ourselves (timeout process)
unsafe { libc::kill(-pid, term_sig) };

// Also send SIGCONT if needed
let kill_signal = 9; // SIGKILL
let cont_signal = 19; // SIGCONT on most systems
if term_sig != kill_signal && term_sig != cont_signal {
unsafe { libc::kill(pid, cont_signal) };
unsafe { libc::kill(-pid, cont_signal) };
}
}
}
}

let handler = signal::SigHandler::Handler(handle_sigterm);
unsafe { signal::signal(signal::Signal::SIGTERM, handler) }.unwrap();
let handler = signal::SigHandler::Handler(handle_signal);

// Install signal handlers using sigaction with SA_RESTART (like GNU timeout)
let signals_to_catch = [
signal::Signal::SIGTERM,
signal::Signal::SIGINT,
signal::Signal::SIGHUP,
signal::Signal::SIGQUIT,
signal::Signal::SIGALRM,
signal::Signal::SIGUSR1,
signal::Signal::SIGUSR2,
];

let sa = signal::SigAction::new(
handler,
signal::SaFlags::SA_RESTART, // Restart syscalls if possible
signal::SigSet::empty(), // Allow concurrent calls to handler
);

for sig in &signals_to_catch {
unsafe { signal::sigaction(*sig, &sa) }.unwrap();
}
}

/// Report that a signal is being sent if the verbose flag is set.
Expand Down Expand Up @@ -321,27 +375,84 @@ fn timeout(
#[cfg(unix)]
enable_pipe_errors()?;

let process = &mut process::Command::new(&cmd[0])
let mut cmd_builder = process::Command::new(&cmd[0]);
cmd_builder
.args(&cmd[1..])
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.spawn()
.map_err(|err| {
let status_code = if err.kind() == ErrorKind::NotFound {
// FIXME: not sure which to use
127
} else {
// FIXME: this may not be 100% correct...
126
};
USimpleError::new(
status_code,
translate!("timeout-error-failed-to-execute-process", "error" => err),
)
})?;
.stderr(Stdio::inherit());

// Block signals in parent BEFORE spawning child (like GNU timeout line 548)
// This prevents signals from arriving before handlers are installed
#[cfg(unix)]
let orig_set = unsafe {
let mut blocked_set: libc::sigset_t = std::mem::zeroed();
let mut orig_set: libc::sigset_t = std::mem::zeroed();

libc::sigemptyset(&mut blocked_set);
libc::sigaddset(&mut blocked_set, libc::SIGALRM);
libc::sigaddset(&mut blocked_set, libc::SIGINT);
libc::sigaddset(&mut blocked_set, libc::SIGTERM);
libc::sigaddset(&mut blocked_set, libc::SIGHUP);
libc::sigaddset(&mut blocked_set, libc::SIGQUIT);
libc::sigaddset(&mut blocked_set, libc::SIGCHLD);

libc::pthread_sigmask(libc::SIG_BLOCK, &blocked_set, &mut orig_set);
orig_set
};

// On Unix, reset signal handlers in child before exec (like GNU timeout lines 556-567)
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
unsafe {
cmd_builder.pre_exec(|| {
// Reset signal mask to empty (unblock all signals)
let mut set: libc::sigset_t = std::mem::zeroed();
libc::sigemptyset(&mut set);
libc::pthread_sigmask(libc::SIG_SETMASK, &set, std::ptr::null_mut());

// Reset signal handlers that might have been set to SIG_IGN
libc::signal(libc::SIGTTIN, libc::SIG_DFL);
libc::signal(libc::SIGTTOU, libc::SIG_DFL);
libc::signal(libc::SIGINT, libc::SIG_DFL);
libc::signal(libc::SIGTERM, libc::SIG_DFL);
libc::signal(libc::SIGHUP, libc::SIG_DFL);
libc::signal(libc::SIGQUIT, libc::SIG_DFL);

Ok(())
});
}
}

let process = &mut cmd_builder.spawn().map_err(|err| {
let status_code = if err.kind() == ErrorKind::NotFound {
// FIXME: not sure which to use
127
} else {
// FIXME: this may not be 100% correct...
126
};
USimpleError::new(
status_code,
translate!("timeout-error-failed-to-execute-process", "error" => err),
)
})?;
unblock_sigchld();
catch_sigterm();

// Store child PID and signal configuration in statics for signal handler
MONITORED_PID.store(process.id() as i32, atomic::Ordering::Relaxed);
TERM_SIGNAL.store(signal as i32, atomic::Ordering::Relaxed);
FOREGROUND.store(foreground, atomic::Ordering::Relaxed);

catch_signals();

// Unblock signals now that handlers are installed (like GNU timeout line 584)
#[cfg(unix)]
unsafe {
libc::pthread_sigmask(libc::SIG_SETMASK, &orig_set, std::ptr::null_mut());
}

// Wait for the child process for the specified time period.
//
// If the process exits within the specified time period (the
Expand All @@ -363,19 +474,35 @@ fn timeout(
send_signal(process, signal, foreground);
match kill_after {
None => {
let status = process.wait()?;
// If we already received a signal (e.g., external SIGTERM), we should
// exit quickly rather than waiting indefinitely for the child.
// Check SIGNALED before wait() to avoid hanging with SA_RESTART.
if SIGNALED.load(atomic::Ordering::Relaxed) {
Err(ExitStatus::Terminated.into())
} else if preserve_status {
if let Some(ec) = status.code() {
Err(ec.into())
} else if let Some(sc) = status.signal() {
Err(ExitStatus::SignalSent(sc.try_into().unwrap()).into())
} else {
let received_sig = RECEIVED_SIGNAL.load(atomic::Ordering::Relaxed);
if received_sig == libc::SIGALRM {
// Timeout expired - wait for child and return 124
let _status = process.wait()?;
Err(ExitStatus::CommandTimedOut.into())
} else {
// External signal - give child a moment to exit, then return signal code
std::thread::sleep(std::time::Duration::from_millis(100));
let _ = process.try_wait()?; // Non-blocking check
Err(ExitStatus::SignalSent(received_sig.try_into().unwrap()).into())
}
} else {
Err(ExitStatus::CommandTimedOut.into())
// Normal timeout - wait for child
let status = process.wait()?;
if preserve_status {
if let Some(ec) = status.code() {
Err(ec.into())
} else if let Some(sc) = status.signal() {
Err(ExitStatus::SignalSent(sc.try_into().unwrap()).into())
} else {
Err(ExitStatus::CommandTimedOut.into())
}
} else {
Err(ExitStatus::CommandTimedOut.into())
}
}
}
Some(kill_after) => {
Expand Down
79 changes: 78 additions & 1 deletion tests/by-util/test_timeout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ use std::time::Duration;
use rstest::rstest;

use uucore::display::Quotable;
use uutests::new_ucmd;
use uutests::{new_ucmd, util::TestScenario};

#[test]
fn test_invalid_arg() {
Expand Down Expand Up @@ -223,3 +223,80 @@ fn test_terminate_child_on_receiving_terminate() {
.code_is(143)
.stdout_contains("child received TERM");
}

/// Test cascaded timeouts (timeout within timeout) to ensure signal propagation works.
/// This test verifies that when an outer timeout sends a signal to an inner timeout,
/// the inner timeout correctly propagates that signal to its child process.
/// Regression test for issue #9127.
#[test]
fn test_cascaded_timeout_signal_propagation() {
// Create a shell script that traps SIGINT and outputs when it receives it
let script = "trap 'echo got_signal' INT; sleep 10";

// Run: outer_timeout -s ALRM 0.5 inner_timeout -s INT 5 sh -c "script"
// The outer timeout will send SIGALRM to the inner timeout after 0.5 seconds
// The inner timeout should then send SIGINT to the shell script
// The shell script's trap should fire and output "got_signal"

// For the multicall binary, we need to pass "timeout" as the first arg to the nested call
let ts = TestScenario::new("timeout");
let timeout_bin = ts.bin_path.to_str().unwrap();

ts.ucmd()
.args(&[
"-s",
"ALRM",
"0.5",
timeout_bin,
"timeout",
"-s",
"INT",
"5",
"sh",
"-c",
script,
])
.fails_with_code(124)
.stdout_contains("got_signal");
}

/// Test that cascaded timeouts work with bash-style process substitution.
/// This ensures signal handlers are properly reset in child processes.
#[test]
fn test_cascaded_timeout_with_bash_trap() {
// Use bash if available, otherwise skip
if std::process::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
while true; do sleep 0.1; done
"#;

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,
])
.fails_with_code(124)
.stdout_contains("bash_trap_fired");
}
Loading