Closing a socket when user is removed from system

I’m looking for a way to forcibly close a Phoenix socket (the entire socket, not just one channel) when a user account is deleted. Consider the following simplified example:

defmodule Main.UserSocket do
  use Phoenix.Socket
  
  def connect(%{"login_token" => token}, socket) do
    with {:ok, user} <- SessionLogic.verify_login_token(token) do
      {:ok, assign(socket, :user, user)}
    else
      _ -> :error
    end
  end
  def connect(_params, _socket), do: :error  # no unauthenticated sockets
end

So we disallow any socket creation unless we can verify that there is a valid token associated with a valid user.

Suppose while the socket is open, we get notice that the user has been deleted (i.e. no longer valid in the system). I’m looking for a way to cause this socket to be immediately closed upon receipt of that notice. There doesn’t seem to be any of the GenServer infrastructure here, as there is in the channel case. Am I missing something?

The generated docs for your UserSocket show how to do just this:

  # Socket id's are topics that allow you to identify all sockets for a given user:
  #
  #     def id(socket), do: "user_socket:#{socket.assigns.user_id}"
  #
  # Would allow you to broadcast a "disconnect" event and terminate
  # all active sockets and channels for a given user:
  #
  #     <%= YourAppp.Endpoint.broadcast("user_socket:#{user.id}", "disconnect", %{})
  #
  # Returning `nil` makes this socket anonymous.

So you can broadcast the “disconnect” event from your endpoint to the socket’s id and it will close any physical connection for that user

3 Likes

@chrismccord thanks for the follow-up. I’m unfortunately not seeing this work as advertised. I’ve added the following code in UserSocket:

  def id(socket), do: "users_socket:#{socket.assigns.user.id}"

  def close_sockets_for_user_id(user_id) do
    Main.Endpoint.broadcast("users_socket:#{user_id}", "disconnect", %{})
  end

I’m testing this with the following:

  test "socket closes when user deleted", %{user: test_user} do
    {:ok, user_socket} = connect(UserSocket, %{"login_token" => login_token})
    {:ok, _, socket} = subscribe_and_join(user_socket, "user:#{user.id}")

    Process.flag(:trap_exit, true)

    :ok = UserLogic.delete(user.id)

    channel_pid = socket.channel_pid
    assert_receive {:EXIT, ^channel_pid, {:shutdown, :user_deleted}}
  end

I know through logging statements (which I’ve omitted here) that the call to UserLogic.delete/1 does trigger the call to UserSocket.close_sockets_for_user_id/1 but the broadcast message seems to go nowhere.

OTOH if I change the close_sockets... logic to broadcast to "user:#{user_id}" (the name of the channel, not the socket), my test still fails, but I see broadcast messages in the mailbox:

  1) test socket closes when user deleted (Main.UserSocketTest)
     test/channels/user_socket_test.exs:59
     No message matching {:EXIT, ^channel_pid, {:shutdown, :user_deleted}} after 100ms.
     The following variables were pinned:
       channel_pid = #PID<0.1191.0>
     Process mailbox:
       %Phoenix.Socket.Broadcast{event: "disconnect", payload: %{}, topic: "user:oLCOPHzlJqkBNnZ99GfPsQ"}
       %Phoenix.Socket.Message{event: "disconnect", payload: %{}, ref: nil, topic: "user:oLCOPHzlJqkBNnZ99GfPsQ"}
     stacktrace:
       test/channels/user_socket_test.exs:73: (test)

With the code as originally written, there are no messages in the process mailbox when the test fails (even allowing 1 second for message receipt).

1 Like

@chrismccord digging a bit deeper, I believe Phoenix.ChannelTest's implementation of the transport protocol does not honor the "disconnect" broadcast message. (There’s no occurence of that string in that file in version 1.2.1, which is what we’re using.)

I’ve written this up as https://github.com/phoenixframework/phoenix/issues/2355.

I’m out of time for today, but next opportunity I’ll upgrade to 1.2.4 and see if the issue remains.