Skip to content

Commit 78e5184

Browse files
authored
feat: http2 sendfile streaming (#565)
* Stream HTTP/2 sendfile in chunks * test: cover chunked HTTP/2 sendfile * http2: make sendfile chunk size configurable * fix: remove unneeded boolean * chore: lint
1 parent 5af3c8f commit 78e5184

File tree

4 files changed

+92
-7
lines changed

4 files changed

+92
-7
lines changed

lib/bandit.ex

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,8 @@ defmodule Bandit do
172172
Specified as a tuple of `{count, milliseconds}` where `count` is the maximum number of
173173
RST_STREAM frames allowed within the time window of `milliseconds`. Defaults to `{500, 10_000}`
174174
(500 resets per 10 seconds). Setting this to `nil` disables rate limiting
175+
* `sendfile_chunk_size`: The maximum number of bytes read per sendfile chunk when streaming
176+
HTTP/2 responses. Defaults to 1_048_576 (1 MiB)
175177
* `default_local_settings`: Options to override the default values for local HTTP/2
176178
settings. Values provided here will override the defaults specified in RFC9113§6.5.2
177179
"""
@@ -180,6 +182,7 @@ defmodule Bandit do
180182
| {:max_header_block_size, pos_integer()}
181183
| {:max_requests, pos_integer()}
182184
| {:max_reset_stream_rate, {pos_integer(), pos_integer()} | nil}
185+
| {:sendfile_chunk_size, pos_integer()}
183186
| {:default_local_settings, keyword()}
184187
]
185188

@@ -241,7 +244,7 @@ defmodule Bandit do
241244
@top_level_keys ~w(plug scheme port ip keyfile certfile otp_app cipher_suite display_plug startup_log thousand_island_options http_options http_1_options http_2_options websocket_options)a
242245
@http_keys ~w(compress response_encodings deflate_options zstd_options log_exceptions_with_status_codes log_protocol_errors log_client_closures)a
243246
@http_1_keys ~w(enabled max_request_line_length max_header_length max_header_count max_requests clear_process_dict gc_every_n_keepalive_requests log_unknown_messages)a
244-
@http_2_keys ~w(enabled max_header_block_size max_requests max_reset_stream_rate default_local_settings)a
247+
@http_2_keys ~w(enabled max_header_block_size max_requests max_reset_stream_rate sendfile_chunk_size default_local_settings)a
245248
@websocket_keys ~w(enabled max_frame_size validate_text_frames compress deflate_options primitive_ops_module)a
246249
@thousand_island_keys ThousandIsland.ServerConfig.__struct__()
247250
|> Map.from_struct()

lib/bandit/http2/connection.ex

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -227,11 +227,15 @@ defmodule Bandit.HTTP2.Connection do
227227
:new ->
228228
new_stream!(connection, stream_id)
229229

230+
sendfile_chunk_size =
231+
Keyword.get(connection.opts.http_2, :sendfile_chunk_size, 1_048_576)
232+
230233
stream =
231234
Bandit.HTTP2.Stream.init(
232235
self(),
233236
stream_id,
234-
connection.remote_settings.initial_window_size
237+
connection.remote_settings.initial_window_size,
238+
sendfile_chunk_size
235239
)
236240

237241
case Bandit.HTTP2.StreamProcess.start_link(

lib/bandit/http2/stream.ex

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ defmodule Bandit.HTTP2.Stream do
4545
state: :idle,
4646
recv_window_size: 65_535,
4747
send_window_size: nil,
48+
sendfile_chunk_size: nil,
4849
bytes_remaining: nil,
4950
read_timeout: 15_000
5051

@@ -64,15 +65,17 @@ defmodule Bandit.HTTP2.Stream do
6465
state: state(),
6566
recv_window_size: non_neg_integer(),
6667
send_window_size: non_neg_integer(),
68+
sendfile_chunk_size: pos_integer(),
6769
bytes_remaining: non_neg_integer() | nil,
6870
read_timeout: timeout()
6971
}
7072

71-
def init(connection_pid, stream_id, initial_send_window_size) do
73+
def init(connection_pid, stream_id, initial_send_window_size, sendfile_chunk_size) do
7274
%__MODULE__{
7375
connection_pid: connection_pid,
7476
stream_id: stream_id,
75-
send_window_size: initial_send_window_size
77+
send_window_size: initial_send_window_size,
78+
sendfile_chunk_size: sendfile_chunk_size
7679
}
7780
end
7881

@@ -479,9 +482,10 @@ defmodule Bandit.HTTP2.Stream do
479482
case :file.open(path, [:raw, :binary]) do
480483
{:ok, fd} ->
481484
try do
482-
case :file.pread(fd, offset, length) do
483-
{:ok, data} -> send_data(stream, data, true)
484-
{:error, reason} -> raise "Error reading file for sendfile: #{inspect(reason)}"
485+
if length == 0 do
486+
send_data(stream, "", true)
487+
else
488+
sendfile_loop(stream, fd, offset, length, 0)
485489
end
486490
after
487491
:file.close(fd)
@@ -492,6 +496,37 @@ defmodule Bandit.HTTP2.Stream do
492496
end
493497
end
494498

499+
defp sendfile_loop(stream, _fd, _offset, length, sent) when sent >= length do
500+
stream
501+
end
502+
503+
defp sendfile_loop(stream, fd, offset, length, sent) do
504+
read_size = min(length - sent, sendfile_chunk_size(stream))
505+
506+
case :file.pread(fd, offset + sent, read_size) do
507+
{:ok, data} ->
508+
now_sent = byte_size(data)
509+
end_stream = sent + now_sent >= length
510+
stream = send_data(stream, data, end_stream)
511+
512+
if end_stream do
513+
stream
514+
else
515+
sendfile_loop(stream, fd, offset, length, sent + now_sent)
516+
end
517+
518+
:eof ->
519+
raise "Error reading file for sendfile: :eof"
520+
521+
{:error, reason} ->
522+
raise "Error reading file for sendfile: #{inspect(reason)}"
523+
end
524+
end
525+
526+
defp sendfile_chunk_size(%@for{sendfile_chunk_size: sendfile_chunk_size}) do
527+
max(sendfile_chunk_size, 1)
528+
end
529+
495530
defp split_data(data, desired_length) do
496531
data_length = IO.iodata_length(data)
497532

test/bandit/http2/protocol_test.exs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,49 @@ defmodule HTTP2ProtocolTest do
271271
conn |> send_resp(200, String.duplicate("a", 50_000))
272272
end
273273

274+
test "sendfile streams in multiple DATA frames when max frame size allows large frames",
275+
context do
276+
size = 1_500_000
277+
278+
tmp_path =
279+
Path.join(
280+
System.tmp_dir!(),
281+
"bandit-sendfile-large-#{System.unique_integer([:positive])}"
282+
)
283+
284+
File.write!(tmp_path, :binary.copy(<<"a">>, size))
285+
:persistent_term.put({__MODULE__, :large_sendfile_path}, tmp_path)
286+
287+
on_exit(fn ->
288+
:persistent_term.erase({__MODULE__, :large_sendfile_path})
289+
File.rm(tmp_path)
290+
end)
291+
292+
socket = SimpleH2Client.tls_client(context)
293+
SimpleH2Client.exchange_prefaces(socket)
294+
295+
SimpleH2Client.exchange_client_settings(
296+
socket,
297+
<<4::16, 3_000_000::32, 5::16, 2_000_000::32>>
298+
)
299+
300+
SimpleH2Client.send_window_update(socket, 0, 3_000_000)
301+
SimpleH2Client.send_simple_headers(socket, 1, :get, "/sendfile_large_chunked", context.port)
302+
303+
assert {:ok, 1, false, [{":status", "200"} | _], _ctx} =
304+
SimpleH2Client.recv_headers(socket)
305+
306+
assert {:ok, 1, false, first} = SimpleH2Client.recv_body(socket)
307+
assert byte_size(first) > 0
308+
assert {:ok, 1, true, second} = SimpleH2Client.recv_body(socket)
309+
assert byte_size(second) > 0
310+
end
311+
312+
def sendfile_large_chunked(conn) do
313+
path = :persistent_term.get({__MODULE__, :large_sendfile_path})
314+
send_file(conn, 200, path, 0, :all)
315+
end
316+
274317
test "the server preserves existing settings which are NOT sent by the client", context do
275318
socket = SimpleH2Client.tls_client(context)
276319
SimpleH2Client.exchange_prefaces(socket)

0 commit comments

Comments
 (0)