Most people have at some point hammered Ctrl-C at a terminal while a shell loop carried on regardless, one iteration after another, and put it down to the keypress landing at a bad moment. I wanted to know what bash actually does with that keypress, so I read the part of its source that handles it and then drove it through a pseudo-terminal, so the Ctrl-C went through the kernel's terminal driver the way a real one does, rather than through kill.
The short version is that bash doesn't simply die when you press Ctrl-C. It waits for the program it's running to finish, looks at how that program ended, and only stops the loop if the program was killed by the signal. A program that catches Ctrl-C, tidies up and exits normally (which is exactly what we tell people to write) leaves bash convinced the interrupt was meant for that program alone.
The setup
The loop was as plain as I could make it:
for i in 1 2 3; do
"$@"
echo "after $i: rc=$?"
done
echo "loop finished"
The child was a small Python script that sleeps for three seconds and catches KeyboardInterrupt, the way a lot of CLI tools do:
import sys, time
print("START", flush=True)
try:
time.sleep(3)
except KeyboardInterrupt:
print("cleaning up", flush=True)
sys.exit(1)
A harness written with Python's pty module started bash loop.sh python3 py_caught.py, waited for START, and wrote byte 0x03 to the master side of the terminal. This was bash 5.3.9 on Linux 6.6 under WSL2, with Python 3.14.4 and Node 24.15.0.
What happened
With sleep as the child, the whole thing died at the first press, as you'd expect. With the catching Python script, it didn't:
START
^Ccleaning up
after 1: rc=1
START
after 2: rc=0
START
after 3: rc=0
loop finished
--- shell: exit 0
Exiting with 130 instead of 1, the conventional "I was interrupted" code, made no difference. Neither did a Node script with process.on("SIGINT", () => process.exit(130)), which I've seen in plenty of real tools. When I pressed Ctrl-C once per iteration, every one of the three iterations printed cleaning up and the loop still ran to the end and exited 0. I repeated every case three times and got the same outcome each time.
Then I ran the same loop under the other shells I had to hand:
| Child | bash 5.3.9 | dash 0.5.12 | zsh 5.9 | busybox ash 1.36.1 | mksh |
|---|---|---|---|---|---|
sleep (dies of SIGINT) |
stops | stops | stops | stops | stops |
Python, catches, exit(1)
|
carries on | stops | stops | stops | stops |
Python, catches, exit(130)
|
carries on | stops | stops | not run | not run |
Node, process.exit(130)
|
carries on | stops | stops | not run | not run |
So this is a bash behaviour, not a Unix one. busybox ash and mksh ran in an Alpine 3.20 container, and I only tried the sleep and exit(1) cases there.
Who actually receives the signal
The terminal driver doesn't send Ctrl-C to "the program". It sends SIGINT to every process in the terminal's foreground process group. I had the Python child print ps for its own terminal just before it slept:
PID PPID PGID SID TPGID STAT COMMAND
19043 19041 19043 19043 19043 Ss+ bash
19044 19043 19043 19043 19043 S+ python3
A script run like this has no job control, so bash and its child share process group 19043, which is also the terminal's foreground group (TPGID). Both of them get the signal at the same instant. dash and zsh take it at face value and die. bash, which has installed its own handler while it waits, just sets a flag.
The code that decides
The flag is set in wait_sigint_handler in jobs.c. The decision comes later, in waitchld, once the child has been reaped (lines 4149 to 4159 in the 5.3 tarball):
/* Linux kernels appear to signal the parent but not interrupt the
waitpid() (or restart it even without SA_RESTART) on SIGINT, so if
we saw a SIGINT and the process exited or died due to some other
signal, assume the child caught the SIGINT. */
if (wait_sigint_received && (WIFSIGNALED (status) == 0 || WTERMSIG (status) != SIGINT))
child_caught_sigint = 1;
/* If the child process did die due to SIGINT, forget our assumption
that it caught or otherwise handled it. */
if (WIFSIGNALED (status) && WTERMSIG (status) == SIGINT)
child_caught_sigint = 0;
Further down, in set_job_status_and_cleanup, bash only acts on the interrupt when wait_sigint_received is set and child_caught_sigint is zero. That explains why 130 doesn't help. bash isn't reading the exit code at all, it's reading the waitpid status word, and "exited with status 130" and "terminated by signal 2" are different there even though $? shows 130 for both. The pattern goes back a long way and has a name, wait-and-cooperative-exit. The CPython source links to Martin Cracauer's page about it.
The bash 5.3 man page does document this now, in the SIGNALS section. It says that if the command doesn't terminate due to SIGINT, bash assumes "the SIGINT was used as part of the program's normal operation (e.g., emacs uses it to abort editing commands) or deliberately discarded."
That is the honest case for the design. If a script runs an editor, a REPL or less, Ctrl-C inside that program is ordinary input, and dash or zsh would kill the whole script around it. bash's rule is a heuristic for telling the two apart, and it guesses wrong precisely when a program handles the signal politely.
Python already does the right thing, until you catch it
An uncaught KeyboardInterrupt in my Python stopped the loop, and the reason is in Modules/main.c:
static int
exit_sigint(void)
{
/* bpo-1054041: We need to exit via the
* SIG_DFL handler for SIGINT if KeyboardInterrupt went unhandled.
* If we don't, a calling process such as a shell may not know
* about the user's ^C. https://www.cons.org/cracauer/sigint.html */
if (PyOS_setsig(SIGINT, SIG_DFL) == SIG_ERR) {
perror("signal");
} else {
kill(getpid(), SIGINT);
}
CPython resets the handler and kills itself with the signal so that the parent sees a real signal death. Catching the exception and calling sys.exit skips all of that.
The fix for a handler is the same trick done by hand. All three versions stopped the loop in all three runs:
except KeyboardInterrupt:
print("cleaning up", flush=True)
signal.signal(signal.SIGINT, signal.SIG_DFL)
os.kill(os.getpid(), signal.SIGINT)
A bare raise after the cleanup works too, because the exception then goes unhandled and reaches exit_sigint. In Node I used process.once so the listener is gone by the time it re-sends the signal:
process.once("SIGINT", () => { console.log("cleaning up"); process.kill(process.pid, "SIGINT"); });
A bash script that traps INT has exactly the same problem. trap 'cleanup; exit 130' INT let the outer loop carry on, and trap 'cleanup; trap - INT; kill -INT $$' INT stopped it.
Where the rule gets odder
A SIGINT sent with kill to the child alone, with no terminal involved, killed it outright. bash reported rc=130 and went straight on to the next iteration. The source comment at line 4401 explains why: bash only treats the child's death as an interrupt if it saw the SIGINT itself, which is how it tells a keyboard signal, sent to the whole group, from one sent to a single process. When I sent the signal to the whole process group instead, bash exited at once.
Interactive bash gets there by a different road. When I typed the loop at a bash -i prompt, the child sat in its own process group with the terminal's foreground pointing at it, and bash wasn't in that group at all:
PID PPID PGID SID TPGID STAT COMMAND
19541 19539 19541 19541 19542 Ss bash
19542 19541 19542 19541 19542 S+ python3
The man page says an interactive shell "does not pay attention to the SIGINT, even if the foreground command terminates as a result, other than noting its exit status." That isn't quite what I saw. When the child died of SIGINT the typed loop stopped, which matches a branch in jobs.c commented "act as if the shell received SIGINT as well, so the loop or list can be broken". With the catching child, the interactive loop carried on exactly as the script had.
Ctrl-\ is no way out either. It killed the Python child (Quit, rc=131) and the loop moved on, because, as the man page says, "In all cases, bash ignores SIGQUIT." dash died from the same keypress. What did work, from an interactive prompt, was Ctrl-Z to stop the whole job followed by kill %1, after which jobs reported it Terminated.
One related thing also caught me out, and it applies to every shell I tried. A script that starts sleep 300 & and is then interrupted leaves that sleep running after the script has gone, reparented to /init. Its status file showed SigIgn: 0000000001001006, and bit 1 there is SIGINT, because without job control the shell starts background commands with SIGINT and SIGQUIT ignored. dash left an orphan in the same way. If your script starts background work, it needs a trap that kills it.
What I got wrong on the way
My first run printed command not found three times, because I was looping over the child commands in zsh, which doesn't word-split an unquoted $c the way bash does, and that cost me a minute before I noticed the loop variable had gone through as one word.
The second mistake was worse. To test kill from outside, I started the loop in the background, grabbed a PID with pgrep -n -x sleep and signalled it, and the loop reported rc=0 as if nothing had happened. It looked like a finding. A later ps showed an unrelated sleep 20 belonging to some other process on the machine, so I almost certainly signalled the wrong sleep. I rewrote the test to take the child PID from pgrep -P with the loop's own PID, and that run gave the rc=130 and carry-on result above.
My first test for orphaned background jobs found nothing, because the harness made bash the session leader of its own terminal, and when it died the terminal went away and took the sleep with it, most likely by SIGHUP, though I didn't confirm which signal. Running the script from an interactive prompt, which is how people actually run scripts, showed the orphan.
Try it
Save this as catch.py, then type the loop at a bash prompt and press Ctrl-C while the first iteration is sleeping:
cat > catch.py <<'EOF'
import sys, time
try:
time.sleep(3)
except KeyboardInterrupt:
print("tidying up")
sys.exit(1)
EOF
for i in 1 2 3; do python3 catch.py; echo "after $i: $?"; done
When I ran it, I got tidying up, then after 1: 1, and the second iteration started. Replace sys.exit(1) with raise and the same press ends the loop.
If you write a CLI that handles Ctrl-C, finish the handler by restoring the default disposition and sending SIGINT to yourself rather than calling exit, whatever code you pick. That's the only thing a calling bash looks at, and it costs two lines. If you write bash loops around programs you don't control, putting || break after the command stopped my loop when the catching script exited 1, and did nothing when I made it exit 0 instead, and nothing stops a handler from choosing 0.
Top comments (0)