Skip to content

The signals you cannot catch

Level: 201 · for anyone who has pressed Ctrl-C twice and waited

One line: trap catches SIGINT and SIGQUIT in every shell here, all three shells accept trap … KILL without a word of complaint, none of them can honour it — and there is a third way a handler silently never runs that has nothing to do with the kernel, where bash and zsh disagree.

Press Ctrl-C at a program that has taken SIGINT over and the counter keeps counting. Press it again and it counts faster. The question this page answers is what does work, and the answer is in three parts: what a shell can trap, what the kernel will not let it trap, and what a shell says it has trapped while quietly doing nothing.

A shell script's handler is a trap:

trap 'echo "caught SIGINT -- not stopping"' INT

That one line is the whole mechanism, and it takes a signal name. The number behind the name is where the portability goes — SIGKILL is 9 everywhere worth caring about, and SIGSTOP is 17 on macOS and 19 on Linux.

Measured

Nothing below sleeps a guessed amount: each step waits for a file the previous one wrote, and the signals are sent with kill rather than typed.

Verified output of uncatchable_sh.sh on Linux — regenerated by tools/run_examples.py, never hand-typed.

1. This script traps four signals, and a helper sends it three
--------------------------------------------------------------
trap 'echo "caught SIGINT  -- not stopping" >> log' INT
trap 'echo "caught SIGQUIT -- not stopping" >> log' QUIT
trap 'echo "caught SIGTERM -- would clean up here" >> log' TERM
trap 'echo "caught SIGKILL" >> log' KILL

bash accepted all four without a word. Sending the first three:

$ cat log
caught SIGINT  -- not stopping
caught SIGQUIT -- not stopping
caught SIGTERM -- would clean up here

Three signals, three lines, and this script is still the one printing.
Ctrl-C sends SIGINT and Ctrl-\ sends SIGQUIT, so at a terminal neither
key would have ended it either.

2. The fourth trap, which will never run
--------------------------------------------------------------
A child with the same 'trap ... KILL' line, then kill -KILL:

$ cat log
caught SIGINT  -- not stopping
caught SIGQUIT -- not stopping
caught SIGTERM -- would clean up here

$ wait "$victim"; echo $?
137

The log is unchanged -- still the three lines from section 1. SIGKILL is
never delivered to the process; the kernel acts on it instead, so there
is no handler to run and no last words to write. A shell that accepts
the trap is doing what POSIX allows: trapping it is undefined, not an
error, so nothing tells you.

3. What the number means
--------------------------------------------------------------
A process ended BY a signal reports 128 + that signal's number:

signal     number   status   where you meet it
SIGINT     2        130      Ctrl-C, or a build you interrupted
SIGQUIT    3        131      Ctrl-\, and a core dump if your ulimit allows one
SIGTERM    15       143      kill with no argument; what an init system sends first
SIGKILL    9        137      kill -9, and the out-of-memory killer

So 130 in a CI log is somebody's Ctrl-C, 137 is usually the
out-of-memory killer, and 143 is a shutdown that asked politely first.

4. kill -l, and the one number that is not portable
--------------------------------------------------------------
$ kill -l KILL
9

$ kill -l STOP
19

SIGKILL is 9 on every Unix in use. SIGSTOP is not -- macOS says 17 and
Linux says 19, which is the whole difference between the two columns on
this page. Name signals in a script; never number them.

5. The other one nothing can catch
--------------------------------------------------------------
SIGSTOP suspends a process and is equally uncatchable. The difference is
that it is reversible:

$ ps -o state= -p "$victim" | tr -d " " | cut -c1
T

$ ps -o state= -p "$victim" | tr -d " " | cut -c1
S

T is stopped, and the process still exists -- still holding its memory,
its open files and its place in the process table. Only SIGCONT starts
it again. That pair is what Ctrl-Z and fg are made of, which is why they
are not in a trap table.

6. The third way a trap never runs, and no kernel rule is involved
--------------------------------------------------------------
One script, run two ways. First in the background, from this
non-interactive shell, then sent a SIGINT:

