Skip to content

fix(http2): Http2Client applies response-body backpressure to the HTTP/2 stream and uses 4 MiB / 16 MiB receive windows - #2029

Merged
mosuem merged 1 commit into
masterfrom
mosum/http2-6b-http2-client-backpressure-and-windows
Oct 23, 2026
Merged

mosuem merged 1 commit into
masterfrom
mosum/http2-6b-http2-client-backpressure-and-windows

Conversation

@mosuem

@mosuem mosuem commented Oct 22, 2026

Copy link
Copy Markdown
Member

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):

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:

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:

00:00 +1: All tests passed!

Full package:http2 suite at this point of the stack (on master): +268 ~7: All tests passed!

…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.

`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.

- **`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.

`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!`
@github-actions

Copy link
Copy Markdown

PR Health

Coverage ✔️
File Coverage
pkgs/http2/lib/src/http2_client.dart 💚 89 % ⬆️ 0 %

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 skip-coverage-check.

License Headers ✔️
// Copyright (c) 2026, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
Files
no missing headers

All source files should start with a license header.

Unrelated files missing license headers
Files
pkgs/http_multi_server/test/cert.dart

This check can be disabled by tagging the PR with skip-license-check.

Breaking changes ✔️
Package Change Current Version New Version Needed Version Looking good?
http2 Non-Breaking 3.1.0 3.2.0-wip 3.2.0-wip ✔️

This check can be disabled by tagging the PR with skip-breaking-check.

Unused Dependencies ✔️
Package Status
http2 ✔️ All dependencies utilized correctly.

For details on how to fix these, see dependency_validator.

This check can be disabled by tagging the PR with skip-unused-dependencies-check.

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.

Package Leaked API symbol Leaking sources

This check can be disabled by tagging the PR with skip-leaking-check.

Changelog Entry ✔️
Package Changed Files

Changes to files need to be accounted for in their respective changelogs.

This check can be disabled by tagging the PR with skip-changelog-check (for example, for non-user-visible changes when the package version is already at -wip).

@mosuem
mosuem requested a review from brianquinlan October 22, 2026 08:13
@mosuem
mosuem removed this pull request from stack #2030 October 22, 2026 08:19
@mosuem
mosuem added this pull request to stack #2032 October 22, 2026 08:19
@mosuem
mosuem merged commit be2b51f into master Oct 23, 2026
125 checks passed
@mosuem
mosuem deleted the mosum/http2-6b-http2-client-backpressure-and-windows branch October 23, 2026 05:31
copybara-service Bot pushed a commit to dart-lang/sdk that referenced this pull request Oct 28, 2026
Revisions updated by 19 package dependencies found.
Moving forward revisions for: http.

Commit message:

[deps] rev http

Revisions updated by `dart tools/rev_sdk_deps.dart http`.

http (https://github.com/dart-lang/http/compare/c76dfb3..c76dfb3):
  c76dfb3  Wed Oct 28 10:21:55 2026 -0700  Brian Quinlan  fix(http2): accept larger headers (dart-lang/http#2041)
  23495b0  Mon Oct 26 15:17:28 2026 -0700  Brian Quinlan  chore(cronet_http): ready 1.10.1 (dart-lang/http#2039)
  27591d4  Mon Oct 26 15:08:10 2026 -0700  Brian Quinlan  fix(cronet): avoid cronet namespace duplication (dart-lang/http#2034)
  9cdb8da  Fri Oct 23 11:14:21 2026 -0700  Kevin Moore  Tighten pull_request_label workflow permissions (dart-lang/http#2035)
  3998b87  Fri Oct 23 07:31:29 2026 +0200  Moritz  fix(http2): do not head-of-line block HEADERS, RST_STREAM and GOAWAY behind window-exhausted DATA (dart-lang/http#2013)
  be2b51f  Fri Oct 23 07:31:28 2026 +0200  Moritz  fix(http2): Http2Client applies response-body backpressure to the HTTP/2 stream and uses 4 MiB / 16 MiB receive windows (dart-lang/http#2029)
  3b504cf  Thu Oct 22 06:28:12 2026 +0200  Moritz  fix(http2): never write RST_STREAM ahead of the stream's still-queued HEADERS (dart-lang/http#2008)
  a641fac  Thu Oct 22 06:28:12 2026 +0200  Moritz  fix(http2): preserve the complete response on RST_STREAM(NO_ERROR) (dart-lang/http#2012)
  5d896c7  Thu Oct 22 06:28:11 2026 +0200  Moritz  fix(http2): keep flow-controlled end-stream DATA alive until it is flushed (`#1998`) (dart-lang/http#2007)
  c834aa0  Thu Oct 22 06:28:11 2026 +0200  Moritz  fix(http2): ignore PRIORITY and unknown frames on streams and reject non-zero stream control frames (dart-lang/http#2006)
  2d35515  Thu Oct 22 06:15:59 2026 +0200  Moritz  fix(http2): replenish connection receive window when stream closes with unconsumed data (dart-lang/http#2005)
  1217181  Tue Oct 20 19:17:59 2026 -0400  Mouad Debbar  [http_multi_server] Prepare to release 3.2.3 (dart-lang/http#2028).

http (https://github.com/dart-lang/http/compare/a8a24a0..c76dfb3):
  c76dfb3  Wed Oct 28 10:21:55 2026 -0700  Brian Quinlan  fix(http2): accept larger headers (dart-lang/http#2041)
  23495b0  Mon Oct 26 15:17:28 2026 -0700  Brian Quinlan  chore(cronet_http): ready 1.10.1 (dart-lang/http#2039)
  27591d4  Mon Oct 26 15:08:10 2026 -0700  Brian Quinlan  fix(cronet): avoid cronet namespace duplication (dart-lang/http#2034)
  9cdb8da  Fri Oct 23 11:14:21 2026 -0700  Kevin Moore  Tighten pull_request_label workflow permissions (dart-lang/http#2035)
  3998b87  Fri Oct 23 07:31:29 2026 +0200  Moritz  fix(http2): do not head-of-line block HEADERS, RST_STREAM and GOAWAY behind window-exhausted DATA (dart-lang/http#2013)
  be2b51f  Fri Oct 23 07:31:28 2026 +0200  Moritz  fix(http2): Http2Client applies response-body backpressure to the HTTP/2 stream and uses 4 MiB / 16 MiB receive windows (dart-lang/http#2029)
  3b504cf  Thu Oct 22 06:28:12 2026 +0200  Moritz  fix(http2): never write RST_STREAM ahead of the stream's still-queued HEADERS (dart-lang/http#2008)
  a641fac  Thu Oct 22 06:28:12 2026 +0200  Moritz  fix(http2): preserve the complete response on RST_STREAM(NO_ERROR) (dart-lang/http#2012)
  5d896c7  Thu Oct 22 06:28:11 2026 +0200  Moritz  fix(http2): keep flow-controlled end-stream DATA alive until it is flushed () (dart-lang/http#2007)
  c834aa0  Thu Oct 22 06:28:11 2026 +0200  Moritz  fix(http2): ignore PRIORITY and unknown frames on streams and reject non-zero stream control frames (dart-lang/http#2006)
  2d35515  Thu Oct 22 06:15:59 2026 +0200  Moritz  fix(http2): replenish connection receive window when stream closes with unconsumed data (dart-lang/http#2005)
  1217181  Tue Oct 20 19:17:59 2026 -0400  Mouad Debbar  [http_multi_server] Prepare to release 3.2.3 (dart-lang/http#2028)

Change-Id: I259130f06771f4b7ba61c7610847e6677b5c8ed5
Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/570641
Reviewed-by: Nate Bosch <[email protected]>
Commit-Queue: Brian Quinlan <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants