Skip to content
Merged
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
22 changes: 21 additions & 1 deletion lib/bandit.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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} ->
Expand Down
118 changes: 118 additions & 0 deletions test/bandit/hibernation_test.exs
Original file line number Diff line number Diff line change
@@ -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

Check failure on line 28 in test/bandit/hibernation_test.exs

View workflow job for this annotation

GitHub Actions / test / test (1.19.0, 28.x)

test idle connection processes hibernate on HTTP/1 between keepalive requests, then serve the next request (HibernationTest)
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

Check failure on line 46 in test/bandit/hibernation_test.exs

View workflow job for this annotation

GitHub Actions / test / test (1.19.0, 28.x)

test idle connection processes hibernate on HTTP/2 between requests, then serve the next request (HibernationTest)
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

Check failure on line 66 in test/bandit/hibernation_test.exs

View workflow job for this annotation

GitHub Actions / test / test (1.19.0, 28.x)

test idle connection processes hibernate on an idle WebSocket, then handle the next frame (HibernationTest)
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
Loading