$ cat log

Empty. A shell starting a background job sets the child's SIGINT and
SIGQUIT to ignored before it execs, and POSIX says a signal already
ignored when a shell starts cannot be trapped, reset or caught. bash
honours that: the trap command succeeds and does nothing.

The same trap worked in section 1, where this script was in front. And
it is bash being strict rather than the kernel being strict -- the same
child written in zsh or fish catches the signal, because neither honours
that rule. So a wrapper script can work when you test it by hand, work
under zsh, and go quiet the day it is backgrounded under bash.

Verified output of uncatchable_sh.sh on macOS — regenerated by tools/run_examples.py, never hand-typed.

1. This script traps four signals, and a helper sends it three
--------------------------------------------------------------
trap 'echo "caught SIGINT  -- not stopping" >> log' INT
trap 'echo "caught SIGQUIT -- not stopping" >> log' QUIT
trap 'echo "caught SIGTERM -- would clean up here" >> log' TERM
trap 'echo "caught SIGKILL" >> log' KILL

bash accepted all four without a word. Sending the first three:

$ cat log
caught SIGINT  -- not stopping
caught SIGQUIT -- not stopping
caught SIGTERM -- would clean up here

Three signals, three lines, and this script is still the one printing.
Ctrl-C sends SIGINT and Ctrl-\ sends SIGQUIT, so at a terminal neither
key would have ended it either.

2. The fourth trap, which will never run
--------------------------------------------------------------
A child with the same 'trap ... KILL' line, then kill -KILL:

$ cat log
caught SIGINT  -- not stopping
caught SIGQUIT -- not stopping
caught SIGTERM -- would clean up here

$ wait "$victim"; echo $?
137

The log is unchanged -- still the three lines from section 1. SIGKILL is
never delivered to the process; the kernel acts on it instead, so there
is no handler to run and no last words to write. A shell that accepts
the trap is doing what POSIX allows: trapping it is undefined, not an
error, so nothing tells you.

3. What the number means
--------------------------------------------------------------
A process ended BY a signal reports 128 + that signal's number:

signal     number   status   where you meet it
SIGINT     2        130      Ctrl-C, or a build you interrupted
SIGQUIT    3        131      Ctrl-\, and a core dump if your ulimit allows one
SIGTERM    15       143      kill with no argument; what an init system sends first
SIGKILL    9        137      kill -9, and the out-of-memory killer

So 130 in a CI log is somebody's Ctrl-C, 137 is usually the
out-of-memory killer, and 143 is a shutdown that asked politely first.

4. kill -l, and the one number that is not portable
--------------------------------------------------------------
$ kill -l KILL
9

$ kill -l STOP
17

SIGKILL is 9 on every Unix in use. SIGSTOP is not -- macOS says 17 and
Linux says 19, which is the whole difference between the two columns on
this page. Name signals in a script; never number them.

5. The other one nothing can catch
--------------------------------------------------------------
SIGSTOP suspends a process and is equally uncatchable. The difference is
that it is reversible:

$ ps -o state= -p "$victim" | tr -d " " | cut -c1
T

$ ps -o state= -p "$victim" | tr -d " " | cut -c1
S

T is stopped, and the process still exists -- still holding its memory,
its open files and its place in the process table. Only SIGCONT starts
it again. That pair is what Ctrl-Z and fg are made of, which is why they
are not in a trap table.

6. The third way a trap never runs, and no kernel rule is involved
--------------------------------------------------------------
One script, run two ways. First in the background, from this
non-interactive shell, then sent a SIGINT:

$ cat log

Empty. A shell starting a background job sets the child's SIGINT and
SIGQUIT to ignored before it execs, and POSIX says a signal already
ignored when a shell starts cannot be trapped, reset or caught. bash
honours that: the trap command succeeds and does nothing.

The same trap worked in section 1, where this script was in front. And
it is bash being strict rather than the kernel being strict -- the same
child written in zsh or fish catches the signal, because neither honours
that rule. So a wrapper script can work when you test it by hand, work
under zsh, and go quiet the day it is backgrounded under bash.

