Repository: phoenixframework/phoenix
Pull request #5543 (2023-08-08 16:01:02)

[Socket Draining] Dynamic socket drainer configuration
To configure the socket `drainer`, one must currently set it up during the compile time. I suggest adding an option to pass `{M, F, A}` to return the drainer configuration keyword list, just like the `check_origin` option. This would allow for runtime configuration of the drainer.

However, I could not find any tests related to this feature.


Patch:
diff --git a/guides/introduction/community.md b/guides/introduction/community.md
index 086edecb0..331880418 100644
--- a/guides/introduction/community.md
+++ b/guides/introduction/community.md
@@ -35,7 +35,9 @@ There are a number of places to connect with community members at all experience
 ## Screencasts/Courses
 
   * [Phoenix LiveView Free Course (by The Pragmatic Studio - 2023)](https://pragmaticstudio.com/courses/phoenix-liveview)
-  
+
+  * [Build It With Phoenix video course (by Geoffrey Lessel - 2023)](https://builditwithphoenix.com)
+
   * [Free Crash Course: Phoenix LiveView (by Productive Programmer - 2023)](https://www.productiveprogrammer.com/learn-phoenix-liveview-free)
 
   * [Groxio LiveView: Self Study Program (by Bruce Tate - 2020)](https://grox.io/language/liveview/course)
diff --git a/guides/routing.md b/guides/routing.md
index 856982a3c..5cd644123 100644
--- a/guides/routing.md
+++ b/guides/routing.md
@@ -521,7 +521,7 @@ end
 
 The above assumes there is a plug called `HelloWeb.Authentication` that performs authentication and is now part of the `:auth` pipeline.
 
-Note that pipelines themselves are plugs, so we can plug a pipeline inside another pipeline. For example, we could rewrite the `review_checks` pipeline above to automatically invoke `browser`, simplifying the downstream pipeline call:
+Note that pipelines themselves are plugs, so we can plug a pipeline inside another pipeline. For example, we could rewrite the `auth` pipeline above to automatically invoke `browser`, simplifying the downstream pipeline call:
 
 ```elixir
   pipeline :auth do
diff --git a/installer/templates/phx_static/home.css b/installer/templates/phx_static/home.css
index 4746663b6..f8c5b6e07 100644
--- a/installer/templates/phx_static/home.css
+++ b/installer/templates/phx_static/home.css
@@ -597,16 +597,7 @@ select {
   --tw-skew-y: 0;
   --tw-scale-x: 1;
   --tw-scale-y: 1;
-  --tw-pan-x:  ;
-  --tw-pan-y:  ;
-  --tw-pinch-zoom:  ;
   --tw-scroll-snap-strictness: proximity;
-  --tw-ordinal:  ;
-  --tw-slashed-zero:  ;
-  --tw-numeric-figure:  ;
-  --tw-numeric-spacing:  ;
-  --tw-numeric-fraction:  ;
-  --tw-ring-inset:  ;
   --tw-ring-offset-width: 0px;
   --tw-ring-offset-color: #fff;
   --tw-ring-color: rgb(59 130 246 / 0.5);
@@ -614,24 +605,6 @@ select {
   --tw-ring-shadow: 0 0 #0000;
   --tw-shadow: 0 0 #0000;
   --tw-shadow-colored: 0 0 #0000;
-  --tw-blur:  ;
-  --tw-brightness:  ;
-  --tw-contrast:  ;
-  --tw-grayscale:  ;
-  --tw-hue-rotate:  ;
-  --tw-invert:  ;
-  --tw-saturate:  ;
-  --tw-sepia:  ;
-  --tw-drop-shadow:  ;
-  --tw-backdrop-blur:  ;
-  --tw-backdrop-brightness:  ;
-  --tw-backdrop-contrast:  ;
-  --tw-backdrop-grayscale:  ;
-  --tw-backdrop-hue-rotate:  ;
-  --tw-backdrop-invert:  ;
-  --tw-backdrop-opacity:  ;
-  --tw-backdrop-saturate:  ;
-  --tw-backdrop-sepia:  ;
 }
 
 ::backdrop {
@@ -644,16 +617,7 @@ select {
   --tw-skew-y: 0;
   --tw-scale-x: 1;
   --tw-scale-y: 1;
-  --tw-pan-x:  ;
-  --tw-pan-y:  ;
-  --tw-pinch-zoom:  ;
   --tw-scroll-snap-strictness: proximity;
-  --tw-ordinal:  ;
-  --tw-slashed-zero:  ;
-  --tw-numeric-figure:  ;
-  --tw-numeric-spacing:  ;
-  --tw-numeric-fraction:  ;
-  --tw-ring-inset:  ;
   --tw-ring-offset-width: 0px;
   --tw-ring-offset-color: #fff;
   --tw-ring-color: rgb(59 130 246 / 0.5);
@@ -661,24 +625,6 @@ select {
   --tw-ring-shadow: 0 0 #0000;
   --tw-shadow: 0 0 #0000;
   --tw-shadow-colored: 0 0 #0000;
-  --tw-blur:  ;
-  --tw-brightness:  ;
-  --tw-contrast:  ;
-  --tw-grayscale:  ;
-  --tw-hue-rotate:  ;
-  --tw-invert:  ;
-  --tw-saturate:  ;
-  --tw-sepia:  ;
-  --tw-drop-shadow:  ;
-  --tw-backdrop-blur:  ;
-  --tw-backdrop-brightness:  ;
-  --tw-backdrop-contrast:  ;
-  --tw-backdrop-grayscale:  ;
-  --tw-backdrop-hue-rotate:  ;
-  --tw-backdrop-invert:  ;
-  --tw-backdrop-opacity:  ;
-  --tw-backdrop-saturate:  ;
-  --tw-backdrop-sepia:  ;
 }
 
 .static {
diff --git a/lib/phoenix/endpoint.ex b/lib/phoenix/endpoint.ex
index 551456970..3885f95b8 100644
--- a/lib/phoenix/endpoint.ex
+++ b/lib/phoenix/endpoint.ex
@@ -807,8 +807,12 @@ defmodule Phoenix.Endpoint do
     * `:code_reloader` - enable or disable the code reloader. Defaults to your
       endpoint configuration
 
-    * `:drainer` - a keyword list configuring how to drain sockets
-      on application shutdown. The goal is to notify all channels (and
+    * `:drainer` - a keyword list or a custom MFA function returning a keyword list, for example:
+
+          {MyAppWeb.Socket, :drainer_configuration, []}
+
+      configuring how to drain sockets on application shutdown.
+      The goal is to notify all channels (and
       LiveViews) clients to reconnect. The supported options are:
 
       * `:batch_size` - How many clients to notify at once in a given batch.
diff --git a/lib/phoenix/logger.ex b/lib/phoenix/logger.ex
index 9375573e1..97f7fea7a 100644
--- a/lib/phoenix/logger.ex
+++ b/lib/phoenix/logger.ex
@@ -230,7 +230,7 @@ defmodule Phoenix.Logger do
       level ->
         Logger.log(level, fn ->
           %{status: status, state: state} = conn
-          status = Integer.to_string(status)
+          status = status_to_string(status)
           [connection_type(state), ?\s, status, " in ", duration(duration)]
         end)
     end
@@ -252,12 +252,16 @@ defmodule Phoenix.Logger do
         ?\s,
         error_banner(kind, reason),
         " to ",
-        Integer.to_string(status),
+        status_to_string(status),
         " response"
       ]
     end)
   end
 
+  defp status_to_string(status) do
+    status |> Plug.Conn.Status.code() |> Integer.to_string()
+  end
+
   defp error_banner(:error, %type{}), do: inspect(type)
   defp error_banner(_kind, reason), do: inspect(reason)
 
diff --git a/lib/phoenix/router.ex b/lib/phoenix/router.ex
index bcc589db7..e17bca29d 100644
--- a/lib/phoenix/router.ex
+++ b/lib/phoenix/router.ex
@@ -277,7 +277,9 @@ defmodule Phoenix.Router do
   Perhaps more importantly, it is also very common to define pipelines specific
   to authentication and authorization. For example, you might have a pipeline
   that requires all users are authenticated. Another pipeline may enforce only
-  admin users can access certain routes.
+  admin users can access certain routes. Since routes are matched top to bottom,
+  it is recommended to place the authenticated/authorized routes before the
+  less restricted routes to ensure they are matched first.
 
   Once your pipelines are defined, you reuse the pipelines in the desired
   scopes, grouping your routes around their pipelines. For example, imagine
@@ -294,17 +296,17 @@ defmodule Phoenix.Router do
       end
 
       scope "/" do
-        pipe_through [:browser]
+        pipe_through [:browser, :auth]
 
-        get "/posts", PostController, :index
-        get "/posts/:id", PostController, :show
+        get "/posts/new", PostController, :new
+        post "/posts", PostController, :create
       end
 
       scope "/" do
-        pipe_through [:browser, :auth]
+        pipe_through [:browser]
 
-        get "/posts/new", PostController, :new
-        post "/posts", PostController, :create
+        get "/posts", PostController, :index
+        get "/posts/:id", PostController, :show
       end
 
   Note in the above how the routes are split across different scopes.
diff --git a/lib/phoenix/socket.ex b/lib/phoenix/socket.ex
index 468c0c043..77286a8db 100644
--- a/lib/phoenix/socket.ex
+++ b/lib/phoenix/socket.ex
@@ -220,8 +220,15 @@ defmodule Phoenix.Socket do
   See `Phoenix.Token` documentation for examples in
   performing token verification on connect.
   """
-  @callback connect(params :: map, Socket.t) :: {:ok, Socket.t} | {:error, term} | :error
-  @callback connect(params :: map, Socket.t, connect_info :: map) :: {:ok, Socket.t} | {:error, term} | :error
+  @callback connect(params :: map, Socket.t(), connect_info :: map) ::
+              {:ok, Socket.t()} | {:error, term} | :error
+
+  @doc """
+  Shortcut version of `connect/3` which does not receive `connect_info`.
+
+  Provided for backwards compatibility.
+  """
+  @callback connect(params :: map, Socket.t()) :: {:ok, Socket.t()} | {:error, term} | :error
 
   @doc ~S"""
   Identifies the socket connection.
@@ -237,7 +244,7 @@ defmodule Phoenix.Socket do
 
   Returning `nil` makes this socket anonymous.
   """
-  @callback id(Socket.t) :: String.t | nil
+  @callback id(Socket.t()) :: String.t() | nil
 
   @optional_callbacks connect: 2, connect: 3
 
@@ -270,15 +277,15 @@ defmodule Phoenix.Socket do
           channel_pid: pid,
           endpoint: atom,
           handler: atom,
-          id: String.t | nil,
+          id: String.t() | nil,
           joined: boolean,
           ref: term,
           private: map,
           pubsub_server: atom,
           serializer: atom,
-          topic: String.t,
+          topic: String.t(),
           transport: atom,
-          transport_pid: pid,
+          transport_pid: pid
         }
 
   defmacro __using__(opts) do
@@ -341,7 +348,7 @@ defmodule Phoenix.Socket do
   end
 
   def assign(%Socket{} = socket, attrs)
-  when is_map(attrs) or is_list(attrs) do
+      when is_map(attrs) or is_list(attrs) do
     %{socket | assigns: Map.merge(socket.assigns, Map.new(attrs))}
   end
 
@@ -420,7 +427,7 @@ defmodule Phoenix.Socket do
     case String.split(topic_pattern, "*") do
       [prefix, ""] -> quote do: <<unquote(prefix) <> _rest>>
       [bare_topic] -> bare_topic
-      _            -> raise ArgumentError, "channels using splat patterns must end with *"
+      _ -> raise ArgumentError, "channels using splat patterns must end with *"
     end
   end
 
@@ -445,7 +452,14 @@ defmodule Phoenix.Socket do
     opts = Keyword.merge(socket_options, opts)
 
     if drainer = Keyword.get(opts, :drainer, []) do
-      {Phoenix.Socket.PoolDrainer, {endpoint, handler, drainer}}
+      drainer =
+        case drainer do
+          {module, function, arguments} ->
+            apply(module, function, arguments)
+          _ ->
+            drainer
+        end
+        {Phoenix.Socket.PoolDrainer, {endpoint, handler, drainer}}
     else
       :ignore
     end
@@ -555,13 +569,16 @@ defmodule Phoenix.Socket do
             {:ok, serializer}
 
           :error ->
-            Logger.warning "The client's requested transport version \"#{vsn}\" " <>
-                          "does not match server's version requirements of #{inspect serializers}"
+            Logger.warning(
+              "The client's requested transport version \"#{vsn}\" " <>
+                "does not match server's version requirements of #{inspect(serializers)}"
+            )
+
             :error
         end
 
       :error ->
-        Logger.warning "Client sent invalid transport version \"#{vsn}\""
+        Logger.warning("Client sent invalid transport version \"#{vsn}\"")
         :error
     end
   end
@@ -599,8 +616,11 @@ defmodule Phoenix.Socket do
             {:ok, {state, %{socket | id: id}}}
 
           invalid ->
-            Logger.warning "#{inspect handler}.id/1 returned invalid identifier " <>
-                           "#{inspect invalid}. Expected nil or a string."
+            Logger.warning(
+              "#{inspect(handler)}.id/1 returned invalid identifier " <>
+                "#{inspect(invalid)}. Expected nil or a string."
+            )
+
             :error
         end
 
@@ -611,9 +631,14 @@ defmodule Phoenix.Socket do
         err
 
       invalid ->
-        connect_arity = if function_exported?(handler, :connect, 3), do: "connect/3", else: "connect/2"
-        Logger.error "#{inspect handler}. #{connect_arity} returned invalid value #{inspect invalid}. " <>
-                     "Expected {:ok, socket}, {:error, reason} or :error"
+        connect_arity =
+          if function_exported?(handler, :connect, 3), do: "connect/3", else: "connect/2"
+
+        Logger.error(
+          "#{inspect(handler)}. #{connect_arity} returned invalid value #{inspect(invalid)}. " <>
+            "Expected {:ok, socket}, {:error, reason} or :error"
+        )
+
         :error
     end
   end
@@ -629,22 +654,41 @@ defmodule Phoenix.Socket do
     {:reply, :ok, encode_reply(socket, reply), {state, socket}}
   end
 
-  defp handle_in(nil, %{event: "phx_join", topic: topic, ref: ref, join_ref: join_ref} = message, state, socket) do
+  defp handle_in(
+         nil,
+         %{event: "phx_join", topic: topic, ref: ref, join_ref: join_ref} = message,
+         state,
+         socket
+       ) do
     case socket.handler.__channel__(topic) do
       {channel, opts} ->
         case Phoenix.Channel.Server.join(socket, channel, message, opts) do
           {:ok, reply, pid} ->
-            reply = %Reply{join_ref: join_ref, ref: ref, topic: topic, status: :ok, payload: reply}
+            reply = %Reply{
+              join_ref: join_ref,
+              ref: ref,
+              topic: topic,
+              status: :ok,
+              payload: reply
+            }
+
             state = put_channel(state, pid, topic, join_ref)
             {:reply, :ok, encode_reply(socket, reply), {state, socket}}
 
           {:error, reply} ->
-            reply = %Reply{join_ref: join_ref, ref: ref, topic: topic, status: :error, payload: reply}
+            reply = %Reply{
+              join_ref: join_ref,
+              ref: ref,
+              topic: topic,
+              status: :error,
+              payload: reply
+            }
+
             {:reply, :error, encode_reply(socket, reply), {state, socket}}
         end
 
       _ ->
-        Logger.warning "Ignoring unmatched topic \"#{topic}\" in #{inspect(socket.handler)}"
+        Logger.warning("Ignoring unmatched topic \"#{topic}\" in #{inspect(socket.handler)}")
         {:reply, :error, encode_ignore(socket, message), {state, socket}}
     end
   end
@@ -657,7 +701,7 @@ defmodule Phoenix.Socket do
         if status != :leaving do
           Logger.debug(fn ->
             "Duplicate channel join for topic \"#{topic}\" in #{inspect(socket.handler)}. " <>
-            "Closing existing channel for new join."
+              "Closing existing channel for new join."
           end)
         end
     end
@@ -688,7 +732,12 @@ defmodule Phoenix.Socket do
     {:ok, {state, socket}}
   end
 
-  defp handle_in(nil, %{event: "phx_leave", ref: ref, topic: topic, join_ref: join_ref}, state, socket) do
+  defp handle_in(
+         nil,
+         %{event: "phx_leave", ref: ref, topic: topic, join_ref: join_ref},
+         state,
+         socket
+       ) do
     reply = %Reply{
       ref: ref,
       join_ref: join_ref,
@@ -711,8 +760,8 @@ defmodule Phoenix.Socket do
     monitor_ref = Process.monitor(pid)
 
     %{
-      state |
-        channels: Map.put(channels, topic, {pid, monitor_ref, :joined}),
+      state
+      | channels: Map.put(channels, topic, {pid, monitor_ref, :joined}),
         channels_inverse: Map.put(channels_inverse, pid, {topic, join_ref})
     }
   end
@@ -722,8 +771,8 @@ defmodule Phoenix.Socket do
     Process.demonitor(monitor_ref, [:flush])
 
     %{
-      state |
-        channels: Map.delete(channels, topic),
+      state
+      | channels: Map.delete(channels, topic),
         channels_inverse: Map.delete(channels_inverse, pid)
     }
   end
@@ -744,7 +793,14 @@ defmodule Phoenix.Socket do
   end
 
   defp encode_close(socket, topic, join_ref) do
-    message = %Message{join_ref: join_ref, ref: join_ref, topic: topic, event: "phx_close", payload: %{}}
+    message = %Message{
+      join_ref: join_ref,
+      ref: join_ref,
+      topic: topic,
+      event: "phx_close",
+      payload: %{}
+    }
+
     encode_reply(socket, message)
   end
 
diff --git a/lib/phoenix/socket/transport.ex b/lib/phoenix/socket/transport.ex
index d2e8595bb..277d9732b 100644
--- a/lib/phoenix/socket/transport.ex
+++ b/lib/phoenix/socket/transport.ex
@@ -136,10 +136,12 @@ defmodule Phoenix.Socket.Transport do
   Connects to the socket.
 
   The transport passes a map of metadata and the socket
-  returns `{:ok, state}`. `{:error, reason}` or `:error`. 
-  The state must be stored by the transport and returned 
-  in all future operations. `{:error, reason}` can only 
-  be used with websockets.
+  returns `{:ok, state}`, `{:error, reason}` or `:error`.
+  The state must be stored by the transport and returned
+  in all future operations. When `{:error, reason}` is
+  returned, some transports - such as WebSockets - allow
+  customizing the response based on `reason` via a custom
+  `:error_handler`.
 
   This function is used for authorization purposes and it
   may be invoked outside of the process that effectively
@@ -494,7 +496,7 @@ defmodule Phoenix.Socket.Transport do
          conn = put_in(conn.secret_key_base, endpoint.config(:secret_key_base)),
          {_, session} <- store.get(conn, cookie, init),
          csrf_state when is_binary(csrf_state) <-
-          Plug.CSRFProtection.dump_state_from_session(session[csrf_token_key]),
+           Plug.CSRFProtection.dump_state_from_session(session[csrf_token_key]),
          true <- Plug.CSRFProtection.valid_state_and_csrf_token?(csrf_state, csrf_token) do
       session
     else
diff --git a/lib/phoenix/transports/long_poll_server.ex b/lib/phoenix/transports/long_poll_server.ex
index fd0fb8740..092feb798 100644
--- a/lib/phoenix/transports/long_poll_server.ex
+++ b/lib/phoenix/transports/long_poll_server.ex
@@ -16,6 +16,7 @@ defmodule Phoenix.Transports.LongPoll.Server do
       params: params,
       connect_info: connect_info
     }
+
     window_ms = Keyword.fetch!(options, :window_ms)
 
     case handler.connect(config) do
@@ -75,6 +76,7 @@ defmodule Phoenix.Transports.LongPoll.Server do
     case state.buffer do
       [] ->
         {:noreply, %{state | client_ref: {client_ref, ref}, last_client_poll: now_ms()}}
+
       buffer ->
         broadcast_from!(state, client_ref, {:messages, Enum.reverse(buffer), ref})
         {:noreply, %{state | client_ref: nil, last_client_poll: now_ms(), buffer: []}}
@@ -116,12 +118,16 @@ defmodule Phoenix.Transports.LongPoll.Server do
 
   defp broadcast_from!(state, client_ref, msg) when is_binary(client_ref),
     do: PubSub.broadcast_from!(state.pubsub_server, self(), client_ref, msg)
+
   defp broadcast_from!(_state, client_ref, msg) when is_pid(client_ref),
     do: send(client_ref, msg)
 
   defp publish_reply(state, reply) when is_map(reply) do
-    IO.warn "Returning a map from the LongPolling serializer is deprecated. " <>
-            "Please return JSON encoded data instead (see Phoenix.Socket.Serializer)"
+    IO.warn(
+      "Returning a map from the LongPolling serializer is deprecated. " <>
+        "Please return JSON encoded data instead (see Phoenix.Socket.Serializer)"
+    )
+
     publish_reply(state, Phoenix.json_library().encode_to_iodata!(reply))
   end
 


Test patch:
diff --git a/test/phoenix/socket/socket_test.exs b/test/phoenix/socket/socket_test.exs
index fb72c5dd2..d089a8de2 100644
--- a/test/phoenix/socket/socket_test.exs
+++ b/test/phoenix/socket/socket_test.exs
@@ -81,4 +81,49 @@ defmodule Phoenix.SocketTest do
       assert socket.assigns[:abc] == :def
     end
   end
+
+  describe "drainer_spec/1" do
+    defmodule Endpoint do
+      use Phoenix.Endpoint, otp_app: :phoenix
+    end
+
+    defmodule DrainerSpecSocket do
+      use Phoenix.Socket
+
+      def id(_), do: "123"
+
+      def dynamic_drainer_config do
+        [
+          batch_size: 200,
+          batch_interval: 2_000,
+          shutdown: 20_000
+        ]
+      end
+    end
+
+    test "loads static drainer config" do
+      drainer_spec = [
+        batch_size: 100,
+        batch_interval: 1_000,
+        shutdown: 10_000
+      ]
+
+      assert DrainerSpecSocket.drainer_spec(drainer: drainer_spec, endpoint: Endpoint) ==
+               {Phoenix.Socket.PoolDrainer, {Endpoint, DrainerSpecSocket, drainer_spec}}
+    end
+
+    test "loads dynamic drainer config" do
+      drainer_spec = DrainerSpecSocket.dynamic_drainer_config()
+
+      assert DrainerSpecSocket.drainer_spec(
+               drainer: {DrainerSpecSocket, :dynamic_drainer_config, []},
+               endpoint: Endpoint
+             ) ==
+               {Phoenix.Socket.PoolDrainer, {Endpoint, DrainerSpecSocket, drainer_spec}}
+    end
+
+    test "returns ignore if drainer is set to false" do
+      assert DrainerSpecSocket.drainer_spec(drainer: false, endpoint: Endpoint) == :ignore
+    end
+  end
 end