Skip to content

Replace Cowboy with Bandit - #5090

Open
IvanIvanoff wants to merge 1 commit into
masterfrom
migrate-bandit
Open

Replace Cowboy with Bandit#5090
IvanIvanoff wants to merge 1 commit into
masterfrom
migrate-bandit

Conversation

@IvanIvanoff

@IvanIvanoff IvanIvanoff commented Mar 30, 2026

Copy link
Copy Markdown
Member

Changes

Ticket

Checklist:

  • I have performed a self-review of my own code
  • I have made corresponding changes to the documentation
  • I have tried to find clearer solution before commenting hard-to-understand parts of code
  • I have added tests that prove my fix is effective or that my feature works

Summary by CodeRabbit

  • Infrastructure Updates

    • Switched the app's HTTP server adapter to a different implementation.
    • Adjusted HTTP timeouts and header/request-line limits.
    • Updated shutdown behavior to rely on the server's built-in connection draining.
  • Tests

    • Added tests validating connection draining, refusal of new connections during shutdown, and shutdown timing/behavior.

@coderabbitai

coderabbitai Bot commented Mar 30, 2026

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

Replaces Cowboy/Cowlib/PlugCowboy with Bandit: endpoint configs switch to Bandit.PhoenixAdapter using http_1_options and thousand_island_options; SanbaseWeb.ConnectionDrainer and its supervision child were removed (Bandit/ThousandIsland handles draining); tests added to validate Bandit draining behavior; deps updated to include :bandit.

Changes

Cohort / File(s) Summary
HTTP Server Configuration
config/config.exs, config/dev.exs, config/runtime.exs
Switched endpoint transport to adapter: Bandit.PhoenixAdapter. Replaced Cowboy protocol_options with thousand_island_options (read/shutdown timeouts) and http_1_options (header/count/line limits). Moved compress into http_options in dev config.
Application Supervision
lib/sanbase/application/application.ex
Removed the explicit supervision child that ran a custom connection drainer; inline comment updated to note ThousandIsland/Bandit’s built-in connection draining behavior.
Removed Connection Drainer
lib/sanbase_web/connection_drainer.ex
Entire module SanbaseWeb.ConnectionDrainer (GenServer used with Ranch) deleted — child_spec/start_link/init/terminate removed.
Dependencies
mix.exs
Removed :cowboy, :cowlib, and :plug_cowboy entries; added :bandit dependency.
Tests
test/sanbase_web/connection_drainer_test.exs
Added ExUnit tests that start an in-process Bandit server and assert draining behavior: in-flight completion, refusal of new connections after shutdown, fast-stop when idle, and forced termination after shutdown_timeout.

Sequence Diagram(s)

sequenceDiagram
    participant App as Application / Supervisor
    participant Endpoint as Phoenix Endpoint
    participant Bandit as Bandit / ThousandIsland
    participant Client as HTTP Client

    App->>Endpoint: stop endpoint / initiate shutdown
    Endpoint->>Bandit: close listening socket (suspend new accept)
    Note right of Bandit: in-flight requests continue\nuntil finished or shutdown_timeout
    Client->>Bandit: in-flight request (started before suspend)
    Bandit-->>Client: response if completed before timeout
    Client->>Bandit: new request (after suspend)
    Bandit-->>Client: connection refused / reset
    par wait for in-flight or timeout
      Bandit->>Bandit: track active connection pids
    end
    Bandit->>App: shutdown completed (or forced after timeout)
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐇 I hopped from Cowboy to Bandit's shore,

Listeners closed, but the slow ones wore —
ThousandIsland hummed, "finish, then part",
New knocks denied, the old warmed heart,
A rabbit cheers this tidy depart.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 22.22% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title 'Replace Cowboy with Bandit' directly and clearly summarizes the main change—migrating from the Cowboy HTTP server to Bandit across configuration files, dependencies, and related code.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch migrate-bandit

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
config/dev.exs (1)

14-20: ⚠️ Potential issue | 🟠 Major

