Signals are software interrupts. The kernel (or another process) sends a signal to a process to notify it that something happened. The process can catch the signal and handle it, ignore it, or let the default action occur.
Most developers know kill -9 and Ctrl+C. There’s more to it.
signals every developer should know
SIGTERM (15) — “please exit”
The default signal sent by kill. Asks the process to terminate cleanly. The process can catch this, run cleanup code (close files, flush buffers, release locks), and exit gracefully. This is what you should send first.
kill 12345 # sends SIGTERM
kill -15 12345 # same thing, explicit
SIGKILL (9) — “exit now”
Uncatchable. The kernel terminates the process immediately. No cleanup handlers run, no files get flushed, no child processes get notified. Use this only when SIGTERM doesn’t work.
kill -9 12345 # last resort
SIGKILL cannot be caught, blocked, or ignored. The kernel handles it directly — the target process never sees it.
SIGINT (2) — “interrupt”
Sent when you press Ctrl+C. Like SIGTERM, it can be caught. Many programs use it to trigger a clean shutdown. The difference from SIGTERM is convention: SIGINT means “the user pressed Ctrl+C” while SIGTERM means “please exit.”
SIGHUP (1) — “hangup”
Originally meant “the terminal disconnected.” Now has two common uses:
-
Terminal close: When you close a terminal window or SSH disconnects, the kernel sends SIGHUP to the session leader, which forwards it to child processes. This is why background processes die when you close your terminal (and why
nohupexists). -
Config reload: By convention, many daemons (nginx, Apache, sshd) reload their configuration on SIGHUP instead of exiting. This lets you apply config changes without downtime.
kill -HUP $(pgrep nginx) # reload nginx config
SIGSTOP (19) and SIGCONT (18) — “freeze and resume”
SIGSTOP freezes a process. Like SIGKILL, it cannot be caught — the process is unconditionally suspended. SIGCONT resumes it.
kill -STOP 12345 # freeze
kill -CONT 12345 # resume
Ctrl+Z in a terminal sends SIGTSTP (note: TSTP, not STOP). Unlike SIGSTOP, SIGTSTP can be caught. The shell then sends SIGSTOP internally. fg and bg send SIGCONT.
SIGPIPE (13) — “broken pipe”
Sent when a process writes to a pipe or socket whose read end has closed. The default action is to terminate the process — which is why piping to head doesn’t cause infinite output:
cat /dev/urandom | head -c 100
cat gets SIGPIPE when head exits after reading 100 bytes. Without SIGPIPE, cat would keep writing to a dead pipe and error out.
Gotcha: SIGPIPE silently kills processes in scripts. If you pipe output to a command that exits early, the upstream process dies without any error message. In long-running services, this is a common source of mysterious crashes. Most servers ignore SIGPIPE and handle the write error directly.
SIGSEGV (11) — “segmentation fault”
Sent when a process accesses memory it shouldn’t (null pointer dereference, buffer overflow, etc.). The default action is to terminate and dump core. You’ll see this as “Segmentation fault (core dumped).”
This is not a signal you send — the kernel sends it to the offending process.
SIGCHLD (20) — “child exited”
Sent to a parent process when a child process exits or stops. This is how the kernel tells the parent to call wait() and collect the child’s exit status. If the parent doesn’t handle SIGCHLD, the child becomes a zombie.
SIGUSR1 (30) and SIGUSR2 (31) — “user-defined”
No predefined meaning. Applications define their own behavior. Common uses:
- Node.js: SIGUSR1 starts the debugger
- dd: SIGUSR1 prints transfer progress
- nginx: SIGUSR2 triggers binary upgrade
kill -USR1 $(pgrep dd) # print dd progress
the uncatchable two
Only two signals cannot be caught, blocked, or ignored: SIGKILL and SIGSTOP. The kernel enforces this — the signal delivery mechanism bypasses the process entirely.
This means:
- A process in an infinite loop can always be killed (SIGKILL)
- A runaway process can always be frozen (SIGSTOP)
- No process can make itself unkillable (with one exception: uninterruptible sleep / D state, where signals are queued until I/O completes)
signal handling in practice
bash
trap 'echo "caught SIGTERM"; cleanup; exit' TERM
trap 'echo "caught SIGINT"; exit 1' INT
trap '' PIPE # ignore SIGPIPE
C
#include <signal.h>
void handler(int sig) {
// minimal work here — only async-signal-safe functions
write(STDOUT_FILENO, "caught\n", 7);
_exit(0);
}
signal(SIGTERM, handler); // simple but has portability issues
sigaction(SIGTERM, &sa, NULL); // preferred — more control
Node.js
process.on('SIGTERM', () => {
console.log('shutting down gracefully');
server.close(() => process.exit(0));
});
// SIGKILL cannot be caught — this does nothing:
process.on('SIGKILL', () => {}); // never fires
Python
import signal, sys
def handler(signum, frame):
print("shutting down")
sys.exit(0)
signal.signal(signal.SIGTERM, handler)
Go
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGTERM, syscall.SIGINT)
go func() {
sig := <-sigChan
fmt.Printf("received %s, shutting down\n", sig)
cleanup()
os.Exit(0)
}()
standard signals are not queued
If the same signal is sent twice before the handler runs, the process only sees it once. Standard signals (1-31) are represented as a bitmask — the bit is set on delivery and cleared when handled. Pending duplicates are lost.
Real-time signals (32-64) are queued and delivered in order, but most applications never use them.
full reference table
| # | signal | default action | catchable? | common use |
|---|---|---|---|---|
| 1 | SIGHUP | terminate | yes | terminal hangup / config reload |
| 2 | SIGINT | terminate | yes | Ctrl+C |
| 3 | SIGQUIT | terminate + core | yes | Ctrl+\ (quit with core dump) |
| 4 | SIGILL | terminate + core | yes | illegal instruction |
| 5 | SIGTRAP | terminate + core | yes | debugger breakpoint |
| 6 | SIGABRT | terminate + core | yes | abort() called |
| 8 | SIGFPE | terminate + core | yes | floating point exception |
| 9 | SIGKILL | terminate | no | force kill |
| 10 | SIGBUS | terminate + core | yes | bus error (bad memory access) |
| 11 | SIGSEGV | terminate + core | yes | segmentation fault |
| 13 | SIGPIPE | terminate | yes | broken pipe |
| 14 | SIGALRM | terminate | yes | alarm() timer expired |
| 15 | SIGTERM | terminate | yes | graceful shutdown |
| 17 | SIGCHLD | ignore | yes | child process exited |
| 18 | SIGCONT | continue | yes | resume stopped process |
| 19 | SIGSTOP | stop | no | freeze process |
| 20 | SIGTSTP | stop | yes | Ctrl+Z |
| 21 | SIGTTIN | stop | yes | background read from terminal |
| 22 | SIGTTOU | stop | yes | background write to terminal |
| 23 | SIGURG | ignore | yes | urgent socket data |
| 26 | SIGVTALRM | terminate | yes | virtual timer expired |
| 27 | SIGPROF | terminate | yes | profiling timer expired |
| 28 | SIGWINCH | ignore | yes | terminal window resize |
| 29 | SIGIO | terminate | yes | I/O possible |
| 30 | SIGUSR1 | terminate | yes | user-defined |
| 31 | SIGUSR2 | terminate | yes | user-defined |
Note: signal numbers vary by platform (the above are for macOS/BSD). Linux uses different numbers for some signals (e.g., SIGCHLD is 17 on Linux, 20 on macOS). Use signal names, not numbers, in portable scripts.
with proc
proc’s stop command sends SIGTERM first with a configurable timeout, then escalates to SIGKILL. proc stuck identifies processes consuming >50% CPU, and proc unstick sends recovery signals (SIGCONT, then SIGINT) before escalating.
proc stop node # SIGTERM, wait, then SIGKILL
proc stop node --timeout 10 # 10 second grace period
proc stuck # find high-CPU stuck processes
proc unstick # recover with escalating signals
Install
brew install yazeed/proc/proc # macOS
cargo install proc-cli # Rust
npm install -g proc-cli # npm/bun
See the GitHub repo for all installation options.