From ba092b933a5a72f2818d5d70a90ec8723becdc89 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 1/4] 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 961c2b01..8500acdb 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, @@ -1220,7 +1220,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 @@ -1355,11 +1355,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. @@ -1387,8 +1387,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 @@ -2011,16 +2011,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) @@ -2096,7 +2096,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, receive_window_size: conn.client_settings.initial_window_size, received_first_headers?: false } @@ -2194,12 +2194,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 @@ -2210,14 +2210,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 5bb5e827fa8a0db04feea1718b367b98f6841a0e 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 2/4] 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 71c1c6ff..1ed53390 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 8500acdb..63a1aec0 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 @@ -1102,7 +1105,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 = @@ -1131,12 +1144,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), @@ -1149,6 +1163,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 1ab2ef47..71d2112b 100644 --- a/test/mint/http2/conn_test.exs +++ b/test/mint/http2/conn_test.exs @@ -134,6 +134,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 @@ -1886,6 +1888,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 6e4a881989d1bff55c301b35344b3e2e53b0fd47 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 3/4] 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 | 115 ++++++++++++++++++++++++++++++---- test/mint/http2/conn_test.exs | 87 +++++++++++++++++++++++++ 3 files changed, 196 insertions(+), 12 deletions(-) diff --git a/lib/mint/http.ex b/lib/mint/http.ex index 1ed53390..8c2c8243 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 63a1aec0..af591c20 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 -> @@ -1105,12 +1122,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 = @@ -1145,7 +1160,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) @@ -1183,6 +1200,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`. """ @@ -1260,6 +1285,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 } @@ -1754,17 +1782,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 @@ -2132,6 +2222,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, received_first_headers?: false } diff --git a/test/mint/http2/conn_test.exs b/test/mint/http2/conn_test.exs index 71d2112b..9799b0f8 100644 --- a/test/mint/http2/conn_test.exs +++ b/test/mint/http2/conn_test.exs @@ -140,10 +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 {: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) @@ -156,10 +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 {: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) @@ -1895,6 +1899,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 @@ -1905,11 +1910,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] @@ -1918,6 +1925,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 @@ -1931,6 +1939,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 70b522ee80a69f3c44f899b6c807b89a3ad75b16 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 4/4] 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 | 63 ++++++++++++++++++++++++----------- test/mint/http2/conn_test.exs | 44 +++++++++++++++++++----- 3 files changed, 84 insertions(+), 28 deletions(-) diff --git a/lib/mint/http.ex b/lib/mint/http.ex index 8c2c8243..343f84bd 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 af591c20..573336a9 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 -> @@ -1122,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, []) @@ -1161,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), @@ -1287,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 } @@ -1418,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. @@ -1791,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 @@ -1816,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 @@ -1827,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 @@ -1845,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) @@ -2222,7 +2247,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 9799b0f8..cfd2ef0f 100644 --- a/test/mint/http2/conn_test.exs +++ b/test/mint/http2/conn_test.exs @@ -140,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) @@ -158,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) @@ -1899,7 +1899,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 @@ -1910,13 +1910,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] @@ -1925,7 +1953,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