Move response compression configuration to http_options.

With Bandit.PhoenixAdapter, the entries inside http: are passed directly to Bandit. Response compression must be configured under http_options, not at the top level of the http: key, as compress is not a recognized top-level option in Bandit.options/0.

Suggested fix
 config :sanbase, SanbaseWeb.Endpoint,
   http: [
-    compress: true,
+    http_options: [compress: true],
     port: port,
     thousand_island_options: [
       read_timeout: 100_000
     ]
   ],
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@config/dev.exs` around lines 14 - 20, Move the response compression option
out of the top-level http map and into http_options for the SanbaseWeb.Endpoint
config: remove compress: true from inside the http: map and add it under
http_options (e.g., http_options: [compress: true, thousand_island_options:
[...]]), keeping the existing thousand_island_options intact; update the
SanbaseWeb.Endpoint config so Bandit receives compress via http_options rather
than as a top-level http key.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@lib/sanbase_web/connection_drainer.ex`:
- Around line 37-57: The current case branch assumes ThousandIsland.suspend/1
and ThousandIsland.connection_pids/1 always return {:ok, ...} and will crash on
:error; update the {:ok, server_pid} branch in the safe_bandit_pid handling to
use a with that calls ThousandIsland.suspend(server_pid) and then
ThousandIsland.connection_pids(server_pid), handling {:ok, _} and matching
:error by logging/skipping draining instead of hard-matching; on success keep
the IO.puts messages and call wait_for_drain(server_pid), but if either call
returns :error, log the failure and do not call wait_for_drain.

In `@test/sanbase_web/connection_drainer_test.exs`:
- Around line 13-20: Replace the sleep rendezvous in SlowPlug.call/2 with an
explicit test-process handshake: have SlowPlug.call/2 send a message like
{:slow_plug_started, self()} (or {:slow_plug_started, test_pid} if you pass test
PID via conn private assigns) to the test process as soon as handling begins,
then block on a receive to continue; in the test (connection_drainer_test), wait
for that {:slow_plug_started, _} message after triggering the request (instead
of relying on Process.sleep/1_000) so the drainer is guaranteed to start
draining only after the plug has actually begun handling the request—use symbols
SlowPlug.call/2, request_started, and Req.get!/1 to locate where to add the
send/receive handshake.
- Around line 7-21: Move the nested SlowPlug module out of the test file into
its own top-level support file: create a new module SlowPlug with the same
functions init/1 and call/2 (keeping the Process.sleep,
put_resp_header/put_resp_content_type/send_resp behavior) and `@moduledoc` false,
remove the nested module from connection_drainer_test.exs, and update the test
to reference the standalone SlowPlug (no alias needed if named the same); ensure
the new support file is picked up by the test suite (compiled by test farm) so
tests continue to run.

---

Outside diff comments:
In `@config/dev.exs`:
- Around line 14-20: Move the response compression option out of the top-level
http map and into http_options for the SanbaseWeb.Endpoint config: remove
compress: true from inside the http: map and add it under http_options (e.g.,
http_options: [compress: true, thousand_island_options: [...]]), keeping the
existing thousand_island_options intact; update the SanbaseWeb.Endpoint config
so Bandit receives compress via http_options rather than as a top-level http
key.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 420a168e-55c9-40d5-929d-870144bc2d4c

📥 Commits

Reviewing files that changed from the base of the PR and between 97fca6e and de232d6.

