Repository: phoenixframework/phoenix
Pull request #4231 (2021-03-02 18:37:04)

Copy edit - How-to's: Custom error pages


Patch:
diff --git a/README.md b/README.md
index a26c22e2a..a059d387e 100644
--- a/README.md
+++ b/README.md
@@ -25,10 +25,10 @@ You can create a new project using the latest Phoenix source installer (the `phx
 
 1. Remove any previously installed `phx_new` archives so that Mix will pick up the local source code. This can be done with `mix archive.uninstall phx_new` or by simply deleting the file, which is usually in `~/.mix/archives/`.
 2. Copy this repo via `git clone https://github.com/phoenixframework/phoenix` or by downloading it
-3. Run the `phx.new` mix task from within the `installer` directory, for example:
+3. Run the `phx.new` Mix task from within the `installer` directory, for example:
 
 ```bash
-$ cd installer
+$ cd phoenix/installer
 $ mix phx.new dev_app --dev
 ```
 
diff --git a/RELEASE.md b/RELEASE.md
index babdd4ffe..1dc48d1ab 100644
--- a/RELEASE.md
+++ b/RELEASE.md
@@ -15,7 +15,5 @@
   * `CHANGELOG`
   * `mix.exs`
   * `installer/mix.exs`
-  * `installer/README.md`
   * `package.json`
   * `assets/package.json`
-  * `guides/introduction/installation.md`
diff --git a/guides/ecto.md b/guides/ecto.md
index 8bf3fc136..0346f7fc4 100644
--- a/guides/ecto.md
+++ b/guides/ecto.md
@@ -97,7 +97,7 @@ hello_dev=# \d users
 Table "public.users"
 Column         |            Type             | Modifiers
 ---------------+-----------------------------+----------------------------------------------------
-id             | integer                     | not null default nextval('users_id_seq'::regclass)
+id             | bigint                      | not null default nextval('users_id_seq'::regclass)
 name           | character varying(255)      |
 email          | character varying(255)      |
 bio            | character varying(255)      |
diff --git a/guides/howto/custom_error_pages.md b/guides/howto/custom_error_pages.md
index 9a935b1aa..65771a750 100644
--- a/guides/howto/custom_error_pages.md
+++ b/guides/howto/custom_error_pages.md
@@ -1,10 +1,10 @@
 # Custom Error Pages
 
-Phoenix has a view called the `ErrorView` which lives in `lib/hello_web/views/error_view.ex`. The purpose of the `ErrorView` is to handle errors in a general way, from one centralized location.
+Phoenix has a view called the `ErrorView` which lives in `lib/hello_web/views/error_view.ex`. The purpose of `ErrorView` is to handle errors in a general way, from one centralized location.
 
 ## The ErrorView
 
-For new applications, the ErrorView looks like this:
+For new applications, the `ErrorView` view looks like this:
 
 ```elixir
 defmodule HelloWeb.ErrorView do
@@ -49,7 +49,7 @@ def template_not_found(template, _assigns) do
 end
 ```
 
-Great, so we have this `template_not_found/2` function that takes a template and an `assigns` map, which we ignore. The `template_not_found/2` is invoked whenever a Phoenix.View attempts to render a template but no template is found.
+Great, so we have this `template_not_found/2` function that takes a template and an `assigns` map, which we ignore. The `template_not_found/2` is invoked whenever a `Phoenix.View` view attempts to render a template but no template is found.
 
 In other words, to provide custom error pages, we could simply define a proper `render/2` function clause in `HelloWeb.ErrorView`.
 
@@ -61,7 +61,7 @@ end
 
 But we can do even better.
 
-Phoenix generates an `ErrorView` for us, but it doesn't give us a `lib/hello_web/templates/error` directory. Let's create one now. Inside our new directory, let's add a template, `404.html.eex` and give it some markup – a mixture of our application layout and a new `div` with our message to the user.
+Phoenix generates an `ErrorView` for us, but it doesn't give us a `lib/hello_web/templates/error` directory. Let's create one now. Inside our new directory, let's add a template named`404.html.eex` and give it some markup – a mixture of our application layout and a new `<div>` with our message to the user.
 
 ```html
 <!DOCTYPE html>
@@ -98,14 +98,14 @@ Phoenix generates an `ErrorView` for us, but it doesn't give us a `lib/hello_web
 
 Now when we go back to [http://localhost:4000/such/a/wrong/path](http://localhost:4000/such/a/wrong/path), we should see a much nicer error page. It is worth noting that we did not render our `404.html.eex` template through our application layout, even though we want our error page to have the look and feel of the rest of our site. This is to avoid circular errors. For example, what happens if our application failed due to an error in the layout? Attempting to render the layout again will just trigger another error. So ideally we want to minimize the amount of dependencies and logic in our error templates, sharing only what is necessary.
 
-## Custom Exceptions
+## Custom exceptions
 
-Elixir provides a macro called `defexception` for defining custom exceptions. Exceptions are represented as structs, and structs need to be defined inside of modules.
+Elixir provides a macro called `defexception/1` for defining custom exceptions. Exceptions are represented as structs, and structs need to be defined inside of modules.
 
-In order to create a custom exception, we need to define a new module. Conventionally this will have "Error" in the name. Inside of that module, we need to define a new exception with `defexception`.
+In order to create a custom exception, we need to define a new module. Conventionally this will have "Error" in the name. Inside of that module, we need to define a new exception with `defexception/1`, the file `lib/hello_web.ex` seems like a good place for it.
 
 ```elixir
-defmodule MyApp.SomethingNotFoundError do
+defmodule HelloWeb.SomethingNotFoundError do
   defexception [:message]
 end
 ```
@@ -113,15 +113,15 @@ end
 You can raise your new exception like this:
 
 ```elixir
-raise MyApp.SomethingNotFoundError, "oops"
+raise HelloWeb.SomethingNotFoundError, "oops"
 ```
 
 By default, Plug and Phoenix will treat all exceptions as 500 errors. However, Plug provides a protocol called `Plug.Exception` where we are able to customize the status and add actions that exception structs can return on the debug error page.
 
-If we wanted to supply a status of 404 for an `MyApp.SomethingNotFoundError`, we could do it by defining an implementation for the `Plug.Exception` protocol like this:
+If we wanted to supply a status of 404 for an `HelloWeb.SomethingNotFoundError` error, we could do it by defining an implementation for the `Plug.Exception` protocol like this, in `lib/hello_web.ex`:
 
 ```elixir
-defimpl Plug.Exception, for: MyApp.SomethingNotFoundError do
+defimpl Plug.Exception, for: HelloWeb.SomethingNotFoundError do
   def status(_exception), do: 404
   def actions(_exception), do: []
 end
@@ -130,27 +130,40 @@ end
 Alternatively, you could define a `plug_status` field directly in the exception struct:
 
 ```elixir
-defmodule MyApp.SomethingNotFoundError do
+defmodule HelloWeb.SomethingNotFoundError do
   defexception [:message, plug_status: 404]
 end
 ```
 
-However, implementing the `Plug.Exception` protocol by hand can be convenient in certain occasions, such as when providing Actionable Errors.
+However, implementing the `Plug.Exception` protocol by hand can be convenient in certain occasions, such as when providing actionable errors.
 
-## Actionable Errors
+## Actionable errors
 
 Exception actions are functions that can be triggered by the error page, and they're basically a list of maps defining a `label` and a `handler` to be executed.
 
-They are rendered in the error page as a collection of buttons and follow the format of: `[%{label: String.t(), handler: {module(), function :: atom(), args :: []}}]`.
+They are rendered in the error page as a collection of buttons and follow the format of:
+```elixir
+[
+  %{
+    label: String.t(),
+    handler: {module(), function :: atom(), args :: []}
+  }
+]
+```
 
-If we wanted to return some actions for an `MyApp.SomethingNotFoundError` we would implement `Plug.Exception` like this:
+If we wanted to return some actions for an `HelloWeb.SomethingNotFoundError` we would implement `Plug.Exception` like this:
 
 ```elixir
-defimpl Plug.Exception, for: MyApp.SomethingNotFoundError do
+defimpl Plug.Exception, for: HelloWeb.SomethingNotFoundError do
   def status(_exception), do: 404
-  def actions(_exception), do: [%{
-      label: "Run seeds",
-      handler: {Code, :eval_file, "priv/repo/seeds.exs"}
-    }]
+
+  def actions(_exception) do
+    [
+      %{
+        label: "Run seeds",
+        handler: {Code, :eval_file, "priv/repo/seeds.exs"}
+      }
+    ]
+  end
 end
 ```
diff --git a/guides/introduction/installation.md b/guides/introduction/installation.md
index 1853b0072..5333ccefe 100644
--- a/guides/introduction/installation.md
+++ b/guides/introduction/installation.md
@@ -43,7 +43,7 @@ Elixir 1.6.3
 Once we have Elixir and Erlang, we are ready to install the Phoenix application generator:
 
 ```console
-$ mix archive.install hex phx_new 1.5.0
+$ mix archive.install hex phx_new
 ```
 
 The `phx.new` generator is now available to generate new applications in the next guide, called [Up and Running](up_and_running.html). The flags mentioned below are command line options to the generator; see all available options by calling `mix help phx.new`.
diff --git a/guides/plug.md b/guides/plug.md
index abcab2003..0403c2360 100644
--- a/guides/plug.md
+++ b/guides/plug.md
@@ -113,7 +113,7 @@ defmodule HelloWeb.Router do
   ...
 ```
 
-In the [`init/1`] callback, we pass a default locale to use if none is present in the params. We also use pattern matching to define multiple [`call/2`] function heads to validate the locale in the params, and fall back to `"en"` if there is no match.
+In the [`init/1`] callback, we pass a default locale to use if none is present in the params. We also use pattern matching to define multiple [`call/2`] function heads to validate the locale in the params, and fall back to `"en"` if there is no match. The [`assign/3`] is a part of the `Plug.Conn` module and it's how we store values in the `conn` data structure.
 
 To see the assign in action, go to the layout in `lib/hello_web/templates/layout/app.html.eex` and add the following close to the main container:
 
diff --git a/installer/README.md b/installer/README.md
index f155fda50..0f3f53819 100644
--- a/installer/README.md
+++ b/installer/README.md
@@ -2,9 +2,9 @@
 
 Provides `phx.new` installer as an archive.
 
-To install from hex, run:
+To install from Hex, run:
 
-    $ mix archive.install hex phx_new 1.5.0
+    $ mix archive.install hex phx_new
 
 To build and install it locally,
 ensure any previous archive versions are removed:
diff --git a/installer/mix.exs b/installer/mix.exs
index 2a0d1e7b3..ce748dcb4 100644
--- a/installer/mix.exs
+++ b/installer/mix.exs
@@ -1,4 +1,4 @@
-for path <- :code.get_path,
+for path <- :code.get_path(),
     Regex.match?(~r/phx_new\-\d+\.\d+\.\d\/ebin$/, List.to_string(path)) do
   Code.delete_path(path)
 end
@@ -6,7 +6,7 @@ end
 defmodule Phx.New.MixProject do
   use Mix.Project
 
-  @version "1.5.0"
+  @version "1.6.0-dev"
   @github_path "phoenixframework/phoenix"
   @url "https://github.com/#{@github_path}"
 
diff --git a/lib/phoenix/config.ex b/lib/phoenix/config.ex
index 685755b82..6927f91bb 100644
--- a/lib/phoenix/config.ex
+++ b/lib/phoenix/config.ex
@@ -46,7 +46,18 @@ defmodule Phoenix.Config do
   """
   @spec cache(module, term, (module -> {:cache | :nocache, term})) :: term
   def cache(module, key, fun) do
-    case :ets.lookup(module, key) do
+    try do
+      :ets.lookup(module, key)
+    rescue
+      e ->
+        case :ets.info(module) do
+          :undefined ->
+            raise "could not find ets table for endpoint #{inspect(module)}. Make sure your endpoint is started and note you cannot access endpoint functions at compile-time."
+
+          _ ->
+            reraise e, __STACKTRACE__
+        end
+    else
       [{^key, :cache, val}] ->
         val
 
diff --git a/lib/phoenix/endpoint.ex b/lib/phoenix/endpoint.ex
index ec1158f63..49ee33ffc 100644
--- a/lib/phoenix/endpoint.ex
+++ b/lib/phoenix/endpoint.ex
@@ -218,7 +218,7 @@ defmodule Phoenix.Endpoint do
       `c:broadcast_from/4`, `c:broadcast_from!/4`, `c:local_broadcast/3`,
       and `c:local_broadcast_from/4`
 
-    * for configuration: `c:start_link/0`, `c:config/2`, and `c:config_change/2`
+    * for configuration: `c:start_link/1`, `c:config/2`, and `c:config_change/2`
 
     * as required by the `Plug` behaviour: `c:Plug.init/1` and `c:Plug.call/2`
 
diff --git a/lib/phoenix/router.ex b/lib/phoenix/router.ex
index 2ec096cfb..2402c49b5 100644
--- a/lib/phoenix/router.ex
+++ b/lib/phoenix/router.ex
@@ -346,6 +346,9 @@ defmodule Phoenix.Router do
 
     case pipeline.(conn) do
       %Plug.Conn{halted: true} = halted_conn ->
+        measurements = %{duration: System.monotonic_time() - start}
+        metadata = %{metadata | conn: halted_conn}
+        :telemetry.execute([:phoenix, :router_dispatch, :stop], measurements, metadata)
         halted_conn
       %Plug.Conn{} = piped_conn ->
         try do
@@ -408,12 +411,6 @@ defmodule Phoenix.Router do
     end
   end
 
-  @anno (if :erlang.system_info(:otp_release) >= '19' do
-    [generated: true]
-  else
-    [line: -1]
-  end)
-
   @doc false
   defmacro __before_compile__(env) do
     routes = env.module |> Module.get_attribute(:phoenix_routes) |> Enum.reverse
@@ -431,9 +428,8 @@ defmodule Phoenix.Router do
         end
       end
 
-    # @anno is used here to avoid warnings if forwarding to root path
     match_404 =
-      quote @anno do
+      quote [generated: true] do
         def __match_route__(_method, _path_info, _host) do
           :error
         end
@@ -628,6 +624,14 @@ defmodule Phoenix.Router do
   are appended to the ones previously given.
   """
   defmacro pipeline(plug, do: block) do
+    with true <- is_atom(plug),
+         imports = __CALLER__.macros ++ __CALLER__.functions,
+         {mod, _} <- Enum.find(imports, fn {_, imports} -> {plug, 2} in imports end) do
+      raise ArgumentError,
+            "cannot define pipeline named #{inspect(plug)} " <>
+              "because there is an import from #{inspect(mod)} with the same name"
+    end
+
     block =
       quote do
         plug = unquote(plug)
diff --git a/lib/phoenix/router/helpers.ex b/lib/phoenix/router/helpers.ex
index d0cc374a0..1fdd30d02 100644
--- a/lib/phoenix/router/helpers.ex
+++ b/lib/phoenix/router/helpers.ex
@@ -5,12 +5,6 @@ defmodule Phoenix.Router.Helpers do
   alias Phoenix.Router.Route
   alias Plug.Conn
 
-  @anno (if :erlang.system_info(:otp_release) >= '19' do
-    [generated: true, unquote: false]
-  else
-    [line: -1, unquote: false]
-  end)
-
   @doc """
   Callback invoked by the url generated in each helper module.
   """
@@ -119,7 +113,7 @@ defmodule Phoenix.Router.Helpers do
 
     catch_all = Enum.map(groups, &defhelper_catch_all/1)
 
-    defhelper = quote @anno do
+    defhelper = quote [generated: true, unquote: false] do
       defhelper = fn helper, vars, opts, bins, segs, trailing_slash? ->
         def unquote(:"#{helper}_path")(conn_or_endpoint, unquote(Macro.escape(opts)), unquote_splicing(vars)) do
           unquote(:"#{helper}_path")(conn_or_endpoint, unquote(Macro.escape(opts)), unquote_splicing(vars), [])
@@ -142,7 +136,7 @@ defmodule Phoenix.Router.Helpers do
       end
     end
 
-    defcatch_all = quote @anno do
+    defcatch_all = quote [generated: true, unquote: false] do
       defcatch_all = fn helper, lengths, routes ->
 	      for length <- lengths do
 	        binding = List.duplicate({:_, [], nil}, length)
diff --git a/priv/templates/phx.gen.html/show.html.eex b/priv/templates/phx.gen.html/show.html.eex
index c19a2c955..469fc4a13 100644
--- a/priv/templates/phx.gen.html/show.html.eex
+++ b/priv/templates/phx.gen.html/show.html.eex
@@ -9,5 +9,5 @@
 <% end %>
 </ul>
 
-<span><%%= link "Edit", to: Routes.<%= schema.route_helper %>_path(@conn, :edit, @<%= schema.singular %>) %></span>
+<span><%%= link "Edit", to: Routes.<%= schema.route_helper %>_path(@conn, :edit, @<%= schema.singular %>) %></span> |
 <span><%%= link "Back", to: Routes.<%= schema.route_helper %>_path(@conn, :index) %></span>
diff --git a/priv/templates/phx.gen.live/show.html.leex b/priv/templates/phx.gen.live/show.html.leex
index 236f80e45..9de080b77 100644
--- a/priv/templates/phx.gen.live/show.html.leex
+++ b/priv/templates/phx.gen.live/show.html.leex
@@ -18,5 +18,5 @@
 <% end %>
 </ul>
 
-<span><%%= live_patch "Edit", to: Routes.<%= schema.route_helper %>_show_path(@socket, :edit, @<%= schema.singular %>), class: "button" %></span>
+<span><%%= live_patch "Edit", to: Routes.<%= schema.route_helper %>_show_path(@socket, :edit, @<%= schema.singular %>), class: "button" %></span> |
 <span><%%= live_redirect "Back", to: Routes.<%= schema.route_helper %>_index_path(@socket, :index) %></span>


Test patch:
diff --git a/test/mix/tasks/phx.gen.cert_test.exs b/test/mix/tasks/phx.gen.cert_test.exs
index 188b2aa63..7e8c66090 100644
--- a/test/mix/tasks/phx.gen.cert_test.exs
+++ b/test/mix/tasks/phx.gen.cert_test.exs
@@ -3,67 +3,62 @@ Code.require_file("../../../installer/test/mix_helper.exs", __DIR__)
 defmodule Mix.Tasks.Phx.CertTest do
   use ExUnit.Case
 
-  @otp_release :erlang.system_info(:otp_release) |> List.to_integer()
+  import MixHelper
+  alias Mix.Tasks.Phx.Gen
 
-  if @otp_release >= 20 do
-    import MixHelper
-    alias Mix.Tasks.Phx.Gen
+  @timeout 5_000
 
-    @timeout 5_000
+  test "write certificate and key files" do
+    in_tmp("mix_phx_gen_cert", fn ->
+      Gen.Cert.run([])
 
-    # RSA key generation requires OTP 20 or later
-    test "write certificate and key files" do
-      in_tmp("mix_phx_gen_cert", fn ->
-        Gen.Cert.run([])
+      assert_received {:mix_shell, :info, ["* creating priv/cert/selfsigned_key.pem"]}
+      assert_received {:mix_shell, :info, ["* creating priv/cert/selfsigned.pem"]}
 
-        assert_received {:mix_shell, :info, ["* creating priv/cert/selfsigned_key.pem"]}
-        assert_received {:mix_shell, :info, ["* creating priv/cert/selfsigned.pem"]}
-
-        assert_file("priv/cert/selfsigned_key.pem", "-----BEGIN RSA PRIVATE KEY-----")
-        assert_file("priv/cert/selfsigned.pem", "-----BEGIN CERTIFICATE-----")
-      end)
-    end
+      assert_file("priv/cert/selfsigned_key.pem", "-----BEGIN RSA PRIVATE KEY-----")
+      assert_file("priv/cert/selfsigned.pem", "-----BEGIN CERTIFICATE-----")
+    end)
+  end
 
-    test "write certificate and key with custom filename" do
-      in_tmp("mix_phx_gen_cert", fn ->
-        Gen.Cert.run(["-o", "priv/cert/localhost"])
+  test "write certificate and key with custom filename" do
+    in_tmp("mix_phx_gen_cert", fn ->
+      Gen.Cert.run(["-o", "priv/cert/localhost"])
 
-        assert_received {:mix_shell, :info, ["* creating priv/cert/localhost_key.pem"]}
-        assert_received {:mix_shell, :info, ["* creating priv/cert/localhost.pem"]}
+      assert_received {:mix_shell, :info, ["* creating priv/cert/localhost_key.pem"]}
+      assert_received {:mix_shell, :info, ["* creating priv/cert/localhost.pem"]}
 
-        assert_file("priv/cert/localhost_key.pem", "-----BEGIN RSA PRIVATE KEY-----")
-        assert_file("priv/cert/localhost.pem", "-----BEGIN CERTIFICATE-----")
-      end)
-    end
-
-    test "TLS connection with generated certificate and key" do
-      Application.ensure_all_started(:ssl)
+      assert_file("priv/cert/localhost_key.pem", "-----BEGIN RSA PRIVATE KEY-----")
+      assert_file("priv/cert/localhost.pem", "-----BEGIN CERTIFICATE-----")
+    end)
+  end
 
-      in_tmp("mix_phx_gen_cert", fn ->
-        Gen.Cert.run([])
+  test "TLS connection with generated certificate and key" do
+    Application.ensure_all_started(:ssl)
 
-        assert {:ok, server} =
-                 :ssl.listen(
-                   0,
-                   certfile: "priv/cert/selfsigned.pem",
-                   keyfile: "priv/cert/selfsigned_key.pem"
-                 )
+    in_tmp("mix_phx_gen_cert", fn ->
+      Gen.Cert.run([])
 
-        {:ok, {_, port}} = :ssl.sockname(server)
+      assert {:ok, server} =
+               :ssl.listen(
+                 0,
+                 certfile: "priv/cert/selfsigned.pem",
+                 keyfile: "priv/cert/selfsigned_key.pem"
+               )
 
-        spawn_link(fn ->
-          with {:ok, conn} <- :ssl.transport_accept(server, @timeout),
-               :ok <- :ssl.handshake(conn, @timeout) do
-            :ssl.close(conn)
-          end
-        end)
+      {:ok, {_, port}} = :ssl.sockname(server)
 
-        # We don't actually verify the server cert contents, we just check that
-        # the client and server are able to complete the TLS handshake
-        assert {:ok, client} = :ssl.connect('localhost', port, [], @timeout)
-        :ssl.close(client)
-        :ssl.close(server)
+      spawn_link(fn ->
+        with {:ok, conn} <- :ssl.transport_accept(server, @timeout),
+             :ok <- :ssl.handshake(conn, @timeout) do
+          :ssl.close(conn)
+        end
       end)
-    end
+
+      # We don't actually verify the server cert contents, we just check that
+      # the client and server are able to complete the TLS handshake
+      assert {:ok, client} = :ssl.connect('localhost', port, [], @timeout)
+      :ssl.close(client)
+      :ssl.close(server)
+    end)
   end
 end
diff --git a/test/phoenix/config_test.exs b/test/phoenix/config_test.exs
index ad69c5d5a..6c0156d49 100644
--- a/test/phoenix/config_test.exs
+++ b/test/phoenix/config_test.exs
@@ -22,6 +22,12 @@ defmodule Phoenix.ConfigTest do
     assert :ets.lookup(meta.test, :custom)  == [custom: true]
   end
 
+  test "raises with warning about compile time when table not started" do
+    assert_raise RuntimeError,
+                 "could not find ets table for endpoint Fooz. Make sure your endpoint is started and note you cannot access endpoint functions at compile-time.",
+                 fn -> cache(Fooz, :foo, fn _ -> {:nocache, :bar} end) end
+  end
+
   test "can change configuration", meta do
     {:ok, pid} = start_link({meta.test, @all, @defaults, []})
     ref = Process.monitor(pid)
diff --git a/test/phoenix/router/pipeline_test.exs b/test/phoenix/router/pipeline_test.exs
index 053838686..2838c2b2c 100644
--- a/test/phoenix/router/pipeline_test.exs
+++ b/test/phoenix/router/pipeline_test.exs
@@ -158,4 +158,19 @@ defmodule Phoenix.Router.PipelineTest do
       end
     end
   end
+
+  test "pipeline raises on conflict" do
+    assert_raise ArgumentError, ~r{there is an import from Kernel with the same nam}, fn ->
+      defmodule ConflictingPipeline do
+        use Phoenix.Router, otp_app: :phoenix
+        pipeline :raise do
+          plug Plug.Head
+        end
+        scope "/" do
+          pipe_through [:raise]
+          get "/", UnknownController, :index
+        end
+      end
+    end
+  end
 end
diff --git a/test/phoenix/router/routing_test.exs b/test/phoenix/router/routing_test.exs
index 888a50571..8dc01c095 100644
--- a/test/phoenix/router/routing_test.exs
+++ b/test/phoenix/router/routing_test.exs
@@ -23,6 +23,11 @@ defmodule Phoenix.Router.RoutingTest do
     def any(conn, _params), do: text(conn, "users any")
     def raise(_conn, _params), do: raise("boom")
     def exit(_conn, _params), do: exit(:boom)
+    def halt(conn, _params) do
+      conn
+      |> send_resp(401, "Unauthorized")
+      |> halt()
+    end
   end
 
   defmodule LogLevel do
@@ -44,6 +49,7 @@ defmodule Phoenix.Router.RoutingTest do
     get "/backups/*path", UserController, :image
     get "/static/images/icons/*image", UserController, :image
     get "/exit", UserController, :exit
+    get "/halt-controller", UserController, :halt
 
     trace("/trace", UserController, :trace)
     options "/options", UserController, :options
@@ -60,9 +66,18 @@ defmodule Phoenix.Router.RoutingTest do
     get "/no_log", SomePlug, [], log: false
     get "/fun_log", SomePlug, [], log: {LogLevel, :log_level, []}
     get "/users/:user_id/files/:id", UserController, :image
+
+    scope "/halt-plug" do
+      pipe_through :halt
+      get "/", UserController, :raise
+    end
+
     get "/*path", UserController, :not_found
 
     defp noop(conn, _), do: conn
+    defp halt(conn, _) do
+      conn |> Plug.Conn.send_resp(401, "Unauthorized") |> halt()
+    end
   end
 
   setup do
@@ -285,6 +300,38 @@ defmodule Phoenix.Router.RoutingTest do
                        {_, %{route: "/users/:id"}, _}}
     end
 
+    test "phoenix.router_dispatch.start and .stop are emitted when conn halted in router" do
+      conn = call(Router, :get, "/halt-plug")
+
+      assert conn.halted
+      assert conn.status == 401
+
+      assert_received {:telemetry_event, @router_start_event,
+                       {_, %{route: "/halt-plug"}, _}}
+
+      assert_received {:telemetry_event, @router_stop_event,
+                       {_, %{route: "/halt-plug"}, _}}
+
+      refute_received {:telemetry_event, @router_exception_event,
+                       {_, %{route: "/halt-plug"}, _}}
+    end
+
+    test "phoenix.router_dispatch.start and .stop are emitted when conn is halted in controller" do
+      conn = call(Router, :get, "/halt-controller")
+
+      assert conn.halted
+      assert conn.status == 401
+
+      assert_received {:telemetry_event, @router_start_event,
+                       {_, %{route: "/halt-controller"}, _}}
+
+      assert_received {:telemetry_event, @router_stop_event,
+                       {_, %{route: "/halt-controller"}, _}}
+
+      refute_received {:telemetry_event, @router_exception_event,
+                       {_, %{route: "/halt-controller"}, _}}
+    end
+
     test "phoenix.router_dispatch.start and .exception are emitted on crash" do
       assert_raise Plug.Conn.WrapperError, ~r/UndefinedFunctionError/, fn ->
         call(Router, :get, "route_that_crashes")