Introduction
You’ve done it a thousand times. A command hangs, a loop spins forever, a
program misbehaves — and you reach for Ctrl+C. The process dies. You move on.
But what exactly happened in that fraction of a second? It’s not magic, and it’s not the operating system simply “killing” the program. There’s a whole chain of events that unfolds the moment your finger hits those two keys — one that touches the kernel, process management, and Unix philosophy all at once.
This is that story.
The Keystroke Journey
When you press Ctrl+C, the first stop is your terminal emulator (e.g.,
GNOME Terminal, iTerm2, xterm). The emulator reads the raw key event and
writes the byte 0x03 (ASCII ETX — “End of Text”) into the PTY master
(pseudo-terminal master).
The kernel’s TTY line discipline sits on the other side of that PTY. It reads the incoming bytes and checks: is this byte the configured interrupt character? By default, it is.
You can inspect and modify this with stty:
stty -a | grep intr
# speed 38400 baud; ...
# intr = ^C; quit = ^\; erase = ^?; kill = ^U; ...
intr = ^C means: when the line discipline sees byte 0x03, treat it as an
interrupt. You can remap it:
stty intr ^X # Ctrl+X becomes the interrupt key
The line discipline does not pass ^C through to the program’s stdin.
Instead, it triggers a signal.
SIGINT: The Signal
The line discipline delivers SIGINT (signal number 2) to the process. The name stands for Signal Interrupt — historically the signal sent when you interrupt a program from the keyboard.
SIGINT’s default behavior is to terminate the process. But unlike SIGKILL, it can be caught or ignored by the program — which is why some programs handle it gracefully (cleanup, save state) before exiting.
Here’s a quick reference for the terminal control signals:
| Shortcut | Signal | Number | Default Action | Catchable? |
|---|---|---|---|---|
Ctrl+C |
SIGINT | 2 | Terminate | Yes |
Ctrl+Z |
SIGTSTP | 20 | Suspend (stop) | Yes |
Ctrl+\ |
SIGQUIT | 3 | Terminate + core dump | Yes |
kill -9 |
SIGKILL | 9 | Terminate (forceful) | No |
Ctrl+D |
(none) | — | EOF on stdin | N/A |
Note that Ctrl+D is not a signal at all — it flushes the input buffer and
sends EOF, which programs may interpret as “no more input.”
Process Groups — Why the Whole Pipeline Dies
Here’s where it gets interesting. SIGINT is not sent to a single PID. It’s sent to the foreground process group.
When you run a pipeline:
cat /dev/urandom | tr -dc 'a-z' | head -c 100
The shell places all three processes (cat, tr, head) into the same process
group. The terminal tracks which process group is in the “foreground.” When you
press Ctrl+C, the kernel sends SIGINT to every process in that group
simultaneously.
You can verify this with:
# In another terminal while the pipeline runs:
ps -o pid,pgid,comm | grep -E 'cat|tr|head'
# PID PGID COMMAND
# 1234 1234 cat
# 1235 1234 tr
# 1236 1234 head
All three share the same PGID. Ctrl+C kills all three — not just the last in the chain.
Catching and Ignoring Signals
Because SIGINT is catchable, programs can intercept it and decide what to do.
In Bash:
trap 'echo "Caught Ctrl+C, cleaning up..."; exit 1' INT
while true; do
echo "working..."
sleep 1
done
Now Ctrl+C runs your cleanup before exiting.
In Python:
import signal
import sys
def handler(sig, frame):
print("\nCaught SIGINT, exiting cleanly.")
sys.exit(0)
signal.signal(signal.SIGINT, handler)
# Or simply catch the exception:
try:
while True:
pass
except KeyboardInterrupt:
print("Interrupted!")
Python maps SIGINT to the KeyboardInterrupt exception, so you can handle it in
a try/except block without touching the signal module at all.
To ignore SIGINT entirely:
signal(SIGINT, SIG_IGN) # C
trap '' INT # Bash
Detaching from the Terminal
If you want a process to survive even when the terminal closes (which sends
SIGHUP), use nohup:
nohup long_running_command &
nohup sets SIGHUP to be ignored and redirects output to nohup.out. The
process won’t receive Ctrl+C either, since it’s in the background.
disown removes an already-running background job from the shell’s job table so
it won’t receive SIGHUP when the shell exits:
long_running_command &
disown %1
Edge Cases
SSH sessions: When you press Ctrl+C in an SSH session, the SSH client
detects it and sends a channel request to the SSH server, which delivers SIGINT
to the remote process. It works the same way — but the signal travels over the
network.
Docker containers: If you run a container without -it, the container’s
init process doesn’t have a proper TTY. Ctrl+C may not reach the container
process at all. Always use:
docker run -it myimage # allocates a pseudo-TTY
Background processes: A process started with & has SIGINT set to ignored
by the shell. It won’t be killed by Ctrl+C from the terminal — you’d need to
kill it explicitly.
Why Ctrl+C Sometimes Doesn’t Work
Two common reasons:
-
The process is catching SIGINT. It has a handler and hasn’t exited yet. Try
Ctrl+\(SIGQUIT) which also produces a core dump, or send SIGTERM first, then SIGKILL. -
The process is in uninterruptible sleep (state
D). This happens during certain kernel I/O waits (e.g., NFS hangs, disk I/O). In stateD, the process cannot be killed by any signal — not even SIGKILL. The kernel has to complete or time out the I/O first.
Check process state:
ps aux | awk '$8 == "D" {print}'
If a process is stuck in D, only fixing the underlying I/O issue (remount,
disconnect NFS share, reboot) will resolve it.
Summary
To summarize what happens in that fraction of a second when you press Ctrl+C:
- Terminal emulator writes byte
0x03to PTY master - Kernel TTY line discipline recognizes it as the interrupt character
- Line discipline sends SIGINT to the foreground process group
- Every process in the group receives SIGINT simultaneously
- Processes with no handler terminate immediately
- Processes with a handler run cleanup logic before (optionally) exiting
That’s the full story of Ctrl+C — from keypress to process death.