⛔ Files ignored due to path filters (1)
  • mix.lock is excluded by !**/*.lock
📒 Files selected for processing (7)
  • config/config.exs
  • config/dev.exs
  • config/runtime.exs
  • lib/sanbase/application/application.ex
  • lib/sanbase_web/connection_drainer.ex
  • mix.exs
  • test/sanbase_web/connection_drainer_test.exs

Comment thread lib/sanbase_web/connection_drainer.ex Outdated
Comment on lines +37 to +57
case safe_bandit_pid(endpoint) do
{:ok, server_pid} ->
# Stop accepting new connections
:ok = ThousandIsland.suspend(server_pid)

{:ok, running_connections} = ThousandIsland.connection_pids(server_pid)

IO.puts(
"[#{DateTime.utc_now(:second)}][ConnectionDrainer] Stopped accepting new connections. Waiting for #{length(running_connections)} connections to finish."
)

# Wait until the connections are all finished.
# If it takes more time, the `:shutdown` timeout will kick in
# and kill this process. This way we have a balance between
# waiting for most connections to finish, but not waiting too long
# or getting stuck.
wait_for_drain(server_pid)

IO.puts(
"[#{DateTime.utc_now(:second)}][ConnectionDrainer] Finished draining connections."
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

What are the documented return values of ThousandIsland.suspend/1andThousandIsland.connection_pids/1, and should callers handle :error?

💡 Result:

ThousandIsland.suspend/1 returns :ok on success or :error if the server is not in a usable state (e.g., if the listener PID is unavailable). ThousandIsland.connection_pids/1 returns {:ok, [pid]} listing active connection processes (with a note that the list may be inconsistent due to concurrency) or :error if the acceptor pool supervisor PID is unavailable. Callers should handle :error in both cases, as it indicates the server may not be running properly or the requested component is missing.

Citations:


Use with to handle potential :error returns from ThousandIsland.suspend/1 and ThousandIsland.connection_pids/1.

Both functions can return :error if the server is unavailable. Hard-matching these with pattern matching would crash the shutdown callback. Use with to chain operations and gracefully skip draining if either call fails.

Suggested fix
     case safe_bandit_pid(endpoint) do
       {:ok, server_pid} ->
-        # Stop accepting new connections
-        :ok = ThousandIsland.suspend(server_pid)
-
-        {:ok, running_connections} = ThousandIsland.connection_pids(server_pid)
-
-        IO.puts(
-          "[#{DateTime.utc_now(:second)}][ConnectionDrainer] Stopped accepting new connections. Waiting for #{length(running_connections)} connections to finish."
-        )
-
-        # Wait until the connections are all finished.
-        # If it takes more time, the `:shutdown` timeout will kick in
-        # and kill this process. This way we have a balance between
-        # waiting for most connections to finish, but not waiting too long
-        # or getting stuck.
-        wait_for_drain(server_pid)
-
-        IO.puts(
-          "[#{DateTime.utc_now(:second)}][ConnectionDrainer] Finished draining connections."
-        )
+        with :ok <- ThousandIsland.suspend(server_pid),
+             {:ok, running_connections} <- ThousandIsland.connection_pids(server_pid) do
+          IO.puts(
+            "[#{DateTime.utc_now(:second)}][ConnectionDrainer] Stopped accepting new connections. Waiting for #{length(running_connections)} connections to finish."
+          )
+
+          wait_for_drain(server_pid)
+
+          IO.puts(
+            "[#{DateTime.utc_now(:second)}][ConnectionDrainer] Finished draining connections."
+          )
+        else
+          :error ->
+            IO.puts(
+              "[#{DateTime.utc_now(:second)}][ConnectionDrainer] No HTTP server found, nothing to drain."
+            )
+        end
 
       _ ->
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/sanbase_web/connection_drainer.ex` around lines 37 - 57, The current case
branch assumes ThousandIsland.suspend/1 and ThousandIsland.connection_pids/1
always return {:ok, ...} and will crash on :error; update the {:ok, server_pid}
branch in the safe_bandit_pid handling to use a with that calls
ThousandIsland.suspend(server_pid) and then
ThousandIsland.connection_pids(server_pid), handling {:ok, _} and matching
:error by logging/skipping draining instead of hard-matching; on success keep
the IO.puts messages and call wait_for_drain(server_pid), but if either call
returns :error, log the failure and do not call wait_for_drain.

Comment on lines +7 to +21
defmodule SlowPlug do
@moduledoc false
import Plug.Conn

def init(opts), do: opts

def call(conn, _opts) do
Process.sleep(1_000)

conn
|> put_resp_header("connection", "close")
|> put_resp_content_type("text/plain")
|> send_resp(200, "ok")
end
end

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Move SlowPlug into a separate support file.

Keeping a second module nested inside this test file violates the repo rule against multiple modules per file and makes the helper harder to reuse from other shutdown cases.

As per coding guidelines, "Never nest multiple modules in the same file as it can cause cyclic dependencies and compilation errors."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/sanbase_web/connection_drainer_test.exs` around lines 7 - 21, Move the
nested SlowPlug module out of the test file into its own top-level support file:
create a new module SlowPlug with the same functions init/1 and call/2 (keeping
the Process.sleep, put_resp_header/put_resp_content_type/send_resp behavior) and
`@moduledoc` false, remove the nested module from connection_drainer_test.exs, and
update the test to reference the standalone SlowPlug (no alias needed if named
the same); ensure the new support file is picked up by the test suite (compiled
by test farm) so tests continue to run.

Comment on lines +13 to +20
def call(conn, _opts) do
Process.sleep(1_000)

conn
|> put_resp_header("connection", "close")
|> put_resp_content_type("text/plain")
|> send_resp(200, "ok")
end

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Replace the sleep-based rendezvous with a real “request entered the plug” signal.

request_started is sent before Req.get!/1, so Line 66 is the only thing making this request “in-flight”. On a slower runner the drainer can still suspend the listener before the request reaches SlowPlug, which makes the test flaky. Have SlowPlug.call/2 notify the test process when handling starts and wait on that message instead.

Also applies to: 51-66

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/sanbase_web/connection_drainer_test.exs` around lines 13 - 20, Replace
the sleep rendezvous in SlowPlug.call/2 with an explicit test-process handshake:
have SlowPlug.call/2 send a message like {:slow_plug_started, self()} (or
{:slow_plug_started, test_pid} if you pass test PID via conn private assigns) to
the test process as soon as handling begins, then block on a receive to
continue; in the test (connection_drainer_test), wait for that
{:slow_plug_started, _} message after triggering the request (instead of relying
on Process.sleep/1_000) so the drainer is guaranteed to start draining only
after the plug has actually begun handling the request—use symbols
SlowPlug.call/2, request_started, and Req.get!/1 to locate where to add the
send/receive handshake.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (1)
lib/sanbase_web/connection_drainer.ex (1)

38-64: ⚠️ Potential issue | 🟠 Major

Use with to handle potential :error returns from ThousandIsland APIs.

The hard pattern matches on Lines 41 and 43 (":ok = ThousandIsland.suspend(server_pid)" and "{:ok, running_connections} = ThousandIsland.connection_pids(server_pid)") will crash if ThousandIsland returns :error when the server is unavailable. Use with for graceful handling:

Suggested fix using `with`
     case safe_bandit_pid(endpoint) do
       {:ok, server_pid} ->
-        # Stop accepting new connections
-        :ok = ThousandIsland.suspend(server_pid)
-
-        {:ok, running_connections} = ThousandIsland.connection_pids(server_pid)
-
-        IO.puts(
-          "[#{DateTime.utc_now(:second)}][ConnectionDrainer] Stopped accepting new connections. Waiting for #{length(running_connections)} connections to finish."
-        )
-
-        # Wait until the connections are all finished.
-        # If it takes more time, the `:shutdown` timeout will kick in
-        # and kill this process. This way we have a balance between
-        # waiting for most connections to finish, but not waiting too long
-        # or getting stuck.
-        wait_for_drain(server_pid)
-
-        IO.puts(
-          "[#{DateTime.utc_now(:second)}][ConnectionDrainer] Finished draining connections."
-        )
+        with :ok <- ThousandIsland.suspend(server_pid),
+             {:ok, running_connections} <- ThousandIsland.connection_pids(server_pid) do
+          IO.puts(
+            "[#{DateTime.utc_now(:second)}][ConnectionDrainer] Stopped accepting new connections. Waiting for #{length(running_connections)} connections to finish."
+          )
+
+          wait_for_drain(server_pid)
+
+          IO.puts(
+            "[#{DateTime.utc_now(:second)}][ConnectionDrainer] Finished draining connections."
+          )
+        else
+          :error ->
+            IO.puts(
+              "[#{DateTime.utc_now(:second)}][ConnectionDrainer] Server unavailable during drain, skipping."
+            )
+        end

       _ ->

As per coding guidelines, "Use with for chaining operations that return {:ok, _} or {:error, _} tuples."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/sanbase_web/connection_drainer.ex` around lines 38 - 64, The current
drain logic in the case branch for safe_bandit_pid uses hard pattern matches on
ThousandIsland.suspend(server_pid) and
ThousandIsland.connection_pids(server_pid) which will crash on :error; replace
those sequential calls with a with-based chain (e.g., with {:ok, server_pid}
from safe_bandit_pid(...), {:ok, _} <- ThousandIsland.suspend(server_pid), {:ok,
running_connections} <- ThousandIsland.connection_pids(server_pid) do ...) so
you can handle {:error, _} returns gracefully (log via IO.puts or a logger and
skip wait_for_drain or return {:error, reason}); keep wait_for_drain(server_pid)
only inside the successful with block and ensure the fallback clause logs the
error instead of crashing.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@lib/sanbase_web/connection_drainer.ex`:
- Around line 67-75: The resume_listener/1 function currently hard-matches :ok =
ThousandIsland.resume(server_pid) which will crash if ThousandIsland.resume/1
returns :error; change it to handle both :ok and :error (similar to terminate/2)
by calling ThousandIsland.resume(server_pid) inside a case/with and treating
:error as a no-op (or log it) so init does not crash; use the existing
safe_bandit_pid/1 to get server_pid and handle the {:ok, server_pid} branch by
matching on the resume result instead of forcing :ok.

In `@test/sanbase_web/connection_drainer_test.exs`:
- Around line 37-49: start_endpoint!/1 currently calls Supervisor.start_link to
launch the Bandit child but never registers the supervisor with the test
process, causing a resource leak; replace the Supervisor.start_link call and
manual child_spec registration with start_supervised!/1 so the supervisor is
tied to the test process and automatically stopped after each test.
Specifically, in start_endpoint!/1 update the code path that constructs
bandit_child and calls Supervisor.start_link to instead call
start_supervised!(bandit_child) (or start_supervised!([{Bandit, bandit_opts, id:
{endpoint, :http}}]) as appropriate), then continue using
Bandit.PhoenixAdapter.bandit_pid(endpoint, :http) and
ThousandIsland.listener_info(bandit_pid) unchanged.

---

Duplicate comments:
In `@lib/sanbase_web/connection_drainer.ex`:
- Around line 38-64: The current drain logic in the case branch for
safe_bandit_pid uses hard pattern matches on ThousandIsland.suspend(server_pid)
and ThousandIsland.connection_pids(server_pid) which will crash on :error;
replace those sequential calls with a with-based chain (e.g., with {:ok,
server_pid} from safe_bandit_pid(...), {:ok, _} <-
ThousandIsland.suspend(server_pid), {:ok, running_connections} <-
ThousandIsland.connection_pids(server_pid) do ...) so you can handle {:error, _}
returns gracefully (log via IO.puts or a logger and skip wait_for_drain or
return {:error, reason}); keep wait_for_drain(server_pid) only inside the
successful with block and ensure the fallback clause logs the error instead of
crashing.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 3b0abf27-7c17-4e63-bed8-15d80328316b

📥 Commits

Reviewing files that changed from the base of the PR and between de232d6 and eb7d3a6.

⛔ Files ignored due to path filters (1)
  • mix.lock is excluded by !**/*.lock
