Death, children, and OTP

Summary

Previously in this (what has become a) series of posts we have looked at how an OTP process behaves when we try and kill it and what happens to an OTP process when a linked process dies.

In an Elixir Form thread a forum member, The Wild Goose, pointed out that there was special behaviour when a the exit signal is received from a processes parent. I was sceptical of this, finding no reference to parent / child relationship in the Erlang process documentation.

I was wrong to be sceptical. While not being part of Erlang, there is parent/child behaviour specified in the OTP documentation: when a linked parent dies then the exit signal becomes untrappable in OTP conformant processes, such as a GenServer.

Here’s a summary of the behaviour.

Trapping exits? Diffent to non parent/child behaviour Reason for linked parent exit Exit message received? Exits? terminate/2 callback?
no no :normal no no no
no no any reason other than :normal no yes no
yes yes any reason other than :normal no yes yes
yes yes :normal no yes yes

So indeed, exits are not trapped by OTP processes, eg GenServer, when the processes parent dies - parent being the process that has created this process.

What raised my left eyebrow was that if the parent process exits with a :normal reason then this does not affect the child unless the child is trapping exits; when trapping exits a parent terminating with :normal also causes the child to die.

The LiveBook page of this post is here and you can follow the instructions in this post to execute.

Code

Here is a GenServer that we are going to use to investigate this parent / child exit behaviour.

defmodule Life do
  use GenServer

  def trap_exits(server) do
    :ok = GenServer.call(server, :trap_exits)
  end

  def make_parent_and_child do
    {:ok, parent} = GenServer.start(__MODULE__, :parent)
    {:ok, child} = GenServer.call(parent, :make_child)
    {:ok, parent, child}
  end

  def make_parent_and_linked_child do
    {:ok, parent} = GenServer.start(__MODULE__, :parent)
    {:ok, child} = GenServer.call(parent, :make_linked_child)
    {:ok, parent, child}
  end

  def alive_after_wait_for_death?(pid, count \\ 50)
  def alive_after_wait_for_death?(pid, 0), do: Process.alive?(pid)

  def alive_after_wait_for_death?(pid, count) do
    case Process.alive?(pid) do
      true ->
        :timer.sleep(1)
        alive_after_wait_for_death?(pid, count - 1)

      _ ->
        false
    end
  end

  def init(tag), do: {:ok, %{tag: tag}}

  def handle_call(:trap_exits, _, s) do
    Process.flag(:trap_exit, true)
    {:reply, :ok, s}
  end

  def handle_call(:make_child, _, s) do
    {:reply, GenServer.start(__MODULE__, :child), s}
  end

  def handle_call(:make_linked_child, _, s) do
    {:reply, GenServer.start_link(__MODULE__, :child), s}
  end

  def handle_info(event, %{tag: tag} = s) do
    IO.inspect({tag, self(), event}, label: :handle_info)
    {:noreply, s}
  end

  def terminate(reason, %{tag: tag}) do
    IO.inspect({tag, self(), reason}, label: :terminate)
  end
end

Effect of killing a parent on its unlinked child

Just to get it out of the way let’s look at what happens to an unlinked child when the parent exits.

{:ok, parent, child} = Life.make_parent_and_child()

Process.exit(parent, :kill)

true = Life.alive_after_wait_for_death?(child)

As we would expect, when there is no link the parent’s exit has no impact on the child.

Effect of killing a parent on a linked child

{:ok, parent, child} = Life.make_parent_and_linked_child()

Process.exit(parent, :kill)

false = Life.alive_after_wait_for_death?(child)

Of course, if we kill a linked parent then the child will also die without calling terminate/2, just like killing any other linked process. No suprise here.

Parent exits with a reason other than :normal

{:ok, parent, child} = Life.make_parent_and_linked_child()

GenServer.stop(parent, :some_reason)

false = Life.alive_after_wait_for_death?(child)

When a linked process exits, a child that is not trapping exits will also die without a callback to terminate/2, regardless of whether there is a parent/child relationship.

{:ok, parent, child} = Life.make_parent_and_linked_child()
Life.trap_exits(child)

GenServer.stop(parent, :some_reason)

false = Life.alive_after_wait_for_death?(child)

In contrast to a non parent / child linked process exit, if the child is trapping exits and the parent dies, the exit is not trapped and the child will still die. The terminate/2 callback is called (if supplied).

How about the effect on a linked child when a parent exits normally?

{:ok, parent, child} = Life.make_parent_and_linked_child()

GenServer.stop(parent, :normal)

true = Life.alive_after_wait_for_death?(child)

A process exiting with a :normal reason, does not cause any linked processes to exit regardless of a parent/child relationship.

{:ok, parent, child} = Life.make_parent_and_linked_child()
Life.trap_exits(child)

GenServer.stop(parent, :normal)

false = Life.alive_after_wait_for_death?(child)

If a child is trapping exits and its parent dies with a :normal reason then the child will also exit with the same reason and a callback to terminate/2. It’s all a bit Greek and tragic: only by doing the thing that you might expect would prevent the child’s death (trapping exits), does its death come about.

This series

Starting out looking at exit signals and OTP process death has turned into a small series of posts, including this one. These are:

Updates

  • 2021-06-29: included the section linking to posts in this series.

  • 2021-06-29: added a reference to Oedipus and Laius to make me seem erudite (though in that case it was the child killing the parent so it’s maybe a bit misleading).