From 759064c541e1b9c528d1d335873cd30302ce1745 Mon Sep 17 00:00:00 2001 From: Jakub Hajto Date: Fri, 10 Jul 2026 15:34:50 +0200 Subject: [PATCH 1/3] feat: make HTTP backend pluggable and default to Bandit Introduce a `Bypass.Adapter` behaviour so the underlying HTTP server can be switched via config, and make Bandit the default backend. Cowboy remains available as an opt-in adapter backed by the now-optional `:plug_cowboy` dependency. - Add `Bypass.Adapter` behaviour (`start/4`, `stop/1`, opaque handle). - Add `Bypass.Adapter.Bandit` (default) and `Bypass.Adapter.Cowboy` (opt-in). - Select the adapter with `config :bypass, adapter: `. - Rework `Bypass.Instance` to store an opaque server handle + adapter instead of a raw socket/ref; grab the free port with `:gen_tcp` and drop the direct `:ranch` dependency. - Move `so_reuseport/0` into `Bypass.Utils`. - Bandit uses the `reuseport` inet option (OTP 25+) and drops in-flight connections on shutdown to avoid deadlocking a plug that calls `Bypass.down/1` on itself. - Bump minimum Elixir to 1.14 / OTP 25; make `:plug_cowboy` optional. - Parametrize the test suite via `BYPASS_ADAPTER` and run both adapters in CI. - Relax teardown assertions that over-specified transport-level behaviour which legitimately differs between Bandit and modern Cowboy. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/elixir.yml | 80 ++++++++----------------- CHANGELOG.md | 12 ++++ README.md | 19 +++++- lib/bypass/adapter.ex | 38 ++++++++++++ lib/bypass/adapter/bandit.ex | 42 +++++++++++++ lib/bypass/adapter/cowboy.ex | 57 ++++++++++++++++++ lib/bypass/instance.ex | 111 ++++++++++++----------------------- lib/bypass/utils.ex | 31 ++++++++++ mix.exs | 8 +-- mix.lock | 44 +++++++------- test/bypass_test.exs | 27 +++++++-- test/test_helper.exs | 7 +++ 12 files changed, 319 insertions(+), 157 deletions(-) create mode 100644 lib/bypass/adapter.ex create mode 100644 lib/bypass/adapter/bandit.ex create mode 100644 lib/bypass/adapter/cowboy.ex diff --git a/.github/workflows/elixir.yml b/.github/workflows/elixir.yml index 5bd47c3..1886901 100644 --- a/.github/workflows/elixir.yml +++ b/.github/workflows/elixir.yml @@ -9,56 +9,20 @@ on: permissions: contents: read - - - jobs: build: - name: Build and test - Erlang ${{matrix.otp}} / Elixir ${{matrix.elixir}} + name: Test - ${{matrix.adapter}} - Erlang ${{matrix.otp}} / Elixir ${{matrix.elixir}} runs-on: ${{matrix.os}} strategy: + fail-fast: false matrix: + # Bandit is the default adapter and requires Elixir 1.14+ and OTP 25+ + # (the `reuseport` inet option). The Cowboy adapter is exercised via the + # optional :plug_cowboy dependency and the BYPASS_ADAPTER env var. # https://hexdocs.pm/elixir/compatibility-and-deprecations.html#compatibility-between-elixir-and-erlang-otp + adapter: ["bandit", "cowboy"] include: - # Elixir 1.12: 22-24 - - elixir: "1.12" - otp: "22.3" - os: "ubuntu-20.04" - - - elixir: "1.12" - otp: "23.3" - os: "ubuntu-20.04" - - - elixir: "1.12" - otp: "24.3" - os: "ubuntu-22.04" - - # Elixir 1.13: 22-24 - - elixir: "1.13.4" - otp: "22.3" - os: "ubuntu-20.04" - - - elixir: "1.13.4" - otp: "23.3" - os: "ubuntu-20.04" - - - elixir: "1.13.4" - otp: "24.3" - os: "ubuntu-22.04" - - - elixir: "1.13.4" - otp: "25.3" - os: "ubuntu-22.04" - - # Elixir 1.14: 23-25 (and 26 from v1.14.5) - - elixir: "1.14" - otp: "23.3" - os: "ubuntu-20.04" - - - elixir: "1.14" - otp: "24.3" - os: "ubuntu-22.04" - + # Elixir 1.14: 25 - elixir: "1.14" otp: "25.3" os: "ubuntu-22.04" @@ -67,11 +31,7 @@ jobs: otp: "26.2" os: "ubuntu-22.04" - # Elixir 1.15: 24-26 - - elixir: "1.15" - otp: "24.3" - os: "ubuntu-22.04" - + # Elixir 1.15: 25-26 - elixir: "1.15" otp: "25.3" os: "ubuntu-22.04" @@ -80,28 +40,38 @@ jobs: otp: "26.2" os: "ubuntu-22.04" - # Elixir 1.16: 24-25 + # Elixir 1.16: 25-26 - elixir: "1.16" - otp: "24.3" + otp: "25.3" os: "ubuntu-22.04" - elixir: "1.16" - otp: "25.3" + otp: "26.2" os: "ubuntu-22.04" - - elixir: "1.16" + # Elixir 1.17: 25-27 + - elixir: "1.17" otp: "26.2" os: "ubuntu-22.04" + - elixir: "1.17" + otp: "27.2" + os: "ubuntu-24.04" + + # Elixir 1.18: 25-27 + - elixir: "1.18" + otp: "27.2" + os: "ubuntu-24.04" + steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Elixir uses: erlef/setup-beam@ae6e9db1bf49000a27750a9e283cf4069da9d171 with: otp-version: ${{matrix.otp}} elixir-version: ${{matrix.elixir}} - name: Restore dependencies cache - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: deps key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }} @@ -110,3 +80,5 @@ jobs: run: mix deps.get - name: Run tests run: mix test + env: + BYPASS_ADAPTER: ${{ matrix.adapter }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e3a216..1fd1550 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## Unreleased + +* Switch the default HTTP server backend from Cowboy to + [Bandit](https://hex.pm/packages/bandit). +* Introduce the `Bypass.Adapter` behaviour, making the backend pluggable via + `config :bypass, adapter: `. Ships `Bypass.Adapter.Bandit` (default) + and `Bypass.Adapter.Cowboy`. +* `:plug_cowboy` is now an optional dependency. To keep using Cowboy, add + `{:plug_cowboy, "~> 2.0"}` to your deps and set + `config :bypass, adapter: Bypass.Adapter.Cowboy`. +* Require at least Elixir 1.14 and OTP 25 (Bandit / the `reuseport` inet option). + ## v2.1.0 - 13 Nov 2020 * Support latest Cowboy. diff --git a/README.md b/README.md index 94d54e5..2589f96 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,24 @@ requests. This is most useful in tests, when you want to create a mock HTTP server and test how your HTTP client handles different types of responses from the server. -Bypass supports Elixir 1.10 and OTP 21 and up. It works with Cowboy 2. +Bypass supports Elixir 1.14 and OTP 25 and up. By default it runs on +[Bandit](https://hex.pm/packages/bandit); [Cowboy](https://hex.pm/packages/plug_cowboy) +is available as an opt-in adapter. + +### HTTP server backend + +Bypass runs on Bandit out of the box — no configuration required. To use Cowboy +instead, add the optional dependency and select the adapter: + +```elixir +# mix.exs +{:plug_cowboy, "~> 2.0", only: :test} +``` + +```elixir +# config/test.exs +config :bypass, adapter: Bypass.Adapter.Cowboy +``` ## Usage diff --git a/lib/bypass/adapter.ex b/lib/bypass/adapter.ex new file mode 100644 index 0000000..81a580e --- /dev/null +++ b/lib/bypass/adapter.ex @@ -0,0 +1,38 @@ +defmodule Bypass.Adapter do + @moduledoc """ + Behaviour for pluggable HTTP server backends. + + Bypass ships two adapters: + + * `Bypass.Adapter.Bandit` (default) + * `Bypass.Adapter.Cowboy` (requires the optional `:plug_cowboy` dependency) + + Select one globally via config: + + config :bypass, adapter: Bypass.Adapter.Cowboy + + An adapter owns the full lifecycle of the underlying HTTP server, including + any socket / `SO_REUSEPORT` handling, because that differs per backend. The + `t:handle/0` returned by `c:start/4` is opaque to Bypass and is only ever + passed back to `c:stop/1`. + """ + + @typedoc "Opaque, adapter-specific server handle stored by `Bypass.Instance`." + @type handle :: term + + @type opts :: [ + ref: reference, + listen_ip: :inet.ip_address(), + num_acceptors: pos_integer, + reuseport_raw: [term] + ] + + @callback start( + plug :: module, + plug_opts :: keyword, + port :: :inet.port_number(), + opts + ) :: {:ok, handle} | {:error, term} + + @callback stop(handle) :: :ok +end diff --git a/lib/bypass/adapter/bandit.ex b/lib/bypass/adapter/bandit.ex new file mode 100644 index 0000000..87124d1 --- /dev/null +++ b/lib/bypass/adapter/bandit.ex @@ -0,0 +1,42 @@ +defmodule Bypass.Adapter.Bandit do + @moduledoc """ + `Bypass.Adapter` implementation backed by [Bandit](https://hex.pm/packages/bandit). + + This is the default adapter. It requires no extra configuration; to switch + back to Cowboy see `Bypass.Adapter.Cowboy`. + """ + + @behaviour Bypass.Adapter + + @impl true + def start(plug, plug_opts, port, opts) do + listen_ip = Keyword.fetch!(opts, :listen_ip) + + Bandit.start_link( + plug: {plug, plug_opts}, + scheme: :http, + port: port, + thousand_island_options: [ + num_acceptors: Keyword.fetch!(opts, :num_acceptors), + # Drop in-flight connections immediately on shutdown instead of draining + # them. Bypass already waits (via callers_awaiting_down) for retained plug + # processes to finish before stopping, so by the time we tear the server + # down there is nothing to drain — and draining would deadlock a plug that + # calls Bypass.down/1 on itself while still in-flight. + shutdown_timeout: 0, + # Thousand Island opens its own listen socket, so (unlike the Cowboy + # adapter) we can't hand it a pre-opened one. Instead we ask the kernel + # for SO_REUSEPORT via the `reuseport` inet option so we can re-listen on + # the same port across down/up cycles without hitting :eaddrinuse. + # `reuseport` requires OTP 25+; Thousand Island rejects the raw setsockopt + # tuple form the Cowboy adapter uses, so this is the only option here. + transport_options: [ip: listen_ip, reuseport: true] + ] + ) + end + + @impl true + def stop(pid) when is_pid(pid) do + :ok = Supervisor.stop(pid, :normal, :infinity) + end +end diff --git a/lib/bypass/adapter/cowboy.ex b/lib/bypass/adapter/cowboy.ex new file mode 100644 index 0000000..e4cfa11 --- /dev/null +++ b/lib/bypass/adapter/cowboy.ex @@ -0,0 +1,57 @@ +defmodule Bypass.Adapter.Cowboy do + @moduledoc """ + `Bypass.Adapter` implementation backed by Cowboy via `Plug.Cowboy`. + + Requires the optional `:plug_cowboy` dependency. Add it to your deps and + select this adapter with: + + config :bypass, adapter: Bypass.Adapter.Cowboy + """ + + @behaviour Bypass.Adapter + + # `:plug_cowboy` (and its `:ranch` transitive dep) is optional. Consumers who + # do not depend on it should not get "undefined function" warnings for this + # module, which is only reachable when they explicitly select this adapter. + @compile {:no_warn_undefined, [Plug.Cowboy, :ranch_tcp]} + + @impl true + def start(plug, plug_opts, port, opts) do + Code.ensure_loaded?(Plug.Cowboy) || + raise """ + Bypass.Adapter.Cowboy requires the optional :plug_cowboy dependency, which \ + is not loaded. Add {:plug_cowboy, "~> 2.0"} to your deps, or use the default \ + Bypass.Adapter.Bandit adapter. + """ + + listen_ip = Keyword.fetch!(opts, :listen_ip) + reuseport_raw = Keyword.fetch!(opts, :reuseport_raw) + + {:ok, socket} = :ranch_tcp.listen(reuseport_raw ++ [ip: listen_ip, port: port]) + + cowboy_opts = [ + ref: Keyword.fetch!(opts, :ref), + port: port, + transport_options: [num_acceptors: Keyword.fetch!(opts, :num_acceptors), socket: socket] + ] + + {:ok, _pid} = Plug.Cowboy.http(plug, plug_opts, cowboy_opts) + {:ok, %{ref: cowboy_opts[:ref], socket: socket}} + end + + @impl true + def stop(%{ref: ref, socket: socket}) do + :ok = Plug.Cowboy.shutdown(ref) + + # `port_close` is synchronous, so after it has returned we _know_ that the socket has been + # closed. If we'd rely on ranch's supervisor shutting down the acceptor processes and thereby + # killing the socket we would run into race conditions where the socket port hasn't yet gotten + # the EXIT signal and would still be open, thereby breaking tests that rely on a closed socket. + case :erlang.port_info(socket, :name) do + :undefined -> :ok + _ -> :erlang.port_close(socket) + end + + :ok + end +end diff --git a/lib/bypass/instance.ex b/lib/bypass/instance.ex index f8ae2b1..3fd01de 100644 --- a/lib/bypass/instance.ex +++ b/lib/bypass/instance.ex @@ -24,20 +24,21 @@ defmodule Bypass.Instance do # GenServer callbacks def init([opts]) do + adapter = Application.get_env(:bypass, :adapter, Bypass.Adapter.Bandit) + # Get a free port from the OS - case :ranch_tcp.listen(so_reuseport() ++ [ip: listen_ip(), port: Keyword.get(opts, :port, 0)]) do + case :gen_tcp.listen(Keyword.get(opts, :port, 0), so_reuseport() ++ [ip: listen_ip()]) do {:ok, socket} -> {:ok, port} = :inet.port(socket) :erlang.port_close(socket) - ref = make_ref() - socket = do_up(port, ref) + server = do_up(port, adapter) state = %{ expectations: %{}, port: port, - ref: ref, - socket: socket, + adapter: adapter, + server: server, callers_awaiting_down: [], callers_awaiting_exit: [], pass: false, @@ -76,31 +77,31 @@ defmodule Bypass.Instance do {:reply, port, state} end - defp do_handle_call(:up, _from, %{port: port, ref: ref, socket: nil} = state) do - socket = do_up(port, ref) - {:reply, :ok, %{state | socket: socket}} + defp do_handle_call(:up, _from, %{port: port, adapter: adapter, server: nil} = state) do + server = do_up(port, adapter) + {:reply, :ok, %{state | server: server}} end defp do_handle_call(:up, _from, state) do {:reply, {:error, :already_up}, state} end - defp do_handle_call(:down, _from, %{socket: nil} = state) do + defp do_handle_call(:down, _from, %{server: nil} = state) do {:reply, {:error, :already_down}, state} end defp do_handle_call( :down, from, - %{socket: socket, ref: ref, callers_awaiting_down: callers_awaiting_down} = state + %{server: server, adapter: adapter, callers_awaiting_down: callers_awaiting_down} = state ) - when not is_nil(socket) do + when not is_nil(server) do if retained_plugs_count(state) > 0 do # wait for plugs to finish {:noreply, %{state | callers_awaiting_down: [from | callers_awaiting_down]}} else - do_down(ref, socket) - {:reply, :ok, %{state | socket: nil}} + do_down(server, adapter) + {:reply, :ok, %{state | server: nil}} end end @@ -209,12 +210,12 @@ defmodule Bypass.Instance do defp do_exit(state) do updated_state = case state do - %{socket: nil} -> + %{server: nil} -> state - %{socket: socket, ref: ref} -> - do_down(ref, socket) - %{state | socket: nil} + %{server: server, adapter: adapter} -> + do_down(server, adapter) + %{state | server: nil} end result = @@ -338,25 +339,22 @@ defmodule Bypass.Instance do defp match_route(_, _), do: {false, nil} - defp do_up(port, ref) do + defp do_up(port, adapter) do plug_opts = [bypass_instance: self()] - {:ok, socket} = :ranch_tcp.listen(so_reuseport() ++ [ip: listen_ip(), port: port]) - cowboy_opts = cowboy_opts(port, ref, socket) - {:ok, _pid} = Plug.Cowboy.http(Bypass.Plug, plug_opts, cowboy_opts) - socket - end - - defp do_down(ref, socket) do - :ok = Plug.Cowboy.shutdown(ref) - - # `port_close` is synchronous, so after it has returned we _know_ that the socket has been - # closed. If we'd rely on ranch's supervisor shutting down the acceptor processes and thereby - # killing the socket we would run into race conditions where the socket port hasn't yet gotten - # the EXIT signal and would still be open, thereby breaking tests that rely on a closed socket. - case :erlang.port_info(socket, :name) do - :undefined -> :ok - _ -> :erlang.port_close(socket) - end + + opts = [ + ref: make_ref(), + listen_ip: listen_ip(), + num_acceptors: 5, + reuseport_raw: so_reuseport() + ] + + {:ok, server} = adapter.start(Bypass.Plug, plug_opts, port, opts) + server + end + + defp do_down(server, adapter) do + :ok = adapter.stop(server) end defp retain_plug_process({method, path} = route, {caller_pid, _}, state) do @@ -388,16 +386,16 @@ defmodule Bypass.Instance do %{ callers_awaiting_down: down_callers, callers_awaiting_exit: exit_callers, - socket: socket, - ref: ref + server: server, + adapter: adapter } = state ) do if retained_plugs_count(state) == 0 do down_reset = if length(down_callers) > 0 do - do_down(ref, socket) + do_down(server, adapter) Enum.each(down_callers, &GenServer.reply(&1, :ok)) - %{state | socket: nil, callers_awaiting_down: []} + %{state | server: nil, callers_awaiting_down: []} end if length(exit_callers) > 0 do @@ -438,41 +436,6 @@ defmodule Bypass.Instance do new_route(fun, build_path_match(path) |> elem(1), expected) end - defp cowboy_opts(port, ref, socket) do - [ref: ref, port: port, transport_options: [num_acceptors: 5, socket: socket]] - end - - # Use raw socket options to set SO_REUSEPORT so we fix {:error, :eaddrinuse} - where the OS errors - # when we attempt to listen on the same port as before, since it's still considered in use. - # - # See https://lwn.net/Articles/542629/ for details on SO_REUSEPORT. - # - # See https://github.com/aetrion/erl-dns/blob/0c8d768/src/erldns_server_sup.erl#L81 for an - # Erlang library using this approach. - # - # We want to do this: - # - # int optval = 1; - # setsockopt(sfd, SOL_SOCKET, SO_REUSEPORT, &optval, sizeof(optval)); - # - # Use the following C program to find the values on each OS: - # - # #include - # #include - # - # int main() { - # printf("SOL_SOCKET: %d\n", SOL_SOCKET); - # printf("SO_REUSEPORT: %d\n", SO_REUSEPORT); - # return 0; - # } - defp so_reuseport() do - case :os.type() do - {:unix, :linux} -> [{:raw, 1, 15, <<1::32-native>>}] - {:unix, :darwin} -> [{:raw, 65_535, 512, <<1::32-native>>}] - _ -> [] - end - end - # This is used to override the default behaviour of ranch_tcp # and limit the range of interfaces it will listen on to just # the configured interface. Loopback is a default interface. diff --git a/lib/bypass/utils.ex b/lib/bypass/utils.ex index 50674b0..ce1bacc 100644 --- a/lib/bypass/utils.ex +++ b/lib/bypass/utils.ex @@ -13,4 +13,35 @@ defmodule Bypass.Utils do end end end + + # Use raw socket options to set SO_REUSEPORT so we fix {:error, :eaddrinuse} - where the OS errors + # when we attempt to listen on the same port as before, since it's still considered in use. + # + # See https://lwn.net/Articles/542629/ for details on SO_REUSEPORT. + # + # See https://github.com/aetrion/erl-dns/blob/0c8d768/src/erldns_server_sup.erl#L81 for an + # Erlang library using this approach. + # + # We want to do this: + # + # int optval = 1; + # setsockopt(sfd, SOL_SOCKET, SO_REUSEPORT, &optval, sizeof(optval)); + # + # Use the following C program to find the values on each OS: + # + # #include + # #include + # + # int main() { + # printf("SOL_SOCKET: %d\n", SOL_SOCKET); + # printf("SO_REUSEPORT: %d\n", SO_REUSEPORT); + # return 0; + # } + def so_reuseport() do + case :os.type() do + {:unix, :linux} -> [{:raw, 1, 15, <<1::32-native>>}] + {:unix, :darwin} -> [{:raw, 65_535, 512, <<1::32-native>>}] + _ -> [] + end + end end diff --git a/mix.exs b/mix.exs index 9dd16da..7387123 100644 --- a/mix.exs +++ b/mix.exs @@ -8,7 +8,7 @@ defmodule Bypass.Mixfile do [ app: :bypass, version: @version, - elixir: "~> 1.7", + elixir: "~> 1.14", description: description(), package: package(), deps: deps(), @@ -29,9 +29,9 @@ defmodule Bypass.Mixfile do defp deps do [ - {:plug_cowboy, "~> 2.0"}, - {:plug, "~> 1.7"}, - {:ranch, "~> 1.7"}, + {:bandit, "~> 1.5"}, + {:plug_cowboy, "~> 2.0", optional: true}, + {:plug, "~> 1.14"}, {:ex_doc, "> 0.0.0", only: :dev}, {:espec, "~> 1.6", only: [:dev, :test]}, {:mint, "~> 1.1", only: :test}, diff --git a/mix.lock b/mix.lock index 85c2cd5..4803d6a 100644 --- a/mix.lock +++ b/mix.lock @@ -1,22 +1,26 @@ %{ - "cowboy": {:hex, :cowboy, "2.6.3", "99aa50e94e685557cad82e704457336a453d4abcb77839ad22dbe71f311fcc06", [:rebar3], [{:cowlib, "~> 2.7.3", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.7.1", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "e5580029080f3f1ad17436fb97b0d5ed2ed4e4815a96bac36b5a992e20f58db6"}, - "cowlib": {:hex, :cowlib, "2.7.3", "a7ffcd0917e6d50b4d5fb28e9e2085a0ceb3c97dea310505f7460ff5ed764ce9", [:rebar3], [], "hexpm", "1e1a3d176d52daebbecbbcdfd27c27726076567905c2a9d7398c54da9d225761"}, - "dialyxir": {:hex, :dialyxir, "1.3.0", "fd1672f0922b7648ff9ce7b1b26fcf0ef56dda964a459892ad15f6b4410b5284", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "00b2a4bcd6aa8db9dcb0b38c1225b7277dca9bc370b6438715667071a304696f"}, - "earmark": {:hex, :earmark, "1.3.0", "17f0c38eaafb4800f746b457313af4b2442a8c2405b49c645768680f900be603", [:mix], [], "hexpm", "f8b8820099caf0d5e72ae6482d2b0da96f213cbbe2b5b2191a37966e119eaa27"}, - "earmark_parser": {:hex, :earmark_parser, "1.4.10", "6603d7a603b9c18d3d20db69921527f82ef09990885ed7525003c7fe7dc86c56", [:mix], [], "hexpm", "8e2d5370b732385db2c9b22215c3f59c84ac7dda7ed7e544d7c459496ae519c0"}, - "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, - "espec": {:hex, :espec, "1.6.3", "d9355788e508b82743a1b1b9aa5ac64ba37b0547c6210328d909e8a6eb56d42e", [:mix], [{:meck, "0.8.12", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm", "235ef9931fc6ae8066272b77dc11c462e72af0aa50c6023643acd22b09326d21"}, - "ex_doc": {:hex, :ex_doc, "0.22.2", "03a2a58bdd2ba0d83d004507c4ee113b9c521956938298eba16e55cc4aba4a6c", [:mix], [{:earmark_parser, "~> 1.4.0", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm", "cf60e1b3e2efe317095b6bb79651f83a2c1b3edcb4d319c421d7fcda8b3aff26"}, - "gun": {:git, "https://github.com/PSPDFKit-labs/gun.git", "0462585ec7b0bcb2ca4b8b91e6d2624a45324b6e", []}, - "makeup": {:hex, :makeup, "1.0.3", "e339e2f766d12e7260e6672dd4047405963c5ec99661abdc432e6ec67d29ef95", [:mix], [{:nimble_parsec, "~> 0.5", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "2e9b4996d11832947731f7608fed7ad2f9443011b3b479ae288011265cdd3dad"}, - "makeup_elixir": {:hex, :makeup_elixir, "0.14.1", "4f0e96847c63c17841d42c08107405a005a2680eb9c7ccadfd757bd31dabccfb", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "f2438b1a80eaec9ede832b5c41cd4f373b38fd7aa33e3b22d9db79e640cbde11"}, - "meck": {:hex, :meck, "0.8.12", "1f7b1a9f5d12c511848fec26bbefd09a21e1432eadb8982d9a8aceb9891a3cf2", [:rebar3], [], "hexpm", "7a6ab35a42e6c846636e8ecd6fdf2cc2e3f09dbee1abb15c1a7c705c10775787"}, - "mime": {:hex, :mime, "1.4.0", "5066f14944b470286146047d2f73518cf5cca82f8e4815cf35d196b58cf07c47", [:mix], [], "hexpm", "75fa42c4228ea9a23f70f123c74ba7cece6a03b1fd474fe13f6a7a85c6ea4ff6"}, - "mint": {:hex, :mint, "1.1.0", "1fd0189edd9e3ffdbd7fcd8bc3835902b987a63ec6c4fd1aa8c2a56e2165f252", [:mix], [{:castore, "~> 0.1.0", [hex: :castore, repo: "hexpm", optional: true]}], "hexpm", "5bfd316c3789340b682d5679a8116bcf2112e332447bdc20c1d62909ee45f48d"}, - "nimble_parsec": {:hex, :nimble_parsec, "0.6.0", "32111b3bf39137144abd7ba1cce0914533b2d16ef35e8abc5ec8be6122944263", [:mix], [], "hexpm", "27eac315a94909d4dc68bc07a4a83e06c8379237c5ea528a9acff4ca1c873c52"}, - "plug": {:hex, :plug, "1.10.4", "41eba7d1a2d671faaf531fa867645bd5a3dce0957d8e2a3f398ccff7d2ef017f", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ad1e233fe73d2eec56616568d260777b67f53148a999dc2d048f4eb9778fe4a0"}, - "plug_cowboy": {:hex, :plug_cowboy, "2.1.3", "38999a3e85e39f0e6bdfdf820761abac61edde1632cfebbacc445cdcb6ae1333", [:mix], [{:cowboy, "~> 2.5", [hex: :cowboy, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "056f41f814dbb38ea44613e0f613b3b2b2f2c6afce64126e252837669eba84db"}, - "plug_crypto": {:hex, :plug_crypto, "1.1.2", "bdd187572cc26dbd95b87136290425f2b580a116d3fb1f564216918c9730d227", [:mix], [], "hexpm", "6b8b608f895b6ffcfad49c37c7883e8df98ae19c6a28113b02aa1e9c5b22d6b5"}, - "ranch": {:hex, :ranch, "1.7.1", "6b1fab51b49196860b733a49c07604465a47bdb78aa10c1c16a3d199f7f8c881", [:rebar3], [], "hexpm", "451d8527787df716d99dc36162fca05934915db0b6141bbdac2ea8d3c7afc7d7"}, - "telemetry": {:hex, :telemetry, "0.4.2", "2808c992455e08d6177322f14d3bdb6b625fbcfd233a73505870d8738a2f4599", [:rebar3], [], "hexpm", "2d1419bd9dda6a206d7b5852179511722e2b18812310d304620c7bd92a13fcef"}, + "bandit": {:hex, :bandit, "1.12.0", "6c5214daa2469644ac4ab0113b98abc24f75e348378e6a974c6343b3e5da22ef", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.5", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "45dac82dc86f45cf4a196dee9cc5a8b791d9c9469d996055f055e6ee36c66e20"}, + "cowboy": {:hex, :cowboy, "2.17.0", "059d6ae769214cedf55d115ce5bf38649ff6b32ec693067cf6b185d0ab1b53c3", [:make, :rebar3], [{:cowlib, ">= 2.18.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, ">= 1.8.0 and < 3.0.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "84f0e4c9d5820342cd506472052b79336d0d9d3624e46303f6d0af9dc94e8d5f"}, + "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, + "cowlib": {:hex, :cowlib, "2.18.0", "4a3ef8cb012c21c7cbb7c925f75405e26d7fcd393fd78c46187539dbc9a48310", [:make, :rebar3], [], "hexpm", "c4f89d33a61162d4cfdcb5a1af7e562d80a700b2573cc71020ce6521b152dc1a"}, + "dialyxir": {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"}, + "earmark_parser": {:hex, :earmark_parser, "1.4.45", "cba8369ab2a1342e419bc2760eec731b17be828941dcf494045d44766227e1d5", [:mix], [], "hexpm", "d3ec045bf122965db20c0bdb420e19ee1415843135327124918473feb4b328e8"}, + "erlex": {:hex, :erlex, "0.2.9", "7debbbaa9f4f368b8cd648983e0f1d7963028508e9c59e9d4ed504e94ef52a55", [:mix], [], "hexpm", "8cfffc0ec7159e6d73de2ab28a588064de80f88b2798d5cbe4482cbbc200178b"}, + "espec": {:hex, :espec, "1.10.0", "672069184369f8a712eb066841e679df779e298ad7ec1a013af7b3352005cd16", [:mix], [{:meck, "~> 1.0", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm", "524bc1378db57763a1d0ca56a8df654fa6b9f1d8162bc6caea2187db917409f0"}, + "ex_doc": {:hex, :ex_doc, "0.40.3", "4a972ffe64bc07dc605af487e98fc19b72a4185f55ca031b94c0552d6071c1d9", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "2756e357742fecd9749b489b85d67c9ce99c465f2e75728d9e6dc8d704b973de"}, + "hpax": {:hex, :hpax, "1.0.4", "777de5d433b0fbdc7c418159c8055910faa8047ffdb3d6b31098d2a46cd7685c", [:mix], [], "hexpm", "afc7cb142ebcc2d01ce7816190b98ce5dd49e799111b24249f3443d730f377ca"}, + "makeup": {:hex, :makeup, "1.2.2", "882d46dc0905e9ff7abf2aab61a7e6b3dcc555533977d8a23b06019e6c89ac94", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "9a1a24e5b343b8ae16abea0822c10a6f75da27af7fa802ada5251f7579bfccfa"}, + "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, + "makeup_erlang": {:hex, :makeup_erlang, "1.1.0", "835f7e60792e08824cda445639555d7bf1bbbddb1b60b306e33cb6f6db24dc74", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "1cd6780fb1dd1a03979abaed0fe82712b0625118fd5257d3ebbf73f960c73c3c"}, + "meck": {:hex, :meck, "1.2.0", "c3618447506589a2931be80d006ea883aff156dd56e8822f7c1bb49896e94edb", [:rebar3], [], "hexpm", "a2cfd08306ef4992db7cd2aa3521b797456abec9f562f3f0341633052dee4af9"}, + "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, + "mint": {:hex, :mint, "1.9.1", "3bc120b743ed2e99ad920910f2613e9faebabb2257731b0e2ea4d8ccd9eceede", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "831101bd560b086316fab5f7adb21a4f3455717d8e4bc8368b052e09aa9163e0"}, + "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, + "plug": {:hex, :plug, "1.20.3", "56c480c633ec2ce10140e236e15233bf576e1d323887d7c96711bd02ab5160db", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "be266aee1b8536ef6409d58cf39a3121319f0ec47cfa1b24024485aa0e76ad76"}, + "plug_cowboy": {:hex, :plug_cowboy, "2.9.0", "87e21e0d9054ced99c36d128f49e3ea2cd8b745fffb97de50bff99706087af4f", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "2002bafba4f3a45b55a58e68d70211b153a7ed18d37edb1ceb6e96e7a92c422e"}, + "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, + "ranch": {:hex, :ranch, "1.8.1", "208169e65292ac5d333d6cdbad49388c1ae198136e4697ae2f474697140f201c", [:make, :rebar3], [], "hexpm", "aed58910f4e21deea992a67bf51632b6d60114895eb03bb392bb733064594dd0"}, + "telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"}, + "thousand_island": {:hex, :thousand_island, "1.5.0", "f50a213cac97262b6d5ebb85745aa2c00fec1413191e6e66834788d45425cecb", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "708923d40523e43cf99041ab37a0d4b0ec426ac6438fa3716ab23d919eaeb412"}, + "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, } diff --git a/test/bypass_test.exs b/test/bypass_test.exs index 8c26cc8..5bbb865 100644 --- a/test/bypass_test.exs +++ b/test/bypass_test.exs @@ -115,13 +115,27 @@ defmodule BypassTest do bypass, fn _conn -> Bypass.pass(bypass) - Process.exit(self(), :shutdown) + # Kill the plug process before it can send a response. `:kill` is + # untrappable, so the connection is dropped regardless of whether the + # backend traps exits in its connection process (Bandit does, Cowboy + # doesn't). The exact transport error is backend-specific; what matters + # is that the request fails and Bypass.pass still satisfies the + # expectation (verified implicitly by the test not raising on exit). + Process.exit(self(), :kill) end ]) capture_log(fn -> - assert {:error, _conn, %Mint.TransportError{reason: :timeout}, _responses} = - request(bypass.port) + # The plug is killed before responding. Depending on the backend the client + # either sees a dropped connection (transport error) or a 500 synthesised by + # the server; either way the request did not complete normally. What this + # test really asserts is that Bypass.pass satisfied the expectation, which is + # verified implicitly by the ExUnit on_exit check not raising :not_called. + case request(bypass.port) do + {:error, _conn, %Mint.TransportError{}, _responses} -> :ok + {:ok, 500, _body} -> :ok + other -> flunk("expected request to fail or 500, got: #{inspect(other)}") + end end) end @@ -145,8 +159,13 @@ defmodule BypassTest do end ]) - assert {:error, _conn, %Mint.TransportError{reason: :closed}, _responses} = + # The server is torn down mid-request. The client sees an interrupted + # request; the exact transport error depends on the backend (Cowboy 2.13+ + # drains gracefully -> :timeout, others hard-close -> :closed). + assert {:error, _conn, %Mint.TransportError{reason: reason}, _responses} = request(bypass.port) + + assert reason in [:closed, :timeout] end test "Bypass.down waits for plug process to terminate before shutting it down with expect" do diff --git a/test/test_helper.exs b/test/test_helper.exs index 869559e..57883e4 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1 +1,8 @@ +case System.get_env("BYPASS_ADAPTER") do + "cowboy" -> Application.put_env(:bypass, :adapter, Bypass.Adapter.Cowboy) + "bandit" -> Application.put_env(:bypass, :adapter, Bypass.Adapter.Bandit) + nil -> :ok + other -> raise "unknown BYPASS_ADAPTER: #{inspect(other)}" +end + ExUnit.start() From 84a9481b5bc3fe6629d4c63744211250de8358d2 Mon Sep 17 00:00:00 2001 From: Jakub Hajto Date: Fri, 10 Jul 2026 15:59:42 +0200 Subject: [PATCH 2/3] fix: address adversarial review of pluggable-backend change - CI matrix: enumerate elixir/otp/os as an object axis crossed with the adapter axis so both backends actually run across all versions (the previous include-only list collapsed to two jobs on a single version). - Raise the real version floor to Elixir 1.15 (plug/plug_cowboy require ~> 1.15); drop the 1.14 CI row. Update README and CHANGELOG accordingly. - Bandit adapter: silence the per-start "Running ..." info log (startup_log: false); apply the `reuseport` inet option only on platforms that support it (mirroring Bypass.Utils.so_reuseport/0); set `reuseaddr: true` explicitly; document the listen-socket-close-on-teardown assumption in stop/1. - Free-port probe: pass `reuseaddr: true` so re-binding a fixed port after traffic does not hit :eaddrinuse on platforms without SO_REUSEPORT. - Bump version to 3.0.0 and document the potentially breaking transport-level behavior changes (interrupted-request error now depends on the backend). - Drop the stale ranch_tcp comment in Bypass.Instance. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/elixir.yml | 65 ++++++++++-------------------------- CHANGELOG.md | 15 +++++++-- README.md | 2 +- lib/bypass/adapter/bandit.ex | 27 ++++++++++++--- lib/bypass/instance.ex | 10 +++--- mix.exs | 4 +-- 6 files changed, 61 insertions(+), 62 deletions(-) diff --git a/.github/workflows/elixir.yml b/.github/workflows/elixir.yml index 1886901..8eba466 100644 --- a/.github/workflows/elixir.yml +++ b/.github/workflows/elixir.yml @@ -11,71 +11,40 @@ permissions: jobs: build: - name: Test - ${{matrix.adapter}} - Erlang ${{matrix.otp}} / Elixir ${{matrix.elixir}} - runs-on: ${{matrix.os}} + name: Test - ${{matrix.adapter}} - Erlang ${{matrix.elixir_otp.otp}} / Elixir ${{matrix.elixir_otp.elixir}} + runs-on: ${{matrix.elixir_otp.os}} strategy: fail-fast: false matrix: - # Bandit is the default adapter and requires Elixir 1.14+ and OTP 25+ + # Bandit is the default adapter and requires Elixir 1.15+ and OTP 25+ # (the `reuseport` inet option). The Cowboy adapter is exercised via the # optional :plug_cowboy dependency and the BYPASS_ADAPTER env var. + # Each combination is enumerated explicitly: an `include`-only matrix + # combined with a real axis does not expand the way you'd expect. # https://hexdocs.pm/elixir/compatibility-and-deprecations.html#compatibility-between-elixir-and-erlang-otp adapter: ["bandit", "cowboy"] - include: - # Elixir 1.14: 25 - - elixir: "1.14" - otp: "25.3" - os: "ubuntu-22.04" - - - elixir: "1.14" - otp: "26.2" - os: "ubuntu-22.04" - - # Elixir 1.15: 25-26 - - elixir: "1.15" - otp: "25.3" - os: "ubuntu-22.04" - - - elixir: "1.15" - otp: "26.2" - os: "ubuntu-22.04" - - # Elixir 1.16: 25-26 - - elixir: "1.16" - otp: "25.3" - os: "ubuntu-22.04" - - - elixir: "1.16" - otp: "26.2" - os: "ubuntu-22.04" - - # Elixir 1.17: 25-27 - - elixir: "1.17" - otp: "26.2" - os: "ubuntu-22.04" - - - elixir: "1.17" - otp: "27.2" - os: "ubuntu-24.04" - - # Elixir 1.18: 25-27 - - elixir: "1.18" - otp: "27.2" - os: "ubuntu-24.04" + elixir_otp: + - { elixir: "1.15", otp: "25.3", os: "ubuntu-22.04" } + - { elixir: "1.15", otp: "26.2", os: "ubuntu-22.04" } + - { elixir: "1.16", otp: "25.3", os: "ubuntu-22.04" } + - { elixir: "1.16", otp: "26.2", os: "ubuntu-22.04" } + - { elixir: "1.17", otp: "26.2", os: "ubuntu-22.04" } + - { elixir: "1.17", otp: "27.2", os: "ubuntu-24.04" } + - { elixir: "1.18", otp: "27.2", os: "ubuntu-24.04" } steps: - uses: actions/checkout@v4 - name: Set up Elixir uses: erlef/setup-beam@ae6e9db1bf49000a27750a9e283cf4069da9d171 with: - otp-version: ${{matrix.otp}} - elixir-version: ${{matrix.elixir}} + otp-version: ${{matrix.elixir_otp.otp}} + elixir-version: ${{matrix.elixir_otp.elixir}} - name: Restore dependencies cache uses: actions/cache@v4 with: path: deps - key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }} - restore-keys: ${{ runner.os }}-mix- + key: ${{ runner.os }}-mix-${{ matrix.elixir_otp.elixir }}-${{ hashFiles('**/mix.lock') }} + restore-keys: ${{ runner.os }}-mix-${{ matrix.elixir_otp.elixir }}- - name: Install dependencies run: mix deps.get - name: Run tests diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fd1550..390127f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## v3.0.0 * Switch the default HTTP server backend from Cowboy to [Bandit](https://hex.pm/packages/bandit). @@ -10,7 +10,18 @@ * `:plug_cowboy` is now an optional dependency. To keep using Cowboy, add `{:plug_cowboy, "~> 2.0"}` to your deps and set `config :bypass, adapter: Bypass.Adapter.Cowboy`. -* Require at least Elixir 1.14 and OTP 25 (Bandit / the `reuseport` inet option). +* Require at least Elixir 1.15 and OTP 25 (Bandit / the `reuseport` inet option). + +### Potentially breaking behavior changes + +The transport-level outcome of an interrupted request now depends on the +backend. Tests that assert on a specific `Mint.TransportError` reason (or similar +low-level client error) may need updating: + +* A plug that exits/crashes before responding: the client sees a dropped + connection under Bandit, versus a `500` under recent Cowboy. +* `Bypass.down/1` while a request is in flight: the client sees `:closed` on + hard-closing backends or `:timeout` on Cowboy 2.13+ (which drains gracefully). ## v2.1.0 - 13 Nov 2020 diff --git a/README.md b/README.md index 2589f96..afb9654 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ requests. This is most useful in tests, when you want to create a mock HTTP server and test how your HTTP client handles different types of responses from the server. -Bypass supports Elixir 1.14 and OTP 25 and up. By default it runs on +Bypass supports Elixir 1.15 and OTP 25 and up. By default it runs on [Bandit](https://hex.pm/packages/bandit); [Cowboy](https://hex.pm/packages/plug_cowboy) is available as an opt-in adapter. diff --git a/lib/bypass/adapter/bandit.ex b/lib/bypass/adapter/bandit.ex index 87124d1..0d56642 100644 --- a/lib/bypass/adapter/bandit.ex +++ b/lib/bypass/adapter/bandit.ex @@ -16,6 +16,9 @@ defmodule Bypass.Adapter.Bandit do plug: {plug, plug_opts}, scheme: :http, port: port, + # Bypass is a test server; suppress Bandit's per-start "Running ... at ..." + # info log so it doesn't spam consumers' test output on every open/up. + startup_log: false, thousand_island_options: [ num_acceptors: Keyword.fetch!(opts, :num_acceptors), # Drop in-flight connections immediately on shutdown instead of draining @@ -25,18 +28,32 @@ defmodule Bypass.Adapter.Bandit do # calls Bypass.down/1 on itself while still in-flight. shutdown_timeout: 0, # Thousand Island opens its own listen socket, so (unlike the Cowboy - # adapter) we can't hand it a pre-opened one. Instead we ask the kernel - # for SO_REUSEPORT via the `reuseport` inet option so we can re-listen on + # adapter) we can't hand it a pre-opened one. `reuseaddr` (a Thousand + # Island default, set explicitly) plus SO_REUSEPORT let us re-listen on # the same port across down/up cycles without hitting :eaddrinuse. - # `reuseport` requires OTP 25+; Thousand Island rejects the raw setsockopt - # tuple form the Cowboy adapter uses, so this is the only option here. - transport_options: [ip: listen_ip, reuseport: true] + # Thousand Island rejects the raw setsockopt tuple the Cowboy adapter + # uses, so we use the `reuseport` inet option (OTP 25+) instead — only on + # platforms where it is supported, mirroring Bypass.Utils.so_reuseport/0. + transport_options: [ip: listen_ip, reuseaddr: true] ++ reuseport_opt() ] ) end @impl true def stop(pid) when is_pid(pid) do + # Stopping the Bandit supervisor terminates the Thousand Island listener, + # which closes the listen socket as it exits. Thousand Island does not close + # the socket in its `terminate/2`, so — like the Cowboy adapter's explicit + # `port_close` — callers relying on an immediate :econnrefused after + # `Bypass.down/1` depend on this teardown completing before we return, which + # the synchronous `Supervisor.stop/3` guarantees. :ok = Supervisor.stop(pid, :normal, :infinity) end + + defp reuseport_opt do + case :os.type() do + {:unix, os} when os in [:linux, :darwin] -> [reuseport: true] + _ -> [] + end + end end diff --git a/lib/bypass/instance.ex b/lib/bypass/instance.ex index 3fd01de..f616790 100644 --- a/lib/bypass/instance.ex +++ b/lib/bypass/instance.ex @@ -27,7 +27,9 @@ defmodule Bypass.Instance do adapter = Application.get_env(:bypass, :adapter, Bypass.Adapter.Bandit) # Get a free port from the OS - case :gen_tcp.listen(Keyword.get(opts, :port, 0), so_reuseport() ++ [ip: listen_ip()]) do + probe_opts = so_reuseport() ++ [ip: listen_ip(), reuseaddr: true] + + case :gen_tcp.listen(Keyword.get(opts, :port, 0), probe_opts) do {:ok, socket} -> {:ok, port} = :inet.port(socket) :erlang.port_close(socket) @@ -436,9 +438,9 @@ defmodule Bypass.Instance do new_route(fun, build_path_match(path) |> elem(1), expected) end - # This is used to override the default behaviour of ranch_tcp - # and limit the range of interfaces it will listen on to just - # the configured interface. Loopback is a default interface. + # Limits the range of interfaces the listen socket will bind to, to just the + # configured interface. Loopback is the default. Passed through to the adapter + # (and the free-port probe) as the `:ip` option. defp listen_ip do case Application.get_env(:bypass, :listen_ip, {127, 0, 0, 1}) do listen_ip when is_tuple(listen_ip) -> diff --git a/mix.exs b/mix.exs index 7387123..5d1f2d7 100644 --- a/mix.exs +++ b/mix.exs @@ -1,14 +1,14 @@ defmodule Bypass.Mixfile do use Mix.Project - @version "2.1.0" + @version "3.0.0" @source_url "https://github.com/PSPDFKit-labs/bypass" def project do [ app: :bypass, version: @version, - elixir: "~> 1.14", + elixir: "~> 1.15", description: description(), package: package(), deps: deps(), From bc9957d1240ab6de921e8bebe1e2a6f518609474 Mon Sep 17 00:00:00 2001 From: Jakub Hajto Date: Sat, 11 Jul 2026 15:42:57 +0200 Subject: [PATCH 3/3] fix: ranch 2.x compatibility, deferred-exit bug, and dev ergonomics Follow-ups from an example-app integration test and adversarial review. ranch / Cowboy adapter: - Re-pin ranch to ~> 1.8. The Cowboy adapter hands ranch a pre-opened listen socket (for SO_REUSEPORT), which ranch 2.x removed support for. Dropping the direct ranch dep let fresh consumers resolve ranch 2.x and break; a non-optional pin constrains their resolution back to 1.x. - Open the pre-opened socket with :gen_tcp.listen/2 rather than :ranch_tcp.listen/1, whose argument shape differs across ranch 1.x/2.x. - Raise a clear error if ranch 2.x is somehow loaded. Instance: - Fix Bypass.Instance.dispatch_awaiting_callers/1: it called GenServer.stop/1 with :normal as the server ref (crashing with :noproc) and re-ran do_exit/1 on pre-teardown state (double-stopping the server). It now returns proper {:stop, :normal, state} / {:noreply, state} tuples and reuses the torn-down state. Add a regression test covering :on_exit deferred while a request is in flight. Tooling: - Add a `mix test.adapters` alias that runs the suite against both backends, each in a fresh VM. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 2 ++ README.md | 3 +++ lib/bypass/adapter/cowboy.ex | 50 +++++++++++++++++++++++++++++++----- lib/bypass/instance.ex | 20 +++++++++------ mix.exs | 29 +++++++++++++++++++++ test/bypass_test.exs | 36 ++++++++++++++++++++++++++ 6 files changed, 125 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 390127f..a7926df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ `{:plug_cowboy, "~> 2.0"}` to your deps and set `config :bypass, adapter: Bypass.Adapter.Cowboy`. * Require at least Elixir 1.15 and OTP 25 (Bandit / the `reuseport` inet option). +* Pin `ranch` to 1.x. The Cowboy adapter hands ranch a pre-opened listen socket + (for `SO_REUSEPORT`), which ranch 2.x no longer supports. ### Potentially breaking behavior changes diff --git a/README.md b/README.md index afb9654..d0f19ff 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,9 @@ instead, add the optional dependency and select the adapter: config :bypass, adapter: Bypass.Adapter.Cowboy ``` +To run Bypass's own test suite against a specific backend, set `BYPASS_ADAPTER` +(`bandit` or `cowboy`), or run both at once with `mix test.adapters`. + ## Usage To use Bypass in a test case, open a connection and use its port to connect your diff --git a/lib/bypass/adapter/cowboy.ex b/lib/bypass/adapter/cowboy.ex index e4cfa11..637814f 100644 --- a/lib/bypass/adapter/cowboy.ex +++ b/lib/bypass/adapter/cowboy.ex @@ -2,18 +2,30 @@ defmodule Bypass.Adapter.Cowboy do @moduledoc """ `Bypass.Adapter` implementation backed by Cowboy via `Plug.Cowboy`. - Requires the optional `:plug_cowboy` dependency. Add it to your deps and - select this adapter with: + Requires the optional `:plug_cowboy` dependency: + # mix.exs + {:plug_cowboy, "~> 2.0", only: :test} + + # config/test.exs config :bypass, adapter: Bypass.Adapter.Cowboy + + This adapter pre-opens the listen socket (to apply `SO_REUSEPORT`) and hands it + to Cowboy, which only ranch 1.x supports — ranch 2.x removed the pre-opened + socket option. Bypass depends on `ranch ~> 1.8`, which constrains resolution + for you, so no explicit ranch pin is normally needed. """ @behaviour Bypass.Adapter - # `:plug_cowboy` (and its `:ranch` transitive dep) is optional. Consumers who - # do not depend on it should not get "undefined function" warnings for this - # module, which is only reachable when they explicitly select this adapter. - @compile {:no_warn_undefined, [Plug.Cowboy, :ranch_tcp]} + # `:plug_cowboy` is optional. Consumers who do not depend on it should not get + # "undefined function" warnings for this module, which is only reachable when + # they explicitly select this adapter. + @compile {:no_warn_undefined, [Plug.Cowboy]} + + # Socket options mirroring ranch_tcp's defaults, so the pre-opened socket we + # hand to Cowboy behaves the same as one Cowboy/ranch would open itself. + @socket_opts [:binary, active: false, reuseaddr: true, nodelay: true, backlog: 1_024] @impl true def start(plug, plug_opts, port, opts) do @@ -24,10 +36,25 @@ defmodule Bypass.Adapter.Cowboy do Bypass.Adapter.Bandit adapter. """ + ranch_1_x?() || + raise """ + Bypass.Adapter.Cowboy requires ranch 1.x, but ranch #{ranch_vsn()} is loaded. \ + ranch 2.x removed support for handing the server a pre-opened listen socket, \ + which this adapter relies on. Pin ranch in your deps: + + {:ranch, "~> 1.8", override: true} + + or use the default Bypass.Adapter.Bandit adapter. + """ + listen_ip = Keyword.fetch!(opts, :listen_ip) reuseport_raw = Keyword.fetch!(opts, :reuseport_raw) - {:ok, socket} = :ranch_tcp.listen(reuseport_raw ++ [ip: listen_ip, port: port]) + # Pre-open the listen socket ourselves (with SO_REUSEPORT) and hand it to + # Cowboy. We use `:gen_tcp.listen/2` rather than `:ranch_tcp.listen/1` because + # the latter's argument shape changed between ranch 1.x (proplist) and 2.x + # (map); `:gen_tcp` is stable across both. + {:ok, socket} = :gen_tcp.listen(port, reuseport_raw ++ [ip: listen_ip] ++ @socket_opts) cowboy_opts = [ ref: Keyword.fetch!(opts, :ref), @@ -39,6 +66,15 @@ defmodule Bypass.Adapter.Cowboy do {:ok, %{ref: cowboy_opts[:ref], socket: socket}} end + defp ranch_1_x?, do: String.starts_with?(ranch_vsn(), "1.") + + defp ranch_vsn do + case Application.spec(:ranch, :vsn) do + nil -> "unknown" + vsn -> to_string(vsn) + end + end + @impl true def stop(%{ref: ref, socket: socket}) do :ok = Plug.Cowboy.shutdown(ref) diff --git a/lib/bypass/instance.ex b/lib/bypass/instance.ex index f616790..70b4023 100644 --- a/lib/bypass/instance.ex +++ b/lib/bypass/instance.ex @@ -62,12 +62,12 @@ defmodule Bypass.Instance do {route, state} -> result = {:exit, {:exit, reason, []}} - {:noreply, route |> put_result(ref, result, state) |> dispatch_awaiting_callers()} + route |> put_result(ref, result, state) |> dispatch_awaiting_callers() end end def handle_cast({:put_expect_result, route, ref, result}, state) do - {:noreply, route |> put_result(ref, result, state) |> dispatch_awaiting_callers()} + route |> put_result(ref, result, state) |> dispatch_awaiting_callers() end def handle_call(request, from, state) do @@ -393,22 +393,26 @@ defmodule Bypass.Instance do } = state ) do if retained_plugs_count(state) == 0 do - down_reset = + state = if length(down_callers) > 0 do do_down(server, adapter) Enum.each(down_callers, &GenServer.reply(&1, :ok)) %{state | server: nil, callers_awaiting_down: []} + else + state end if length(exit_callers) > 0 do - {result, _updated_state} = do_exit(state) + # Use the (possibly already torn-down) state so do_exit/1 does not stop + # the server a second time. + {result, updated_state} = do_exit(state) Enum.each(exit_callers, &GenServer.reply(&1, result)) - GenServer.stop(:normal) + {:stop, :normal, updated_state} + else + {:noreply, state} end - - down_reset || state else - state + {:noreply, state} end end diff --git a/mix.exs b/mix.exs index 5d1f2d7..661a77e 100644 --- a/mix.exs +++ b/mix.exs @@ -12,6 +12,7 @@ defmodule Bypass.Mixfile do description: description(), package: package(), deps: deps(), + aliases: aliases(), docs: docs(), dialyzer: [ plt_add_apps: [:ex_unit] @@ -19,6 +20,29 @@ defmodule Bypass.Mixfile do ] end + defp aliases do + # `mix test.adapters` runs the suite against both HTTP backends, each in a + # fresh VM. Extra args are forwarded, e.g. `mix test.adapters --seed 0`. + ["test.adapters": &test_adapters/1] + end + + defp test_adapters(args) do + statuses = + for adapter <- ~w(bandit cowboy) do + IO.puts(IO.ANSI.format([:cyan, "\n==> mix test with BYPASS_ADAPTER=#{adapter}\n"])) + + {_, status} = + System.cmd("mix", ["test" | args], + env: [{"BYPASS_ADAPTER", adapter}], + into: IO.stream(:stdio, :line) + ) + + status + end + + if Enum.any?(statuses, &(&1 != 0)), do: exit({:shutdown, 1}) + end + def application do [ extra_applications: [:logger], @@ -31,6 +55,11 @@ defmodule Bypass.Mixfile do [ {:bandit, "~> 1.5"}, {:plug_cowboy, "~> 2.0", optional: true}, + # Pinned to ranch 1.x: the Cowboy adapter hands ranch a pre-opened listen + # socket (for SO_REUSEPORT), an option ranch 2.x removed. A non-optional + # dep constrains a consumer's resolution, so pinning here keeps the Cowboy + # adapter working without every consumer needing an `override: true`. + {:ranch, "~> 1.8"}, {:plug, "~> 1.14"}, {:ex_doc, "> 0.0.0", only: :dev}, {:espec, "~> 1.6", only: [:dev, :test]}, diff --git a/test/bypass_test.exs b/test/bypass_test.exs index 5bbb865..4414f04 100644 --- a/test/bypass_test.exs +++ b/test/bypass_test.exs @@ -500,6 +500,42 @@ defmodule BypassTest do end) end + test "on_exit deferred while a request is in-flight replies and stops the instance cleanly" do + bypass = Bypass.open() + test = self() + ref = Process.monitor(bypass.pid) + + # Neutralise Bypass' automatic on_exit; this test drives :on_exit itself. + ExUnit.Callbacks.on_exit({Bypass, bypass.pid}, fn -> :ok end) + + Bypass.expect(bypass, fn conn -> + send(test, {:plug, self()}) + + receive do + :proceed -> :ok + end + + Plug.Conn.send_resp(conn, 200, "") + end) + + req_task = Task.async(fn -> request(bypass.port) end) + assert_receive {:plug, plug_pid}, 1_000 + + # The plug is still retained, so :on_exit is deferred until it finishes. + exit_task = Task.async(fn -> Bypass.Instance.call(bypass.pid, :on_exit) end) + Process.sleep(50) + + # Letting the plug finish triggers the deferred-exit dispatch path. + send(plug_pid, :proceed) + + assert Task.await(exit_task) == :ok + assert {:ok, 200, ""} = Task.await(req_task) + + # The instance must stop normally, not crash (which would restart it under + # its :transient policy) or double-stop the server. + assert_receive {:DOWN, ^ref, :process, _pid, :normal}, 1_000 + end + @doc ~S""" Open a new HTTP connection and perform the request. We don't want to use httpc, hackney or another "high-level" HTTP client, since they do connection pooling and we will sometimes get a connection