📒 Files selected for processing (7)
  • config/config.exs
  • config/dev.exs
  • config/runtime.exs
  • lib/sanbase/application/application.ex
  • lib/sanbase_web/connection_drainer.ex
  • mix.exs
  • test/sanbase_web/connection_drainer_test.exs
🚧 Files skipped from review as they are similar to previous changes (3)
  • lib/sanbase/application/application.ex
  • config/runtime.exs
  • mix.exs

Comment thread lib/sanbase_web/connection_drainer.ex Outdated
Comment on lines +37 to +49
defp start_endpoint!(plug) do
endpoint =
String.to_atom("connection_drainer_test_endpoint_#{System.unique_integer([:positive])}")

bandit_opts = [plug: plug, port: 0, startup_log: false]
bandit_child = Supervisor.child_spec({Bandit, bandit_opts}, id: {endpoint, :http})

{:ok, _endpoint_sup} =
Supervisor.start_link([bandit_child], strategy: :one_for_one, name: endpoint)

{:ok, bandit_pid} = Bandit.PhoenixAdapter.bandit_pid(endpoint, :http)
{:ok, {_ip, port}} = ThousandIsland.listener_info(bandit_pid)
{endpoint, bandit_pid, port}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Potential resource leak: Supervisor started in start_endpoint!/1 is never stopped.

