Skip to content

fix(http2): keep flow-controlled end-stream DATA alive until it is flushed (#1998) - #2007

Open
mosuem wants to merge 1 commit into
mosum/http2-2-priority-unknown-and-control-framesfrom
mosum/http2-3-rst-noerror-and-end-stream-flow-control
Open

mosuem wants to merge 1 commit into
mosum/http2-2-priority-unknown-and-control-framesfrom
mosum/http2-3-rst-noerror-and-end-stream-flow-control

Conversation

@mosuem

@mosuem mosuem commented Oct 8, 2026 •

Copy link
Copy Markdown
Member

Note

This PR was generated by an AI coding agent (Jetski) on behalf of @mosuem.

Summary

Fixes #1998: sendData(bytes, endStream: true) with more bytes than the peer's flow-control window stalled forever, because the stream was torn down before the flow-controlled remainder could be sent.

Two things went wrong:

  1. In StreamMessageQueueOut.enqueueMessage, if (message.endStream) startClosing() ran before _messages.addLast(message). startClosing() synchronously calls onCheckForClose(), which saw an empty _messages and closed the queue while the DataMessage was still waiting for WINDOW_UPDATE credit.
  2. In StreamHandler._handleEndOfStreamRemote, when the peer sent END_STREAM on a stream that was already HalfClosedLocal (the local side had called sendData(..., endStream: true)), the stream was removed from _openStreams immediately, even if stream.outgoingQueue still had DATA waiting on credit. Subsequent stream-level WINDOW_UPDATE frames from the peer were then ignored (RFC 9113 Section 6.9: a WINDOW_UPDATE on a closed stream is discarded), so the remaining data could never be sent.

Changes

  • lib/src/flowcontrol/stream_queues.dart: Call startClosing() after _messages.addLast(message), so onCheckForClose() only closes the queue once _messages has drained.
  • lib/src/streams/stream_handler.dart: In _handleEndOfStreamRemote, only remove the stream from _openStreams if stream.outgoingQueue.wasClosed; otherwise _cleanupClosedStream removes it once the outgoing queue has drained.

Test Verification (Fails Before $\rightarrow$ Passes After)

Added send-data-exceeding-window-with-end-stream-completes in test/transport_test.dart (server and client each send 100 000 bytes with endStream: true through a 65 535-byte window; the second case has the server respond with END_STREAM before the upload has drained).

Before fix:

00:05 +0 -1: transport-test flow-control send-data-exceeding-window-with-end-stream-completes [E]
  TimeoutException after 0:00:05.000000: Test timed out after 5 seconds.

After fix:

00:00 +1: All tests passed!

@mosuem
mosuem added this pull request to stack #2011 October 8, 2026 08:32
@mosuem
mosuem force-pushed the mosum/http2-3-rst-noerror-and-end-stream-flow-control branch from e79d54d to e3c9026 Compare October 8, 2026 10:36
@mosuem mosuem changed the title fix(http2): preserve complete response on RST_STREAM(NO_ERROR) and keep queued end-stream data until flushed fix(http2): keep flow-controlled end-stream DATA alive until it is flushed (#1998) Oct 8, 2026
@mosuem
mosuem removed this pull request from stack #2011 October 8, 2026 10:38
@mosuem
mosuem added this pull request to stack #2016 October 8, 2026 10:42
@mosuem
mosuem force-pushed the mosum/http2-3-rst-noerror-and-end-stream-flow-control branch from e3c9026 to fd56245 Compare October 8, 2026 12:04
@mosuem
mosuem force-pushed the mosum/http2-3-rst-noerror-and-end-stream-flow-control branch from fd56245 to 6259a19 Compare October 9, 2026 07:54
@mosuem
mosuem removed this pull request from stack #2016 October 9, 2026 07:55
@mosuem
mosuem added this pull request to stack #2020 October 9, 2026 07:55
@mosuem
mosuem force-pushed the mosum/http2-3-rst-noerror-and-end-stream-flow-control branch from 6259a19 to 4f56a92 Compare October 9, 2026 08:18
@github-actions

github-actions Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

PR Health

Coverage ✔️
File Coverage
pkgs/http2/lib/src/flowcontrol/stream_queues.dart 💚 90 %
pkgs/http2/lib/src/streams/stream_handler.dart 💚 91 % ⬆️ 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.

…ushed (#1998)

> [!NOTE]
> This PR was generated by an AI coding agent (Jetski) on behalf of @mosuem.

### Summary

Fixes #1998: `sendData(bytes, endStream: true)` with more bytes than the peer's flow-control window stalled forever, because the stream was torn down before the flow-controlled remainder could be sent.

Two things went wrong:

1. In `StreamMessageQueueOut.enqueueMessage`, `if (message.endStream) startClosing()` ran *before* `_messages.addLast(message)`. `startClosing()` synchronously calls `onCheckForClose()`, which saw an empty `_messages` and closed the queue while the `DataMessage` was still waiting for `WINDOW_UPDATE` credit.
2. In `StreamHandler._handleEndOfStreamRemote`, when the peer sent `END_STREAM` on a stream that was already `HalfClosedLocal` (the local side had called `sendData(..., endStream: true)`), the stream was removed from `_openStreams` immediately, even if `stream.outgoingQueue` still had `DATA` waiting on credit. Subsequent stream-level `WINDOW_UPDATE` frames from the peer were then ignored (RFC 9113 Section 6.9: a `WINDOW_UPDATE` on a closed stream is discarded), so the remaining data could never be sent.

### Changes
- **`lib/src/flowcontrol/stream_queues.dart`**: Call `startClosing()` after `_messages.addLast(message)`, so `onCheckForClose()` only closes the queue once `_messages` has drained.
- **`lib/src/streams/stream_handler.dart`**: In `_handleEndOfStreamRemote`, only remove the stream from `_openStreams` if `stream.outgoingQueue.wasClosed`; otherwise `_cleanupClosedStream` removes it once the outgoing queue has drained.

### Test Verification (Fails Before $\rightarrow$ Passes After)

Added `send-data-exceeding-window-with-end-stream-completes` in `test/transport_test.dart` (server and client each send 100 000 bytes with `endStream: true` through a 65 535-byte window; the second case has the server respond with `END_STREAM` before the upload has drained).

**Before fix:**
```text
00:05 +0 -1: transport-test flow-control send-data-exceeding-window-with-end-stream-completes [E]
  TimeoutException after 0:00:05.000000: Test timed out after 5 seconds.
```

**After fix:**
```text
00:00 +1: All tests passed!
```
@mosuem
mosuem force-pushed the mosum/http2-3-rst-noerror-and-end-stream-flow-control branch from 4f56a92 to 7f3979c Compare October 9, 2026 08:48
@mosuem
mosuem marked this pull request as ready for review October 9, 2026 08:54
@mosuem
mosuem requested a review from brianquinlan October 9, 2026 08:54
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.

http2 server: a response body larger than the peer's window stalls when sent with endStream

1 participant