Repository navigation
http2, http3: a streamed response learns from FlushAsync that its peer has gone, on all four stacks - #274
Merged
Conversation
… the stream, on both stacks FlushAsync returns a FlushResult, IsCompleted once the peer has reset the stream or the connection is gone - what a TCP pipe writer already reports for a closed peer. ioxide.http2 kept the reset stream's window, so it went on sending DATA after RST_STREAM and a writer out of credit parked until the connection ended. RST_STREAM now drops the window and wakes the writer, and nothing more is sent on the stream. nghttp2 forgets a reset stream, so the next write failed - and failed the connection, with every other stream on it. That write now ends only its own stream.
…ed reading, on both stacks FlushAsync returns a FlushResult here too, IsCompleted once the peer has sent STOP_SENDING (or the stream closed under the handler) or the connection is gone. nghttp3 never pulls a stopped stream again, so a writer waiting for its chunk to be taken looped on PumpAsync - synchronously outside a read pass, which pinned the reactor thread at 100% and starved every connection on it. A stopped stream now ends that wait. The writer is also no longer pooled when its stream closes under a running handler: the next request on the connection could rent it while the old handler still wrote into it. It goes back when the second of its two owners lets go. ioxide.http3 kept sending into a stopped stream and never told the handler; a parked writer now wakes to find out.
…ready makes SendCredit's window lookup now doubles as the check: negative means the peer reset the stream or the connection is gone. The flush loop no longer pays a second dictionary lookup per DATA chunk for it - measured at -1.7% on a streamed response of 8 one-KiB chunks.
This was referenced Oct 4, 2026
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #269
FlushAsyncon all four streamed writers (Http2ResponseWriter,Nghttp2ResponseWriter,Http3ResponseWriter,Nghttp3ResponseWriter) now returnsValueTask<FlushResult>.IsCompletedis set once the peer has reset or stopped the stream, or the connection is gone - whatTcpConnectionPipeWriteralready reports for a closed peer, so GenHTTP's HTTP/1.1 check carries over as is.await writer.FlushAsync();still compiles.What each stack did with a stream its peer abandoned, measured on main with the old behaviour behind the new signature:
PumpAsync: the reactor thread sat at 100% CPU and the next request on the connection was never answerednghttp3, from reading the code (the 100% spin comes first, so a test cannot reach it on main): when a stream closed under a running handler, its writer went back to the pool while the handler still held it, and the next request on the connection could rent it. The writer now goes back when the second of its two owners - handler and stream - lets go, as the request already does with
Detached.Once the peer has abandoned a stream, nothing else is sent on it either: no END_STREAM, and no RST_STREAM in answer to the peer's own (RFC 9113 5.4.2).
Tests
StreamedPeerGoneTests, one per stack: an endless response (1 KiB every 5 ms) that the client abandons. HTTP/2 drops it unread through HttpClient (RST_STREAM CANCEL - the pure-C# writer is parked on credit by then); HTTP/3 uses the newH3TestClient.RequestThenCancel(STOP_SENDING + RESET_STREAM, H3_REQUEST_CANCELLED). Each asserts that the handler's flush reports it, that the next request on the same connection answers 200, and that only one connection was used.All suites pass: E2E 235, Unit 60, Chaos 47, Http 44, Tls 151 (the 7 kTLS tests skip without sudo), File 4.
Bench
Interleaved in rotating order, 2 reactors pinned to the P-cores;
controlis a second build of main, so its column is the noise. Each figure is the median of the per-round ratio, req/s against main:The first run put ioxide.http2 at -1.7% (-2.0% against the control): the liveness check cost a second dictionary lookup per DATA chunk. The last commit reads it from the window lookup
SendCreditalready makes; the row above is the 10-round rerun after it. The other rows are 5 rounds.