The start_endpoint!/1 function starts a supervisor via Supervisor.start_link/2 but doesn't register it with start_supervised!/1. This means the supervisor and Bandit server may not be properly cleaned up between tests, potentially causing port conflicts or resource leaks.

Suggested fix using start_supervised!
   defp start_endpoint!(plug) do
     endpoint =
       String.to_atom("connection_drainer_test_endpoint_#{System.unique_integer([:positive])}")

     bandit_opts = [plug: plug, port: 0, startup_log: false]
     bandit_child = Supervisor.child_spec({Bandit, bandit_opts}, id: {endpoint, :http})

-    {:ok, _endpoint_sup} =
-      Supervisor.start_link([bandit_child], strategy: :one_for_one, name: endpoint)
+    endpoint_sup =
+      start_supervised!(
+        Supervisor.child_spec(
+          %{
+            id: endpoint,
+            start: {Supervisor, :start_link, [[bandit_child], [strategy: :one_for_one, name: endpoint]]},
+            type: :supervisor
+          },
+          []
+        )
+      )

     {:ok, bandit_pid} = Bandit.PhoenixAdapter.bandit_pid(endpoint, :http)
     {:ok, {_ip, port}} = ThousandIsland.listener_info(bandit_pid)
     {endpoint, bandit_pid, port}
   end
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/sanbase_web/connection_drainer_test.exs` around lines 37 - 49,
start_endpoint!/1 currently calls Supervisor.start_link to launch the Bandit
child but never registers the supervisor with the test process, causing a
resource leak; replace the Supervisor.start_link call and manual child_spec
registration with start_supervised!/1 so the supervisor is tied to the test
process and automatically stopped after each test. Specifically, in
start_endpoint!/1 update the code path that constructs bandit_child and calls
Supervisor.start_link to instead call start_supervised!(bandit_child) (or
start_supervised!([{Bandit, bandit_opts, id: {endpoint, :http}}]) as
appropriate), then continue using Bandit.PhoenixAdapter.bandit_pid(endpoint,
:http) and ThousandIsland.listener_info(bandit_pid) unchanged.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (3)
test/sanbase_web/connection_drainer_test.exs (3)

53-55: ⚠️ Potential issue | 🟠 Major

Manage the server supervisor with ExUnit lifecycle helpers.

Starting it with Supervisor.start_link/2 here leaves cleanup to test success paths; crashes/timeouts can leak listeners and make async runs flaky.

🧪 Proposed fix
-    {:ok, sup} = Supervisor.start_link([{Bandit, bandit_opts}], strategy: :one_for_one)
+    sup =
+      start_supervised!(
+        Supervisor.child_spec(
+          %{
+            id: make_ref(),
+            start: {Supervisor, :start_link, [[{Bandit, bandit_opts}], [strategy: :one_for_one]]},
+            type: :supervisor
+          },
+          []
+        )
+      )
     [{_id, server_pid, _type, _modules}] = Supervisor.which_children(sup)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/sanbase_web/connection_drainer_test.exs` around lines 53 - 55, The test
