In the edge case where --verbose diagnostics might block
or induce signals, ensure we always timeout the monitored command
and do not loop diagnosing diagnostic induced signals.

* NEWS: Mention the bug fix.
* src/timeout.c (cleanup): Avoid diagnosing SIGPIPE and SIGXFSZ.
Also send the signal before diagnosing to ensure command is timed out.
* tests/env/env-signal-handler.sh: Run timeout(1) with -f to ensure
the SIGKILL is diagnosed.
* tests/timeout/timeout.sh: Likewise.  Also add test cases for
SIGPIPE and SIGXFSZ.

Discovered by Anthropic's OSS Scanner, as bug ANT-2026-GV4Z4T3T.
---
 NEWS                            |  6 ++++++
 src/timeout.c                   | 27 ++++++++++++++++++--------
 tests/env/env-signal-handler.sh |  3 ++-
 tests/timeout/timeout.sh        | 34 ++++++++++++++++++++++++++++++---
 4 files changed, 58 insertions(+), 12 deletions(-)

diff --git a/NEWS b/NEWS
index 275140f89..87bfbbba0 100644
--- a/NEWS
+++ b/NEWS
@@ -70,6 +70,12 @@ GNU coreutils NEWS                                    -*- 
outline -*-
   if events arrived faster than tail could process them.
   [bug introduced in coreutils-7.5]
 
+  '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.
+  Also --kill-after without --foreground, will not have a diagnostic issued.
+  [hang bug introduced in coreutils-8.29, and loop bug in coreutils 9.10]
+
   'unexpand' no longer overflows a heap buffer on non-GLIBC platforms,
   given whitespace characters whose display width is negative.  E.g.,
   this edge case can occur with \u0085 (next line) characters on musl.
diff --git a/src/timeout.c b/src/timeout.c
index 34a2b82c4..31b01a836 100644
--- a/src/timeout.c
+++ b/src/timeout.c
@@ -203,6 +203,12 @@ chld (MAYBE_UNUSED int sig)
 static void
 cleanup (int sig)
 {
+  /* Don't diagnose signals that the diagnostic itself could generate.  */
+  bool diagnose = verbose && sig != SIGPIPE;
+#ifdef SIGXFSZ
+  diagnose &= sig != SIGXFSZ;
+#endif
+
   if (sig == SIGALRM)
     {
       timed_out = 1;
@@ -229,14 +235,6 @@ cleanup (int sig)
       /* Send the signal directly to the monitored child,
          in case it has itself become group leader,
          or is not running in a separate group.  */
-      if (verbose)
-        {
-          char signame[MAX (SIG2STR_MAX, INT_BUFSIZE_BOUND (int))];
-          if (sig == 0 || sig2str (sig, signame) != 0)
-            snprintf (signame, sizeof signame, "%d", sig);
-          error (0, 0, _("sending signal %s to command %s"),
-                 signame, quote (command));
-        }
       send_sig (monitored_pid, sig);
 
       /* The normal case is the job has remained in our
@@ -250,6 +248,19 @@ cleanup (int sig)
               send_sig (0, SIGCONT);
             }
         }
+
+      /* 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
+         corresponding shell diagnostic at least.  */
+      if (diagnose)
+        {
+          char signame[MAX (SIG2STR_MAX, INT_BUFSIZE_BOUND (int))];
+          if (sig == 0 || sig2str (sig, signame) != 0)
+            snprintf (signame, sizeof signame, "%d", sig);
+          error (0, 0, _("sending signal %s to command %s"),
+                 signame, quote (command));
+        }
     }
   else if (monitored_pid == -1)
     { /* were in the parent, so let it continue to exit below.  */
diff --git a/tests/env/env-signal-handler.sh b/tests/env/env-signal-handler.sh
index a27afd10b..b8920b3cc 100755
--- a/tests/env/env-signal-handler.sh
+++ b/tests/env/env-signal-handler.sh
@@ -89,7 +89,8 @@ env_ignore_delay_()
   local delay="$1"
 
   # The first 'env' is just to ensure timeout is not a shell built-in.
-  env timeout --verbose --kill-after=.1 --signal=$timeout_sig $delay \
+  env timeout --foreground --verbose --kill-after=.1 \
+    --signal=$timeout_sig $delay \
     env $env_opt sleep 10 > /dev/null 2>outt
   # check only the first two lines from stderr, which are printed by timeout.
   # (operating systems might add more messages, like "killed").
diff --git a/tests/timeout/timeout.sh b/tests/timeout/timeout.sh
index f0591073b..577c68b29 100755
--- a/tests/timeout/timeout.sh
+++ b/tests/timeout/timeout.sh
@@ -71,7 +71,7 @@ timeout: sending signal 0 to command 'sleep'
 timeout: sending signal KILL to command 'sleep'
 EOF
 for opt in -v --verbose; do
-  timeout $opt -s0 -k .1 .1 sleep 10 2> errt
+  timeout --foreground $opt -s0 -k .1 .1 sleep 10 2> errt
   sed '/^Killed/d' < errt > err || framework_failure_
   compare exp err || fail=1
 done
@@ -81,10 +81,38 @@ done
 # I.e., that we're not killed by the SIGPIPE (and leave the sleep running).
 # timeout would exit with 141 usually if SIGPIPE wasn't being handled.
 echo 124 > timeout.exp || framework_failure_
-{ timeout -v .1 sleep 10 2>&1; echo $? >timeout.status; } | :
-compare timeout.exp timeout.status || fail=1
+for opt in '' '--foreground'; do
+  { timeout -s KILL 10 timeout $opt -v .1 sleep 10 2>&1
+    echo $? >timeout.status; } | :
+  compare timeout.exp timeout.status || fail=1
+done
 # Ensure we don't catch/propagate ignored signals
 (trap '' PIPE && timeout 10 yes |:) 2>&1 |
   grep 'Broken pipe' >/dev/null || fail=1
 
+# A diagnostic exceeding the file-size limit must not loop on SIGXFSZ.
+if (trap - XFSZ && ulimit -f 0) 2>/dev/null; then
+  returns_ 124 timeout -s KILL 10 sh -c '
+    trap - XFSZ
+    ulimit -f 0 || exit 1
+    exec timeout --foreground -v .1 sleep 10 2>err
+  ' || fail=1
+fi
+
+# Signal the command and its group before writing to a blocked stderr.
+file_seen() { sleep "$1"; test -s "$file"; }
+file=term.seen
+timeout -v .1 sh -c 'trap "echo 1 >term.seen" TERM; yes >&2' 2>&1 |
+  { retry_delay_ file_seen .1 6 || touch term.missed; }
+test -e term.missed && fail=1
+
+# A group-wide SIGKILL must not wait for the verbose diagnostic either.
+file=kill.status
+{ timeout -v -s KILL .1 yes 2>&1
+  echo $? >kill.status; } |
+  { retry_delay_ file_seen .1 6 || touch kill.missed; }
+test -e kill.missed && fail=1
+echo 137 > kill.exp || framework_failure_
+compare kill.exp kill.status || fail=1
+
 Exit $fail
-- 
2.55.0


Reply via email to