What the run shows

Sections 1 and 2 are the pair worth keeping. bash accepted four trap lines and honoured three. SIGKILL is never delivered to the process at all — the kernel acts on it — so there is no moment at which a handler could run and no last words to write. A shell that accepts the trap is doing exactly what POSIX permits, because trapping SIGKILL is undefined rather than an error, so nothing tells you. SIGSTOP is the other one, and section 5 shows the difference: uncatchable too, but reversible, and the process sits in state T still holding its memory and its open files until SIGCONT arrives. That pair is what Ctrl-Z and fg are made of.

128 + N is the arithmetic to memorise. Section 3 measures it rather than asserting it. A 137 in a CI log is very rarely a mystery: 137 − 128 = 9, and something sent SIGKILL — usually the out-of-memory killer. 143 is 15, SIGTERM, a shutdown that asked politely first. 130 is 2, somebody's Ctrl-C.

Section 6 is the one that looks like your bug. The same script that caught SIGINT perfectly in section 1 catches nothing when it is started in the background. No kernel rule is involved. A shell starting a background job sets that child's SIGINT and SIGQUIT to ignored before it execs, and POSIX says a signal that was already ignored when a shell started can never be trapped, reset or caught afterwards. trap returns success and does nothing.

That last one is where the shells stop agreeing, so it is worth stating as a table. The child's shell decides; the parent's makes no difference, measured every way round:

The backgrounded script is Its SIGINT handler
#!/usr/bin/env bash never runs — bash honours the POSIX rule
#!/usr/bin/env zsh runs — zsh installs the trap anyway
#!/usr/bin/env fish runs — fish does the same

So a wrapper script whose Ctrl-C handling works under zsh can go silent the day somebody changes its first line to bash, with nothing printed about it anywhere.

On a Mac

Two differences, and one of them is the reason the block above has two columns.

kill -l STOP answers 17 on macOS and 19 on Linux. SIGSTOP is one of the signals POSIX numbers differently between systems, which is the concrete reason to write kill -STOP and trap … STOP and never kill -17. SIGKILL is 9 on both, and on every Unix still in service; it is the one number safe to carry in your head.

ps -o state= is the other, and section 5 hides it on purpose. On Linux the state column is one letter, and the flags live in a separate stat column; on a Mac state is another name for stat — the letter, then flags about the process that have nothing to do with this lesson. Without its cut -c1, section 5 printed T for the stopped child on macOS 26 on x86-64 and T< on macos-latest on arm64, where < is BSD ps reporting a raised CPU scheduling priority. Nothing in the script changes a priority, so the child can only have inherited it from the runner; N is the opposite flag, for a process started under nice. Cut to its first character — the run state, where T means stopped on both — section 5 reads identically on both.

In zsh and fish

Both catch the same signals bash does, neither can catch SIGKILL or SIGSTOP, and each has its own spelling.

zsh takes bash's trap unchanged, and adds a second form: a function named TRAPINT, TRAPQUIT or TRAPTERM is the handler, with no trap command anywhere. It reads better once a handler is more than one line, and it is zsh-only — bash sees a plain function that nothing calls. One other difference matters before you copy a script across: bash turns job control on inside a script with set -m, and zsh refuses in a non-interactive shell and says so.

Verified output of uncatchable_zsh.zsh on Linux — regenerated by tools/run_examples.py, never hand-typed.

1. Two spellings, and zsh takes both
--------------------------------------------------------------
trap 'echo "trap INT   -- not stopping" >> log' INT     # bash's spelling
TRAPQUIT() { echo "TRAPQUIT   -- not stopping" >> log }  # zsh's own
trap 'echo "trap KILL"                  >> log' KILL

zsh accepted all three without a word, including the last. Sending the
first two:

$ cat log
trap INT   -- not stopping
TRAPQUIT   -- not stopping

A function named TRAPINT, TRAPQUIT or TRAPTERM is a handler, with no
trap command anywhere. It is the readable form when the handler is more
than one line, and it is zsh-only -- bash reads it as a plain function.

