timeout: ensure we timeout with --verbose failures

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.
This commit is contained in:
Pádraig Brady committed 2026-10-27 23:15:30 +00:00
1 parent 074eb270fe
commit 8fcaebfe47
4 files changed
+58 -12

No files matched your search

+6
View File
@@ -81,6 +81,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.
+19 -8
View File
@@ -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. */
+2 -1
View File
@@ -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").
+31 -3
View File
@@ -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