Repository navigation
Conversation
…ng, and a connection reuses its request objects A writer whose flush only waits for the pump's next turn now returns that wait on a waiter it keeps (TurnWaiter, an IValueTaskSource), from a flush that is no longer an async method on the reactor: no state-machine boxes, no TaskCompletionSource, and the turn is a chain linked through its waiters instead of a list copied into an array. The turn whose own flush faulted is woken too. A retired PendingRequest goes back to its connection with its Http2Request, header list and field ranges, and the next stream reuses them. The HPACK sink takes its request as state instead of a closure, and a dispatch swaps two ready lists instead of copying one.
This was referenced Nov 4, 2026
Open
…me; the request pool is capped and lets go of what it held From review: - SendAfterCreditAsync called back into Send, so every credit stall inside one flush nested another async frame, kept until the flush ended: 20,000 one-byte WINDOW_UPDATEs on the connection retained 4.1 MB, and the deep unwind moved the writer onto the thread pool. It loops again, as the original method did. - A turn that ended in a fault or a disposed connection now wakes its writers with IsCompleted, rather than with a flush that looked as if it had gone out. - The fast path hands exceptions back through the returned task, as the async method did. - The request pool keeps at most 32, never one whose field list grew past 64, and only on the reactor. A retired request lets go of the arena's memories. A stream reset before its request completed goes back to the pool too, instead of costing a new one. - Tests: the credit-stall case; the turn test asserts that its flush waited; the warm-request test warms both ready lists and counts its handlers; the reuse test changes the scheme as well.
…tch walks the ready list in place, and the tests guard the review fixes From the second review: - SendAfterCreditAsync ignored what its flush's turn returned. A writer short of credit whose turn faulted (the pump being another writer, so the read loop does not tear down) went on to wait for a WINDOW_UPDATE that cannot come, until the connection's next read. It returns PeerGone now. - The second ready list guarded against something that cannot happen: only ParseAvailable adds to the list, and it never runs during the walk. _dispatching and TakeReady are gone. - FlushAsync's doc says each call has to complete before the next. Tests: - the faulted-turn test checks the flush returned IsCompleted, and a new one covers the credit wait; - the reuse test splits two header blocks over HEADERS + CONTINUATION, so stale HeadersDone or RequestEnded would show, and new ones cover the parked handler and a reset stream's Discard; - the warm-request bound is 100 B, which main's HPACK closure (145 B) fails. Each fails against a mutation of the code it guards. New comments are cut to one line.
This was referenced Nov 5, 2026
…ispatched, and a dead write side releases credit waits A dispatch that walked the list in place left dispatched requests in it, so a handler disposing the connection mid-walk disposed a parked handler's request. A writer already parked on credit when another writer's flush faulted waited for a teardown the read loop had no reason to start. Tests guard the request pool's cap, its header-line limit, and a retired request letting go of its arena.
This was referenced Nov 5, 2026
This branch has not been deployed
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.
What was wrong
A streamed flush allocated about 385 B whenever it waited for the write pump (#484)
Http2ResponseWriter.FlushCoreandHttp2Connection.FlushAsync), so both state machines were boxed, plus a newTaskCompletionSourceper wait and aTaskCompletionSource[]per turn.A buffered request allocated about 700 B of parser objects (#485)
PendingRequest,Http2Request, requestKeyValueListand header array, and a field-rangeListwith its array.Found on the way: a faulted flush stranded the writers whose bytes it carried
The fix
Turn waits
TurnWaiter(new, internal): anIValueTaskSource<FlushResult>that a streamed writer keeps for its whole life.Http2ResponseWriter.FlushCoreis no longer an async method on the reactor. It sends what credit allows and returns the flush as it is: done, the pump, or a wait on the writer's ownTurnWaiter.Http2Connection.FlushAsyncis no longer async either; the pump moved intoPumpAsync.TurnWaiter, one object instead of aTaskCompletionSourceand itsTask.EndTurnalso wakes the turn whose own flush faulted. Writers woken by a faulted turn, or by a disposed connection, getIsCompleted, as a TCP pipe writer reports a closed peer.SendAfterCreditAsyncloops in one frame until the bytes are out, however many times the peer opens the window.Request objects
PendingRequestgoes back to its connection (_pendingPool, like_writerPool) with itsHttp2Request, header list and field ranges, and the next stream reuses them.ArrayPool, so the pool pins nothing.Queuethat a dispatch drains, instead of aListcopied to an array each pass. It only ever holds requests not yet dispatched, soDisposecan free what it finds there.Behaviour
Http2Requestis reused for a later stream on the same connection once its handler is done. It was already only valid during the handler: its memories point into an arena that goes back to the pool at retire. The doc comment says so now.ObjectDisposedException, and returns it as a faultedValueTask.FlushAsyncagain before the last flush completed is now an error in practice. The writer's contract already rules it out, andFlushAsync's doc now says each call has to complete before the next.ValueTaskis no longer valid.API
None public. Internal signatures changed:
FlushAsync,MaybeFlushAsync,FlushOutboundAsyncandResetStreamedAsyncreturnValueTask<FlushResult>, andHpackDecoder.Decodetakes a state argument.Tests
New, in the Unit suite:
h2 queue: a streamed flush that waits for the pump's turn allocates nothing(Http2OutputQueueTests.cs)writer.FlushAsync()on the handler's thread while the flush waits for its turn, and checks that the flush did wait.h2 queue: a faulted flush wakes the writer whose bytes it carried(Http2OutputQueueTests.cs)IsCompleted, not merely wake.h2 queue: a flush waiting for credit hears the peer gone when the turn it waits on faults(Http2OutputQueueTests.cs)IsCompletedfrom the turn itself.h2 queue: a flush parked on credit hears the peer gone when another writer's flush faults(Http2OutputQueueTests.cs)h2 queue: a flush that waits for credit again and again keeps one frame, and resumes inline(Http2OutputQueueTests.cs)h2 request: the next stream's request holds nothing of the one before(Http2RequestReuseTests.cs)HeadersDoneorRequestEndedleft set by the request before would dispatch them early.h2 request: a parked handler's request goes back once it answers(Http2RequestReuseTests.cs)CompleteBufferedAsyncpath. Once back, the request's memories are empty, so the pool pins no arena.h2 request: a request reset before it ends comes back for the next stream holding nothing(Http2RequestReuseTests.cs)Discardtakes the request back (the pool, read by reflection, holds it), and the next GET carries nothing of it.h2 request: a handler that disposes the connection leaves a parked handler's request alone(Http2RequestReuseTests.cs)h2 request: a burst leaves at most 32 requests on the connection(Http2RequestReuseTests.cs)h2 request: a request with 64 header lines is kept for the next stream, and with 65 is not (Http2RequestReuseTests.cs)h2 request: a warm connection's request allocates none of the parser's objects(Http2RequestReuseTests.cs)Against main's
ioxide.http2(the tests only):Those are the two test classes only: 6 passed, 11 failed. The other 4 failures read
_pendingPool, which main does not have.Each test added or tightened in the review rounds was also run against a mutation of the code it guards and failed there: the faulted turn woken with
default, the credit wait ignoring its turn, credit waits not released on a fault,HeadersDoneorRequestEndednot reset,Discarddisposing instead of recycling, the HPACK sink capturing a closure, the parked path disposing instead of retiring, a dispatch that leaves dispatched requests in the ready list, the cap removed, the 64-line limit removed or moved, and a retired request keeping its memories.With the fix (after the review changes below):
tlsmodule loaded here).The solution builds with 0 errors.
Review
An 8-agent review of this PR and its siblings (#490, #491, #492) found the following, fixed in 7dea7fa:
SendAfterCreditAsynccalled back intoSend, so every credit stall inside one flush nested another async frame, kept until the flush ended.Two more review rounds followed. The second found a credit wait ignoring its turn's
PeerGone, fixed in bcc0bb7. The third found a regression that commit brought in, fixed in 7cdc212:connection.Dispose()during the walk disposed a parked handler's request under it. Nothing in this repo does that. TheQueueremoves the case, and the walk's diff against main is one line.Bench
Re-measured on the final code, with the same setup, 6 more rounds:
ManagedBuffered: -16.6% server CPU per request (control -0.3%), +7.3% req/s.ManagedStreamedBoth: -34.9% server CPU per flush (control -1.0%), +53.8% flushes/s.Queuehas no visible cost: before it, the same rig measured -14.8% and -34.9%.The tables below are the first run, before those changes.
Setup:
h2load -c 32 -m 16 -t 1pinned to CPU 2: different physical cores, with their siblings left idle.Http2/ManagedBuffered/helloHttp2/ManagedStreamedBoth/feed, 1 KiB per flushAllocations, from
dotnet-countersover 20 s under the same load:/hello, B per request/feed, B per flush/hello//feed/hello, from agc-verbosetrace of the branch:Http2Responseand itsKeyValueList, 80 B, which is app code;Not in this PR: the other stacks named in #484 (
ioxide.nghttp2,ioxide.nghttp3,ioxide.http3) still allocate when their flushes wait.Fixes #484.
Fixes #485.