starts the Bandit supervisor directly with Supervisor.start_link/2 (and then
inspects children via Supervisor.which_children/1 and
ThousandIsland.listener_info/1), which can leak listeners on failures; replace
that manual start with ExUnit's supervised helpers so the supervisor is
automatically terminated on test exit — e.g., in the test setup call
start_supervised!/1 (or start_supervised/1) with the same spec used for
Supervisor.start_link([{Bandit, bandit_opts}], strategy: :one_for_one) and then
continue to call Supervisor.which_children/1 and ThousandIsland.listener_info/1
against the supervised pid; ensure the change is placed in the test setup or the
test body so ExUnit will clean up the supervisor on crashes/timeouts.

19-21: ⚠️ Potential issue | 🟠 Major

Replace sleep-based request coordination with an explicit plug-entry signal.

:request_started is sent before the request is actually handled, so shutdown can race ahead and make these assertions nondeterministic (including the forced-timeout test).

🛠️ Proposed stabilization
-    def call(conn, _opts) do
+    def call(conn, opts) do
+      if test_pid = Keyword.get(opts, :test_pid) do
+        send(test_pid, :slow_plug_started)
+      end
       Process.sleep(1_000)
@@
-    {sup, _server_pid, port} = start_server!(SlowPlug)
+    {sup, _server_pid, port} = start_server!({SlowPlug, test_pid: self()})
@@
-    request_task =
-      Task.async(fn ->
-        send(test_pid, :request_started)
-        Req.get!("http://127.0.0.1:#{port}/")
-      end)
-
-    assert_receive :request_started, 1_000
-    # Let Bandit accept the connection before initiating shutdown
-    Process.sleep(100)
+    request_task = Task.async(fn -> Req.get!("http://127.0.0.1:#{port}/") end)
+    assert_receive :slow_plug_started, 1_000
@@
-    {sup, _server_pid, port} = start_server!(SlowPlug)
+    {sup, _server_pid, port} = start_server!({SlowPlug, test_pid: self()})
@@
-    _request_task =
-      Task.async(fn ->
-        send(test_pid, :request_started)
-        Req.get("http://127.0.0.1:#{port}/")
-      end)
-
-    assert_receive :request_started, 1_000
-    Process.sleep(100)
+    _request_task = Task.async(fn -> Req.get("http://127.0.0.1:#{port}/") end)
+    assert_receive :slow_plug_started, 1_000
@@
-    {sup, _server_pid, port} = start_server!(SlowPlug, shutdown_timeout: 200)
+    {sup, _server_pid, port} = start_server!({SlowPlug, test_pid: self()}, shutdown_timeout: 200)
@@
-    Process.sleep(100)
+    assert_receive :slow_plug_started, 1_000