2. The third one, which no spelling reaches
--------------------------------------------------------------
$ cat log
trap INT   -- not stopping
TRAPQUIT   -- not stopping

$ wait $victim; echo $?
137

Two lines still, and 128 + 9 = 137. SIGKILL and SIGSTOP are removed by
the kernel before any process sees them, so there is no shell, no
handler and no spelling that changes this.

3. kill -l, which zsh prints differently from bash
--------------------------------------------------------------
$ kill -l INT
2

$ kill -l STOP
19

$ kill -l
HUP INT QUIT ILL TRAP IOT BUS FPE KILL USR1 SEGV USR2 PIPE ALRM TERM STKFLT CHLD CONT STOP TSTP TTIN TTOU URG XCPU XFSZ VTALRM PROF WINCH POLL PWR SYS

zsh's bare 'kill -l' is a list of names and nothing else; bash prints
numbered pairs. Reading a number out of either is the habit to lose --
SIGSTOP is 17 on macOS and 19 on Linux, as the two columns here show.

4. The one thing zsh will not let a script do
--------------------------------------------------------------
$ zsh -c "setopt monitor"
zsh:setopt:1: can't change option: monitor

bash turns job control on inside a script with 'set -m'; zsh refuses in
a non-interactive shell and says so. Worth knowing before you copy a
bash script's 'set -m' across and wonder why the job is still in the
same process group.

5. A background job's SIGINT was never yours to catch
--------------------------------------------------------------
$ cat log
catcher: caught SIGINT

It caught it -- and the bash column of this page did not. Both shells
were handed the same thing: a parent starting a background job sets the
child's SIGINT and SIGQUIT to ignored before exec, whichever shell the
parent is. POSIX then says a signal already ignored at startup cannot be
trapped, reset or caught. bash honours that rule; zsh overrides it and
installs the trap anyway, and so does fish.

Measured every way round: the parent shell makes no difference and the
child shell makes all of it. A backgrounded wrapper whose Ctrl-C handler
works is a wrapper running under zsh or fish, and changing its shebang
to bash is enough to lose the handler with nothing printed about it.

Verified output of uncatchable_zsh.zsh on macOS — regenerated by tools/run_examples.py, never hand-typed.

1. Two spellings, and zsh takes both
--------------------------------------------------------------
trap 'echo "trap INT   -- not stopping" >> log' INT     # bash's spelling
TRAPQUIT() { echo "TRAPQUIT   -- not stopping" >> log }  # zsh's own
trap 'echo "trap KILL"                  >> log' KILL

zsh accepted all three without a word, including the last. Sending the
first two:

$ cat log
trap INT   -- not stopping
TRAPQUIT   -- not stopping

A function named TRAPINT, TRAPQUIT or TRAPTERM is a handler, with no
trap command anywhere. It is the readable form when the handler is more
than one line, and it is zsh-only -- bash reads it as a plain function.

2. The third one, which no spelling reaches
--------------------------------------------------------------
$ cat log
trap INT   -- not stopping
TRAPQUIT   -- not stopping

$ wait $victim; echo $?
137

Two lines still, and 128 + 9 = 137. SIGKILL and SIGSTOP are removed by
the kernel before any process sees them, so there is no shell, no
handler and no spelling that changes this.

3. kill -l, which zsh prints differently from bash
--------------------------------------------------------------
$ kill -l INT
2

$ kill -l STOP
17

$ kill -l
HUP INT QUIT ILL TRAP ABRT EMT FPE KILL BUS SEGV SYS PIPE ALRM TERM URG STOP TSTP CONT CHLD TTIN TTOU IO XCPU XFSZ VTALRM PROF WINCH INFO USR1 USR2

zsh's bare 'kill -l' is a list of names and nothing else; bash prints
numbered pairs. Reading a number out of either is the habit to lose --
SIGSTOP is 17 on macOS and 19 on Linux, as the two columns here show.

