Make HTTP backend pluggable and default to Bandit#153
Conversation
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: <module>`. - 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) <noreply@anthropic.com>
- 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
|
weather_client.zip |
| {result, updated_state} = do_exit(state) | ||
| Enum.each(exit_callers, &GenServer.reply(&1, result)) | ||
| GenServer.stop(:normal) | ||
| {:stop, :normal, updated_state} |
There was a problem hiding this comment.
I can migrate this fix into its own PR.
| {result, updated_state} = do_exit(state) | ||
| Enum.each(exit_callers, &GenServer.reply(&1, result)) | ||
| GenServer.stop(:normal) | ||
| {:stop, :normal, updated_state} |
There was a problem hiding this comment.
:on_exit deferral — the bug fixed in dispatch_awaiting_callers/1
At the end of a test, :on_exit verifies expectations. If a request is still being handled
(a retained plug), the instance defers the check and parks the caller in
callers_awaiting_exit; the deferred exit is completed later by
dispatch_awaiting_callers/1 once the last plug returns.
The deferral itself was fine — the completion path was broken. It only fired when a request
was in flight at teardown (a narrow race), so it lurked.
Before — completion path crashes:
flowchart TD
A[":on_exit while a request is in-flight"] --> B["defer: push caller to callers_awaiting_exit"]
P["in-flight plug returns"] --> D["dispatch_awaiting_callers/1"]
D --> E{"retained_plugs_count == 0 ?"}
E -- no --> F["keep waiting"]
E -- yes --> G["down-callers: do_down + reply :ok"]
G --> H["do_exit(state) — uses PRE-teardown state<br/>⚠ stops the server a second time"]
H --> I["reply exit-callers with result"]
I --> J["GenServer.stop(:normal)<br/>⚠ :normal read as the server ref → exit :noproc"]
J --> K["💥 instance crashes; :transient policy restarts it"]
style H fill:#fbe6e3,stroke:#c0392b,color:#1c1a24
style J fill:#fbe6e3,stroke:#c0392b,color:#1c1a24
style K fill:#fbe6e3,stroke:#c0392b,color:#1c1a24
After — returns proper GenServer tuples and reuses the torn-down state:
flowchart TD
A[":on_exit while a request is in-flight"] --> B["defer: push caller to callers_awaiting_exit"]
P["in-flight plug returns"] --> D["dispatch_awaiting_callers/1"]
D --> E{"retained_plugs_count == 0 ?"}
E -- no --> F["{:noreply, state}"]
E -- yes --> G{"any down-callers?"}
G -- yes --> H["do_down + reply :ok<br/>state := torn-down"]
G -- no --> I["state unchanged"]
H --> J{"any exit-callers?"}
I --> J
J -- yes --> K["do_exit(state) on the torn-down state<br/>✓ single stop"]
K --> L["reply result → {:stop, :normal, state}"]
J -- no --> M["{:noreply, state}"]
style K fill:#e0f2e9,stroke:#1f8a5b,color:#1c1a24
style L fill:#e0f2e9,stroke:#1f8a5b,color:#1c1a24
Why it matters: verifying while a request is mid-flight is wrong two ways — the server gets
torn down under the running plug (request recorded as an error → false failure), or the plug
hasn't recorded its result yet (false :not_called). Deferral makes verification observe the
true outcome; this fix makes the deferred completion actually stop the instance cleanly instead
of crashing. Covered by a new regression test that drives :on_exit while a request is in flight.
What
Makes the HTTP server backend pluggable behind a new
Bypass.Adapterbehaviourand switches the default from Cowboy to Bandit.
Cowboy stays available as an opt-in adapter.
Why
Bandit is a pure-Elixir, actively developed Plug server. Decoupling Bypass from
Cowboy lets users pick their backend and keeps Bypass lighter by default.
Changes
Bypass.Adapterbehaviour:start/4,stop/1, opaque server handle.Bypass.Adapter.Bandit(default) andBypass.Adapter.Cowboy(opt-in).config :bypass, adapter: Bypass.Adapter.Cowboy.Bypass.Instancenow stores an opaque server handle + adapter instead of araw socket/ref; free port grabbed with
:gen_tcp; direct:ranchdep dropped.so_reuseport/0moved toBypass.Utils.reuseportinet option (OTP 25+) and drops in-flightconnections on shutdown (
shutdown_timeout: 0) so a plug that callsBypass.down/1on itself does not deadlock.:plug_cowboyis now optional; a clear error is raised if the Cowboy adapteris selected without it.
Breaking
{:plug_cowboy, "~> 2.0"}and setconfig :bypass, adapter: Bypass.Adapter.Cowboy.Security context
The Cowboy stack (
cowboy+cowlib+plug_cowboy) has racked up roughly adozen CVEs, the bulk of them denial-of-service in the HTTP parsers:
cowboyunbounded buffer accumulation in multipart headerparsing (DoS), Cowboy 2.0.0–2.14.0.
cowboyCRLF injection in HTTP headers (request/responsesplitting).
plug_cowboyunauthenticated remote DoS via HTTP/2:schemeatom-table exhaustion.cowlibchunked transfer-encoding parser, O(N²) CPU/memoryDoS, cowlib 0.6.0–2.16.0.
cowlibCRLF injection / XSS in SSE frame building(
cow_sse:event/1).cowlibSPDY decompression bomb (cow_spdy:inflate/2),memory-exhaustion DoS.
Fair caveat: Bypass is a test-only mock server bound to loopback, so these
CVEs barely touch how Bypass itself is used — the real driver is that Bandit is
pure-Elixir and actively maintained. But cutting a hard Cowboy dependency does
shed that whole advisory surface for anyone auditing their test dependency tree.
Testing
BYPASS_ADAPTER=bandit|cowboy;CI matrix exercises both. Local: bandit 36/36, cowboy 36/36, dialyzer clean.
behaviour that legitimately differs between Bandit and modern Cowboy
(e.g. in-flight
downyields:closedon hard-close backends,:timeoutonCowboy 2.13+ graceful drain).
🤖 Generated with Claude Code