These signals are used by timeout(1) itself, so need special handling.
Since commit v8.13-11-g80a1aca0d we incorrectly ignored them,
which was exacerbated by commit v8.26-39-g2f69dba5d which caused
the -sCHLD case to hang upon child exit.

* src/timeout.c (send_sig): Don't ignore SIGHLD, and discard
the self sent SIGALRM, and rearm the handler.
* tests/timeout/timeout.sh: Add test cases.
* NEWS: Mention the fixes.

Discovered by Anthropic's OSS Scanner, as bug ANT-2026-2EQL2L05.
---
 NEWS                     |  5 ++++
 src/timeout.c            | 50 +++++++++++++++++++++++++++++-----------
 tests/timeout/timeout.sh | 12 +++++++++-
 3 files changed, 52 insertions(+), 15 deletions(-)

diff --git a/NEWS b/NEWS
index 6c0ebb3b8..abd1cf9b7 100644
--- a/NEWS
+++ b/NEWS
@@ -70,6 +70,11 @@ GNU coreutils NEWS                                    -*- 
outline -*-
   if events arrived faster than tail could process them.
   [bug introduced in coreutils-7.5]
 
+  'timeout --signal=ALRM -k' again honors the -k option.
+  Similarly 'timeout --signal=CHLD' no longer reports "No child processes",
+  or hangs as it has done since coreutils-8.27, once the command exits.
+  [bugs introduced in coreutils-8.14]
+
   'timeout --verbose' no longer hangs or loops forever in the edge case
   where writing the diagnostics would block or generate a signal respectively.
   Instead these edge cases will not have a corresponding diagnostic issued.
diff --git a/src/timeout.c b/src/timeout.c
index 175bb81d4..bc6e97b18 100644
--- a/src/timeout.c
+++ b/src/timeout.c
@@ -187,10 +187,30 @@ send_sig (pid_t where, int sig)
      propagate the first one, which hopefully won't be an issue.  Note this
      process can be implicitly multithreaded due to some timer_settime()
      implementations, therefore a signal sent to the group, can be sent
-     multiple times to this process.  */
-  if (where == 0)
-    signal (sig, SIG_IGN);
-  return kill (where, sig);
+     multiple times to this process.  SIGCHLD is not ignored though,
+     as it's needed to notice the command exiting, and its handler
+     doesn't send signals so there is no loop to avoid.  */
+  struct sigaction old_sa;
+  bool ignore = where == 0 && sig != SIGCHLD;
+  if (ignore)
+    {
+      sigaction (sig, NULL, &old_sa);
+      signal (sig, SIG_IGN);
+    }
+
+  int ret = kill (where, sig);
+
+  /* SIGALRM is needed to notice the --kill-after timeout, so restore its
+     handler.  First discard the copy sent to ourselves above, which may
+     be pending rather than ignored, as SIGALRM is blocked while in the
+     SIGALRM handler.  */
+  if (ignore && sig == SIGALRM)
+    {
+      signal (sig, SIG_IGN);
+      sigaction (sig, &old_sa, NULL);
+    }
+
+  return ret;
 }
 
 /* Signal handler which is required for sigsuspend() to be interrupted
@@ -222,16 +242,6 @@ cleanup (int sig)
     }
   if (0 < monitored_pid)
     {
-      if (kill_after)
-        {
-          int saved_errno = errno; /* settimeout may reset.  */
-          /* Start a new timeout after which we'll send SIGKILL.  */
-          term_signal = SIGKILL;
-          settimeout (kill_after, false);
-          kill_after = 0; /* Don't let later signals reset kill alarm.  */
-          errno = saved_errno;
-        }
-
       /* Send the signal directly to the monitored child,
          in case it has itself become group leader,
          or is not running in a separate group.  */
@@ -249,6 +259,18 @@ cleanup (int sig)
             }
         }
 
+      /* Do this after send_sig() so the new SIGALRM can't be discarded,
+         but before diagnosing, as the diagnostic could block.  */
+      if (kill_after)
+        {
+          int saved_errno = errno; /* settimeout may reset.  */
+          /* Start a new timeout after which we'll send SIGKILL.  */
+          term_signal = SIGKILL;
+          settimeout (kill_after, false);
+          kill_after = 0; /* Don't let later signals reset kill alarm.  */
+          errno = saved_errno;
+        }
+
       /* Diagnose only after sending signals, since stderr could block.
          Note this implies we don't diagnose a --kill-after without -f,
          but being killed will be apparent in the exit status and
diff --git a/tests/timeout/timeout.sh b/tests/timeout/timeout.sh
index 6f4467e31..8d736c7fe 100755
--- a/tests/timeout/timeout.sh
+++ b/tests/timeout/timeout.sh
@@ -17,7 +17,7 @@
 # along with this program.  If not, see <https://www.gnu.org/licenses/>.
 
 . "${srcdir=.}/tests/init.sh"; path_prepend_ ./src
-print_ver_ timeout
+print_ver_ timeout env
 require_trap_signame_
 
 # no timeout
@@ -47,6 +47,16 @@ returns_ 124 timeout -s0 -k1 .1 sleep 10 && fail=1
 # Ensure a consistent exit status with --foreground
 returns_ 124 timeout --foreground -s0 -k1 .1 sleep 10 && fail=1
 
+# Keep handling SIGALRM after sending it to the process group.
+# Also use a tiny kill delay to check that its SIGALRM is not discarded.
+for delay in .1 1e-9; do
+  returns_ 137 timeout -s ALRM -k $delay .1 \
+    env --ignore-signal=ALRM sleep 10 || fail=1
+done
+
+# Keep handling SIGCHLD so the command's exit wakes the monitor.
+returns_ 124 timeout -s KILL 10 timeout -s CHLD .1 sleep .5 || fail=1
+
 # Ensure 'timeout' is immune to parent's SIGCHLD handler
 # Use a subshell and an exec to work around a bug in FreeBSD 5.0 /bin/sh.
 (
-- 
2.55.0


Reply via email to