From 8eb787db038ee55b71a642014207bc67473aa39d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Sun, 12 Apr 2026 11:26:59 -0700 Subject: [PATCH 1/5] Add Mint.HTTP2.set_window_size/3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Advertises a larger HTTP/2 receive window (connection-level or per-stream) by sending a WINDOW_UPDATE frame. Needed because RFC 7540 makes the connection-level initial window tunable only via WINDOW_UPDATE — not SETTINGS — leaving the spec default of 64 KB as the only reachable value without an API like this. In hex's `mix deps.get` — many parallel multi-MB tarball downloads sharing one HTTP/2 connection — raising the connection window from 64 KB to 8 MB via this function drops 10 runs from 32.7s to 29.2s (10.8%), matching their HTTP/1 pool. Deliberately asymmetric with get_window_size/2 (which returns the client *send* window). Docstrings on both carry warning callouts spelling out send-vs-receive so callers don't assume they round-trip. Target is :connection or {:request, ref}; grow-only (shrink attempts return {:error, conn, %HTTPError{reason: :window_size_too_small}}); new_size validated against 1..2^31-1. Tracks the advertised peak on new receive_window_size fields on the connection and stream. --- lib/mint/http2.ex | 182 +++++++++++++++++++++++++++++++--- test/mint/http2/conn_test.exs | 91 +++++++++++++++++ 2 files changed, 257 insertions(+), 16 deletions(-) diff --git a/lib/mint/http2.ex b/lib/mint/http2.ex index dad9e506..1e2ae8af 100644 --- a/lib/mint/http2.ex +++ b/lib/mint/http2.ex @@ -176,7 +176,16 @@ defmodule Mint.HTTP2 do # Fields of the connection. buffer: "", + # `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. 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 (or the spec default of 65_535 + # if we've never sent one). Mint auto-refills to maintain this peak + # as DATA frames arrive. + receive_window_size: @default_window_size, encode_table: HPAX.new(4096), decode_table: HPAX.new(4096), @@ -729,11 +738,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 +763,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 @@ -797,6 +818,118 @@ 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 -> + put_in(conn.receive_window_size, 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 -> + put_in(conn.streams[stream_id].receive_window_size, 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`. """ @@ -1083,7 +1216,15 @@ defmodule Mint.HTTP2 do id: conn.next_stream_id, ref: make_ref(), state: :idle, + # 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. 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, received_first_headers?: false } @@ -2223,6 +2364,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..f6fb983e 100644 --- a/test/mint/http2/conn_test.exs +++ b/test/mint/http2/conn_test.exs @@ -131,6 +131,97 @@ defmodule Mint.HTTP2Test do assert_http2_error error, {:protocol_error, "received invalid frame ping during handshake"} refute HTTP2.open?(conn) end + + end + + describe "set_window_size/3" do + 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 {:ok, conn} = HTTP2.set_window_size(conn, :connection, 1_000_000) + + assert conn.receive_window_size == 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 {:ok, conn} = HTTP2.set_window_size(conn, {:request, ref}, current + 10_000) + + assert conn.streams[stream_id].receive_window_size == 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 From 26759f269d92bf8656f7d9636cf93f131726c5ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Sun, 12 Apr 2026 17:26:40 -0700 Subject: [PATCH 2/5] Rename window_size field to send_window_size The connection and stream structs tracked a `window_size` field for the client's outbound (send) window and a separately-named `receive_window_size` field for the inbound window. Renaming the former to `send_window_size` makes the pair symmetric and removes a long-standing source of confusion about which direction a bare `window_size` refers to. --- lib/mint/http2.ex | 44 ++++++++++++++++++++++---------------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/lib/mint/http2.ex b/lib/mint/http2.ex index 1e2ae8af..951e4ec4 100644 --- a/lib/mint/http2.ex +++ b/lib/mint/http2.ex @@ -176,10 +176,10 @@ defmodule Mint.HTTP2 do # Fields of the connection. buffer: "", - # `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. - 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 (or the spec default of 65_535 @@ -804,13 +804,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, @@ -1219,7 +1219,7 @@ defmodule Mint.HTTP2 do # 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. - window_size: conn.server_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 @@ -1354,11 +1354,11 @@ 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. @@ -1386,8 +1386,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 @@ -2005,16 +2005,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) @@ -2073,7 +2073,7 @@ 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, received_first_headers?: false } @@ -2170,12 +2170,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 @@ -2186,14 +2186,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 From 3aced03b2d3def44589a5f464c2d03089c3ec84f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Sun, 12 Apr 2026 17:29:44 -0700 Subject: [PATCH 3/5] Raise default HTTP/2 receive windows Default connection receive window is now 16 MB (was 65_535), sent via a WINDOW_UPDATE on stream 0 as part of the connection preface. Default stream receive window is now 4 MB (was 65_535), advertised via SETTINGS_INITIAL_WINDOW_SIZE in the same preface. Both settable via the new `:connection_window_size` option and the existing `:client_settings` option. Window size / RTT sets a hard cap on per-stream throughput. At the previous 65_535-byte stream window: Path (typical RTT) | 65 KB | 4 MB | 16 MB -------------------------|----------|----------|---------- LAN (1 ms) | 62 MB/s | 4 GB/s | 16 GB/s Region (20 ms) | 3.1 MB/s | 200 MB/s | 800 MB/s Cross-country (70 ms) | 0.9 MB/s | 57 MB/s | 229 MB/s Transatlantic (100 ms) | 0.6 MB/s | 40 MB/s | 160 MB/s Transpacific (130 ms) | 0.5 MB/s | 31 MB/s | 123 MB/s Antipodal (230 ms) | 0.3 MB/s | 17 MB/s | 70 MB/s Any caller talking to a server more than a few milliseconds away was bottlenecked well below their link bandwidth without knowing why. 4 MB per stream saturates gigabit anywhere on earth; 16 MB at the connection level lets four streams run in parallel at full rate before the shared pool binds. Callers who want the old behaviour can pass `connection_window_size: 65_535` and `client_settings: [initial_window_size: 65_535]` to `connect/4`. --- lib/mint/http.ex | 5 +++ lib/mint/http2.ex | 48 ++++++++++++++++++++++---- test/mint/http2/conn_test.exs | 47 +++++++++++++++++++++++++ test/support/mint/http2/test_server.ex | 16 ++++++--- 4 files changed, 105 insertions(+), 11 deletions(-) diff --git a/lib/mint/http.ex b/lib/mint/http.ex index 0178d58d..5fc1390f 100644 --- a/lib/mint/http.ex +++ b/lib/mint/http.ex @@ -238,6 +238,11 @@ 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`. + 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 951e4ec4..8192c585 100644 --- a/lib/mint/http2.ex +++ b/lib/mint/http2.ex @@ -143,6 +143,8 @@ 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 @default_max_frame_size 16_384 @@ -182,9 +184,10 @@ defmodule Mint.HTTP2 do 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 (or the spec default of 65_535 - # if we've never sent one). Mint auto-refills to maintain this peak - # as DATA frames arrive. + # `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, encode_table: HPAX.new(4096), decode_table: HPAX.new(4096), @@ -216,7 +219,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 @@ -1101,7 +1104,17 @@ 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) + 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 = @@ -1130,12 +1143,13 @@ defmodule Mint.HTTP2 do mode: mode, scheme: scheme_string, state: :handshaking, - log: log? + log: log?, + receive_window_size: connection_window_size } + 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), @@ -1148,6 +1162,26 @@ 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 + @doc """ See `Mint.HTTP.get_socket/1`. """ diff --git a/test/mint/http2/conn_test.exs b/test/mint/http2/conn_test.exs index f6fb983e..560b667d 100644 --- a/test/mint/http2/conn_test.exs +++ b/test/mint/http2/conn_test.exs @@ -135,6 +135,8 @@ defmodule Mint.HTTP2Test do 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 @@ -1865,6 +1867,51 @@ 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 + 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 + 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 + 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 + 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 + 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 From d425d96a2c0f6274527988203814d843078a1756 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Sun, 12 Apr 2026 17:30:35 -0700 Subject: [PATCH 4/5] Batch HTTP/2 receive-window refills MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously `refill_client_windows/3` sent a WINDOW_UPDATE on both the connection and the stream after every DATA frame, with the increment set to the frame's byte size. That kept the advertised window pinned at its peak but tied outbound WINDOW_UPDATE traffic one-to-one with inbound DATA frames. An adversarial server can exploit that ratio. By sending many small DATA frames — in the limit, one byte of body per frame — it can force the client to emit one 13-byte WINDOW_UPDATE per frame. At high frame rates that's a small but real client-side amplification: a flood of outbound control frames driven entirely by the peer. This change gates refills on a threshold. The client tracks the current remaining window for the connection and each stream and only sends a WINDOW_UPDATE once that remaining drops to `:receive_window_update_threshold` bytes. The update then tops the window straight back up to its configured peak. One frame per `receive_window_size - receive_window_update_threshold` bytes consumed, not per DATA frame. The default threshold is 160_000 bytes — roughly 10× the default 16 KB max frame size, leaving the server a safety margin before the window would starve it. Behaviour-wise: * With the 4 MB / 16 MB default windows, the client sends roughly one stream-level WINDOW_UPDATE per ~3.84 MB consumed (previously ~250 per 4 MB), and one connection-level update per ~15.84 MB (previously ~1000 per 16 MB). * Callers that explicitly set the stream or connection window down to the 65_535 spec minimum get the old behaviour — one refill per frame — because remaining is always below the default 160_000 threshold. The threshold is tunable via the new `:receive_window_update_threshold` option to `Mint.HTTP.connect/4`. --- lib/mint/http.ex | 6 ++ lib/mint/http2.ex | 116 ++++++++++++++++++++++++++++++---- test/mint/http2/conn_test.exs | 87 +++++++++++++++++++++++++ 3 files changed, 197 insertions(+), 12 deletions(-) diff --git a/lib/mint/http.ex b/lib/mint/http.ex index 5fc1390f..459af5d5 100644 --- a/lib/mint/http.ex +++ b/lib/mint/http.ex @@ -243,6 +243,12 @@ defmodule Mint.HTTP do 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). + 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 8192c585..34cf992c 100644 --- a/lib/mint/http2.ex +++ b/lib/mint/http2.ex @@ -147,6 +147,12 @@ defmodule Mint.HTTP2 do @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 @@ -189,6 +195,15 @@ defmodule Mint.HTTP2 do # 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` 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: @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), @@ -896,7 +911,8 @@ defmodule Mint.HTTP2 do 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 -> - put_in(conn.receive_window_size, size) + conn = put_in(conn.receive_window_size, size) + put_in(conn.receive_window, size) end) catch :throw, {:mint, conn, error} -> {:error, conn, error} @@ -908,7 +924,8 @@ defmodule Mint.HTTP2 do current = conn.streams[stream_id].receive_window_size do_set_window_size(conn, stream_id, current, new_size, fn conn, size -> - put_in(conn.streams[stream_id].receive_window_size, size) + conn = put_in(conn.streams[stream_id].receive_window_size, size) + put_in(conn.streams[stream_id].receive_window, size) end) :error -> @@ -1104,12 +1121,10 @@ 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) - + 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 = @@ -1144,7 +1159,9 @@ defmodule Mint.HTTP2 do scheme: scheme_string, state: :handshaking, log: log?, - receive_window_size: connection_window_size + receive_window_size: connection_window_size, + receive_window: connection_window_size, + receive_window_update_threshold: receive_window_update_threshold } preface = build_preface(client_settings_params, connection_window_size) @@ -1182,6 +1199,14 @@ defmodule Mint.HTTP2 do 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`. """ @@ -1259,6 +1284,9 @@ defmodule Mint.HTTP2 do # 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: conn.client_settings.initial_window_size, received_first_headers?: false } @@ -1753,17 +1781,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, &(&1 - data_size)) - if open?(conn) do - send!(conn, [Frame.encode(connection_frame), Frame.encode(stream_frame)]) + conn = + case Map.fetch(conn.streams, stream_id) do + {:ok, _stream} -> + update_in(conn.streams[stream_id].receive_window, &(&1 - data_size)) + + :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 <= conn.receive_window_update_threshold do + increment = conn.receive_window_size - conn.receive_window + [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 <= conn.receive_window_update_threshold do + increment = stream.receive_window_size - stream.receive_window + + [ + 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, conn.receive_window_size) + + window_update(stream_id: stream_id), conn -> + put_in( + conn.streams[stream_id].receive_window, + conn.streams[stream_id].receive_window_size + ) + end) + end + # HEADERS defp handle_headers(conn, frame, responses) do @@ -2108,6 +2198,8 @@ defmodule Mint.HTTP2 do ref: make_ref(), state: :reserved_remote, send_window_size: conn.server_settings.initial_window_size, + receive_window_size: conn.client_settings.initial_window_size, + receive_window: conn.client_settings.initial_window_size, received_first_headers?: false } diff --git a/test/mint/http2/conn_test.exs b/test/mint/http2/conn_test.exs index 560b667d..9118561d 100644 --- a/test/mint/http2/conn_test.exs +++ b/test/mint/http2/conn_test.exs @@ -141,10 +141,12 @@ defmodule Mint.HTTP2Test do %{conn: conn} do assert HTTP2.get_window_size(conn, :connection) == 65_535 assert conn.receive_window_size == 65_535 + assert conn.receive_window == 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 == 1_000_000 assert_recv_frames [ window_update(stream_id: 0, window_size_increment: 934_465) @@ -157,10 +159,12 @@ defmodule Mint.HTTP2Test do assert_recv_frames [headers(stream_id: stream_id)] current = conn.streams[stream_id].receive_window_size + assert conn.streams[stream_id].receive_window == 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 == current + 10_000 assert_recv_frames [ window_update(stream_id: ^stream_id, window_size_increment: 10_000) @@ -1874,6 +1878,7 @@ defmodule Mint.HTTP2Test do # 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 == 16 * 1024 * 1024 end test "advertises the configured stream window via SETTINGS", %{conn: conn} do @@ -1884,11 +1889,13 @@ defmodule Mint.HTTP2Test do 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 == 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 == 1_000_000 end @tag connect_options: [connection_window_size: 65_535] @@ -1897,6 +1904,7 @@ defmodule Mint.HTTP2Test 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 == 65_535 end test "rejects a :connection_window_size below the spec minimum" do @@ -1910,6 +1918,85 @@ defmodule Mint.HTTP2Test do 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 From 5d8c622fd5e76ca8cb415a8855a345f2ece86842 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Mon, 13 Apr 2026 14:11:56 -0700 Subject: [PATCH 5/5] Track advertised receive window synchronously in initiate/5 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Streams opened before the server's SETTINGS ACK arrived were reading their initial receive window from `conn.client_settings`, which still held library defaults at that point. If the user advertised a stream window smaller than the default (e.g. `initial_window_size: 65_535`), the stream struct tracked the 4 MB default locally while the server respected the 65_535 we sent in SETTINGS. The client's remaining window never dropped to the refill threshold, stream-level WINDOW_UPDATE frames never fired, and the connection stalled once the server exhausted its per-stream send window. Mirror the advertised `client_settings_params` into `conn.client_settings` during `initiate/5` — the sender already knows what it committed to and doesn't need to wait for the ACK to act on it. Add a regression test that opens a stream before the ACK round trip and asserts the stream struct reflects the advertised value. Also rename `receive_window` to `receive_window_remaining` so the peak/remaining distinction is clear at the call site, and document that `:receive_window_update_threshold` is shared between the connection and per-stream windows (so windows at or below the threshold refill on every DATA frame). --- lib/mint/http.ex | 5 ++- lib/mint/http2.ex | 66 ++++++++++++++++++++++++----------- test/mint/http2/conn_test.exs | 45 +++++++++++++++++++----- 3 files changed, 86 insertions(+), 30 deletions(-) diff --git a/lib/mint/http.ex b/lib/mint/http.ex index 459af5d5..32dd4ca2 100644 --- a/lib/mint/http.ex +++ b/lib/mint/http.ex @@ -247,7 +247,10 @@ defmodule Mint.HTTP do 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). + (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 diff --git a/lib/mint/http2.ex b/lib/mint/http2.ex index 34cf992c..7e721b0c 100644 --- a/lib/mint/http2.ex +++ b/lib/mint/http2.ex @@ -195,12 +195,12 @@ defmodule Mint.HTTP2 do # 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` is the server's current view of our receive + # `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: @default_window_size, + 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, @@ -912,7 +912,7 @@ defmodule Mint.HTTP2 do 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, size) + put_in(conn.receive_window_remaining, size) end) catch :throw, {:mint, conn, error} -> {:error, conn, error} @@ -925,7 +925,7 @@ defmodule Mint.HTTP2 do 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, size) + put_in(conn.streams[stream_id].receive_window_remaining, size) end) :error -> @@ -935,7 +935,8 @@ defmodule Mint.HTTP2 do :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 + defp do_set_window_size(conn, _stream_id, current, new_size, _update) + when new_size == current do {:ok, conn} end @@ -1121,9 +1122,19 @@ 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) + + 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) + + 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, []) @@ -1160,10 +1171,21 @@ defmodule Mint.HTTP2 do state: :handshaking, log: log?, receive_window_size: connection_window_size, - receive_window: 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), @@ -1286,7 +1308,7 @@ defmodule Mint.HTTP2 do 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: conn.client_settings.initial_window_size, + receive_window_remaining: conn.client_settings.initial_window_size, received_first_headers?: false } @@ -1417,10 +1439,14 @@ defmodule Mint.HTTP2 do cond do data_size > stream.send_window_size -> - throw({:mint, conn, wrap_error({:exceeds_window_size, :request, stream.send_window_size})}) + throw( + {:mint, conn, wrap_error({:exceeds_window_size, :request, stream.send_window_size})} + ) data_size > conn.send_window_size -> - throw({:mint, conn, wrap_error({:exceeds_window_size, :connection, 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. @@ -1790,12 +1816,12 @@ defmodule Mint.HTTP2 do # roughly one update per `(receive_window_size - threshold)` bytes # consumed. defp refill_client_windows(conn, stream_id, data_size) do - conn = update_in(conn.receive_window, &(&1 - 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, &(&1 - data_size)) + update_in(conn.streams[stream_id].receive_window_remaining, &(&1 - data_size)) :error -> conn @@ -1815,8 +1841,8 @@ defmodule Mint.HTTP2 do end defp maybe_refill_conn(frames, conn) do - if conn.receive_window <= conn.receive_window_update_threshold do - increment = conn.receive_window_size - conn.receive_window + 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 @@ -1826,8 +1852,8 @@ defmodule Mint.HTTP2 do defp maybe_refill_stream(frames, conn, stream_id) do case Map.fetch(conn.streams, stream_id) do {:ok, stream} -> - if stream.receive_window <= conn.receive_window_update_threshold do - increment = stream.receive_window_size - stream.receive_window + 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 @@ -1844,11 +1870,11 @@ defmodule Mint.HTTP2 do defp apply_refills(conn, frames) do Enum.reduce(frames, conn, fn window_update(stream_id: 0), conn -> - put_in(conn.receive_window, conn.receive_window_size) + 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, + conn.streams[stream_id].receive_window_remaining, conn.streams[stream_id].receive_window_size ) end) @@ -2199,7 +2225,7 @@ defmodule Mint.HTTP2 do state: :reserved_remote, send_window_size: conn.server_settings.initial_window_size, receive_window_size: conn.client_settings.initial_window_size, - receive_window: conn.client_settings.initial_window_size, + receive_window_remaining: conn.client_settings.initial_window_size, received_first_headers?: false } diff --git a/test/mint/http2/conn_test.exs b/test/mint/http2/conn_test.exs index 9118561d..42bc075f 100644 --- a/test/mint/http2/conn_test.exs +++ b/test/mint/http2/conn_test.exs @@ -131,7 +131,6 @@ defmodule Mint.HTTP2Test do assert_http2_error error, {:protocol_error, "received invalid frame ping during handshake"} refute HTTP2.open?(conn) end - end describe "set_window_size/3" do @@ -141,12 +140,12 @@ defmodule Mint.HTTP2Test do %{conn: conn} do assert HTTP2.get_window_size(conn, :connection) == 65_535 assert conn.receive_window_size == 65_535 - assert conn.receive_window == 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 == 1_000_000 + assert conn.receive_window_remaining == 1_000_000 assert_recv_frames [ window_update(stream_id: 0, window_size_increment: 934_465) @@ -159,12 +158,12 @@ defmodule Mint.HTTP2Test do assert_recv_frames [headers(stream_id: stream_id)] current = conn.streams[stream_id].receive_window_size - assert conn.streams[stream_id].receive_window == current + 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 == 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) @@ -1878,7 +1877,7 @@ defmodule Mint.HTTP2Test do # 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 == 16 * 1024 * 1024 + assert conn.receive_window_remaining == 16 * 1024 * 1024 end test "advertises the configured stream window via SETTINGS", %{conn: conn} do @@ -1889,13 +1888,41 @@ defmodule Mint.HTTP2Test do 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 == 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 == 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] @@ -1904,7 +1931,7 @@ defmodule Mint.HTTP2Test 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 == 65_535 + assert conn.receive_window_remaining == 65_535 end test "rejects a :connection_window_size below the spec minimum" do