Configuration Information:
Machine: x86_64
OS: linux-gnu
Bash Version: 5.2.21 (Ubuntu 24.04 package); also seen on 5.2.15 and 5.3
Description:
A non-interactive shell that receives SIGINT while it waits for a
child (command substitution or a foreground external command) can
livelock at 100% CPU, forever, even when SIGINT is ignored with
trap '' INT.
strace of the stuck process shows this loop, with si_pid equal to
the shell's own pid:
--- SIGINT {si_signo=SIGINT, si_code=SI_USER, si_pid=<self>} ---
getpid() = <self>
kill(<self>, SIGINT) = 0
rt_sigreturn({mask=[CHLD]}) = 0
... repeats ...
Our reading of jobs.c (5.3, wait_for, around line 3093): when
job_control == 0 || (subshell_environment & SUBSHELL_COMSUB), the
code does
temp_sigint_handler = set_signal_handler (SIGINT, wait_sigint_handler);
if (temp_sigint_handler == wait_sigint_handler)
...
else
old_sigint_handler = temp_sigint_handler;
so wait_sigint_handler is installed before old_sigint_handler is
assigned. A SIGINT that lands in that window runs
wait_sigint_handler while old_sigint_handler is still
INVALID_SIGNAL_HANDLER. restore_sigint_handler() is then a no-op,
and the handler's kill (getpid (), SIGINT) re-delivers to
wait_sigint_handler itself, forever. The guard does not depend on
the SIGINT disposition, so trap '' INT does not prevent it. A gdb
backtrace of a stuck shell showed command_substitute -> wait_for ->
set_signal_handler. The race fix mentioned in the 5.3 CHANGES does
not appear to cover this window.
A possible fix: block SIGINT around the install and the
old_sigint_handler assignment, or assign old_sigint_handler before
the new handler can run.
Repeat-By:
Run this on Linux. It usually reports at least one stall within a
few dozen runs (on bash 5.2.21: 71/150 and 2/20 runs stalled; the
rate depends on timing):
#!/bin/bash
runs=${1:-50}; hits=0
for ((r=1; r<=runs; r++)); do
rm -f /tmp/ctr
bash -c 'trap "" INT; i=0; while :; do x=$(/bin/true); ((i++ % 50)) || echo
$i > /tmp/ctr; done' &
t=$!
sleep 0.05
( end=$((SECONDS+1)); while ((SECONDS<end)); do kill -INT $t 2>/dev/null ||
exit; done ) &
wait $! 2>/dev/null
a=$(cat /tmp/ctr 2>/dev/null); sleep 1; b=$(cat /tmp/ctr 2>/dev/null)
if [ "$a" = "$b" ] && [ -d /proc/$t ]; then
hits=$((hits+1)); echo "run $r: stuck pid=$t state=$(awk '{print $3}'
/proc/$t/stat)"
fi
kill -KILL $t 2>/dev/null; wait $t 2>/dev/null
done
echo "bash=$BASH_VERSION stuck=$hits/$runs"
A stuck target stays in state R with no children. Its counter has
stopped although the SIGINT flood has ended, and attaching strace
shows the loop above.
Fix:
(see Description)