There is a callback to track when the socket has been mounted, but there is no callback to be notified when the socket has been unmounted. This makes cleaning up the user's session difficult (like committing unsaved work to a database when the user closes a browser tab, for example).
Have a callback for when the socket is unmounted.
def unmount(session, socket) do
# clean up or post disconnect operation
{:noreply, socket}
end
Looks like I can just use the terminate callback (https://github.com/phoenixframework/phoenix_live_view/blob/master/lib/phoenix_live_view.ex#L467). That'll work. Sorry for the pollution.
terminate only works if you are trapping exits, which you rarely want to do. To handle the view process going away, you'd handle it the same way you handle any other elixir process going away 鈥撀爕ou have another process monitor you. This should get you moving:
defmodule MyLive do
use Phoenix.LiveView
def mount(_, socket) do
LiveMonitor.monitor(self(), __MODULE__, %{id: socket.id})
end
def unmount(%{id: id}, _reason) do
IO.puts("view #{id} unmounted")
:ok
end
end
defmodule LiveMonitor do
use GenServer
def monitor(pid, view_module, meta) do
GenServer.call(pid, {:monitor, pid, view_module, meta})
end
def init(_) do
{:ok, %{views: %{}}}
end
def handle_call({:monitor, pid, view_module}, _, %{views: views} = state) do
Process.monitor(pid)
{:reply, :ok, %{state | views: Map.put(views, pid, {view_module, meta})}}
end
def handle_info({:DOWN, _ref, :process, pid, reason}, state) do
{{module, meta}, new_views} = Map.pop(state.views)
module.unmount(reason, meta) # should wrap in isolated task or rescue from exception
{:noreply, %{state | views: new_views}}
end
end
I like this solution, but it reveals another problem: mount appears to be called twice with the same socket id but with different pids. I believe I would need to be able to rely on a stable pid/socket_id combination to make this usable. Perhaps there's another perspective I should consider?
[info] GET /
[debug] Processing with PhoenixGenWeb.PageController.index/2
Parameters: %{}
Pipelines: [:browser]
mount session: %{}, socket.id: phx-9bGz7y4A, pid: #PID<0.515.0> <=== mount called
monitor #PID<0.515.0> . <=== monitor setup
:DOWN #PID<0.515.0> . <=== ah, maybe that pid is already dead??
[info] Sent 200 in 114ms
view phx-9bGz7y4A unmounted
[info] CONNECT Phoenix.LiveView.Socket
Transport: :websocket
Connect Info: %{}
Parameters: %{"vsn" => "2.0.0"}
[info] Replied Phoenix.LiveView.Socket :ok
mount session: %{}, socket.id: phx-9bGz7y4A, pid: #PID<0.528.0> . <=== mount called again, diff pid
monitor #PID<0.528.0> <=== this monitor appears to work
[info] Replied phoenix:live_reload :ok
ahh nvm, I see it's the initial render, followed by the socket connect, where mount/2 is called twice and each disambiguated via connected?. Sorry for the noise.
You should use connected?(socket) to conditionally call the code path instead to avoid
Trying to follow this example and stumbling a bit... shouldn't the LiveView#monitor method be calling to it's own pid (aka self()), not the pid passed (which is of the socket connection)? And does this assume the GenServer has already had start_link somewhere earlier in the app? Or am I misunderstanding the GenServer relationship here?
Appreciate any guidance, really enjoying tinkering with LiveView!
From the _liveview process_, you would call LiveView.monitor(self(), __MODULE__, %{}). This is so that pid may be passed to Process.monitor(pid) so the _monitor_ will be notified of a :DOWN event when the liveview process is terminated. Yes, you will need to start the montior as a GenServer in the usual manner.
Right, I think what's throwing me is inside LiveMonitor#monitor when it calls GenServer.call(pid, {:monitor, pid, view_module, meta}). That's calling {:monitor} back to the live view process, isn't it? Instead, it should be invoking that on the PID coming back from the GenServer's start_link, shouldn't it?
Ah yes, this is what I ended up using, tailored for my needs, including the need to demonitor that you might not need:
def monitor(socket_id, game_pid) do
pid = GenServer.whereis({:global, __MODULE__})
GenServer.call(pid, {:monitor, {socket_id, game_pid}})
end
def demonitor() do
pid = GenServer.whereis({:global, __MODULE__})
GenServer.call(pid, :demonitor)
end
def start_link(init_arg) do
GenServer.start_link(__MODULE__, init_arg, name: {:global, __MODULE__})
end
def init(_) do
{:ok, %{views: %{}}}
end
def handle_call({:monitor, {socket_id, game_pid}}, {view_pid, _ref}, %{views: views} = state) do
mref = Process.monitor(view_pid)
{:reply, :ok, %{state | views: Map.put(views, view_pid, {socket_id, game_pid, mref})}}
end
def handle_call(:demonitor, {view_pid, _ref}, state) do
{{_socket_id, game_pid, mref}, new_views} = Map.pop(state.views, view_pid)
:erlang.demonitor(mref)
# more logic ...
new_state = %{state | views: new_views}
{:reply, :ok, new_state}
end
def handle_info({:DOWN, _ref, :process, view_pid, _reason}, state) do
# IO.puts(":DOWN #{inspect view_pid}")
{{_socket_id, game_pid, mref}, new_views} = Map.pop(state.views, view_pid)
# more logic ...
new_state = %{state | views: new_views}
{:noreply, new_state}
end
That helped clarify those details for me a lot, thank you very much! Working great now.
terminate only works if you are trapping exits, which you rarely want to do.
It's trapping exits a bad idea? why?
My question is because if I use the unmount(reason, meta) approach, do I have some option to get the last state of the socket in unmount?
Thanks :)
Most helpful comment
terminate only works if you are trapping exits, which you rarely want to do. To handle the view process going away, you'd handle it the same way you handle any other elixir process going away 鈥撀爕ou have another process monitor you. This should get you moving: