Skip to content

Commit 3ad49c8

Browse files
authored
Implement max_concurrent_streams (#524)
* Implement max_concurrent_streams literally * Ensure max_requests=0 means no limit * Raise earlier and avoid a few conditionals for performance
1 parent 521c7cd commit 3ad49c8

5 files changed

Lines changed: 182 additions & 32 deletions

File tree

lib/bandit/http2/connection.ex

Lines changed: 49 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -222,42 +222,51 @@ defmodule Bandit.HTTP2.Connection do
222222
connection.streams
223223

224224
:new ->
225-
if accept_stream?(connection) do
226-
stream =
227-
Bandit.HTTP2.Stream.init(
228-
self(),
229-
stream_id,
230-
connection.remote_settings.initial_window_size
231-
)
232-
233-
case Bandit.HTTP2.StreamProcess.start_link(
234-
stream,
235-
connection.plug,
236-
connection.telemetry_span,
237-
connection.conn_data,
238-
connection.opts
239-
) do
240-
{:ok, pid} ->
241-
streams = Bandit.HTTP2.StreamCollection.insert(connection.streams, stream_id, pid)
242-
with_stream(%{connection | streams: streams}, stream_id, fun)
243-
244-
_ ->
245-
raise "Unable to start stream process"
246-
end
247-
else
248-
connection_error!("Connection count exceeded", Bandit.HTTP2.Errors.refused_stream())
225+
new_stream!(connection, stream_id)
226+
227+
stream =
228+
Bandit.HTTP2.Stream.init(
229+
self(),
230+
stream_id,
231+
connection.remote_settings.initial_window_size
232+
)
233+
234+
case Bandit.HTTP2.StreamProcess.start_link(
235+
stream,
236+
connection.plug,
237+
connection.telemetry_span,
238+
connection.conn_data,
239+
connection.opts
240+
) do
241+
{:ok, pid} ->
242+
streams = Bandit.HTTP2.StreamCollection.insert(connection.streams, stream_id, pid)
243+
with_stream(%{connection | streams: streams}, stream_id, fun)
244+
245+
_ ->
246+
raise "Unable to start stream process"
249247
end
250248

251249
:invalid ->
252250
connection_error!("Received invalid stream identifier")
253251
end
254252
end
255253

256-
defp accept_stream?(connection) do
254+
defp new_stream!(connection, stream_id) do
257255
max_requests = Keyword.get(connection.opts.http_2, :max_requests, 0)
258256

259-
max_requests == 0 ||
260-
Bandit.HTTP2.StreamCollection.stream_count(connection.streams) < max_requests
257+
if max_requests != 0 and
258+
max_requests <= Bandit.HTTP2.StreamCollection.stream_count(connection.streams) do
259+
connection_error!("Connection count exceeded", Bandit.HTTP2.Errors.refused_stream())
260+
end
261+
262+
if connection.local_settings.max_concurrent_streams <=
263+
Bandit.HTTP2.StreamCollection.open_stream_count(connection.streams) do
264+
stream_error!(
265+
"Concurrent stream count exceeded",
266+
stream_id,
267+
Bandit.HTTP2.Errors.refused_stream()
268+
)
269+
end
261270
end
262271

263272
defp check_oversize_fragment!(fragment, connection) do
@@ -401,6 +410,19 @@ defmodule Bandit.HTTP2.Connection do
401410
raise Bandit.HTTP2.Errors.ConnectionError, message: message, error_code: error_code
402411
end
403412

413+
@spec stream_error!(
414+
String.t(),
415+
Bandit.HTTP2.Stream.stream_id(),
416+
Bandit.HTTP2.Errors.error_code()
417+
) ::
418+
no_return()
419+
defp stream_error!(message, stream_id, error_code) do
420+
raise Bandit.HTTP2.Errors.StreamError,
421+
message: message,
422+
error_code: error_code,
423+
stream_id: stream_id
424+
end
425+
404426
defp send_frame(frame, socket, connection) do
405427
_ =
406428
ThousandIsland.Socket.send(

lib/bandit/http2/handler.ex

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ defmodule Bandit.HTTP2.Handler do
1414
connection = Bandit.HTTP2.Connection.init(socket, state.plug, state.opts)
1515
{:continue, Map.merge(state, %{buffer: <<>>, connection: connection})}
1616
rescue
17-
error -> rescue_error(error, __STACKTRACE__, socket, state)
17+
error -> rescue_connection_error(error, __STACKTRACE__, socket, state)
1818
end
1919

2020
@impl ThousandIsland.Handler
@@ -38,7 +38,8 @@ defmodule Bandit.HTTP2.Handler do
3838
end)
3939
|> then(&{:continue, &1})
4040
rescue
41-
error -> rescue_error(error, __STACKTRACE__, socket, state)
41+
error in Bandit.HTTP2.Errors.StreamError -> rescue_stream_error(error, socket, state)
42+
error -> rescue_connection_error(error, __STACKTRACE__, socket, state)
4243
end
4344

4445
@impl ThousandIsland.Handler
@@ -148,7 +149,18 @@ defmodule Bandit.HTTP2.Handler do
148149
{:noreply, {socket, %{state | connection: connection}}, socket.read_timeout}
149150
end
150151

151-
defp rescue_error(error, stacktrace, socket, state) do
152+
defp rescue_stream_error(error, socket, state) do
153+
Bandit.HTTP2.Connection.send_rst_stream(
154+
error.stream_id,
155+
error.error_code,
156+
socket,
157+
state.connection
158+
)
159+
160+
{:continue, state}
161+
end
162+
163+
defp rescue_connection_error(error, stacktrace, socket, state) do
152164
do_rescue_error(error, stacktrace, socket, state)
153165
{:close, state}
154166
end

lib/bandit/http2/stream_collection.ex

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,9 @@ defmodule Bandit.HTTP2.StreamCollection do
7070
@spec stream_count(t()) :: non_neg_integer()
7171
def stream_count(collection), do: collection.stream_count
7272

73+
@spec open_stream_count(t()) :: non_neg_integer()
74+
def open_stream_count(collection), do: collection.pid_to_id |> map_size()
75+
7376
@spec last_stream_id(t()) :: Bandit.HTTP2.Stream.stream_id()
7477
def last_stream_id(collection), do: collection.last_stream_id
7578
end

test/bandit/http2/protocol_test.exs

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
defmodule HTTP2ProtocolTest do
22
use ExUnit.Case, async: true
33
use ServerHelpers
4+
use Machete
45

56
import Bitwise
67

@@ -233,6 +234,16 @@ defmodule HTTP2ProtocolTest do
233234
assert_receive {:log, %{level: :error, msg: {:string, msg}}}, 500
234235
assert msg == "** (Bandit.HTTP2.Errors.ConnectionError) Connection count exceeded"
235236
end
237+
238+
@tag :capture_log
239+
test "max_requests zero does not put a limit", context do
240+
context = https_server(context, http_2_options: [max_requests: 0])
241+
socket = SimpleH2Client.setup_connection(context)
242+
port = context[:port]
243+
SimpleH2Client.send_simple_headers(socket, 1, :get, "/body_response", port)
244+
{:ok, 1, false, _, _} = SimpleH2Client.recv_headers(socket)
245+
assert SimpleH2Client.recv_body(socket) == {:ok, 1, true, "OK"}
246+
end
236247
end
237248

238249
describe "settings exchange" do
@@ -3050,4 +3061,99 @@ defmodule HTTP2ProtocolTest do
30503061
echo_components(conn)
30513062
end
30523063
end
3064+
3065+
describe "max_concurrent_streams flag" do
3066+
test "refuses streams when max_concurrent_streams limit is exceeded", context do
3067+
context =
3068+
context
3069+
|> https_server(http_2_options: [default_local_settings: [max_concurrent_streams: 2]])
3070+
|> Enum.into(context)
3071+
3072+
port = context.port
3073+
3074+
socket = setup_connection_with_custom_settings(context)
3075+
3076+
{:ok, send_ctx} =
3077+
SimpleH2Client.send_simple_headers(socket, 1, :get, "/slow_body_response", port)
3078+
3079+
{:ok, send_ctx} =
3080+
SimpleH2Client.send_simple_headers(socket, 3, :get, "/slow_body_response", port, send_ctx)
3081+
3082+
{:ok, send_ctx} =
3083+
SimpleH2Client.send_simple_headers(socket, 5, :get, "/slow_body_response", port, send_ctx)
3084+
3085+
t0 = for _ <- 1..3, do: SimpleH2Client.recv_frame(socket)
3086+
3087+
# Now fourth stream should be accepted since we're below the limit again
3088+
SimpleH2Client.send_simple_headers(socket, 7, :get, "/echo", context.port, send_ctx)
3089+
3090+
t1 = for _ <- 1..5, do: SimpleH2Client.recv_frame(socket)
3091+
3092+
t0
3093+
|> Enum.concat(t1)
3094+
|> assert
3095+
~> in_any_order([
3096+
{:ok, :headers, integer(), 1, string()},
3097+
{:ok, :data, integer(), 1, "OK"},
3098+
{:ok, :headers, integer(), 3, string()},
3099+
{:ok, :data, integer(), 3, "OK"},
3100+
{:ok, :rst_stream, 0, 5, <<7::32>>},
3101+
{:ok, :headers, integer(), 7, string()},
3102+
{:ok, :data, integer(), 7, "OK"}
3103+
])
3104+
3105+
assert Transport.recv(socket, 0) == {:error, :closed}
3106+
end
3107+
3108+
test "allows new streams after previous streams complete", context do
3109+
context =
3110+
context
3111+
|> https_server(http_2_options: [default_local_settings: [max_concurrent_streams: 2]])
3112+
|> Enum.into(context)
3113+
3114+
port = context.port
3115+
3116+
socket = setup_connection_with_custom_settings(context)
3117+
3118+
{:ok, send_ctx} =
3119+
SimpleH2Client.send_simple_headers(socket, 1, :get, "/slow_body_response", port)
3120+
3121+
{:ok, send_ctx} =
3122+
SimpleH2Client.send_simple_headers(socket, 3, :get, "/slow_body_response", port, send_ctx)
3123+
3124+
t0 = for _ <- 1..2, do: SimpleH2Client.recv_frame(socket)
3125+
3126+
SimpleH2Client.send_simple_headers(socket, 5, :get, "/slow_body_response", port, send_ctx)
3127+
3128+
t1 = for _ <- 1..5, do: SimpleH2Client.recv_frame(socket)
3129+
3130+
t0
3131+
|> Enum.concat(t1)
3132+
|> assert
3133+
~> in_any_order([
3134+
{:ok, :headers, integer(), 1, string()},
3135+
{:ok, :headers, integer(), 3, string()},
3136+
{:ok, :headers, integer(), 5, string()},
3137+
{:ok, :data, integer(), 1, "OK"},
3138+
{:ok, :data, integer(), 3, "OK"},
3139+
{:ok, :data, integer(), 5, "OK"},
3140+
{:ok, :goaway, 0, 0, <<5::32, 0::32>>}
3141+
])
3142+
3143+
assert Transport.recv(socket, 0) == {:error, :closed}
3144+
end
3145+
3146+
def slow_body_response(conn) do
3147+
Process.sleep(50)
3148+
conn |> send_resp(200, "OK")
3149+
end
3150+
3151+
# Helper function to set up connection when server has custom settings
3152+
defp setup_connection_with_custom_settings(context) do
3153+
socket = SimpleH2Client.tls_client(context)
3154+
SimpleH2Client.exchange_prefaces(socket, true)
3155+
SimpleH2Client.exchange_client_settings(socket)
3156+
socket
3157+
end
3158+
end
30533159
end

test/support/simple_h2_client.ex

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,16 @@ defmodule SimpleH2Client do
1212
socket
1313
end
1414

15-
def exchange_prefaces(socket) do
15+
def exchange_prefaces(socket, with_settings \\ false) do
1616
Transport.send(socket, "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n")
17-
{:ok, <<0, 0, 0, 4, 0, 0, 0, 0, 0>>} = Transport.recv(socket, 9)
17+
{:ok, <<length::24, 4, 0, 0, 0, 0, 0>>} = Transport.recv(socket, 9)
18+
19+
if with_settings and length > 0 do
20+
{:ok, _settings_data} = Transport.recv(socket, length)
21+
else
22+
0 = length
23+
end
24+
1825
Transport.send(socket, <<0, 0, 0, 4, 1, 0, 0, 0, 0>>)
1926
end
2027

0 commit comments

Comments
 (0)