Also applies to: 65-74, 97-104, 130-137

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/sanbase_web/connection_drainer_test.exs` around lines 19 - 21, The test
plug's sleep-based coordination in call(conn, _opts) (and the similar blocks at
the other occurrences) should be replaced with an explicit plug-entry signal:
instead of Process.sleep(1_000), send a deterministic message from the plug
(e.g., send(test_pid, {:plug_entered, self()}) or similar) and have the test
wait/receive that message; do not rely on :request_started since it fires before
request handling. Update the tests to inject/obtain the test process PID and
assert against the explicit {:plug_entered, pid} signal so shutdown timing
becomes deterministic.

13-39: 🛠️ Refactor suggestion | 🟠 Major

Extract SlowPlug and FastPlug into separate support files.

Keeping multiple modules inside this test file violates the repo rule and reduces reusability across shutdown tests.

♻️ Proposed refactor
-defmodule SanbaseWeb.ConnectionDrainingTest do
+defmodule SanbaseWeb.ConnectionDrainingTest do
@@
-  defmodule SlowPlug do
-    `@moduledoc` false
-    import Plug.Conn
-
-    def init(opts), do: opts
-
-    def call(conn, _opts) do
-      Process.sleep(1_000)
-
-      conn
-      |> put_resp_content_type("text/plain")
-      |> send_resp(200, "ok")
-    end
-  end
-
-  defmodule FastPlug do
-    `@moduledoc` false
-    import Plug.Conn
-
-    def init(opts), do: opts
-
-    def call(conn, _opts) do
-      conn
-      |> put_resp_content_type("text/plain")
-      |> send_resp(200, "ok")
-    end
-  end
+  # Use SanbaseWeb.TestSupport.SlowPlug / SanbaseWeb.TestSupport.FastPlug

As per coding guidelines, "Never nest multiple modules in the same file as it can cause cyclic dependencies and compilation errors."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/sanbase_web/connection_drainer_test.exs` around lines 13 - 39, Extract
the nested modules SlowPlug and FastPlug out of the test file into separate
support modules so they are top-level and reusable; create dedicated support
files that define SlowPlug and FastPlug (keeping the same module names and
public functions init/1 and call/2), remove their definitions from
test/sanbase_web/connection_drainer_test.exs, and ensure the test suite loads
these support files (so tests can reference SlowPlug and FastPlug unchanged) to
comply with the rule against multiple modules in one file.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@test/sanbase_web/connection_drainer_test.exs`:
- Around line 53-55: The test starts the Bandit supervisor directly with
Supervisor.start_link/2 (and then inspects children via
Supervisor.which_children/1 and ThousandIsland.listener_info/1), which can leak
listeners on failures; replace that manual start with ExUnit's supervised
helpers so the supervisor is automatically terminated on test exit — e.g., in
the test setup call start_supervised!/1 (or start_supervised/1) with the same
spec used for Supervisor.start_link([{Bandit, bandit_opts}], strategy:
:one_for_one) and then continue to call Supervisor.which_children/1 and
ThousandIsland.listener_info/1 against the supervised pid; ensure the change is
placed in the test setup or the test body so ExUnit will clean up the supervisor
on crashes/timeouts.
- Around line 19-21: The test plug's sleep-based coordination in call(conn,
_opts) (and the similar blocks at the other occurrences) should be replaced with
an explicit plug-entry signal: instead of Process.sleep(1_000), send a
deterministic message from the plug (e.g., send(test_pid, {:plug_entered,
self()}) or similar) and have the test wait/receive that message; do not rely on
:request_started since it fires before request handling. Update the tests to
inject/obtain the test process PID and assert against the explicit
{:plug_entered, pid} signal so shutdown timing becomes deterministic.
- Around line 13-39: Extract the nested modules SlowPlug and FastPlug out of the
test file into separate support modules so they are top-level and reusable;
create dedicated support files that define SlowPlug and FastPlug (keeping the
same module names and public functions init/1 and call/2), remove their
definitions from test/sanbase_web/connection_drainer_test.exs, and ensure the
test suite loads these support files (so tests can reference SlowPlug and
FastPlug unchanged) to comply with the rule against multiple modules in one
file.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 40f9cdf3-a864-46da-8b9d-7fbd4a497934

📥 Commits

Reviewing files that changed from the base of the PR and between eb7d3a6 and 20bb209.

⛔ Files ignored due to path filters (1)
  • mix.lock is excluded by !**/*.lock
📒 Files selected for processing (7)
  • config/config.exs
  • config/dev.exs
  • config/runtime.exs
  • lib/sanbase/application/application.ex
  • lib/sanbase_web/connection_drainer.ex
  • mix.exs
  • test/sanbase_web/connection_drainer_test.exs
💤 Files with no reviewable changes (1)
  • lib/sanbase_web/connection_drainer.ex
🚧 Files skipped from review as they are similar to previous changes (3)
  • config/runtime.exs
  • mix.exs
  • config/dev.exs

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant