From 20bb209589c62ccbc6e1677c0cb409b9296589f4 Mon Sep 17 00:00:00 2001 From: Ivan Ivanov Date: Thu, 9 Apr 2026 11:52:51 +0300 Subject: [PATCH] Replace Cowboy with Bandit --- config/config.exs | 11 +- config/dev.exs | 9 +- config/runtime.exs | 16 +- lib/sanbase/application/application.ex | 17 ++- lib/sanbase_web/connection_drainer.ex | 54 ------- mix.exs | 4 +- mix.lock | 6 +- test/sanbase_web/connection_drainer_test.exs | 148 +++++++++++++++++++ 8 files changed, 185 insertions(+), 80 deletions(-) delete mode 100644 lib/sanbase_web/connection_drainer.ex create mode 100644 test/sanbase_web/connection_drainer_test.exs diff --git a/config/config.exs b/config/config.exs index 42837c8404..285b1edc78 100644 --- a/config/config.exs +++ b/config/config.exs @@ -144,7 +144,16 @@ config :sanbase, Sanbase.Accounts.Hmac, secret_key: {:system, "APIKEY_HMAC_SECRE # Configures the endpoint config :sanbase, SanbaseWeb.Endpoint, - http: [protocol_options: [max_request_line_length: 16_384, max_header_value_length: 8192]], + adapter: Bandit.PhoenixAdapter, + http: [ + http_1_options: [ + max_request_line_length: 16_384, + max_header_count: 100, + # Cookies can be large; Bandit's max_header_length covers the full + # header line (name + value). Cowboy had max_header_value_length: 8192. + max_header_length: 16_384 + ] + ], url: [host: "localhost"], secret_key_base: "not_secret_please_do_not_report_Vq7Rfo0T4EfiLX2/ryYal3O0l9ebBNhyh58cfWdTAUHxEJGu2p9u1WTQ31Ki4Phj", diff --git a/config/dev.exs b/config/dev.exs index d9e552f0d4..34fedb8f43 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -13,11 +13,12 @@ port = String.to_integer(System.get_env("PORT") || "4000") config :sanbase, SanbaseWeb.Endpoint, http: [ - compress: true, port: port, - protocol_options: [ - # Bump up cowboy2's timeout to 100 seconds - idle_timeout: 100_000 + thousand_island_options: [ + read_timeout: 100_000 + ], + http_options: [ + compress: true ] ], url: [host: "localhost"], diff --git a/config/runtime.exs b/config/runtime.exs index 98fbd52b51..96692ad9c5 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -84,8 +84,8 @@ if config_env() == :prod do # MCP tool calls (e.g. combined_trends_tool) can run long due to document # collection + AI summarization. The timeout chain must be ordered: - # Cowboy idle_timeout (180s) > Anubis request_timeout (150s) > task work (120s) - idle_timeout = + # Bandit read_timeout (180s) > Anubis request_timeout (150s) > task work (120s) + read_timeout = if System.get_env("CONTAINER_TYPE") == "mcp", do: 180_000, else: 100_000 config :sanbase, SanbaseWeb.Endpoint, @@ -93,12 +93,14 @@ if config_env() == :prod do http: [ :inet6, port: port, - protocol_options: [ - max_header_name_length: 64, - max_header_value_length: 8192, + thousand_island_options: [ + read_timeout: read_timeout, + shutdown_timeout: 30_000 + ], + http_1_options: [ max_request_line_length: 16_384, - max_headers: 100, - idle_timeout: idle_timeout + max_header_count: 100, + max_header_length: 16_384 ] ], secret_key_base: secret_key_base, diff --git a/lib/sanbase/application/application.ex b/lib/sanbase/application/application.ex index bd86cfab08..4f3222d729 100644 --- a/lib/sanbase/application/application.ex +++ b/lib/sanbase/application/application.ex @@ -364,15 +364,18 @@ defmodule Sanbase.Application do # Start the Presence SanbaseWeb.Presence, - # Start the endpoint when the application starts + # Start the endpoint when the application starts. + # Bandit/ThousandIsland has built-in connection draining on shutdown + # (configured via thousand_island_options.shutdown_timeout: 30s in runtime.exs). + # When the supervisor stops the endpoint, ThousandIsland will: + # 1. Close the listening socket (reject new connections) + # 2. Wait up to 30s for in-flight requests to complete + # 3. Forcibly terminate any remaining connections after the timeout + # Cowboy had no connection draining — on shutdown all connections were + # killed immediately, so we needed a custom ConnectionDrainer GenServer. + # Bandit handles this natively, so the custom drainer was removed. SanbaseWeb.Endpoint, - # Drain the running connections before closing. This will allow the - # currently executing API calls to finish. The drainer first makes - # the TCP acceptor to stop accepting new connections and then waits - # until there are no connections or 30 seconds pass. - {SanbaseWeb.ConnectionDrainer, shutdown: 30_000, ranch_ref: SanbaseWeb.Endpoint.HTTP}, - # Process that starts test-only deps start_in(Sanbase.TestSetupService, [:test]), Sanbase.EventBus.children() diff --git a/lib/sanbase_web/connection_drainer.ex b/lib/sanbase_web/connection_drainer.ex deleted file mode 100644 index 5f7134054e..0000000000 --- a/lib/sanbase_web/connection_drainer.ex +++ /dev/null @@ -1,54 +0,0 @@ -defmodule SanbaseWeb.ConnectionDrainer do - @moduledoc ~s""" - Implement a graceful shutdown for the Phoenix server by draining connections. - - In the Supervision tree, processes start in the order they are defined - and are stopped in the reverse order. This process should be put after the - Endpoint. - """ - use GenServer - - def child_spec(options) when is_list(options) do - ranch_ref = Keyword.fetch!(options, :ranch_ref) - shutdown = Keyword.fetch!(options, :shutdown) - - %{ - id: __MODULE__, - start: {__MODULE__, :start_link, [ranch_ref]}, - shutdown: shutdown - } - end - - def start_link(ranch_ref) do - GenServer.start_link(__MODULE__, ranch_ref, name: __MODULE__) - end - - def init(ranch_ref) do - Process.flag(:trap_exit, true) - # Maybe not needed, but if this process is killed for some reason and restarted, - # we want to be able to resume accepting connections. - :ranch.resume_listener(ranch_ref) - {:ok, ranch_ref} - end - - def terminate(reason, ranch_ref) do - # If we're in terminating state, the Logger does not work here for some reason - IO.puts("[#{DateTime.utc_now(:second)}][ConnectionDrainer] Terminating with reason #{reason}") - # Stop accepting new connections - :ok = :ranch.suspend_listener(ranch_ref) - running_connections = :ranch.procs(ranch_ref, :connections) - - 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. - :ok = :ranch.wait_for_connections(ranch_ref, :==, 0) - - IO.puts("[#{DateTime.utc_now(:second)}][ConnectionDrainer] Finished draining connections.") - end -end diff --git a/mix.exs b/mix.exs index 0c7aa09595..8b4900347d 100644 --- a/mix.exs +++ b/mix.exs @@ -54,8 +54,7 @@ defmodule Sanbase.Mixfile do {:ecto_ch, "~> 0.8"}, {:ch, "~> 0.7"}, {:con_cache, "~> 1.0"}, - {:cowboy, "~> 2.0"}, - {:cowlib, ">= 2.16.0 and < 3.0.0", override: true}, + {:bandit, "~> 1.0"}, {:crc32cer, github: "zmstone/crc32cer", submodules: true, override: true}, {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, {:dataloader, "~> 2.0.0"}, @@ -131,7 +130,6 @@ defmodule Sanbase.Mixfile do {:phoenix_live_view, "~> 1.0.1", override: true}, {:phoenix_pubsub, "~> 2.0"}, {:phoenix_view, "~> 2.0"}, - {:plug_cowboy, "~> 2.5"}, {:postgrex, "~> 0.19"}, {:prom_ex, "~> 1.8"}, {:req, "~> 0.5"}, diff --git a/mix.lock b/mix.lock index c8a0ba40cc..e4eafc516f 100644 --- a/mix.lock +++ b/mix.lock @@ -3,6 +3,7 @@ "absinthe_phoenix": {:hex, :absinthe_phoenix, "2.0.4", "f36999412fbd6a2339abb5b7e24a4cc9492bbc7909d5806deeef83b06f55c508", [:mix], [{:absinthe, "~> 1.5", [hex: :absinthe, repo: "hexpm", optional: false]}, {:absinthe_plug, "~> 1.5", [hex: :absinthe_plug, repo: "hexpm", optional: false]}, {:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.5", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.13 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}], "hexpm", "66617ee63b725256ca16264364148b10b19e2ecb177488cd6353584f2e6c1cf3"}, "absinthe_plug": {:hex, :absinthe_plug, "1.5.9", "4f66fd46aecf969b349dd94853e6132db6d832ae6a4b951312b6926ad4ee7ca3", [:mix], [{:absinthe, "~> 1.7", [hex: :absinthe, repo: "hexpm", optional: false]}, {:plug, "~> 1.4", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "dcdc84334b0e9e2cd439bd2653678a822623f212c71088edf0a4a7d03f1fa225"}, "anubis_mcp": {:hex, :anubis_mcp, "1.0.0", "25b2d6b952d440d3ddf718fef70b9e6bb1454ebd30f30cd29b039c02850c20bb", [:mix], [{:burrito, "~> 1.0", [hex: :burrito, repo: "hexpm", optional: true]}, {:finch, "~> 0.19", [hex: :finch, repo: "hexpm", optional: false]}, {:gun, "~> 2.2", [hex: :gun, repo: "hexpm", optional: true]}, {:peri, "0.6.2", [hex: :peri, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: true]}, {:redix, "~> 1.5", [hex: :redix, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3bb61d9eb598f00ca0acdd76e2c43121637e4eca148f2deb36192e3daf1e4e16"}, + "bandit": {:hex, :bandit, "1.10.4", "02b9734c67c5916a008e7eb7e2ba68aaea6f8177094a5f8d95f1fb99069aac17", [: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.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "a5faf501042ac1f31d736d9d4a813b3db4ef812e634583b6a457b0928798a51d"}, "boruta": {:hex, :boruta, "2.3.5", "2f61fb06d4fbd6787c0ee4fc693976a2a41d52beb713b42f42094e7a81791a5d", [:mix], [{:ecto_sql, ">= 3.5.2", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ex_json_schema, "~> 0.6", [hex: :ex_json_schema, repo: "hexpm", optional: false]}, {:finch, "~> 0.16", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:joken, "~> 2.0", [hex: :joken, repo: "hexpm", optional: false]}, {:jose, "~> 1.11", [hex: :jose, repo: "hexpm", optional: false]}, {:nebulex, "~> 2.0", [hex: :nebulex, repo: "hexpm", optional: false]}, {:owl, "~> 0.8.0", [hex: :owl, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}, {:puid, "~> 1.0", [hex: :puid, repo: "hexpm", optional: false]}, {:secure_random, "~> 0.5", [hex: :secure_random, repo: "hexpm", optional: false]}, {:shards, "~> 1.0", [hex: :shards, repo: "hexpm", optional: false]}], "hexpm", "7d6a00ef40dc8b2fef7fb2f1d50f3a03d3ef5fa60a0c8fcd635123bc1961b9c1"}, "brod": {:hex, :brod, "4.5.3", "fceadb37e53c06d20a786552f6e4cc75441bf1562d7e5b67067bc9d1d5a32f80", [:cmake, :rebar3], [{:kafka_protocol, "4.3.4", [hex: :kafka_protocol, repo: "hexpm", optional: false]}], "hexpm", "de21c3d6103d70e85f765515422200ce9278044acf41c86400b82b507db5ba45"}, "browser": {:hex, :browser, "0.5.5", "9ce8065adfa9e30c9f6ccc7d6d7f0a1a7d4e99ed9989c3d9ef457ba0d47b0ced", [:mix], [{:plug, "~> 1.2", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "f3348ee357d9acd765a7dba9f685337385912cc03fa10a79dbe7643487ed2db4"}, @@ -16,14 +17,11 @@ "circular_buffer": {:hex, :circular_buffer, "1.0.0", "25c004da0cba7bd8bc1bdabded4f9a902d095e20600fd15faf1f2ffbaea18a07", [:mix], [], "hexpm", "c829ec31c13c7bafd1f546677263dff5bfb006e929f25635878ac3cfba8749e5"}, "combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"}, "con_cache": {:hex, :con_cache, "1.1.1", "9f47a68dfef5ac3bbff8ce2c499869dbc5ba889dadde6ac4aff8eb78ddaf6d82", [:mix], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "1def4d1bec296564c75b5bbc60a19f2b5649d81bfa345a2febcc6ae380e8ae15"}, - "cowboy": {:hex, :cowboy, "2.14.2", "4008be1df6ade45e4f2a4e9e2d22b36d0b5aba4e20b0a0d7049e28d124e34847", [:make, :rebar3], [{:cowlib, ">= 2.16.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", "569081da046e7b41b5df36aa359be71a0c8874e5b9cff6f747073fc57baf1ab9"}, - "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.16.0", "54592074ebbbb92ee4746c8a8846e5605052f29309d3a873468d76cdf932076f", [:make, :rebar3], [], "hexpm", "7f478d80d66b747344f0ea7708c187645cfcc08b11aa424632f78e25bf05db51"}, "crc32cer": {:git, "https://github.com/zmstone/crc32cer.git", "3f6d73c46442c7d613655dd101b1761a75a61e2c", [submodules: true]}, "credo": {:hex, :credo, "1.7.17", "f92b6aa5b26301eaa5a35e4d48ebf5aa1e7094ac00ae38f87086c562caf8a22f", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "1eb5645c835f0b6c9b5410f94b5a185057bcf6d62a9c2b476da971cde8749645"}, "crontab": {:hex, :crontab, "1.2.0", "503611820257939d5d0fd272eb2b454f48a470435a809479ddc2c40bb515495c", [:mix], [{:ecto, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm", "ebd7ef4d831e1b20fa4700f0de0284a04cac4347e813337978e25b4cc5cc2207"}, "crypto_rand": {:hex, :crypto_rand, "1.0.4", "0d32cbbaa8c229a45e79cdaefd7a48cfb9e6ed803a5168731fe27409daa27c6f", [:mix], [], "hexpm", "ad1862fd3e1c938f60982902632474868ea96901d75dd53f0ec32dd55e123549"}, - "curve25519": {:hex, :curve25519, "1.0.5", "f801179424e4012049fcfcfcda74ac04f65d0ffceeb80e7ef1d3352deb09f5bb", [:mix], [], "hexpm", "0fba3ad55bf1154d4d5fc3ae5fb91b912b77b13f0def6ccb3a5d58168ff4192d"}, "dataloader": {:hex, :dataloader, "2.0.2", "c45075e0692e68638a315e14f747bd8d7065fb5f38705cf980f62d4cd344401f", [:mix], [{:ecto, ">= 3.4.3 and < 4.0.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:opentelemetry_process_propagator, "~> 0.2.1 or ~> 0.3", [hex: :opentelemetry_process_propagator, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "4c6cabc0b55e96e7de74d14bf37f4a5786f0ab69aa06764a1f39dda40079b098"}, "db_connection": {:hex, :db_connection, "2.9.0", "a6a97c5c958a2d7091a58a9be40caf41ab496b0701d21e1d1abff3fa27a7f371", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "17d502eacaf61829db98facf6f20808ed33da6ccf495354a41e64fe42f9c509c"}, "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, @@ -129,7 +127,6 @@ "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, "phoenix_view": {:hex, :phoenix_view, "2.0.4", "b45c9d9cf15b3a1af5fb555c674b525391b6a1fe975f040fb4d913397b31abf4", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "4e992022ce14f31fe57335db27a28154afcc94e9983266835bb3040243eb620b"}, "plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [: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", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"}, - "plug_cowboy": {:hex, :plug_cowboy, "2.8.0", "07789e9c03539ee51bb14a07839cc95aa96999fd8846ebfd28c97f0b50c7b612", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "9cbfaaf17463334ca31aed38ea7e08a68ee37cabc077b1e9be6d2fb68e0171d0"}, "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, "poolboy": {:hex, :poolboy, "1.5.2", "392b007a1693a64540cead79830443abf5762f5d30cf50bc95cb2c1aaafa006b", [:rebar3], [], "hexpm", "dad79704ce5440f3d5a3681c8590b9dc25d1a561e8f5a9c995281012860901e3"}, "postgrex": {:hex, :postgrex, "0.22.0", "fb027b58b6eab1f6de5396a2abcdaaeb168f9ed4eccbb594e6ac393b02078cbd", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "a68c4261e299597909e03e6f8ff5a13876f5caadaddd0d23af0d0a61afcc5d84"}, @@ -165,6 +162,7 @@ "temp": {:hex, :temp, "0.4.9", "eb6355bfa7925a568b3d9eb3bb57e89aa6d2b78bfe8dfb6b698e090631b7f41f", [:mix], [], "hexpm", "bc8bf7b27d9105bef933ef4bf4ba37ac6b899dbeba329deaa88c60b62d6b4b6d"}, "tesla": {:hex, :tesla, "1.16.0", "de77d083aea08ebd1982600693ff5d779d68a4bb835d136a0394b08f69714660", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:finch, "~> 0.13", [hex: :finch, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:gun, ">= 1.0.0", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.21", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "4.4.2", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:mox, "~> 1.0", [hex: :mox, repo: "hexpm", optional: true]}, {:msgpax, "~> 2.3", [hex: :msgpax, repo: "hexpm", optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "eb3bdfc0c6c8a23b4e3d86558e812e3577acff1cb4acb6cfe2da1985a1035b89"}, "text_chunker": {:hex, :text_chunker, "0.6.0", "c53f0d70194a849d21b99816d841d62225d905390d7dc815d579b0f42aef6b79", [:mix], [{:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}], "hexpm", "0a0afeb96b720bdaebffa2fb5f0bc1ff946543025723c4d80cc40176c518d9a2"}, + "thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"}, "tidewave": {:hex, :tidewave, "0.5.5", "a125dfc87f99daf0e2280b3a9719b874c616ead5926cdf9cdfe4fcc19a020eff", [:mix], [{:circular_buffer, "~> 0.4 or ~> 1.0", [hex: :circular_buffer, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_live_reload, ">= 1.6.1", [hex: :phoenix_live_reload, repo: "hexpm", optional: true]}, {:plug, "~> 1.17", [hex: :plug, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "825ebb4fa20de005785efa21e5a88c04d81c3f57552638d12ff3def2f203dbf7"}, "timex": {:hex, :timex, "3.7.13", "0688ce11950f5b65e154e42b47bf67b15d3bc0e0c3def62199991b8a8079a1e2", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 1.1", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "09588e0522669328e973b8b4fd8741246321b3f0d32735b589f78b136e6d4c54"}, "toml": {:hex, :toml, "0.7.0", "fbcd773caa937d0c7a02c301a1feea25612720ac3fa1ccb8bfd9d30d822911de", [:mix], [], "hexpm", "0690246a2478c1defd100b0c9b89b4ea280a22be9a7b313a8a058a2408a2fa70"}, diff --git a/test/sanbase_web/connection_drainer_test.exs b/test/sanbase_web/connection_drainer_test.exs new file mode 100644 index 0000000000..512a8e787f --- /dev/null +++ b/test/sanbase_web/connection_drainer_test.exs @@ -0,0 +1,148 @@ +defmodule SanbaseWeb.ConnectionDrainingTest do + @moduledoc """ + Verify that Bandit/ThousandIsland's built-in connection draining works: + when the server shuts down, in-flight requests complete gracefully + and new connections are refused. + + Cowboy does not have connection draining — on deploy, all connections + are terminated immediately. Bandit/ThousandIsland handles this natively + via the shutdown_timeout option (see runtime.exs). + """ + use ExUnit.Case, async: true + + 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 + + defp start_server!(plug, opts \\ []) do + shutdown_timeout = Keyword.get(opts, :shutdown_timeout, 15_000) + + bandit_opts = [ + plug: plug, + port: 0, + startup_log: false, + thousand_island_options: [shutdown_timeout: shutdown_timeout] + ] + + # Start Bandit under its own supervisor so we can stop it directly, + # simulating what happens during a real application shutdown. + {:ok, sup} = Supervisor.start_link([{Bandit, bandit_opts}], strategy: :one_for_one) + [{_id, server_pid, _type, _modules}] = Supervisor.which_children(sup) + {:ok, {_ip, port}} = ThousandIsland.listener_info(server_pid) + + {sup, server_pid, port} + end + + test "in-flight requests complete before shutdown finishes" do + {sup, _server_pid, port} = start_server!(SlowPlug) + test_pid = self() + + # Start a slow request in a separate process + 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) + + # Stop the supervisor — simulates application shutdown. + # This should block until connections drain. + stop_task = Task.async(fn -> Supervisor.stop(sup, :normal) end) + + # The stop should NOT return instantly because the request is still in-flight + refute Task.yield(stop_task, 200) + + # The in-flight request should complete successfully despite shutdown + response = Task.await(request_task, 5_000) + assert response.status == 200 + assert response.body == "ok" + + # Now shutdown should finish + assert :ok = Task.await(stop_task, 5_000) + end + + test "new connections are refused after shutdown begins" do + {sup, _server_pid, port} = start_server!(SlowPlug) + test_pid = self() + + # Start a slow request to keep the server draining + _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) + + # Begin shutdown in background — server is now draining + _stop_task = Task.async(fn -> Supervisor.stop(sup, :normal) end) + + # Give ThousandIsland a moment to close the listening socket + Process.sleep(100) + + # New connections should be refused + assert {:error, %Req.TransportError{reason: :econnrefused}} = + Req.get("http://127.0.0.1:#{port}/", retry: false) + end + + test "shutdown completes immediately when there are no active connections" do + {sup, _server_pid, _port} = start_server!(FastPlug) + + {elapsed, :ok} = + :timer.tc(fn -> Supervisor.stop(sup, :normal) end, :millisecond) + + assert elapsed < 500 + end + + test "connections are forcibly terminated after shutdown_timeout expires" do + # Use a very short shutdown_timeout so the test doesn't take forever. + # The SlowPlug takes 1s, but we only allow 200ms for draining. + {sup, _server_pid, port} = start_server!(SlowPlug, shutdown_timeout: 200) + + # Start a request that will take longer than the shutdown_timeout + request_task = + Task.async(fn -> + Req.get("http://127.0.0.1:#{port}/", retry: false) + end) + + Process.sleep(100) + + # Shutdown — should forcibly kill after ~200ms, not wait the full 1s + {elapsed, :ok} = + :timer.tc(fn -> Supervisor.stop(sup, :normal) end, :millisecond) + + # Should complete well under the 1s request duration + assert elapsed < 1_000 + + # The request should have been terminated (connection reset/closed) + assert {:error, _} = Task.await(request_task, 2_000) + end +end