diff --git a/lib/mint/http.ex b/lib/mint/http.ex index 0178d58d..32dd4ca2 100644 --- a/lib/mint/http.ex +++ b/lib/mint/http.ex @@ -238,6 +238,20 @@ defmodule Mint.HTTP do server. See `Mint.HTTP2.put_settings/2` for more information. This is only used in HTTP/2 connections. + * `:connection_window_size` - (integer) the initial size of the connection-level + HTTP/2 receive window, in bytes. Sent to the server as a `WINDOW_UPDATE` frame + on stream 0 as part of the connection preface. Defaults to 16 MB. Can be + raised later with `Mint.HTTP2.set_window_size/3`. + + * `:receive_window_update_threshold` - (integer) the minimum number of bytes of receive + window that must remain on a connection or stream before a `WINDOW_UPDATE` + frame is sent to refill it. Lower values send more frequent, smaller updates; + higher values batch updates into fewer, larger ones. Defaults to 160_000 + (approximately 10× the default max frame size). The same threshold applies + to both the connection and per-stream windows; when a window's peak size is + at or below the threshold, the client refills after every DATA frame on + that window. + There may be further protocol specific options that only take effect when the corresponding connection is established. Check `Mint.HTTP1.connect/4` and `Mint.HTTP2.connect/4` for details. diff --git a/lib/mint/http2.ex b/lib/mint/http2.ex index dad9e506..7e721b0c 100644 --- a/lib/mint/http2.ex +++ b/lib/mint/http2.ex @@ -143,8 +143,16 @@ defmodule Mint.HTTP2 do @transport_opts [alpn_advertised_protocols: ["h2"]] @default_window_size 65_535 + @default_connection_window_size 16 * 1024 * 1024 + @default_stream_window_size 4 * 1024 * 1024 @max_window_size 2_147_483_647 + # Defer refilling the receive window until it has dropped to this many + # bytes — roughly 10× the default 16 KB max frame size, so the server + # has a safety margin before the window would starve it. See + # `refill_client_windows/3`. + @default_receive_window_update_threshold 160_000 + @default_max_frame_size 16_384 @valid_max_frame_size_range @default_max_frame_size..16_777_215 @@ -176,7 +184,26 @@ defmodule Mint.HTTP2 do # Fields of the connection. buffer: "", - window_size: @default_window_size, + # `send_window_size` is the client *send* window for the connection + # — how much request-body data we're allowed to send to the server + # before it refills the window with a WINDOW_UPDATE frame. + send_window_size: @default_window_size, + # `receive_window_size` is the client *receive* window for the + # connection — the peak size we've advertised to the server via + # `WINDOW_UPDATE` frames on stream 0. Initialized to the configured + # connection window during `initiate/5`, which also sends the + # matching WINDOW_UPDATE to bring the server's view from the spec + # default of 65_535 up to the advertised peak. + receive_window_size: @default_window_size, + # `receive_window_remaining` is the server's current view of our receive + # window — decremented by DATA frame sizes as they arrive, bumped + # back up to `receive_window_size` whenever we send a + # WINDOW_UPDATE. When it drops to `receive_window_update_threshold`, we + # refill it back to the peak in one frame. + receive_window_remaining: @default_window_size, + # Minimum remaining receive window before we send a WINDOW_UPDATE. + # Configurable via the `:receive_window_update_threshold` connect option. + receive_window_update_threshold: @default_receive_window_update_threshold, encode_table: HPAX.new(4096), decode_table: HPAX.new(4096), @@ -207,7 +234,7 @@ defmodule Mint.HTTP2 do # Settings that the client communicates to the server. client_settings: %{ max_concurrent_streams: 100, - initial_window_size: @default_window_size, + initial_window_size: @default_stream_window_size, max_header_list_size: :infinity, max_frame_size: @default_max_frame_size, enable_push: true @@ -729,11 +756,21 @@ defmodule Mint.HTTP2 do end @doc """ - Returns the window size of the connection or of a single request. + Returns the client **send** window size for the connection or a request. - This function is HTTP/2 specific. It returns the window size of - either the connection if `connection_or_request` is `:connection` or of a single - request if `connection_or_request` is `{:request, request_ref}`. + > #### Send vs receive windows {: .warning} + > + > This function returns the *send* window — how much body data this client + > is still permitted to send to the server before being throttled. It is + > decremented by `request/5` and `stream_request_body/3` and refilled by + > the server, which `stream/2` handles transparently. + > + > It does **not** return the client *receive* window (how much the server + > is permitted to send us). To influence that, use `set_window_size/3`. + + This function is HTTP/2 specific. It returns the send window of either the + connection if `connection_or_request` is `:connection` or of a single request + if `connection_or_request` is `{:request, request_ref}`. Use this function to check the window size of the connection before sending a full request. Also use this function to check the window size of both the @@ -744,21 +781,23 @@ defmodule Mint.HTTP2 do ## HTTP/2 Flow Control - In HTTP/2, flow control is implemented through a - window size. When the client sends data to the server, the window size is decreased - and the server needs to "refill" it on the client side. You don't need to take care of - the refilling of the client window as it happens behind the scenes in `stream/2`. + In HTTP/2, flow control is implemented through a window size. When the client + sends data to the server, the window size is decreased and the server needs + to "refill" it on the client side, which `stream/2` handles transparently. + Symmetrically, the server's outbound flow toward the client is bounded by a + receive window the client advertises and refills — see `set_window_size/3`. - A window size is kept for the entire connection and all requests affect this window - size. A window size is also kept per request. + A window size is kept for the entire connection and all requests affect this + window size. A window size is also kept per request. - The only thing that affects the window size is the body of a request, regardless of - if it's a full request sent with `request/5` or body chunks sent through - `stream_request_body/3`. That means that if we make a request with a body that is - five bytes long, like `"hello"`, the window size of the connection and the window size - of that particular request will decrease by five bytes. + The only thing that affects the send window size is the body of a request, + regardless of whether it's a full request sent with `request/5` or body chunks + sent through `stream_request_body/3`. That means that if we make a request with + a body that is five bytes long, like `"hello"`, the send window size of the + connection and the send window size of that particular request will decrease + by five bytes. - If we use all the window size before the server refills it, functions like + If we use all the send window size before the server refills it, functions like `request/5` will return an error. ## Examples @@ -783,13 +822,13 @@ defmodule Mint.HTTP2 do def get_window_size(conn, connection_or_request) def get_window_size(%__MODULE__{} = conn, :connection) do - conn.window_size + conn.send_window_size end def get_window_size(%__MODULE__{} = conn, {:request, request_ref}) do case Map.fetch(conn.ref_to_stream_id, request_ref) do {:ok, stream_id} -> - conn.streams[stream_id].window_size + conn.streams[stream_id].send_window_size :error -> raise ArgumentError, @@ -797,6 +836,121 @@ defmodule Mint.HTTP2 do end end + @doc """ + Advertises a larger client **receive** window to the server. + + > #### Receive vs send windows {: .warning} + > + > This function sets the *receive* window — the peak amount of body data + > the server is permitted to send us before being throttled. It does + > **not** set the *send* window (how much body data we're permitted to + > send to the server) — the server controls that. See `get_window_size/2` + > for the send window. + + Without calling this, `stream/2` refills the receive window in small + increments as response body data is consumed. Each refill costs a + round-trip before the server can send more, so bulk throughput is capped + at roughly `window / RTT`; on higher-latency links the default 64 KB + window makes that cap well below the link bandwidth. Raising the window + removes those pauses and is the main HTTP/2 tuning knob for bulk or + highly parallel downloads. + + Mint exposes the per-stream initial window as the `:initial_window_size` + client setting passed to `connect/4`, but there is no connection-level + equivalent — use this function for the connection window, and for any + per-stream adjustment after a request has started. + + `connection_or_request` is `:connection` for the whole connection or + `{:request, request_ref}` for a single request. `new_size` must be in + `1..2_147_483_647`. Windows can only grow: `new_size` smaller than the + current receive window returns + `{:error, conn, %Mint.HTTPError{reason: :window_size_too_small}}`, and + `new_size` equal to the current window is a no-op. + + For more information on flow control and window sizes in HTTP/2, see the + section below. + + ## HTTP/2 Flow Control + + See `get_window_size/2` for a description of the client *send* window. + The client *receive* window is the symmetric bound on the server's + outbound flow: it starts at 64 KB for the connection and for each new + request, is decremented by response body bytes, and is refilled by + `stream/2` as the body is consumed. A window size is kept for the entire + connection and all responses affect this window size; a window size is + also kept per request. + + This function raises the *advertised* receive window — the peak the + server is allowed to fill before pausing. It does not pre-allocate any + buffers; it only permits the server to send further ahead of the + client's reads. + + ## Examples + + Bump the connection-level receive window right after connect so the server + can stream multi-MB bodies without flow-control pauses: + + {:ok, conn} = Mint.HTTP2.connect(:https, host, 443) + {:ok, conn} = Mint.HTTP2.set_window_size(conn, :connection, 8_000_000) + + Give one specific request a bigger window than the per-stream default: + + {:ok, conn, ref} = Mint.HTTP2.request(conn, "GET", "/huge", [], nil) + {:ok, conn} = Mint.HTTP2.set_window_size(conn, {:request, ref}, 16_000_000) + + """ + @spec set_window_size(t(), :connection | {:request, Types.request_ref()}, pos_integer()) :: + {:ok, t()} | {:error, t(), Types.error()} + def set_window_size(conn, connection_or_request, new_size) + + def set_window_size(%__MODULE__{} = _conn, _target, new_size) + when not (is_integer(new_size) and new_size >= 1 and new_size <= @max_window_size) do + raise ArgumentError, + "new window size must be an integer in 1..#{@max_window_size}, got: #{inspect(new_size)}" + end + + def set_window_size(%__MODULE__{} = conn, :connection, new_size) do + do_set_window_size(conn, 0, conn.receive_window_size, new_size, fn conn, size -> + conn = put_in(conn.receive_window_size, size) + put_in(conn.receive_window_remaining, size) + end) + catch + :throw, {:mint, conn, error} -> {:error, conn, error} + end + + def set_window_size(%__MODULE__{} = conn, {:request, request_ref}, new_size) do + case Map.fetch(conn.ref_to_stream_id, request_ref) do + {:ok, stream_id} -> + current = conn.streams[stream_id].receive_window_size + + do_set_window_size(conn, stream_id, current, new_size, fn conn, size -> + conn = put_in(conn.streams[stream_id].receive_window_size, size) + put_in(conn.streams[stream_id].receive_window_remaining, size) + end) + + :error -> + {:error, conn, wrap_error({:unknown_request_to_stream, request_ref})} + end + catch + :throw, {:mint, conn, error} -> {:error, conn, error} + end + + defp do_set_window_size(conn, _stream_id, current, new_size, _update) + when new_size == current do + {:ok, conn} + end + + defp do_set_window_size(conn, _stream_id, current, new_size, _update) when new_size < current do + {:error, conn, wrap_error({:window_size_too_small, current, new_size})} + end + + defp do_set_window_size(conn, stream_id, current, new_size, update) do + increment = new_size - current + frame = window_update(stream_id: stream_id, window_size_increment: increment) + conn = send!(conn, Frame.encode(frame)) + {:ok, update.(conn, new_size)} + end + @doc """ See `Mint.HTTP.stream/2`. """ @@ -968,7 +1122,25 @@ defmodule Mint.HTTP2 do scheme_string = Atom.to_string(scheme) mode = Keyword.get(opts, :mode, :active) log? = Keyword.get(opts, :log, false) + + connection_window_size = + Keyword.get(opts, :connection_window_size, @default_connection_window_size) + + validate_window_size!(:connection_window_size, connection_window_size) + + receive_window_update_threshold = + Keyword.get( + opts, + :receive_window_update_threshold, + @default_receive_window_update_threshold + ) + + validate_receive_window_update_threshold!(receive_window_update_threshold) client_settings_params = Keyword.get(opts, :client_settings, []) + + client_settings_params = + Keyword.put_new(client_settings_params, :initial_window_size, @default_stream_window_size) + validate_client_settings!(client_settings_params) # If the port is the default for the scheme, don't add it to the :authority pseudo-header authority = @@ -997,12 +1169,26 @@ defmodule Mint.HTTP2 do mode: mode, scheme: scheme_string, state: :handshaking, - log: log? + log: log?, + receive_window_size: connection_window_size, + receive_window_remaining: connection_window_size, + receive_window_update_threshold: receive_window_update_threshold } + # Mirror the advertised client settings into `conn.client_settings` up + # front. Streams opened before the server's SETTINGS ACK arrives read + # their initial receive window from this map; without this, they would + # track the library default instead of the value we actually sent in + # the SETTINGS frame, and stream-level WINDOW_UPDATEs would never fire + # when the advertised window is smaller than the default. + conn = + update_in(conn.client_settings, fn settings -> + Enum.into(client_settings_params, settings) + end) + + preface = build_preface(client_settings_params, connection_window_size) + with :ok <- Util.inet_opts(transport, socket), - client_settings = settings(stream_id: 0, params: client_settings_params), - preface = [@connection_preface, Frame.encode(client_settings)], :ok <- transport.send(socket, preface), conn = update_in(conn.client_settings_queue, &:queue.in(client_settings_params, &1)), conn = put_in(conn.socket, socket), @@ -1015,6 +1201,34 @@ defmodule Mint.HTTP2 do end end + defp build_preface(client_settings_params, connection_window_size) do + settings_frame = Frame.encode(settings(stream_id: 0, params: client_settings_params)) + + if connection_window_size > @default_window_size do + increment = connection_window_size - @default_window_size + update_frame = Frame.encode(window_update(stream_id: 0, window_size_increment: increment)) + [@connection_preface, settings_frame, update_frame] + else + [@connection_preface, settings_frame] + end + end + + defp validate_window_size!(name, value) do + unless is_integer(value) and value >= @default_window_size and value <= @max_window_size do + raise ArgumentError, + "the :#{name} option must be an integer in " <> + "#{@default_window_size}..#{@max_window_size}, got: #{inspect(value)}" + end + end + + defp validate_receive_window_update_threshold!(value) do + unless is_integer(value) and value >= 1 and value <= @max_window_size do + raise ArgumentError, + "the :receive_window_update_threshold option must be a positive integer no larger than " <> + "#{@max_window_size}, got: #{inspect(value)}" + end + end + @doc """ See `Mint.HTTP.get_socket/1`. """ @@ -1083,7 +1297,18 @@ defmodule Mint.HTTP2 do id: conn.next_stream_id, ref: make_ref(), state: :idle, - window_size: conn.server_settings.initial_window_size, + # Client send window — decremented as we send body bytes, refilled + # by incoming WINDOW_UPDATE frames from the server. Bounded initially + # by the server's SETTINGS_INITIAL_WINDOW_SIZE. + send_window_size: conn.server_settings.initial_window_size, + # Client receive window — the peak we've advertised to the server + # for this stream. Starts at whatever we told the server via our + # SETTINGS_INITIAL_WINDOW_SIZE; can be bumped per-stream with + # `set_window_size/3`. + receive_window_size: conn.client_settings.initial_window_size, + # Current remaining receive window for this stream, tracked + # independently from the peak so that refills can be batched. + receive_window_remaining: conn.client_settings.initial_window_size, received_first_headers?: false } @@ -1213,11 +1438,15 @@ defmodule Mint.HTTP2 do data_size = IO.iodata_length(data) cond do - data_size > stream.window_size -> - throw({:mint, conn, wrap_error({:exceeds_window_size, :request, stream.window_size})}) + data_size > stream.send_window_size -> + throw( + {:mint, conn, wrap_error({:exceeds_window_size, :request, stream.send_window_size})} + ) - data_size > conn.window_size -> - throw({:mint, conn, wrap_error({:exceeds_window_size, :connection, conn.window_size})}) + data_size > conn.send_window_size -> + throw( + {:mint, conn, wrap_error({:exceeds_window_size, :connection, conn.send_window_size})} + ) # If the data size is greater than the max frame size, we chunk automatically based # on the max frame size. @@ -1245,8 +1474,8 @@ defmodule Mint.HTTP2 do when is_integer(stream_id) and is_list(enabled_flags) do chunk_size = IO.iodata_length(chunk) frame = data(stream_id: stream_id, flags: set_flags(:data, enabled_flags), data: chunk) - conn = update_in(conn.streams[stream_id].window_size, &(&1 - chunk_size)) - conn = update_in(conn.window_size, &(&1 - chunk_size)) + conn = update_in(conn.streams[stream_id].send_window_size, &(&1 - chunk_size)) + conn = update_in(conn.send_window_size, &(&1 - chunk_size)) conn = if :end_stream in enabled_flags do @@ -1578,17 +1807,79 @@ defmodule Mint.HTTP2 do end end + # Accounts for `data_size` bytes arriving on the connection and on + # `stream_id`. Sends a WINDOW_UPDATE for either window only once its + # remaining receive credit drops to `conn.receive_window_update_threshold`; + # previously we sent one per DATA frame, so an adversarial server + # emitting many small frames could amplify its inbound bytes into a + # WINDOW_UPDATE flood of outbound frames. Batching caps that ratio at + # roughly one update per `(receive_window_size - threshold)` bytes + # consumed. defp refill_client_windows(conn, stream_id, data_size) do - connection_frame = window_update(stream_id: 0, window_size_increment: data_size) - stream_frame = window_update(stream_id: stream_id, window_size_increment: data_size) + conn = update_in(conn.receive_window_remaining, &(&1 - data_size)) + + conn = + case Map.fetch(conn.streams, stream_id) do + {:ok, _stream} -> + update_in(conn.streams[stream_id].receive_window_remaining, &(&1 - data_size)) - if open?(conn) do - send!(conn, [Frame.encode(connection_frame), Frame.encode(stream_frame)]) + :error -> + conn + end + + frames = + [] + |> maybe_refill_stream(conn, stream_id) + |> maybe_refill_conn(conn) + + if frames != [] and open?(conn) do + conn = send!(conn, Enum.map(frames, &Frame.encode/1)) + apply_refills(conn, frames) else conn end end + defp maybe_refill_conn(frames, conn) do + if conn.receive_window_remaining <= conn.receive_window_update_threshold do + increment = conn.receive_window_size - conn.receive_window_remaining + [window_update(stream_id: 0, window_size_increment: increment) | frames] + else + frames + end + end + + defp maybe_refill_stream(frames, conn, stream_id) do + case Map.fetch(conn.streams, stream_id) do + {:ok, stream} -> + if stream.receive_window_remaining <= conn.receive_window_update_threshold do + increment = stream.receive_window_size - stream.receive_window_remaining + + [ + window_update(stream_id: stream_id, window_size_increment: increment) | frames + ] + else + frames + end + + :error -> + frames + end + end + + defp apply_refills(conn, frames) do + Enum.reduce(frames, conn, fn + window_update(stream_id: 0), conn -> + put_in(conn.receive_window_remaining, conn.receive_window_size) + + window_update(stream_id: stream_id), conn -> + put_in( + conn.streams[stream_id].receive_window_remaining, + conn.streams[stream_id].receive_window_size + ) + end) + end + # HEADERS defp handle_headers(conn, frame, responses) do @@ -1864,16 +2155,16 @@ defmodule Mint.HTTP2 do for {stream_id, stream} <- streams, stream.state in [:open, :half_closed_remote], into: streams do - window_size = stream.window_size + diff + send_window_size = stream.send_window_size + diff - if window_size > @max_window_size do + if send_window_size > @max_window_size do debug_data = - "INITIAL_WINDOW_SIZE parameter of #{window_size} makes some window sizes too big" + "INITIAL_WINDOW_SIZE parameter of #{send_window_size} makes some window sizes too big" send_connection_error!(conn, :flow_control_error, debug_data) end - {stream_id, %{stream | window_size: window_size}} + {stream_id, %{stream | send_window_size: send_window_size}} end end) @@ -1932,7 +2223,9 @@ defmodule Mint.HTTP2 do id: promised_stream_id, ref: make_ref(), state: :reserved_remote, - window_size: conn.server_settings.initial_window_size, + send_window_size: conn.server_settings.initial_window_size, + receive_window_size: conn.client_settings.initial_window_size, + receive_window_remaining: conn.client_settings.initial_window_size, received_first_headers?: false } @@ -2029,12 +2322,12 @@ defmodule Mint.HTTP2 do window_update(stream_id: 0, window_size_increment: wsi), responses ) do - new_window_size = conn.window_size + wsi + new_window_size = conn.send_window_size + wsi if new_window_size > @max_window_size do send_connection_error!(conn, :flow_control_error, "window size too big") else - conn = put_in(conn.window_size, new_window_size) + conn = put_in(conn.send_window_size, new_window_size) {conn, responses} end end @@ -2045,14 +2338,14 @@ defmodule Mint.HTTP2 do responses ) do stream = fetch_stream!(conn, stream_id) - new_window_size = conn.streams[stream_id].window_size + wsi + new_window_size = conn.streams[stream_id].send_window_size + wsi if new_window_size > @max_window_size do conn = close_stream!(conn, stream_id, :flow_control_error) error = wrap_error({:flow_control_error, "window size too big"}) {conn, [{:error, stream.ref, error} | responses]} else - conn = put_in(conn.streams[stream_id].window_size, new_window_size) + conn = put_in(conn.streams[stream_id].send_window_size, new_window_size) {conn, responses} end end @@ -2223,6 +2516,15 @@ defmodule Mint.HTTP2 do "can't stream chunk of data because the request is unknown" end + def format_error({:unknown_request_to_stream, ref}) do + "request with reference #{inspect(ref)} was not found" + end + + def format_error({:window_size_too_small, current, new_size}) do + "set_window_size/3 can only grow a window; new size #{new_size} is " <> + "smaller than the current size #{current}" + end + def format_error(:request_is_not_streaming) do "can't send more data on this request since it's not streaming" end diff --git a/test/mint/http2/conn_test.exs b/test/mint/http2/conn_test.exs index 27215664..42bc075f 100644 --- a/test/mint/http2/conn_test.exs +++ b/test/mint/http2/conn_test.exs @@ -133,6 +133,102 @@ defmodule Mint.HTTP2Test do end end + describe "set_window_size/3" do + @describetag connect_options: [connection_window_size: 65_535] + + test "bumps the connection-level receive window by sending WINDOW_UPDATE on stream 0", + %{conn: conn} do + assert HTTP2.get_window_size(conn, :connection) == 65_535 + assert conn.receive_window_size == 65_535 + assert conn.receive_window_remaining == 65_535 + + assert {:ok, conn} = HTTP2.set_window_size(conn, :connection, 1_000_000) + + assert conn.receive_window_size == 1_000_000 + assert conn.receive_window_remaining == 1_000_000 + + assert_recv_frames [ + window_update(stream_id: 0, window_size_increment: 934_465) + ] + end + + test "bumps a per-stream receive window by sending WINDOW_UPDATE on that stream", + %{conn: conn} do + {conn, ref} = open_request(conn) + assert_recv_frames [headers(stream_id: stream_id)] + + current = conn.streams[stream_id].receive_window_size + assert conn.streams[stream_id].receive_window_remaining == current + + assert {:ok, conn} = HTTP2.set_window_size(conn, {:request, ref}, current + 10_000) + + assert conn.streams[stream_id].receive_window_size == current + 10_000 + assert conn.streams[stream_id].receive_window_remaining == current + 10_000 + + assert_recv_frames [ + window_update(stream_id: ^stream_id, window_size_increment: 10_000) + ] + end + + test "is a no-op when the new size equals the current size", %{conn: conn} do + assert {:ok, ^conn} = HTTP2.set_window_size(conn, :connection, 65_535) + + # Nothing should have gone out on the wire. + assert_recv_frames [] + end + + test "returns an error when attempting to shrink the connection window", %{conn: conn} do + {:ok, conn} = HTTP2.set_window_size(conn, :connection, 1_000_000) + assert_recv_frames [window_update(stream_id: 0)] + + assert {:error, ^conn, error} = HTTP2.set_window_size(conn, :connection, 500_000) + + assert_http2_error error, {:window_size_too_small, 1_000_000, 500_000} + + # No WINDOW_UPDATE was sent for the invalid call. + assert_recv_frames [] + end + + test "returns an error when attempting to shrink a stream window", %{conn: conn} do + {conn, ref} = open_request(conn) + assert_recv_frames [headers(stream_id: stream_id)] + + current = conn.streams[stream_id].receive_window_size + after_grow = current + 10_000 + shrink_target = current + 5_000 + + {:ok, conn} = HTTP2.set_window_size(conn, {:request, ref}, after_grow) + assert_recv_frames [window_update(stream_id: ^stream_id)] + + assert {:error, ^conn, error} = + HTTP2.set_window_size(conn, {:request, ref}, shrink_target) + + assert_http2_error error, {:window_size_too_small, ^after_grow, ^shrink_target} + end + + test "returns an error for an unknown request ref", %{conn: conn} do + fake_ref = make_ref() + + assert {:error, ^conn, error} = HTTP2.set_window_size(conn, {:request, fake_ref}, 1_000_000) + + assert_http2_error error, {:unknown_request_to_stream, ^fake_ref} + end + + test "raises on out-of-range new_size", %{conn: conn} do + assert_raise ArgumentError, ~r/1\.\.2147483647/, fn -> + HTTP2.set_window_size(conn, :connection, 0) + end + + assert_raise ArgumentError, ~r/1\.\.2147483647/, fn -> + HTTP2.set_window_size(conn, :connection, 3_000_000_000) + end + + assert_raise ArgumentError, ~r/1\.\.2147483647/, fn -> + HTTP2.set_window_size(conn, :connection, :nope) + end + end + end + describe "open?/1" do test "returns true if the state is :open or :handshaking", %{conn: conn} do assert HTTP2.open?(%{conn | state: :open}) @@ -1774,6 +1870,162 @@ defmodule Mint.HTTP2Test do end end + describe "default receive windows" do + test "advertises the configured connection window with a WINDOW_UPDATE in the preface", + %{conn: conn} do + # The default :connection_window_size is 16 MB; the preface carries + # a WINDOW_UPDATE for `16 MB - 65_535` so the server sees the peak + # from the start. + assert conn.receive_window_size == 16 * 1024 * 1024 + assert conn.receive_window_remaining == 16 * 1024 * 1024 + end + + test "advertises the configured stream window via SETTINGS", %{conn: conn} do + # The default :client_settings[:initial_window_size] is 4 MB. + assert conn.client_settings.initial_window_size == 4 * 1024 * 1024 + + {conn, _ref} = open_request(conn) + assert_recv_frames [headers(stream_id: stream_id)] + + assert conn.streams[stream_id].receive_window_size == 4 * 1024 * 1024 + assert conn.streams[stream_id].receive_window_remaining == 4 * 1024 * 1024 + end + + @tag connect_options: [connection_window_size: 1_000_000] + test "supports a custom :connection_window_size", %{conn: conn} do + assert conn.receive_window_size == 1_000_000 + assert conn.receive_window_remaining == 1_000_000 + end + + @tag :no_connection + test "streams opened before the SETTINGS ACK track the advertised window, not the default", + %{server_port: port, server_socket_task: server_socket_task} do + # Regression: when the user advertises a stream window smaller than + # the library default (4 MB), streams opened before the server's + # SETTINGS ACK must track the advertised value. Otherwise the + # client holds onto credit the server never granted, never crosses + # the refill threshold, never sends a stream-level WINDOW_UPDATE, + # and the connection stalls after the server exhausts its send + # window. + assert {:ok, conn} = + HTTP2.connect(:https, "localhost", port, + transport_opts: [verify: :verify_none], + client_settings: [initial_window_size: 65_535] + ) + + {:ok, _server_socket} = Task.await(server_socket_task) + + # Open a request *before* the server has ACKed our SETTINGS — this + # is the pre-ACK window where the struct must already reflect what + # was advertised, not the library default. + assert {:ok, conn, ref} = HTTP2.request(conn, "GET", "/", [], nil) + + stream_id = conn.ref_to_stream_id[ref] + assert conn.streams[stream_id].receive_window_size == 65_535 + assert conn.streams[stream_id].receive_window_remaining == 65_535 + end + + @tag connect_options: [connection_window_size: 65_535] + test "omits the preface WINDOW_UPDATE when the configured window equals the spec default", + %{conn: conn} do + # At 65_535 there's nothing to advertise beyond SETTINGS — the + # preface should not carry an extra WINDOW_UPDATE. + assert conn.receive_window_size == 65_535 + assert conn.receive_window_remaining == 65_535 + end + + test "rejects a :connection_window_size below the spec minimum" do + assert_raise ArgumentError, ~r/:connection_window_size/, fn -> + HTTP2.initiate(:https, self(), "localhost", 443, connection_window_size: 1024) + end + end + + test "rejects a :connection_window_size above 2^31-1" do + assert_raise ArgumentError, ~r/:connection_window_size/, fn -> + HTTP2.initiate(:https, self(), "localhost", 443, connection_window_size: 2_147_483_648) + end + end + + test "rejects a non-positive :receive_window_update_threshold" do + assert_raise ArgumentError, ~r/:receive_window_update_threshold/, fn -> + HTTP2.initiate(:https, self(), "localhost", 443, receive_window_update_threshold: 0) + end + end + end + + describe "receive window batching" do + @describetag connect_options: [ + connection_window_size: 100_000, + receive_window_update_threshold: 40_000, + client_settings: [initial_window_size: 100_000] + ] + + test "does not send WINDOW_UPDATE until remaining window drops below threshold", + %{conn: conn} do + {conn, _ref} = open_request(conn) + assert_recv_frames [headers(stream_id: stream_id)] + + # 50_000 bytes consumed leaves 50_000 remaining on both windows, + # above the 40_000 threshold, so no WINDOW_UPDATE should go out. + chunk = String.duplicate("a", 10_000) + + frames = for _ <- 1..5, do: data(stream_id: stream_id, data: chunk) + + assert {:ok, %HTTP2{} = _conn, _responses} = stream_frames(conn, frames) + + assert_recv_frames [] + end + + test "sends one WINDOW_UPDATE topping both windows back to peak once the threshold is crossed", + %{conn: conn} do + {conn, _ref} = open_request(conn) + assert_recv_frames [headers(stream_id: stream_id)] + + # 60_000 bytes consumed drops both windows to exactly the 40_000 + # threshold; the 7th frame lands after the refill so there's + # still just one WINDOW_UPDATE per window. + chunk = String.duplicate("a", 10_000) + + frames = for _ <- 1..7, do: data(stream_id: stream_id, data: chunk) + + assert {:ok, %HTTP2{} = _conn, _responses} = stream_frames(conn, frames) + + assert_recv_frames [ + window_update(stream_id: 0, window_size_increment: 60_000), + window_update(stream_id: ^stream_id, window_size_increment: 60_000) + ] + end + + test "set_window_size/3 raises the target so subsequent refills top up to the new peak", + %{conn: conn} do + {conn, ref} = open_request(conn) + assert_recv_frames [headers(stream_id: stream_id)] + + # Raise the connection peak mid-flight. set_window_size sends its own + # WINDOW_UPDATE for the bump; drain that before proceeding. + {:ok, conn} = HTTP2.set_window_size(conn, :connection, 500_000) + assert_recv_frames [window_update(stream_id: 0, window_size_increment: 400_000)] + + # Also raise the stream peak so the stream doesn't bottleneck the + # connection-level test. + {:ok, conn} = HTTP2.set_window_size(conn, {:request, ref}, 500_000) + assert_recv_frames [window_update(stream_id: ^stream_id, window_size_increment: 400_000)] + + # Consume enough to drop both windows to the 40_000 threshold; + # the refill should top up to the raised 500_000 peak, not the + # original 100_000 configured at connect. + chunk = String.duplicate("a", 10_000) + frames = for _ <- 1..46, do: data(stream_id: stream_id, data: chunk) + + assert {:ok, %HTTP2{} = _conn, _responses} = stream_frames(conn, frames) + + assert_recv_frames [ + window_update(stream_id: 0, window_size_increment: 460_000), + window_update(stream_id: ^stream_id, window_size_increment: 460_000) + ] + end + end + describe "settings" do test "put_settings/2 can be used to send settings to server", %{conn: conn} do {:ok, conn} = diff --git a/test/support/mint/http2/test_server.ex b/test/support/mint/http2/test_server.ex index 3c55bc6f..41834641 100644 --- a/test/support/mint/http2/test_server.ex +++ b/test/support/mint/http2/test_server.ex @@ -1,6 +1,6 @@ defmodule Mint.HTTP2.TestServer do import ExUnit.Assertions - import Mint.HTTP2.Frame, only: [settings: 1, goaway: 1, ping: 1] + import Mint.HTTP2.Frame, only: [settings: 1, goaway: 1, ping: 1, window_update: 1] alias Mint.HTTP2.Frame @@ -144,10 +144,18 @@ defmodule Mint.HTTP2.TestServer do # First we get the connection preface. {:ok, unquote(connection_preface) <> rest} = :ssl.recv(socket, 0, 100) - # Then we get a SETTINGS frame. - assert {:ok, frame, ""} = Frame.decode_next(rest) + # Then we get a SETTINGS frame, optionally followed by a WINDOW_UPDATE + # on stream 0 if the client raised its connection-level receive window. + assert {:ok, frame, rest} = Frame.decode_next(rest) assert settings(flags: ^no_flags, params: _params) = frame - :ok + case rest do + "" -> + :ok + + _ -> + assert {:ok, window_update(stream_id: 0), ""} = Frame.decode_next(rest) + :ok + end end end