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