Skip to content

Commit 4a195ff

Browse files
ericmjwhatyouhide
andauthored
Add Mint.HTTP2.set_window_size/3 (#480)
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. --------- Co-authored-by: Andrea Leopardi <an.leopardi@gmail.com>
1 parent d3fee6e commit 4a195ff

2 files changed

Lines changed: 303 additions & 17 deletions

File tree

lib/mint/http2.ex

Lines changed: 191 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,16 @@ defmodule Mint.HTTP2 do
176176

177177
# Fields of the connection.
178178
buffer: "",
179+
# `window_size` is the client *send* window for the connection — how
180+
# much request-body data we're allowed to send to the server before it
181+
# refills the window with a WINDOW_UPDATE frame.
179182
window_size: @default_window_size,
183+
# `receive_window_size` is the client *receive* window for the
184+
# connection — the peak size we've advertised to the server via
185+
# `WINDOW_UPDATE` frames on stream 0 (or the spec default of 65_535
186+
# if we've never sent one). Mint auto-refills to maintain this peak
187+
# as DATA frames arrive.
188+
receive_window_size: @default_window_size,
180189
encode_table: HPAX.new(4096),
181190
decode_table: HPAX.new(4096),
182191

@@ -729,11 +738,21 @@ defmodule Mint.HTTP2 do
729738
end
730739

731740
@doc """
732-
Returns the window size of the connection or of a single request.
741+
Returns the client **send** window size for the connection or a request.
733742
734-
This function is HTTP/2 specific. It returns the window size of
735-
either the connection if `connection_or_request` is `:connection` or of a single
736-
request if `connection_or_request` is `{:request, request_ref}`.
743+
> #### Send vs receive windows {: .warning}
744+
>
745+
> This function returns the *send* window — how much body data this client
746+
> is still permitted to send to the server before being throttled. It is
747+
> decremented by `request/5` and `stream_request_body/3` and refilled by
748+
> the server, which `stream/2` handles transparently.
749+
>
750+
> It does **not** return the client *receive* window (how much the server
751+
> is permitted to send us). To influence that, use `set_window_size/3`.
752+
753+
This function is HTTP/2 specific. It returns the send window of either the
754+
connection if `connection_or_request` is `:connection` or of a single request
755+
if `connection_or_request` is `{:request, request_ref}`.
737756
738757
Use this function to check the window size of the connection before sending a
739758
full request. Also use this function to check the window size of both the
@@ -744,21 +763,23 @@ defmodule Mint.HTTP2 do
744763
745764
## HTTP/2 Flow Control
746765
747-
In HTTP/2, flow control is implemented through a
748-
window size. When the client sends data to the server, the window size is decreased
749-
and the server needs to "refill" it on the client side. You don't need to take care of
750-
the refilling of the client window as it happens behind the scenes in `stream/2`.
766+
In HTTP/2, flow control is implemented through a window size. When the client
767+
sends data to the server, the window size is decreased and the server needs
768+
to "refill" it on the client side, which `stream/2` handles transparently.
769+
Symmetrically, the server's outbound flow toward the client is bounded by a
770+
receive window the client advertises and refills — see `set_window_size/3`.
751771
752-
A window size is kept for the entire connection and all requests affect this window
753-
size. A window size is also kept per request.
772+
A window size is kept for the entire connection and all requests affect this
773+
window size. A window size is also kept per request.
754774
755-
The only thing that affects the window size is the body of a request, regardless of
756-
if it's a full request sent with `request/5` or body chunks sent through
757-
`stream_request_body/3`. That means that if we make a request with a body that is
758-
five bytes long, like `"hello"`, the window size of the connection and the window size
759-
of that particular request will decrease by five bytes.
775+
The only thing that affects the send window size is the body of a request,
776+
regardless of whether it's a full request sent with `request/5` or body chunks
777+
sent through `stream_request_body/3`. That means that if we make a request with
778+
a body that is five bytes long, like `"hello"`, the send window size of the
779+
connection and the send window size of that particular request will decrease
780+
by five bytes.
760781
761-
If we use all the window size before the server refills it, functions like
782+
If we use all the send window size before the server refills it, functions like
762783
`request/5` will return an error.
763784
764785
## Examples
@@ -797,6 +818,119 @@ defmodule Mint.HTTP2 do
797818
end
798819
end
799820

821+
@doc """
822+
Advertises a larger client **receive** window to the server.
823+
824+
> #### Receive vs send windows {: .warning}
825+
>
826+
> This function sets the *receive* window — the peak amount of body data
827+
> the server is permitted to send us before being throttled. It does
828+
> **not** set the *send* window (how much body data we're permitted to
829+
> send to the server) — the server controls that. See `get_window_size/2`
830+
> for the send window.
831+
832+
Without calling this, `stream/2` refills the receive window in small
833+
increments as response body data is consumed. Each refill costs a
834+
round-trip before the server can send more, so bulk throughput is capped
835+
at roughly `window / RTT`; on higher-latency links the default 64 KB
836+
window makes that cap well below the link bandwidth. Raising the window
837+
removes those pauses and is the main HTTP/2 tuning knob for bulk or
838+
highly parallel downloads.
839+
840+
Mint exposes the per-stream initial window as the `:initial_window_size`
841+
client setting passed to `connect/4`, but there is no connection-level
842+
equivalent — use this function for the connection window, and for any
843+
per-stream adjustment after a request has started.
844+
845+
`connection_or_request` is `:connection` for the whole connection or
846+
`{:request, request_ref}` for a single request. `new_size` must be in
847+
`1..2_147_483_647`. Windows can only grow: `new_size` smaller than the
848+
current receive window returns
849+
`{:error, conn, %Mint.HTTPError{reason: :window_size_too_small}}`, and
850+
`new_size` equal to the current window is a no-op.
851+
852+
For more information on flow control and window sizes in HTTP/2, see the
853+
section below.
854+
855+
## HTTP/2 Flow Control
856+
857+
See `get_window_size/2` for a description of the client *send* window.
858+
The client *receive* window is the symmetric bound on the server's
859+
outbound flow: it starts at 64 KB for the connection and for each new
860+
request, is decremented by response body bytes, and is refilled by
861+
`stream/2` as the body is consumed. A window size is kept for the entire
862+
connection and all responses affect this window size; a window size is
863+
also kept per request.
864+
865+
This function raises the *advertised* receive window — the peak the
866+
server is allowed to fill before pausing. It does not pre-allocate any
867+
buffers; it only permits the server to send further ahead of the
868+
client's reads.
869+
870+
## Examples
871+
872+
Bump the connection-level receive window right after connect so the server
873+
can stream multi-MB bodies without flow-control pauses:
874+
875+
{:ok, conn} = Mint.HTTP2.connect(:https, host, 443)
876+
{:ok, conn} = Mint.HTTP2.set_window_size(conn, :connection, 8_000_000)
877+
878+
Give one specific request a bigger window than the per-stream default:
879+
880+
{:ok, conn, ref} = Mint.HTTP2.request(conn, "GET", "/huge", [], nil)
881+
{:ok, conn} = Mint.HTTP2.set_window_size(conn, {:request, ref}, 16_000_000)
882+
883+
"""
884+
@spec set_window_size(t(), :connection | {:request, Types.request_ref()}, pos_integer()) ::
885+
{:ok, t()} | {:error, t(), Types.error()}
886+
def set_window_size(conn, connection_or_request, new_size)
887+
888+
def set_window_size(%__MODULE__{} = _conn, _target, new_size)
889+
when not (is_integer(new_size) and new_size >= 1 and new_size <= @max_window_size) do
890+
raise ArgumentError,
891+
"new window size must be an integer in 1..#{@max_window_size}, got: #{inspect(new_size)}"
892+
end
893+
894+
def set_window_size(%__MODULE__{} = conn, :connection, new_size) do
895+
do_set_window_size(conn, 0, conn.receive_window_size, new_size, fn conn, size ->
896+
put_in(conn.receive_window_size, size)
897+
end)
898+
catch
899+
:throw, {:mint, conn, error} -> {:error, conn, error}
900+
end
901+
902+
def set_window_size(%__MODULE__{} = conn, {:request, request_ref}, new_size) do
903+
case Map.fetch(conn.ref_to_stream_id, request_ref) do
904+
{:ok, stream_id} ->
905+
current = conn.streams[stream_id].receive_window_size
906+
907+
do_set_window_size(conn, stream_id, current, new_size, fn conn, size ->
908+
put_in(conn.streams[stream_id].receive_window_size, size)
909+
end)
910+
911+
:error ->
912+
{:error, conn, wrap_error({:unknown_request_to_stream, request_ref})}
913+
end
914+
catch
915+
:throw, {:mint, conn, error} -> {:error, conn, error}
916+
end
917+
918+
defp do_set_window_size(conn, _stream_id, current, new_size, _update)
919+
when new_size == current do
920+
{:ok, conn}
921+
end
922+
923+
defp do_set_window_size(conn, _stream_id, current, new_size, _update) when new_size < current do
924+
{:error, conn, wrap_error({:window_size_too_small, current, new_size})}
925+
end
926+
927+
defp do_set_window_size(conn, stream_id, current, new_size, update) do
928+
increment = new_size - current
929+
frame = window_update(stream_id: stream_id, window_size_increment: increment)
930+
conn = send!(conn, Frame.encode(frame))
931+
{:ok, update.(conn, new_size)}
932+
end
933+
800934
@doc """
801935
See `Mint.HTTP.stream/2`.
802936
"""
@@ -1083,7 +1217,15 @@ defmodule Mint.HTTP2 do
10831217
id: conn.next_stream_id,
10841218
ref: make_ref(),
10851219
state: :idle,
1220+
# Client send window — decremented as we send body bytes, refilled
1221+
# by incoming WINDOW_UPDATE frames from the server. Bounded initially
1222+
# by the server's SETTINGS_INITIAL_WINDOW_SIZE.
10861223
window_size: conn.server_settings.initial_window_size,
1224+
# Client receive window — the peak we've advertised to the server
1225+
# for this stream. Starts at whatever we told the server via our
1226+
# SETTINGS_INITIAL_WINDOW_SIZE; can be bumped per-stream with
1227+
# `set_window_size/3`.
1228+
receive_window_size: conn.client_settings.initial_window_size,
10871229
received_first_headers?: false
10881230
}
10891231

@@ -1846,9 +1988,14 @@ defmodule Mint.HTTP2 do
18461988
end
18471989
end
18481990

1991+
valid_client_settings_without_iws = @valid_client_settings -- [:initial_window_size]
1992+
18491993
defp apply_client_settings(conn, client_settings) do
18501994
Enum.reduce(client_settings, conn, fn
1851-
{setting, value}, conn when setting in @valid_client_settings ->
1995+
{:initial_window_size, initial_window_size}, conn ->
1996+
update_client_initial_window_size(conn, initial_window_size)
1997+
1998+
{setting, value}, conn when setting in unquote(valid_client_settings_without_iws) ->
18521999
update_in(conn.client_settings, &%{&1 | setting => value})
18532000

18542001
{setting, _value}, _conn ->
@@ -1880,6 +2027,23 @@ defmodule Mint.HTTP2 do
18802027
put_in(conn.server_settings.initial_window_size, new_iws)
18812028
end
18822029

2030+
defp update_client_initial_window_size(conn, new_iws) do
2031+
diff = new_iws - conn.client_settings.initial_window_size
2032+
2033+
conn =
2034+
update_in(conn.streams, fn streams ->
2035+
for {stream_id, stream} <- streams,
2036+
stream.state in [:open, :half_closed_local, :reserved_remote],
2037+
into: streams do
2038+
receive_window_size = stream.receive_window_size + diff
2039+
2040+
{stream_id, %{stream | receive_window_size: receive_window_size}}
2041+
end
2042+
end)
2043+
2044+
put_in(conn.client_settings.initial_window_size, new_iws)
2045+
end
2046+
18832047
# PUSH_PROMISE
18842048

18852049
defp handle_push_promise(
@@ -1933,6 +2097,7 @@ defmodule Mint.HTTP2 do
19332097
ref: make_ref(),
19342098
state: :reserved_remote,
19352099
window_size: conn.server_settings.initial_window_size,
2100+
receive_window_size: conn.client_settings.initial_window_size,
19362101
received_first_headers?: false
19372102
}
19382103

@@ -2223,6 +2388,15 @@ defmodule Mint.HTTP2 do
22232388
"can't stream chunk of data because the request is unknown"
22242389
end
22252390

2391+
def format_error({:unknown_request_to_stream, ref}) do
2392+
"request with reference #{inspect(ref)} was not found"
2393+
end
2394+
2395+
def format_error({:window_size_too_small, current, new_size}) do
2396+
"set_window_size/3 can only grow a window; new size #{new_size} is " <>
2397+
"smaller than the current size #{current}"
2398+
end
2399+
22262400
def format_error(:request_is_not_streaming) do
22272401
"can't send more data on this request since it's not streaming"
22282402
end

test/mint/http2/conn_test.exs

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,118 @@ defmodule Mint.HTTP2Test do
133133
end
134134
end
135135

136+
describe "set_window_size/3" do
137+
test "bumps the connection-level receive window by sending WINDOW_UPDATE on stream 0",
138+
%{conn: conn} do
139+
assert HTTP2.get_window_size(conn, :connection) == 65_535
140+
assert conn.receive_window_size == 65_535
141+
142+
assert {:ok, conn} = HTTP2.set_window_size(conn, :connection, 1_000_000)
143+
144+
assert conn.receive_window_size == 1_000_000
145+
146+
assert_recv_frames [
147+
window_update(stream_id: 0, window_size_increment: 934_465)
148+
]
149+
end
150+
151+
test "bumps a per-stream receive window by sending WINDOW_UPDATE on that stream",
152+
%{conn: conn} do
153+
{conn, ref} = open_request(conn)
154+
assert_recv_frames [headers(stream_id: stream_id)]
155+
156+
current = conn.streams[stream_id].receive_window_size
157+
158+
assert {:ok, conn} = HTTP2.set_window_size(conn, {:request, ref}, current + 10_000)
159+
160+
assert conn.streams[stream_id].receive_window_size == current + 10_000
161+
162+
assert_recv_frames [
163+
window_update(stream_id: ^stream_id, window_size_increment: 10_000)
164+
]
165+
end
166+
167+
test "uses acknowledged client initial_window_size when bumping an open stream",
168+
%{conn: conn} do
169+
{conn, ref} = open_request(conn)
170+
assert_recv_frames [headers(stream_id: stream_id)]
171+
172+
assert {:ok, conn} = HTTP2.put_settings(conn, initial_window_size: 1_000)
173+
assert_recv_frames [settings(params: [initial_window_size: 1_000])]
174+
175+
assert {:ok, conn, []} =
176+
stream_frames(conn, [settings(flags: set_flags(:settings, [:ack]), params: [])])
177+
178+
assert conn.streams[stream_id].receive_window_size == 1_000
179+
180+
assert {:ok, conn} = HTTP2.set_window_size(conn, {:request, ref}, 10_000)
181+
182+
assert conn.streams[stream_id].receive_window_size == 10_000
183+
184+
assert_recv_frames [
185+
window_update(stream_id: ^stream_id, window_size_increment: 9_000)
186+
]
187+
end
188+
189+
test "is a no-op when the new size equals the current size", %{conn: conn} do
190+
assert {:ok, ^conn} = HTTP2.set_window_size(conn, :connection, 65_535)
191+
192+
# Nothing should have gone out on the wire.
193+
assert_recv_frames []
194+
end
195+
196+
test "returns an error when attempting to shrink the connection window", %{conn: conn} do
197+
{:ok, conn} = HTTP2.set_window_size(conn, :connection, 1_000_000)
198+
assert_recv_frames [window_update(stream_id: 0)]
199+
200+
assert {:error, ^conn, error} = HTTP2.set_window_size(conn, :connection, 500_000)
201+
202+
assert_http2_error error, {:window_size_too_small, 1_000_000, 500_000}
203+
204+
# No WINDOW_UPDATE was sent for the invalid call.
205+
assert_recv_frames []
206+
end
207+
208+
test "returns an error when attempting to shrink a stream window", %{conn: conn} do
209+
{conn, ref} = open_request(conn)
210+
assert_recv_frames [headers(stream_id: stream_id)]
211+
212+
current = conn.streams[stream_id].receive_window_size
213+
after_grow = current + 10_000
214+
shrink_target = current + 5_000
215+
216+
{:ok, conn} = HTTP2.set_window_size(conn, {:request, ref}, after_grow)
217+
assert_recv_frames [window_update(stream_id: ^stream_id)]
218+
219+
assert {:error, ^conn, error} =
220+
HTTP2.set_window_size(conn, {:request, ref}, shrink_target)
221+
222+
assert_http2_error error, {:window_size_too_small, ^after_grow, ^shrink_target}
223+
end
224+
225+
test "returns an error for an unknown request ref", %{conn: conn} do
226+
fake_ref = make_ref()
227+
228+
assert {:error, ^conn, error} = HTTP2.set_window_size(conn, {:request, fake_ref}, 1_000_000)
229+
230+
assert_http2_error error, {:unknown_request_to_stream, ^fake_ref}
231+
end
232+
233+
test "raises on out-of-range new_size", %{conn: conn} do
234+
assert_raise ArgumentError, ~r/1\.\.2147483647/, fn ->
235+
HTTP2.set_window_size(conn, :connection, 0)
236+
end
237+
238+
assert_raise ArgumentError, ~r/1\.\.2147483647/, fn ->
239+
HTTP2.set_window_size(conn, :connection, 3_000_000_000)
240+
end
241+
242+
assert_raise ArgumentError, ~r/1\.\.2147483647/, fn ->
243+
HTTP2.set_window_size(conn, :connection, :nope)
244+
end
245+
end
246+
end
247+
136248
describe "open?/1" do
137249
test "returns true if the state is :open or :handshaking", %{conn: conn} do
138250
assert HTTP2.open?(%{conn | state: :open})

0 commit comments

Comments
 (0)