Spawn linking in a LiveView process

All,
In a LiveView process mount callback I’m spawn linking a conversational bot process:

@impl true
def mount(_params, _session, socket) do
  {:ok, conversation_pid} = Conversation.start_link()
  socket = assign(socket, :conversation_pid, conversation_pid)
  {:ok, socket}
end

Conversation relevant code is:

@spec start_link() :: GenServer.on_start()
def start_link() do
  GenServer.start_link(__MODULE__, [])
end
def init([]) do
  Logger.info("Starting conversation")
  _ = Process.flag(:trap_exit, true)
  {:ok, %{}}
end
@impl true
def handle_info({:EXIT, _pid, _reason}, state) do
  Logger.warning("ON DIED!")
  {:stop, :normal, state}
end

@impl true
def terminate(_reason, _state) do
  Logger.warn("DIED!")
end

When I open a browser on /chat I get:

[info] GET /chat
[debug] Processing with LysaWeb.ChatLive.Elixir.LysaWeb.ChatLive/2
  Parameters: %{}
  Pipelines: [:browser]
[info] Starting conversation
[info] Sent 200 in 1ms
[warning] DIED!
[info] CONNECTED TO Phoenix.LiveView.Socket in 18µs
  Transport: :websocket
  Serializer: Phoenix.Socket.V2.JSONSerializer
  Parameters: %{"_csrf_token" => "PX0APQUODhlUHX9ZEQkKLTIJIzsnURd7U8nLStCxyo3jfGobTqnQcdpC", "_live_referer" => "undefined", "_mounts" => "0", "_track_static" => %{"0" => "http://localhost:4000/assets/app.css", "1" => "http://localhost:4000/assets/app.js"}, "vsn" => "2.0.0"}
[debug] MOUNT LysaWeb.ChatLive
  Parameters: %{}
  Session: %{"_csrf_token" => "hEnqVzMa-rL3wNeOfxMjD5g8"}
[info] Starting conversation
[debug] Replied in 49µs

You can see that the conversation gets started once, then dies, then is started again (to be expected given that mount is called twice).

When I close the browser tab, I get:

[warning] DIED!

==> QUESTION: what I would have expected is for the ON DIED! log message to be displayed as well, given that we are trapping exits: a {:EXIT, #PID<0.113.0>, :normal} message should be received by the Conversation process, but it’s not. Why is that?

Now, if I don’t trap exits:

@impl true
def init([]) do
  Logger.info("Starting conversation")
  # _ = Process.flag(:trap_exit, true)
  {:ok, %State{}}
end

When I open /chat:

[info] GET /chat
[debug] Processing with LysaWeb.ChatLive.Elixir.LysaWeb.ChatLive/2
  Parameters: %{}
  Pipelines: [:browser]
[info] Starting conversation
[info] Sent 200 in 12ms
[info] CONNECTED TO Phoenix.LiveView.Socket in 14µs
  Transport: :websocket
  Serializer: Phoenix.Socket.V2.JSONSerializer
  Parameters: %{"_csrf_token" => "IDI8STU7fFNAJnthASc3OSlJBTAjRwJyHwR8cA12mT7RviRvO1HZgreJ", "_live_referer" => "undefined", "_mounts" => "0", "_track_static" => %{"0" => "http://localhost:4000/assets/app.css", "1" => "http://localhost:4000/assets/app.js"}, "vsn" => "2.0.0"}
[debug] MOUNT LysaWeb.ChatLive
  Parameters: %{}
  Session: %{"_csrf_token" => "hEnqVzMa-rL3wNeOfxMjD5g8"}
[info] Starting conversation
[debug] Replied in 62µs

When I close the browser tab, I don’t get anything.

This means that the Conversation process is never killed, despite it being spawn linked. This could be expected behaviour, given that the exit reason of the LiveView process is probably :normal.

Let’s then see what happens when we blow up the LiveView process on mount:

@impl true
def mount(_params, _session, socket) do
  {:ok, conversation_pid} = Conversation.start_link()
  1/0
end

I get:

[info] GET /chat
[debug] Processing with LysaWeb.ChatLive.Elixir.LysaWeb.ChatLive/2
  Parameters: %{}
  Pipelines: [:browser]
[info] Starting conversation
[info] Sent 500 in 33ms
[error] #PID<0.625.0> running Phoenix.Endpoint.SyncCodeReloadPlug (connection #PID<0.623.0>, stream id 1) terminated
Server: localhost:4000 (http)
Request: GET /chat
** (exit) an exception was raised:
    ** (ArithmeticError) bad argument in arithmetic expression

So yet again the Conversation process isn’t killed, even though the linked LiveView process exited with a non normal reason.

==> QUESTION: why is the Conversation process, which was spawn linked, not killed?

Can some kind soul clarify these for me?
Thank you in advance.

why is the Conversation process, which was spawn linked, not killed?

It should be, I’m not sure why it didn’t logged the message, if you run in iex you could try to print conversation_pid and then confirm via Process.alive?(conversation_pid) (you might need to use helper function pid/1 to construct pid from the string)

what I would have expected is for the ON DIED! log message to be displayed as well, given that we are trapping exits: a {:EXIT, #PID<0.113.0>, :normal} message should be received by the Conversation process, but it’s not. Why is that?

When child process dies - parent receives {:EXIT message… However, when Parent process dies - child only gets terminated first… child doesn’t receives a message about parent exited.

You could play around with simple example:

defmodule Proc do
  use GenServer

  def init(_) do
    _ = Process.flag(:trap_exit, true)
    IO.inspect(self(), label: "Initializing process")
    {:ok, []}
  end

  def handle_info({:EXIT, pid, reason}, state) do
    IO.inspect({pid, reason}, label: "Received EXIT")
    {:stop, :normal, state}
  end

  def terminate(reason, _state) do
    IO.inspect({self(), reason}, label: "Terminating")
  end

  def handle_call(:start_linked_child, _from, state) do
    ok_pid = GenServer.start_link(__MODULE__, [])
    {:reply, ok_pid, state}
  end
end

Now in iex start two linked processes

{:ok, parent} = GenServer.start(Proc, [])
{:ok, child} = GenServer.call(parent, :start_linked_child)

and try to exit parent with

Process.exit(parent, :normal)

see what it prints… then try the same but exit child

Process.exit(child, :normal)
1 Like

From docs for gen_server:start_link/4

start_link(ServerName, Module, Args, Options)
Creates a gen_server process as part of a supervision tree. This function is to be called, directly or indirectly, by the supervisor. For example, it ensures that the gen_server process is linked to the supervisor.

Thinking in terms of a supervision tree it makes sense. There is nothing child can do about parent dying, all is left for it is to clean up after itself and terminate, while when it’s the other way around - when child dies - parent can handle that info… hence, parent receives message :EXIT from dying child, but not the other around.

However, it’s not clear to me when I call GenServer.start_link(Proc, []) from iex - what supervisor it is being part of :sweat_smile:

You are right, it’s a standard parent / child behaviour. I got caught up into not seeing a log when the parent is killed and it’s probably just because the terminate/2 on the child isn’t guaranteed to be called, and this might be one of those cases (termination due to linked parent dying when the child isn’t trapping exits).

Thank you for taking the time!

However, it’s not clear to me when I call GenServer.start_link(Proc, []) from iex - what supervisor it is being part of :sweat_smile:

:slight_smile: