Repository navigation
fix(http2): Http2Client applies response-body backpressure to the HTTP/2 stream and uses 4 MiB / 16 MiB receive windows - #2015
Conversation
3cd4fe8 to
3695462
Compare
3695462 to
37e1b65
Compare
37e1b65 to
24dacab
Compare
PR HealthCoverage ✔️
This check for test coverage is informational (issues shown here will not fail the PR). This check can be disabled by tagging the PR with License Headers ✔️
All source files should start with a license header. Unrelated files missing license headers
This check can be disabled by tagging the PR with Breaking changes ✔️
This check can be disabled by tagging the PR with Unused Dependencies ✔️
For details on how to fix these, see dependency_validator. This check can be disabled by tagging the PR with API leaks ✔️The following packages contain symbols visible in the public API, but not exported by the library. Export these symbols or remove them from your publicly visible API.
This check can be disabled by tagging the PR with Changelog Entry ✔️
Changes to files need to be accounted for in their respective changelogs. This check can be disabled by tagging the PR with |
…P/2 stream and uses 4 MiB / 16 MiB receive windows > [!NOTE] > This PR was generated by an AI coding agent (Jetski) on behalf of @mosuem. ### Summary `Http2Client` forwarded every `DATA` frame of a response into the `StreamController` behind `StreamedResponse.stream` regardless of whether anyone was reading it. The HTTP/2 stream's subscription was never paused, so the stream kept acknowledging data with `WINDOW_UPDATE` frames (RFC 9113 Section 5.2) and the server kept sending: a consumer that paused the body — or simply read it slower than the network delivered it — accumulated the entire response in memory. HTTP/2 flow control exists precisely so that a receiver can stop granting credit when it is not consuming. Separately, the client used the protocol's default 65535-byte windows, which cap a connection at 64 KiB per round trip no matter how many streams it carries. ### Changes - **`lib/src/http2_client.dart`** - `bodyController` now forwards `onPause`/`onResume` to the HTTP/2 stream's subscription. The stream's incoming queue then stops dispatching (`StreamMessageQueueIn._tryDispatch` only dispatches to a non-paused listener, and it is dispatching that calls `windowHandler.dataProcessed`, i.e. sends `WINDOW_UPDATE`), so once the data the server was already allowed to send has arrived it must wait. Memory held by a paused body is bounded by the stream window; across all of a connection's paused streams by the connection window. - `_dial` configures `ClientSettings(streamWindowSize: 4 MiB, connectionWindowSize: 16 MiB)`. The two changes belong together: with backpressure and the 64 KiB *connection* window, one paused body would exhaust the connection's credit and stall every other stream on it (the connection queue stops handing data to a buffering stream and only replenishes connection credit for data it handed over). Not changed, as candidates for follow-ups: a body that has not been listened to yet is still buffered without bound (pausing the HTTP/2 subscription until the first listener would delay `onDone`, and with it the pool slot's release, for callers that never read the body — a behavior change worth its own discussion); and whether the window sizes should be `Http2Client` constructor parameters rather than constants. ### Test Verification (Fails Before $\rightarrow$ Passes After) `a-paused-response-body-stops-granting-flow-control-credit` in `test/http2_client_test.dart`, against a frame-level TLS server (`FrameReader`/`FrameWriter` on the accepted socket), with no sleeps: 1. Asserts the client's SETTINGS advertise a 4 MiB stream window and its stream-0 `WINDOW_UPDATE` raises the connection window to 16 MiB. 2. Sends the response HEADERS, pauses the body subscription, then sends exactly the stream window (4 MiB) of `DATA` followed by a PING. The client answers the PING only after processing every frame before it, so any stream `WINDOW_UPDATE` arriving before the PING ACK was granted while paused. 3. Resumes the body and waits for the full window to be granted back, then sends the final `DATA` with `END_STREAM` and checks the body is complete (4 MiB + 3 bytes). **Before fix** (test on the previous library code — fails at step 1): ```text 00:00 +0 -1: http2-client-test a-paused-response-body-stops-granting-flow-control-credit [E] Expected: <4194304> Actual: <null> ``` With only the window sizes applied (no `onPause`/`onResume` forwarding) it fails at step 2, at the first half-window acknowledgement: ```text 00:00 +0 -1: http2-client-test a-paused-response-body-stops-granting-flow-control-credit [E] The client granted 2097152 bytes of credit while the response body was paused. ``` **After fix:** ```text 00:00 +1: All tests passed! ``` Full `package:http2` suite at this point of the stack (on `master`): `+268 ~7: All tests passed!`
24dacab to
ed41d40
Compare
Note
This PR was generated by an AI coding agent (Jetski) on behalf of @mosuem.
Summary
Http2Clientforwarded everyDATAframe of a response into theStreamControllerbehindStreamedResponse.streamregardless of whether anyone was reading it. The HTTP/2 stream's subscription was never paused, so the stream kept acknowledging data withWINDOW_UPDATEframes (RFC 9113 Section 5.2) and the server kept sending: a consumer that paused the body — or simply read it slower than the network delivered it — accumulated the entire response in memory. HTTP/2 flow control exists precisely so that a receiver can stop granting credit when it is not consuming.Separately, the client used the protocol's default 65535-byte windows, which cap a connection at 64 KiB per round trip no matter how many streams it carries.
Changes
lib/src/http2_client.dartbodyControllernow forwardsonPause/onResumeto the HTTP/2 stream's subscription. The stream's incoming queue then stops dispatching (StreamMessageQueueIn._tryDispatchonly dispatches to a non-paused listener, and it is dispatching that callswindowHandler.dataProcessed, i.e. sendsWINDOW_UPDATE), so once the data the server was already allowed to send has arrived it must wait. Memory held by a paused body is bounded by the stream window; across all of a connection's paused streams by the connection window._dialconfiguresClientSettings(streamWindowSize: 4 MiB, connectionWindowSize: 16 MiB). The two changes belong together: with backpressure and the 64 KiB connection window, one paused body would exhaust the connection's credit and stall every other stream on it (the connection queue stops handing data to a buffering stream and only replenishes connection credit for data it handed over).Not changed, as candidates for follow-ups: a body that has not been listened to yet is still buffered without bound (pausing the HTTP/2 subscription until the first listener would delay
onDone, and with it the pool slot's release, for callers that never read the body — a behavior change worth its own discussion); and whether the window sizes should beHttp2Clientconstructor parameters rather than constants.Test Verification (Fails Before$\rightarrow$ Passes After)
a-paused-response-body-stops-granting-flow-control-creditintest/http2_client_test.dart, against a frame-level TLS server (FrameReader/FrameWriteron the accepted socket), with no sleeps:WINDOW_UPDATEraises the connection window to 16 MiB.DATAfollowed by a PING. The client answers the PING only after processing every frame before it, so any streamWINDOW_UPDATEarriving before the PING ACK was granted while paused.DATAwithEND_STREAMand checks the body is complete (4 MiB + 3 bytes).Before fix (test on the previous library code — fails at step 1):
With only the window sizes applied (no
onPause/onResumeforwarding) it fails at step 2, at the first half-window acknowledgement:After fix:
Full
package:http2suite at this point of the stack (onmaster):+268 ~7: All tests passed!