diff --git a/lib/bandit.ex b/lib/bandit.ex index cf068fdd..0667ee13 100644 --- a/lib/bandit.ex +++ b/lib/bandit.ex @@ -66,7 +66,12 @@ defmodule Bandit do * `thousand_island_options`: A list of options to pass to Thousand Island. Bandit sets some default values in this list based on your top-level configuration; these values will be overridden by values appearing here. A complete list can be found at - `t:ThousandIsland.options/0` + `t:ThousandIsland.options/0`. One default worth knowing about: Bandit sets + `genserver_options: [hibernate_after: 15_000]`, so that a connection process which has been + idle for 15 seconds is hibernated, which collects and compacts its heap and so releases the + memory left over from its last request until the connection is next used. Pass your own + `hibernate_after` within `genserver_options` to change the delay, or `:infinity` to never + hibernate * `http_options`: A list of options to configure the shared aspects of Bandit's HTTP stack. A complete list can be found at `t:http_options/0` * `http_1_options`: A list of options to configure Bandit's HTTP/1 stack. A complete list can @@ -289,6 +294,14 @@ defmodule Bandit do |> Map.keys() |> Enum.sort() + # How long a connection process may sit idle before it is hibernated. An idle keepalive or + # HTTP/2 connection otherwise holds whatever garbage its last request left (tens of KB for a + # small request, hundreds for a templated page) until the next request or collection; + # hibernating collects and compacts the heap down to a couple of KB. Fifteen seconds is long + # enough that a connection in active use never hibernates between requests, and short enough + # that a connection parked by a browser or between WebSocket heartbeats does + @hibernate_after 15_000 + @doc """ Starts a Bandit server using the provided arguments. See `t:options/0` for specific options to pass to this function. @@ -376,6 +389,13 @@ defmodule Bandit do |> Keyword.put(:transport_options, transport_options) |> Keyword.put_new(:handler_module, Bandit.DelegatingHandler) |> Keyword.put_new(:handler_options, handler_options) + |> Keyword.update( + :genserver_options, + [hibernate_after: @hibernate_after], + fn genserver_options -> + Keyword.put_new(genserver_options, :hibernate_after, @hibernate_after) + end + ) |> ThousandIsland.start_link() |> case do {:ok, pid} -> diff --git a/test/bandit/hibernation_test.exs b/test/bandit/hibernation_test.exs new file mode 100644 index 00000000..5850d6cc --- /dev/null +++ b/test/bandit/hibernation_test.exs @@ -0,0 +1,118 @@ +defmodule HibernationTest do + use ExUnit.Case, async: true + use ServerHelpers + + @hibernating {:current_function, {:erlang, :hibernate, 3}} + + def call(conn, _opts) do + conn = fetch_query_params(conn) + + if conn.query_params["websock"], + do: upgrade_adapter(conn, :websocket, {HibernationTest.Sock, [], []}), + else: send_resp(conn, 200, "OK") + end + + defmodule Sock do + use NoopWebSock + def handle_in({data, opcode: :text}, state), do: {:push, {:text, data}, state} + end + + defp connection_pids(context) do + {:ok, pids} = ThousandIsland.connection_pids(context.server_pid) + pids + end + + # A short hibernate_after keeps these tests quick; the point is that nothing in the connection + # process's loop prevents it from hibernating once idle, and that it carries on afterwards + describe "idle connection processes" do + test "hibernate on HTTP/1 between keepalive requests, then serve the next request", context do + context = + context + |> http_server(thousand_island_options: [genserver_options: [hibernate_after: 100]]) + |> Enum.into(context) + + client = SimpleHTTP1Client.tcp_client(context) + SimpleHTTP1Client.send(client, "GET", "/", ["host: localhost"]) + assert {:ok, "200 OK", _headers, "OK"} = SimpleHTTP1Client.recv_reply(client) + + Process.sleep(300) + assert [pid] = connection_pids(context) + assert Process.info(pid, :current_function) == @hibernating + + SimpleHTTP1Client.send(client, "GET", "/", ["host: localhost"]) + assert {:ok, "200 OK", _headers, "OK"} = SimpleHTTP1Client.recv_reply(client) + end + + test "hibernate on HTTP/2 between requests, then serve the next request", context do + context = + context + |> https_server(thousand_island_options: [genserver_options: [hibernate_after: 100]]) + |> Enum.into(context) + + socket = SimpleH2Client.setup_connection(context) + {:ok, send_ctx} = SimpleH2Client.send_simple_headers(socket, 1, :get, "/", context.port) + assert {:ok, 1, false, _headers, recv_ctx} = SimpleH2Client.recv_headers(socket) + assert {:ok, 1, true, "OK"} = SimpleH2Client.recv_body(socket) + + Process.sleep(300) + assert [pid] = connection_pids(context) + assert Process.info(pid, :current_function) == @hibernating + + SimpleH2Client.send_simple_headers(socket, 3, :get, "/", context.port, send_ctx) + assert {:ok, 3, false, _headers, _ctx} = SimpleH2Client.recv_headers(socket, recv_ctx) + assert {:ok, 3, true, "OK"} = SimpleH2Client.recv_body(socket) + end + + test "hibernate on an idle WebSocket, then handle the next frame", context do + context = + context + |> http_server(thousand_island_options: [genserver_options: [hibernate_after: 100]]) + |> Enum.into(context) + + client = SimpleWebSocketClient.tcp_client(context) + SimpleWebSocketClient.http1_handshake(client, HibernationTest.Sock) + + Process.sleep(300) + assert [pid] = connection_pids(context) + assert Process.info(pid, :current_function) == @hibernating + + SimpleWebSocketClient.send_text_frame(client, "still here") + assert {:ok, "still here"} = SimpleWebSocketClient.recv_text_frame(client) + end + + test "do not hibernate when hibernate_after is set to :infinity", context do + context = + context + |> http_server(thousand_island_options: [genserver_options: [hibernate_after: :infinity]]) + |> Enum.into(context) + + client = SimpleHTTP1Client.tcp_client(context) + SimpleHTTP1Client.send(client, "GET", "/", ["host: localhost"]) + assert {:ok, "200 OK", _headers, "OK"} = SimpleHTTP1Client.recv_reply(client) + + Process.sleep(300) + assert [pid] = connection_pids(context) + refute Process.info(pid, :current_function) == @hibernating + end + + @tag :slow + test "hibernate after 15 seconds by default", context do + # The test helpers' 100ms read_timeout would close the connection first + context = + context + |> http_server(thousand_island_options: [read_timeout: 60_000]) + |> Enum.into(context) + + client = SimpleHTTP1Client.tcp_client(context) + SimpleHTTP1Client.send(client, "GET", "/", ["host: localhost"]) + assert {:ok, "200 OK", _headers, "OK"} = SimpleHTTP1Client.recv_reply(client) + + Process.sleep(14_000) + assert [pid] = connection_pids(context) + refute Process.info(pid, :current_function) == @hibernating + + Process.sleep(4_000) + assert Process.info(pid, :current_function) == @hibernating + end + end +end