Skip to content

http2, http3: a streamed response learns from FlushAsync that its peer has gone, on all four stacks - #274

Merged
MDA2AV merged 3 commits into
mainfrom
fix/streamed-peer-gone
Oct 4, 2026
Merged

MDA2AV merged 3 commits into
mainfrom
fix/streamed-peer-gone

Conversation

@MDA2AV

@MDA2AV MDA2AV commented Oct 4, 2026

Copy link
Copy Markdown
Owner

Fixes #269

FlushAsync on all four streamed writers (Http2ResponseWriter, Nghttp2ResponseWriter, Http3ResponseWriter, Nghttp3ResponseWriter) now returns ValueTask<FlushResult>. IsCompleted is set once the peer has reset or stopped the stream, or the connection is gone - what TcpConnectionPipeWriter already 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:

stack before after
ioxide.http2 kept sending DATA after RST_STREAM (RFC 9113 5.1); a writer out of window credit parked until the connection ended RST_STREAM drops the stream's window and wakes its writer; nothing more is sent on the stream
nghttp2 the next write hit a stream nghttp2 had already forgotten and failed the whole connection: the next request on it died with "response ended prematurely" that write ends only its own stream
ioxide.http3 kept sending into a stopped stream; the handler never knew STOP_SENDING, or the stream closing under the handler, marks the writer gone and wakes it
nghttp3 a stopped stream is never pulled again, so the flush loop spun on PumpAsync: the reactor thread sat at 100% CPU and the next request on the connection was never answered a stopped stream ends the wait

nghttp3, 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 new H3TestClient.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.

  • main, old behaviour behind the new signature: all four fail, "the handler never learned its stream was abandoned"
  • this PR: all four pass, 3 runs

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; control is 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:

sample (8 x 1 KiB chunks per response) this PR control
Http2/ManagedStreamedResponse (h2c, 64 conns) +0.5% +0.2%
Http2/Nghttp2Response (h2c, 64 conns) +2.9% +2.5%
Http3/ManagedStreamedBoth (h3, 16 conns) -0.8% -0.5%
Http3/Nghttp3Response (h3, 16 conns) +1.4% +1.4%

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 SendCredit already makes; the row above is the 10-round rerun after it. The other rows are 5 rounds.

MDA2AV added 3 commits October 4, 2026 14:38
… 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

http2, http3: a streamed response cannot learn that its peer has gone - after RST_STREAM the writer keeps sending DATA, then parks forever

1 participant