4. The one thing zsh will not let a script do
--------------------------------------------------------------
$ zsh -c "setopt monitor"
zsh:setopt:1: can't change option: monitor

bash turns job control on inside a script with 'set -m'; zsh refuses in
a non-interactive shell and says so. Worth knowing before you copy a
bash script's 'set -m' across and wonder why the job is still in the
same process group.

5. A background job's SIGINT was never yours to catch
--------------------------------------------------------------
$ cat log
catcher: caught SIGINT

It caught it -- and the bash column of this page did not. Both shells
were handed the same thing: a parent starting a background job sets the
child's SIGINT and SIGQUIT to ignored before exec, whichever shell the
parent is. POSIX then says a signal already ignored at startup cannot be
trapped, reset or caught. bash honours that rule; zsh overrides it and
installs the trap anyway, and so does fish.

Measured every way round: the parent shell makes no difference and the
child shell makes all of it. A backgrounded wrapper whose Ctrl-C handler
works is a wrapper running under zsh or fish, and changing its shebang
to bash is enough to lose the handler with nothing printed about it.

fish has no trap builtin at all. A handler is a function:

function on_int --on-signal INT
    echo "caught SIGINT -- not stopping"
end

The trap you can still type in fish is a compatibility function that defines one of these for you. Two fish-specific findings came out of running the same script there. --on-signal QUIT is accepted and never fires, while --on-signal TERM declared beside it does — so Ctrl-\, the key that ends a program whose SIGINT handler has taken over, does nothing to a fish script either, and that is fish's decision rather than the kernel's. And fish has no kill builtin, so kill -l KILL is whatever /bin/kill your system ships: GNU answers 9, BSD prints its usage and exits non-zero.

Verified output of uncatchable_fish.fish on Linux — regenerated by tools/run_examples.py, never hand-typed.

1. No trap builtin: a handler is a function
--------------------------------------------------------------
function on_int --on-signal INT
    echo "on_int  -- not stopping" >> log
end

fish defined all four without a word, including the last. Sending the
first three, a third of a second apart:

$ cat log
on_int  -- not stopping
on_term -- not stopping

fish ships a `trap` function for compatibility, and it is a thin wrapper
that defines exactly this kind of function for you. There is no builtin
underneath it.

2. Three signals sent, two handlers ran
--------------------------------------------------------------
SIGINT and SIGTERM arrived. SIGQUIT did not, and its handler was
declared in the same breath as the other two. fish does not deliver
SIGQUIT to a --on-signal handler -- so Ctrl-\, which is the key that
ends a program whose SIGINT handler has taken over, does nothing to a
fish script either. That is fish's decision, not the kernel's.

3. And the one no shell can reach
--------------------------------------------------------------
$ cat log
on_int  -- not stopping
on_term -- not stopping

Unchanged. SIGKILL and SIGSTOP are acted on by the kernel before any
process sees them, so no shell has anything to be wrong about -- fish
accepting --on-signal KILL is the same silence as bash accepting
trap ... KILL.

4. The status a signal leaves behind
--------------------------------------------------------------
$ fish -c "kill -INT %self"; echo $status
130

$ fish -c "kill -KILL %self"; echo $status
137

fish spells it $status rather than $?, and the arithmetic is everyone
else's: 128 + the signal number. 130 is somebody's Ctrl-C, 137 is a
kill -9 or the out-of-memory killer, 143 is a shutdown that asked first.

5. fish has no kill builtin, and it shows
--------------------------------------------------------------
$ kill -l KILL
9

bash and zsh both have a kill builtin that takes a signal name. fish
does not, so this is /bin/kill -- and the two columns of this page are
two different /bin/kill programs. Use the status arithmetic above
instead; it is the same on both.

6. A background job's SIGINT, where fish sides with zsh
--------------------------------------------------------------
$ cat log
catcher: caught SIGINT

It caught it. Every shell here starts a background child with SIGINT and
SIGQUIT set to ignored, and POSIX says a signal ignored at startup can
never be trapped afterwards. bash honours that and its handler never
runs; fish and zsh override it and theirs do. The child's shell decides
and the parent's does not -- which is the section to re-read when a
wrapper script goes quiet after somebody changed its first line.

