Skip to content

Commit fa37715

Browse files
committed
Batch HTTP/2 receive-window refills
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`.
1 parent 3d38b10 commit fa37715

3 files changed

Lines changed: 197 additions & 12 deletions

File tree

lib/mint/http.ex

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,12 @@ defmodule Mint.HTTP do
243243
on stream 0 as part of the connection preface. Defaults to 16 MB. Can be
244244
raised later with `Mint.HTTP2.set_window_size/3`.
245245
246+
* `:receive_window_update_threshold` - (integer) the minimum number of bytes of receive
247+
window that must remain on a connection or stream before a `WINDOW_UPDATE`
248+
frame is sent to refill it. Lower values send more frequent, smaller updates;
249+
higher values batch updates into fewer, larger ones. Defaults to 160_000
250+
(approximately 10× the default max frame size).
251+
246252
There may be further protocol specific options that only take effect when the corresponding
247253
connection is established. Check `Mint.HTTP1.connect/4` and `Mint.HTTP2.connect/4` for
248254
details.

lib/mint/http2.ex

Lines changed: 104 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,12 @@ defmodule Mint.HTTP2 do
147147
@default_stream_window_size 4 * 1024 * 1024
148148
@max_window_size 2_147_483_647
149149

150+
# Defer refilling the receive window until it has dropped to this many
151+
# bytes — roughly 10× the default 16 KB max frame size, so the server
152+
# has a safety margin before the window would starve it. See
153+
# `refill_client_windows/3`.
154+
@default_receive_window_update_threshold 160_000
155+
150156
@default_max_frame_size 16_384
151157
@valid_max_frame_size_range @default_max_frame_size..16_777_215
152158

@@ -189,6 +195,15 @@ defmodule Mint.HTTP2 do
189195
# matching WINDOW_UPDATE to bring the server's view from the spec
190196
# default of 65_535 up to the advertised peak.
191197
receive_window_size: @default_window_size,
198+
# `receive_window` is the server's current view of our receive
199+
# window — decremented by DATA frame sizes as they arrive, bumped
200+
# back up to `receive_window_size` whenever we send a
201+
# WINDOW_UPDATE. When it drops to `receive_window_update_threshold`, we
202+
# refill it back to the peak in one frame.
203+
receive_window: @default_window_size,
204+
# Minimum remaining receive window before we send a WINDOW_UPDATE.
205+
# Configurable via the `:receive_window_update_threshold` connect option.
206+
receive_window_update_threshold: @default_receive_window_update_threshold,
192207
encode_table: HPAX.new(4096),
193208
decode_table: HPAX.new(4096),
194209

@@ -896,7 +911,8 @@ defmodule Mint.HTTP2 do
896911

897912
def set_window_size(%__MODULE__{} = conn, :connection, new_size) do
898913
do_set_window_size(conn, 0, conn.receive_window_size, new_size, fn conn, size ->
899-
put_in(conn.receive_window_size, size)
914+
conn = put_in(conn.receive_window_size, size)
915+
put_in(conn.receive_window, size)
900916
end)
901917
catch
902918
:throw, {:mint, conn, error} -> {:error, conn, error}
@@ -908,7 +924,8 @@ defmodule Mint.HTTP2 do
908924
current = conn.streams[stream_id].receive_window_size
909925

910926
do_set_window_size(conn, stream_id, current, new_size, fn conn, size ->
911-
put_in(conn.streams[stream_id].receive_window_size, size)
927+
conn = put_in(conn.streams[stream_id].receive_window_size, size)
928+
put_in(conn.streams[stream_id].receive_window, size)
912929
end)
913930

914931
:error ->
@@ -1104,12 +1121,10 @@ defmodule Mint.HTTP2 do
11041121
scheme_string = Atom.to_string(scheme)
11051122
mode = Keyword.get(opts, :mode, :active)
11061123
log? = Keyword.get(opts, :log, false)
1107-
1108-
connection_window_size =
1109-
Keyword.get(opts, :connection_window_size, @default_connection_window_size)
1110-
1124+
connection_window_size = Keyword.get(opts, :connection_window_size, @default_connection_window_size)
11111125
validate_window_size!(:connection_window_size, connection_window_size)
1112-
1126+
receive_window_update_threshold = Keyword.get(opts, :receive_window_update_threshold, @default_receive_window_update_threshold)
1127+
validate_receive_window_update_threshold!(receive_window_update_threshold)
11131128
client_settings_params = Keyword.get(opts, :client_settings, [])
11141129

11151130
client_settings_params =
@@ -1144,7 +1159,9 @@ defmodule Mint.HTTP2 do
11441159
scheme: scheme_string,
11451160
state: :handshaking,
11461161
log: log?,
1147-
receive_window_size: connection_window_size
1162+
receive_window_size: connection_window_size,
1163+
receive_window: connection_window_size,
1164+
receive_window_update_threshold: receive_window_update_threshold
11481165
}
11491166

11501167
preface = build_preface(client_settings_params, connection_window_size)
@@ -1182,6 +1199,14 @@ defmodule Mint.HTTP2 do
11821199
end
11831200
end
11841201

1202+
defp validate_receive_window_update_threshold!(value) do
1203+
unless is_integer(value) and value >= 1 and value <= @max_window_size do
1204+
raise ArgumentError,
1205+
"the :receive_window_update_threshold option must be a positive integer no larger than " <>
1206+
"#{@max_window_size}, got: #{inspect(value)}"
1207+
end
1208+
end
1209+
11851210
@doc """
11861211
See `Mint.HTTP.get_socket/1`.
11871212
"""
@@ -1259,6 +1284,9 @@ defmodule Mint.HTTP2 do
12591284
# SETTINGS_INITIAL_WINDOW_SIZE; can be bumped per-stream with
12601285
# `set_window_size/3`.
12611286
receive_window_size: conn.client_settings.initial_window_size,
1287+
# Current remaining receive window for this stream, tracked
1288+
# independently from the peak so that refills can be batched.
1289+
receive_window: conn.client_settings.initial_window_size,
12621290
received_first_headers?: false
12631291
}
12641292

@@ -1753,17 +1781,79 @@ defmodule Mint.HTTP2 do
17531781
end
17541782
end
17551783

1784+
# Accounts for `data_size` bytes arriving on the connection and on
1785+
# `stream_id`. Sends a WINDOW_UPDATE for either window only once its
1786+
# remaining receive credit drops to `conn.receive_window_update_threshold`;
1787+
# previously we sent one per DATA frame, so an adversarial server
1788+
# emitting many small frames could amplify its inbound bytes into a
1789+
# WINDOW_UPDATE flood of outbound frames. Batching caps that ratio at
1790+
# roughly one update per `(receive_window_size - threshold)` bytes
1791+
# consumed.
17561792
defp refill_client_windows(conn, stream_id, data_size) do
1757-
connection_frame = window_update(stream_id: 0, window_size_increment: data_size)
1758-
stream_frame = window_update(stream_id: stream_id, window_size_increment: data_size)
1793+
conn = update_in(conn.receive_window, &(&1 - data_size))
17591794

1760-
if open?(conn) do
1761-
send!(conn, [Frame.encode(connection_frame), Frame.encode(stream_frame)])
1795+
conn =
1796+
case Map.fetch(conn.streams, stream_id) do
1797+
{:ok, _stream} ->
1798+
update_in(conn.streams[stream_id].receive_window, &(&1 - data_size))
1799+
1800+
:error ->
1801+
conn
1802+
end
1803+
1804+
frames =
1805+
[]
1806+
|> maybe_refill_stream(conn, stream_id)
1807+
|> maybe_refill_conn(conn)
1808+
1809+
if frames != [] and open?(conn) do
1810+
conn = send!(conn, Enum.map(frames, &Frame.encode/1))
1811+
apply_refills(conn, frames)
17621812
else
17631813
conn
17641814
end
17651815
end
17661816

1817+
defp maybe_refill_conn(frames, conn) do
1818+
if conn.receive_window <= conn.receive_window_update_threshold do
1819+
increment = conn.receive_window_size - conn.receive_window
1820+
[window_update(stream_id: 0, window_size_increment: increment) | frames]
1821+
else
1822+
frames
1823+
end
1824+
end
1825+
1826+
defp maybe_refill_stream(frames, conn, stream_id) do
1827+
case Map.fetch(conn.streams, stream_id) do
1828+
{:ok, stream} ->
1829+
if stream.receive_window <= conn.receive_window_update_threshold do
1830+
increment = stream.receive_window_size - stream.receive_window
1831+
1832+
[
1833+
window_update(stream_id: stream_id, window_size_increment: increment) | frames
1834+
]
1835+
else
1836+
frames
1837+
end
1838+
1839+
:error ->
1840+
frames
1841+
end
1842+
end
1843+
1844+
defp apply_refills(conn, frames) do
1845+
Enum.reduce(frames, conn, fn
1846+
window_update(stream_id: 0), conn ->
1847+
put_in(conn.receive_window, conn.receive_window_size)
1848+
1849+
window_update(stream_id: stream_id), conn ->
1850+
put_in(
1851+
conn.streams[stream_id].receive_window,
1852+
conn.streams[stream_id].receive_window_size
1853+
)
1854+
end)
1855+
end
1856+
17671857
# HEADERS
17681858

17691859
defp handle_headers(conn, frame, responses) do
@@ -2108,6 +2198,8 @@ defmodule Mint.HTTP2 do
21082198
ref: make_ref(),
21092199
state: :reserved_remote,
21102200
send_window_size: conn.server_settings.initial_window_size,
2201+
receive_window_size: conn.client_settings.initial_window_size,
2202+
receive_window: conn.client_settings.initial_window_size,
21112203
received_first_headers?: false
21122204
}
21132205

test/mint/http2/conn_test.exs

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,10 +141,12 @@ defmodule Mint.HTTP2Test do
141141
%{conn: conn} do
142142
assert HTTP2.get_window_size(conn, :connection) == 65_535
143143
assert conn.receive_window_size == 65_535
144+
assert conn.receive_window == 65_535
144145

145146
assert {:ok, conn} = HTTP2.set_window_size(conn, :connection, 1_000_000)
146147

147148
assert conn.receive_window_size == 1_000_000
149+
assert conn.receive_window == 1_000_000
148150

149151
assert_recv_frames [
150152
window_update(stream_id: 0, window_size_increment: 934_465)
@@ -157,10 +159,12 @@ defmodule Mint.HTTP2Test do
157159
assert_recv_frames [headers(stream_id: stream_id)]
158160

159161
current = conn.streams[stream_id].receive_window_size
162+
assert conn.streams[stream_id].receive_window == current
160163

161164
assert {:ok, conn} = HTTP2.set_window_size(conn, {:request, ref}, current + 10_000)
162165

163166
assert conn.streams[stream_id].receive_window_size == current + 10_000
167+
assert conn.streams[stream_id].receive_window == current + 10_000
164168

165169
assert_recv_frames [
166170
window_update(stream_id: ^stream_id, window_size_increment: 10_000)
@@ -1874,6 +1878,7 @@ defmodule Mint.HTTP2Test do
18741878
# a WINDOW_UPDATE for `16 MB - 65_535` so the server sees the peak
18751879
# from the start.
18761880
assert conn.receive_window_size == 16 * 1024 * 1024
1881+
assert conn.receive_window == 16 * 1024 * 1024
18771882
end
18781883

18791884
test "advertises the configured stream window via SETTINGS", %{conn: conn} do
@@ -1884,11 +1889,13 @@ defmodule Mint.HTTP2Test do
18841889
assert_recv_frames [headers(stream_id: stream_id)]
18851890

18861891
assert conn.streams[stream_id].receive_window_size == 4 * 1024 * 1024
1892+
assert conn.streams[stream_id].receive_window == 4 * 1024 * 1024
18871893
end
18881894

18891895
@tag connect_options: [connection_window_size: 1_000_000]
18901896
test "supports a custom :connection_window_size", %{conn: conn} do
18911897
assert conn.receive_window_size == 1_000_000
1898+
assert conn.receive_window == 1_000_000
18921899
end
18931900

18941901
@tag connect_options: [connection_window_size: 65_535]
@@ -1897,6 +1904,7 @@ defmodule Mint.HTTP2Test do
18971904
# At 65_535 there's nothing to advertise beyond SETTINGS — the
18981905
# preface should not carry an extra WINDOW_UPDATE.
18991906
assert conn.receive_window_size == 65_535
1907+
assert conn.receive_window == 65_535
19001908
end
19011909

19021910
test "rejects a :connection_window_size below the spec minimum" do
@@ -1910,6 +1918,85 @@ defmodule Mint.HTTP2Test do
19101918
HTTP2.initiate(:https, self(), "localhost", 443, connection_window_size: 2_147_483_648)
19111919
end
19121920
end
1921+
1922+
test "rejects a non-positive :receive_window_update_threshold" do
1923+
assert_raise ArgumentError, ~r/:receive_window_update_threshold/, fn ->
1924+
HTTP2.initiate(:https, self(), "localhost", 443, receive_window_update_threshold: 0)
1925+
end
1926+
end
1927+
end
1928+
1929+
describe "receive window batching" do
1930+
@describetag connect_options: [
1931+
connection_window_size: 100_000,
1932+
receive_window_update_threshold: 40_000,
1933+
client_settings: [initial_window_size: 100_000]
1934+
]
1935+
1936+
test "does not send WINDOW_UPDATE until remaining window drops below threshold",
1937+
%{conn: conn} do
1938+
{conn, _ref} = open_request(conn)
1939+
assert_recv_frames [headers(stream_id: stream_id)]
1940+
1941+
# 50_000 bytes consumed leaves 50_000 remaining on both windows,
1942+
# above the 40_000 threshold, so no WINDOW_UPDATE should go out.
1943+
chunk = String.duplicate("a", 10_000)
1944+
1945+
frames = for _ <- 1..5, do: data(stream_id: stream_id, data: chunk)
1946+
1947+
assert {:ok, %HTTP2{} = _conn, _responses} = stream_frames(conn, frames)
1948+
1949+
assert_recv_frames []
1950+
end
1951+
1952+
test "sends one WINDOW_UPDATE topping both windows back to peak once the threshold is crossed",
1953+
%{conn: conn} do
1954+
{conn, _ref} = open_request(conn)
1955+
assert_recv_frames [headers(stream_id: stream_id)]
1956+
1957+
# 60_000 bytes consumed drops both windows to exactly the 40_000
1958+
# threshold; the 7th frame lands after the refill so there's
1959+
# still just one WINDOW_UPDATE per window.
1960+
chunk = String.duplicate("a", 10_000)
1961+
1962+
frames = for _ <- 1..7, do: data(stream_id: stream_id, data: chunk)
1963+
1964+
assert {:ok, %HTTP2{} = _conn, _responses} = stream_frames(conn, frames)
1965+
1966+
assert_recv_frames [
1967+
window_update(stream_id: 0, window_size_increment: 60_000),
1968+
window_update(stream_id: ^stream_id, window_size_increment: 60_000)
1969+
]
1970+
end
1971+
1972+
test "set_window_size/3 raises the target so subsequent refills top up to the new peak",
1973+
%{conn: conn} do
1974+
{conn, ref} = open_request(conn)
1975+
assert_recv_frames [headers(stream_id: stream_id)]
1976+
1977+
# Raise the connection peak mid-flight. set_window_size sends its own
1978+
# WINDOW_UPDATE for the bump; drain that before proceeding.
1979+
{:ok, conn} = HTTP2.set_window_size(conn, :connection, 500_000)
1980+
assert_recv_frames [window_update(stream_id: 0, window_size_increment: 400_000)]
1981+
1982+
# Also raise the stream peak so the stream doesn't bottleneck the
1983+
# connection-level test.
1984+
{:ok, conn} = HTTP2.set_window_size(conn, {:request, ref}, 500_000)
1985+
assert_recv_frames [window_update(stream_id: ^stream_id, window_size_increment: 400_000)]
1986+
1987+
# Consume enough to drop both windows to the 40_000 threshold;
1988+
# the refill should top up to the raised 500_000 peak, not the
1989+
# original 100_000 configured at connect.
1990+
chunk = String.duplicate("a", 10_000)
1991+
frames = for _ <- 1..46, do: data(stream_id: stream_id, data: chunk)
1992+
1993+
assert {:ok, %HTTP2{} = _conn, _responses} = stream_frames(conn, frames)
1994+
1995+
assert_recv_frames [
1996+
window_update(stream_id: 0, window_size_increment: 460_000),
1997+
window_update(stream_id: ^stream_id, window_size_increment: 460_000)
1998+
]
1999+
end
19132000
end
19142001

19152002
describe "settings" do

0 commit comments

Comments
 (0)