Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 25 additions & 84 deletions .github/workflows/elixir.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,104 +9,45 @@ on:
permissions:
contents: read




jobs:
build:
name: Build and test - 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.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
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"
otp: "25.3"
os: "ubuntu-22.04"

- elixir: "1.14"
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"
otp: "25.3"
os: "ubuntu-22.04"

- elixir: "1.15"
otp: "26.2"
os: "ubuntu-22.04"

# Elixir 1.16: 24-25
- elixir: "1.16"
otp: "24.3"
os: "ubuntu-22.04"

- elixir: "1.16"
otp: "25.3"
os: "ubuntu-22.04"

- elixir: "1.16"
otp: "26.2"
os: "ubuntu-22.04"
adapter: ["bandit", "cowboy"]
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@v3
- 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@v3
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
run: mix test
env:
BYPASS_ADAPTER: ${{ matrix.adapter }}
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,30 @@
# Changelog

## v3.0.0

* 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: <module>`. 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.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

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

* Support latest Cowboy.
Expand Down
22 changes: 21 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,27 @@ 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.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.

### 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
```

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

Expand Down
38 changes: 38 additions & 0 deletions lib/bypass/adapter.ex
Original file line number Diff line number Diff line change
@@ -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
59 changes: 59 additions & 0 deletions lib/bypass/adapter/bandit.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
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,
# 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
# 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. `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.
# 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
93 changes: 93 additions & 0 deletions lib/bypass/adapter/cowboy.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
defmodule Bypass.Adapter.Cowboy do
@moduledoc """
`Bypass.Adapter` implementation backed by Cowboy via `Plug.Cowboy`.

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` 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
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.
"""

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)

# 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),
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

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)

# `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
Loading