Verified output of uncatchable_fish.fish on macOS — regenerated by tools/run_examples.py, never hand-typed.

1. No trap builtin: a handler is a function
--------------------------------------------------------------
function on_int --on-signal INT
    echo "on_int  -- not stopping" >> log
end

fish defined all four without a word, including the last. Sending the
first three, a third of a second apart:

$ cat log
on_int  -- not stopping
on_term -- not stopping

fish ships a `trap` function for compatibility, and it is a thin wrapper
that defines exactly this kind of function for you. There is no builtin
underneath it.

2. Three signals sent, two handlers ran
--------------------------------------------------------------
SIGINT and SIGTERM arrived. SIGQUIT did not, and its handler was
declared in the same breath as the other two. fish does not deliver
SIGQUIT to a --on-signal handler -- so Ctrl-\, which is the key that
ends a program whose SIGINT handler has taken over, does nothing to a
fish script either. That is fish's decision, not the kernel's.

3. And the one no shell can reach
--------------------------------------------------------------
$ cat log
on_int  -- not stopping
on_term -- not stopping

Unchanged. SIGKILL and SIGSTOP are acted on by the kernel before any
process sees them, so no shell has anything to be wrong about -- fish
accepting --on-signal KILL is the same silence as bash accepting
trap ... KILL.

4. The status a signal leaves behind
--------------------------------------------------------------
$ fish -c "kill -INT %self"; echo $status
130

$ fish -c "kill -KILL %self"; echo $status
137

fish spells it $status rather than $?, and the arithmetic is everyone
else's: 128 + the signal number. 130 is somebody's Ctrl-C, 137 is a
kill -9 or the out-of-memory killer, 143 is a shutdown that asked first.

5. fish has no kill builtin, and it shows
--------------------------------------------------------------
$ kill -l KILL
usage: kill [-s signal_name] pid ...
       kill -l [exit_status]
       kill -signal_name pid ...
       kill -signal_number pid ...

bash and zsh both have a kill builtin that takes a signal name. fish
does not, so this is /bin/kill -- and the two columns of this page are
two different /bin/kill programs. Use the status arithmetic above
instead; it is the same on both.

6. A background job's SIGINT, where fish sides with zsh
--------------------------------------------------------------
$ cat log
catcher: caught SIGINT

It caught it. Every shell here starts a background child with SIGINT and
SIGQUIT set to ignored, and POSIX says a signal ignored at startup can
never be trapped afterwards. bash honours that and its handler never
runs; fish and zsh override it and theirs do. The child's shell decides
and the parent's does not -- which is the section to re-read when a
wrapper script goes quiet after somebody changed its first line.

If you are coming from another library

In a program rather than a shell, the same three rules apply and the ergonomics differ completely. Ctrl-C is a signal ↗ in the Python library is the counterpart to section 1: KeyboardInterrupt is not what Ctrl-C does, it is what Python's default SIGINT handler does with it, and signal.signal() replaces that handler in one line. Catching a signal ↗ in the Rust library is the other extreme: there is no signal API in std at all, so Ctrl-C ends the process with no unwinding and no destructors until you declare signal from libc yourself.

The signal this page does not cover is SIGPIPE, which is what turns up when the reader of your pipeline quits early. head closes the pipe early has it, including the 141 that is 128 + 13.

Which thread receives a signal in a program that has several is a question of its own: Signals ↗ in the concurrency library.

See also

  • A pipeline reports its last command — where an exit status comes from when several processes are involved, which is the other half of the 128 + N arithmetic.
  • head closes the pipe early — SIGPIPE, the signal you meet without ever sending one.
  • signal(7) ↗ — the full table, including which signals dump core and which are ignored by default.
  • kill(1) ↗ — and note the shell builtin of the same name, which is what you usually get.
  • Prompted by the job-control section of MIT's Missing Semester ↗ lecture on the command-line environment; the scripts and prose here are written from scratch.