Repository: uutils/coreutils (2025-06-16T02:13:11Z)

Issue:
timeout: on receiving TERM signal, child process is not terminated
(test system: WSL2, Ubuntu 24.04 - also reproducible in github codespaces)


gnu coreutils behavior is that child process is terminated when receiving TERM signal:
```
$ timeout 42 sleep 42 &
[1] 16343
$ ps -ef |grep sleep
martin     16343    3426  0 17:04 pts/5    00:00:00 timeout 42 sleep 42
martin     16345   16343  0 17:04 pts/5    00:00:00 sleep 42
$ kill 16343
[1]+  Terminated              timeout 42 sleep 42
$ ps -ef |grep sleep
```

with timeout from uutils
```
$ target/debug/coreutils timeout 42 sleep 42 &
[1] 16507
$ ps -ef |grep sleep
martin     16507    3426  0 17:04 pts/5    00:00:00 target/debug/coreutils timeout 42 sleep 42
martin     16509   16507  0 17:04 pts/5    00:00:00 sleep 42
$ kill 16507
[1]+  Terminated              target/debug/coreutils timeout 42 sleep 42
$ ps -ef |grep sleep
martin     16509    2710  0 17:04 pts/5    00:00:00 sleep 42
```
the child process `sleep 42` is still running and parent PID is now `2710`:
```
$ ps -ef |grep 2710
root        2710    2709  0 16:52 ?        00:00:00 /init
```


I saw this behavior when debugging sporadic failures in gnu compatibility tests. 
The test `tests/tail/inotify-rotate.sh` needs the termination of child process for cleanup. 
Otherwise a lot of `tail` processes remain alive after the tests are finished:
```
martin     44860    2710  2 17:12 pts/5    00:00:00 tail -s.1 --max-unchanged-stats=1 -F k
martin     44876    2710  3 17:12 pts/5    00:00:00 tail -s.1 --max-unchanged-stats=1 -F k
martin     44891    2710  3 17:12 pts/5    00:00:00 tail -s.1 --max-unchanged-stats=1 -F k
...
```
and with the next test runs I observe messages like `tail: inotify cannot be used, reverting to polling: Too many open files`.





Discussion:

Patch:
diff --git a/src/uu/timeout/src/status.rs b/src/uu/timeout/src/status.rs
index 7a94c7f9441..422a13ea878 100644
--- a/src/uu/timeout/src/status.rs
+++ b/src/uu/timeout/src/status.rs
@@ -30,6 +30,9 @@ pub(crate) enum ExitStatus {
 
     /// When there is a failure while waiting for the child process to terminate.
     WaitingFailed,
+
+    /// When `SIGTERM` signal received.
+    Terminated,
 }
 
 impl From<ExitStatus> for i32 {
@@ -39,6 +42,7 @@ impl From<ExitStatus> for i32 {
             ExitStatus::TimeoutFailed => 125,
             ExitStatus::SignalSent(s) => 128 + s as Self,
             ExitStatus::WaitingFailed => 124,
+            ExitStatus::Terminated => 143,
         }
     }
 }
diff --git a/src/uu/timeout/src/timeout.rs b/src/uu/timeout/src/timeout.rs
index 50d5afcfbc7..3c45acc2f5a 100644
--- a/src/uu/timeout/src/timeout.rs
+++ b/src/uu/timeout/src/timeout.rs
@@ -12,6 +12,7 @@ use std::collections::HashMap;
 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::{UClapError, UResult, USimpleError, UUsageError};
@@ -185,6 +186,23 @@ fn unblock_sigchld() {
     }
 }
 
+/// 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 {
@@ -246,7 +264,8 @@ fn wait_or_kill_process(
     foreground: bool,
     verbose: bool,
 ) -> std::io::Result<i32> {
-    match process.wait_or_timeout(duration) {
+    // ignore `SIGTERM` here
+    match process.wait_or_timeout(duration, None) {
         Ok(Some(status)) => {
             if preserve_status {
                 Ok(status.code().unwrap_or_else(|| status.signal().unwrap()))
@@ -330,6 +349,7 @@ fn timeout(
             )
         })?;
     unblock_sigchld();
+    catch_sigterm();
     // Wait for the child process for the specified time period.
     //
     // If the process exits within the specified time period (the
@@ -341,7 +361,7 @@ fn timeout(
     // 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) {
+    match process.wait_or_timeout(duration, Some(&SIGNALED)) {
         Ok(Some(status)) => Err(status
             .code()
             .unwrap_or_else(|| preserve_signal_info(status.signal().unwrap()))
@@ -352,7 +372,9 @@ fn timeout(
             match kill_after {
                 None => {
                     let status = process.wait()?;
-                    if preserve_status {
+                    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() {
diff --git a/src/uucore/src/lib/features/process.rs b/src/uucore/src/lib/features/process.rs
index 4656e7c13ea..55e8c36482b 100644
--- a/src/uucore/src/lib/features/process.rs
+++ b/src/uucore/src/lib/features/process.rs
@@ -13,6 +13,8 @@ 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};
 
@@ -88,7 +90,11 @@ pub trait ChildExt {
 
     /// Wait for a process to finish or return after the specified duration.
     /// A `timeout` of zero disables the timeout.
-    fn wait_or_timeout(&mut self, timeout: Duration) -> io::Result<Option<ExitStatus>>;
+    fn wait_or_timeout(
+        &mut self,
+        timeout: Duration,
+        signaled: Option<&AtomicBool>,
+    ) -> io::Result<Option<ExitStatus>>;
 }
 
 impl ChildExt for Child {
@@ -102,7 +108,7 @@ impl ChildExt for Child {
 
     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) } != 0 {
+        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 {
@@ -112,7 +118,11 @@ impl ChildExt for Child {
         }
     }
 
-    fn wait_or_timeout(&mut self, timeout: Duration) -> io::Result<Option<ExitStatus>> {
+    fn wait_or_timeout(
+        &mut self,
+        timeout: Duration,
+        signaled: Option<&AtomicBool>,
+    ) -> io::Result<Option<ExitStatus>> {
         if timeout == Duration::from_micros(0) {
             return self.wait().map(Some);
         }
@@ -125,7 +135,9 @@ impl ChildExt for Child {
                 return Ok(Some(status));
             }
 
-            if start.elapsed() >= timeout {
+            if start.elapsed() >= timeout
+                || signaled.is_some_and(|signaled| signaled.load(atomic::Ordering::Relaxed))
+            {
                 break;
             }
 


Test patch:
diff --git a/tests/by-util/test_timeout.rs b/tests/by-util/test_timeout.rs
index ee8e9d18f94..38dcaa23f5f 100644
--- a/tests/by-util/test_timeout.rs
+++ b/tests/by-util/test_timeout.rs
@@ -206,3 +206,23 @@ fn test_hex_timeout_ending_with_d() {
         .fails_with_code(124)
         .no_output();
 }
+
+#[test]
+fn test_terminate_child_on_receiving_terminate() {
+    let mut timeout_cmd = new_ucmd!()
+        .args(&[
+            "10",
+            "sh",
+            "-c",
+            "trap 'echo child received TERM' TERM; sleep 5",
+        ])
+        .run_no_wait();
+    timeout_cmd.delay(100);
+    timeout_cmd.kill_with_custom_signal(nix::sys::signal::Signal::SIGTERM);
+    timeout_cmd
+        .make_assertion()
+        .is_not_alive()
+        .with_current_output()
+        .code_is(143)
+        .stdout_contains("child received TERM");
+}