The trappable :kill signal
Quick puzzle. Two processes, both with Process.flag(:trap_exit, true). In one case the inner kills itself with exit(:kill) (the exit/1 form). In the other, the outer kills the inner with Process.exit(pid, :kill) (the exit/2 form). Same reason atom. Does the outer see the same {:EXIT, inner, reason} in both?
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
In erts/emulator/beam/erl_proc_sig_queue.c (search for am_kill / am_killed) and the helper send_exit_signal in erts/emulator/beam/erl_process.c. The runtime checks the reason atom at signal-send time and flips both the “untrappable” flag and the reason to killed.
So the short version: :kill is just an atom. The special semantics live in exit/2, not in the atom itself.
Cheers!