Klemens Lukaszczyk

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.

Example 1 - exit/1: inner self-exits with :kill

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:

Example 2 - exit/2: inner self-exit with :kill

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 written exit(any_term).
  • Process.exit(self(), :kill) behaves differently from exit/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 :killed before 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 :kill
  • Process.exit(self(), :kill) - untrappable, target dies, propagated as :killed
  • Process.exit(other, :kill) - untrappable, target dies, propagated as :killed
  • Process.exit(other, :normal) - no-op unless other == self()

From the docs

Elixir, Process.exit/2:

:kill - which occurs when Process.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!

← Back to blog