The trappable :kill signal
· 3 min read
- elixir
- otp
- beam
One of the first things I learned about signals is that the :kill signal isn’t trappable - not even if the process is trapping exits. So imagine my surprise when, while playing around with signals, I saw {:EXIT, pid, :kill} show up in handle_info and the GenServer just kept running. After some digging I found the reason - check out the examples below.
The snippets run in your browser via Popcorn (a thin Elixir/AtomVM wrapper). The first time you click Run, ~11 MB of WASM is downloaded - after that it’s cached.
Notice the outer process didn’t die - even though it received an EXIT with reason :kill, trap_exit turned the signal into a regular mailbox message and the receive ran normally.
Now move the same :kill but use exit/2:
Both inners trap exits. Both deaths use :kill. The outer catches in both - but with different reasons: :kill in example 1, :killed in example 2.
What’s actually going on
The “untrappable kill” isn’t a property of the atom :kill. It’s a property of how Process.exit/2 (the BIF erlang:exit/2) delivers exit signals to processes.
exit(:kill)inside a process is a self-termination. Linked trapping processes get{:EXIT, pid, :kill}. Trappable. The atom is just an atom - same as if you’d writtenexit(any_term).Process.exit(self(), :kill)behaves differently fromexit/1, even though the target is the same process. The runtime inspects the reason at send time; when it’s:kill, it sets the “untrappable” flag and rewrites the reason to:killedbefore delivering it. The target dies regardless of:trap_exit. Trappers see{:EXIT, pid, :killed}.
Why rewrite :kill to :killed? It’s a deliberate hint for linked processes: “this one was killed by someone sending a signal, not because it exited with the atom :kill on its own.”
Cheat sheet
exit(:kill)(self) - normal exit, trappable, reason stays:killProcess.exit(self(), :kill)- untrappable, target dies, propagated as:killedProcess.exit(other, :kill)- untrappable, target dies, propagated as:killedProcess.exit(other, :normal)- no-op unlessother == self()
From the docs
Elixir, Process.exit/2:
:kill- which occurs whenProcess.exit(pid, :kill)is called, an untrappable exit signal is sent to pid which will unconditionally exit with reason:killed.
Erlang, erlang:exit/2:
If Reason is the atom kill, that is, if exit(Dest, kill) is called, an untrappable exit signal is sent to the process that is identified by Dest, which unconditionally exits with exit reason killed. The exit reason is changed from kill to killed to hint to linked processes that the killed process got killed by a call to exit(Dest, kill).
And for the self-exit case, erlang:exit/1:
If a process calls exit(kill) and does not catch the exception, it will terminate with exit reason kill and also emit exit signals with exit reason kill (not killed) to all linked processes. Such exit signals with exit reason kill can be trapped by the linked processes.
That last sentence is the explicit rationale - the runtime treats the two send-paths differently on purpose.
Where the rewrite happens in the VM
The receiver-side handler is handle_exit_signal in erts/emulator/beam/erl_proc_sig_queue.c. Two lines do the work:
- L4740 -
(op != ERTS_SIG_Q_OP_EXIT || reason != am_kill) && (c_p->flags & F_TRAP_EXIT)is the bypass: when the reason isam_kill, the trap-exit branch is skipped even iftrap_exitis set, and the process falls through to the terminate branch. - L4768-L4769 - in the terminate branch:
if (op == ERTS_SIG_Q_OP_EXIT && reason == am_kill) reason = am_killed;. That’s the actual rewrite.
So the short version: :kill is just an atom. The special semantics live in exit/2, not in the atom itself.
Cheers!