Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to detect if a user left a Phoenix channel due to a network disconnect?

I have an Elixir/Phoenix server app and the clients connect through the build in channels system via websockets. Now I want to detect when an user leaves a channel.

Sidenote: I'm using the javascript client library inside a Google Chrome Extension. For this I extracted the ES6 code from Phoenix, transpiled it to javascript, and tweaked it a little so it runs standalone.

Now when I just close the popup, the server immediately triggers the terminate/2 function with reason = {:shutdown, :closed}. There is no kind of close-callback involved on the extension side, so this is great!

But when the client simply looses network connection (I connected a second computer and just pulled out the network plug) then terminate/2 will not trigger.

Why and how do I fix this?

I played around with the timeoutoption of transport :websocket, Phoenix.Transports.WebSocket but this did not work out.

Update: With the new awesome Phoenix 1.2 Presence stuff, this should not be needed anymore.

like image 585
Philip Claren Avatar asked Nov 26 '15 08:11

Philip Claren


1 Answers

The proper way to do this is to not trap exits in your channel, and instead have another process monitor you. When you go down, it can invoke a callback. Below is a snippet to get you started:

# lib/my_app.ex

children = [
  ...
  worker(ChannelWatcher, [:rooms])
]

# web/channels/room_channel.ex

def join("rooms:", <> id, params, socket) do
  uid = socket.assigns.user_id]
  :ok = ChannelWatcher.monitor(:rooms, self(), {__MODULE__, :leave, [id, uid]})

  {:ok, socket}
end

def leave(room_id, user_id) do
  # handle user leaving
end

# lib/my_app/channel_watcher.ex

defmodule ChannelWatcher do
  use GenServer

  ## Client API

  def monitor(server_name, pid, mfa) do
    GenServer.call(server_name, {:monitor, pid, mfa})
  end

  def demonitor(server_name, pid) do
    GenServer.call(server_name, {:demonitor, pid})
  end

  ## Server API

  def start_link(name) do
    GenServer.start_link(__MODULE__, [], name: name)
  end

  def init(_) do
    Process.flag(:trap_exit, true)
    {:ok, %{channels: HashDict.new()}}
  end

  def handle_call({:monitor, pid, mfa}, _from, state) do
    Process.link(pid)
    {:reply, :ok, put_channel(state, pid, mfa)}
  end

  def handle_call({:demonitor, pid}, _from, state) do
    case HashDict.fetch(state.channels, pid) do
      :error       -> {:reply, :ok, state}
      {:ok,  _mfa} ->
        Process.unlink(pid)
        {:reply, :ok, drop_channel(state, pid)}
    end
  end

  def handle_info({:EXIT, pid, _reason}, state) do
    case HashDict.fetch(state.channels, pid) do
      :error -> {:noreply, state}
      {:ok, {mod, func, args}} ->
        Task.start_link(fn -> apply(mod, func, args) end)
        {:noreply, drop_channel(state, pid)}
    end
  end

  defp drop_channel(state, pid) do
    %{state | channels: HashDict.delete(state.channels, pid)}
  end

  defp put_channel(state, pid, mfa) do
    %{state | channels: HashDict.put(state.channels, pid, mfa)}
  end
end

In newer versions of Elixir/Phoenix HashDict has changed name to Map. The correct example for newer codebases is:

# lib/my_app.ex

children = [
  ...
  worker(ChannelWatcher, [:rooms])
]

# web/channels/room_channel.ex

def join("rooms:", <> id, params, socket) do
  uid = socket.assigns.user_id]
  :ok = ChannelWatcher.monitor(:rooms, self(), {__MODULE__, :leave, [id, uid]})

  {:ok, socket}
end

def leave(room_id, user_id) do
  # handle user leaving
end

# lib/my_app/channel_watcher.ex

defmodule ChannelWatcher do
  use GenServer

  ## Client API

  def monitor(server_name, pid, mfa) do
    GenServer.call(server_name, {:monitor, pid, mfa})
  end

  def demonitor(server_name, pid) do
    GenServer.call(server_name, {:demonitor, pid})
  end

  ## Server API

  def start_link(name) do
    GenServer.start_link(__MODULE__, [], name: name)
  end

  def init(_) do
    Process.flag(:trap_exit, true)
    {:ok, %{channels: Map.new()}}
  end

  def handle_call({:monitor, pid, mfa}, _from, state) do
    Process.link(pid)
    {:reply, :ok, put_channel(state, pid, mfa)}
  end

  def handle_call({:demonitor, pid}, _from, state) do
    case Map.fetch(state.channels, pid) do
      :error       -> {:reply, :ok, state}
      {:ok,  _mfa} ->
        Process.unlink(pid)
        {:reply, :ok, drop_channel(state, pid)}
    end
  end

  def handle_info({:EXIT, pid, _reason}, state) do
    case Map.fetch(state.channels, pid) do
      :error -> {:noreply, state}
      {:ok, {mod, func, args}} ->
        Task.start_link(fn -> apply(mod, func, args) end)
        {:noreply, drop_channel(state, pid)}
    end
  end

  defp drop_channel(state, pid) do
    %{state | channels: Map.delete(state.channels, pid)}
  end

  defp put_channel(state, pid, mfa) do
    %{state | channels: Map.put(state.channels, pid, mfa)}
  end
end
like image 127
Chris McCord Avatar answered Sep 24 '22 15:09

Chris McCord