What Actually Happens When You Press ^C?
I was debugging a Python script yesterday. One of those infinite loop bugs where you stare at the code for 20 minutes before realizing you forgot to increment a counter. The script was hammering my CPU, so I pressed Ctrl+C to kill it.
It died instantly.
And that's when the question hit me: How?
I mean, think about it. My program is running some tight loop, doing calculations, not checking anything. It's not polling "hey, did the user press Ctrl+C?" every iteration. The CPU is executing my code, doing math, moving data around. So how does pressing two keys on my keyboard instantly stop everything?
I pressed Ctrl+C again on another process. Same thing. Instant death.
But then I tried it on vim
. Pressed Ctrl+C. It didn't die. It just... did nothing. Or rather, it did something else - exited insert mode.
Wait, what? If Ctrl+C is supposed to kill processes, why does vim get to decide "nah, I'll do something else with that"?
That's when I knew I had to figure this out. Because something that I used a hundred times a day was complete magic to me.

The Theory That Made Too Much Sense (And Was Wrong)
My first guess felt solid: the terminal sees you press Ctrl+C, tells the shell "hey, user wants to stop this", and the shell kills whatever's running in the foreground.
This made perfect sense. The shell is the parent process of whatever I'm running. It started the process, so obviously it can kill it, right?
I tested this theory by writing a simple Python script:
import time
while True: print("Still running...") time.sleep(1)
Started it. Pressed Ctrl+C. Dead.
But here's where my theory started falling apart. I wrote another script:
import signalimport time
def handler(signum, frame): print("Caught Ctrl+C! But I'm not stopping.")
signal.signal(signal.SIGINT, handler)
while True: print("Try to stop me...") time.sleep(1)
Ran it. Pressed Ctrl+C. The program printed "Caught Ctrl+C! But I'm not stopping." and kept running.
Hold on. If the shell was killing the process, how is my program catching anything? How does my program even know Ctrl+C was pressed before it dies?
Then I learned about signals. Specifically, SIGINT. When you press Ctrl+C, a signal called SIGINT (Signal Interrupt) is sent to the running process. By default, receiving SIGINT terminates the process. But programs can install signal handlers to catch it.
Okay, so it's not just "kill the process." It's "send a signal to the process, and the process decides what to do with it."
But that just pushed the question back one level: Who sends the signal?
I assumed it was still the shell. The shell sees you run a command, keeps track of it, and when you press Ctrl+C, the shell sends SIGINT to that process.
I was wrong again.
The Kernel Has Been Listening This Whole Time
Here's what actually happens, and it completely rewired how I think about terminals.
The terminal driver doesn't transmit the Ctrl+C key press to your program. Instead, it sends a SIGINT signal to the foreground process.
Read that again. The terminal driver. Not the shell. Not your terminal emulator (like xterm or iTerm). The terminal driver in the kernel.
There's this thing called the line discipline - a layer in the terminal subsystem that sits between the hardware driver and the application. When you press a key, it goes to the terminal driver first. The line discipline looks at the input stream, and when it sees the interrupt character (typically ^C, which is ASCII code 3), it doesn't pass that character to the program. Instead, it sends SIGINT to the current foreground job.
Your program never sees the ^C
character. The line discipline intercepts it at the kernel level and converts it into a signal.
This is why my Python program could catch it. The signal was being sent TO my program by the kernel. The shell isn't involved in this at all.
But here's the part that broke my brain even more: Ctrl+C doesn't just send SIGINT to one process. It sends it to the foreground process group.
Process groups. When you run a pipeline like cat file.txt | grep "something" | wc -l
, all three processes are in the same process group. The shell creates a new process group for the command and its children, assigning them a unique group ID. When you press Ctrl+C, the signal goes to the entire process group at once.
The shell informs the kernel which process group is in the foreground using tcsetpgrp(). So the kernel always knows who to signal. When you start a background job with &
, the shell doesn't call tcsetpgrp() for it, so pressing Ctrl+C won't affect it - it's not in the foreground process group.
This is why when you run sleep 100 | sleep 200
and press Ctrl+C, both sleep processes die. Not because the shell killed them individually. Because the kernel sent SIGINT to their shared process group.
And vim? Vim overrides the default SIGINT handler and implements its own behavior. When the kernel sends it SIGINT, vim's signal handler runs, does whatever vim wants (like exiting insert mode), and doesn't terminate.
The magic isn't the shell being smart. The magic is the kernel having an entire subsystem dedicated to terminal I/O that can transform certain characters into signals before your program ever sees them.
Proving It To Myself
I needed to see this with my own eyes. First, I checked what character triggers SIGINT on my system:
$ stty -a | grep intrintr = ^C
You can use stty to check or change the characters that generate signals. The intr
(interrupt) setting shows what character generates SIGINT.
So ^C
is configured to trigger SIGINT. But you can change it:
$ stty intr ^O
Now pressing Ctrl+O sends SIGINT instead of Ctrl+C. (Don't actually do this unless you want to confuse yourself for the next hour.)
Then I wrote a C program to really see the signal handling:
#include <stdio.h>#include <signal.h>#include <unistd.h>
void sigint_handler(int signum) { printf("\nCaught SIGINT (signal %d)\n", signum); printf("Press Ctrl+C again to exit, or wait...\n"); signal(SIGINT, SIG_DFL); // Next SIGINT will use default handler}
int main() { signal(SIGINT, sigint_handler);
printf("My PID: %d\n", getpid()); printf("My process group: %d\n", getpgrp()); printf("Running... Press Ctrl+C\n");
while(1) { sleep(1); }
return 0;}
When I run this and press Ctrl+C once, it prints the message. Press it again, and the program exits because the second SIGINT uses the default handler (which terminates the process).
To really see process groups in action, I created a pipeline:
$ sleep 100 | sleep 200 | sleep 300 &$ ps -o pid,pgid,cmd PID PGID CMD 71001 71001 /usr/bin/zsh 71883 71883 sleep 100 71884 71883 sleep 200 71885 71883 sleep 300 72340 72340 ps -o pid,pgid,cmd
Look at the PGID column - all three sleep processes share the same process group ID (71883, which is the PID of the first process). When you check process groups with ps -o pid,pgid,cmd
, you can see that all processes in a pipeline share the same process group ID. When this pipeline is in the foreground and you press Ctrl+C, the kernel sends SIGINT to process group 71883, killing all three at once.
The line discipline is doing this. Not bash. Not the terminal emulator. The kernel's N_TTY line discipline is sitting there, reading every character you type, and when it sees ^C
, it calls a kernel function that looks up the foreground process group and signals it.
Every Keystroke Goes Through a Translator
So here's what changed for me.
I used to think of my terminal as a simple thing: it shows output and sends my keystrokes to my program. That's it.
But there's an entire layer I never knew existed. Every single character you type goes through the line discipline first. The line discipline is a passive kernel module that processes input before it reaches your program. It's not just watching for ^C
. It handles:
^C
→ SIGINT^Z
→ SIGTSTP (suspend)^\
→ SIGQUIT (quit with core dump)^U
→ delete entire line^W
→ delete word- Backspace → delete character
All of this happens in the kernel before your program reads from stdin.
This is why when you type something in bash and press Ctrl+U, the line disappears. Your bash process didn't do that. The line discipline deleted it from its buffer before bash ever saw it.
And here's the kicker: you can set the terminal to raw mode using stty, which disables line discipline processing so characters are sent directly to the program. This is what ssh does. This is what vim does. They disable the line discipline and handle everything themselves.
When you're in vim and press Ctrl+U, vim sees the actual ^U
character. When you press Ctrl+C, vim sees it too and does whatever it wants with it. The line discipline isn't intercepting anymore.
So the next time you press Ctrl+C, know this: your keystroke goes into the kernel, gets intercepted by a 50-year-old subsystem called the TTY line discipline, gets transformed into a signal, and that signal gets sent to every process in the foreground process group. Your program can catch it, ignore it, or die.
The magic is real. It's just happening in the kernel, where you couldn't see it.
And now you've seen it.
References
- The terminal driver intercepts Ctrl+C and sends SIGINT to the foreground process, not transmitting the keypress itself
- The TTY line discipline sends the signal directly to the foreground process group, getting the process group ID and signaling it
- SIGINT is sent when the interactive attention character (typically ^C) appears in the input stream, handled by the TTY driver
- The line discipline sends the SIGINT signal to the foreground process group when it receives a ^C character on the master side