Repository navigation
Conversation
…eam, so a warm request allocates none of its objects Every request stream got a new ReqStream, Http3Request, 1 KiB arena, two lists with their arrays and a carry buffer: about 1.66 KB per request. A request whose body arrived whole left _requests at dispatch, so once its handler is done and its response submitted, the ReqStream goes back to the connection with all of them and the next stream reuses it. A streamed body's stays in _requests until its stream ends, so it is left alone, as before. An arena past 4 KiB is not kept, and one a response body points into goes with the response, since a body waiting for send capacity is read later.
…pool is capped and keeps nothing large From review: - DispatchReadyAsync read pending.Result twice (for Submit and for Recycle). A ValueTask is consumed by reading it, so a handler whose ValueTask comes from a pooled source threw on the second read and took the connection down. It is read once. - The pool keeps at most 32 request streams, never one whose header lists grew past 64 entries (a peer's thousands of header lines), and only on the reactor, the one thread that rents. - The arena check's comment now names the case it covers: a response that outlives its request because the handler returns it again. A body waiting for send capacity never points into a kept arena, since only bodies past 4 KiB wait and no arena past 4 KiB is kept. - Tests: the reuse test runs over the synchronous, parked and streamed handler shapes, and a second POST with a content-length checks that the count-down was reset. The allocation test makes 16 requests instead of 48 (each costs about 1.5 s in the test client) and checks both marks come from the reactor's thread.
…response, and a request with no :authority From review, each of these could regress with every test still passing: - A handler returning an already-completed ValueTask takes the path where the first version read the result twice; a fourth handler shape returns one from a source that throws on a second read. - The check that keeps an arena a response body points into had no test; a handler now keeps its first response, whose body is the request's path, and sends it again for the next request. - The test client always sent :authority, so a pseudo-header range surviving Reset could not show; a request that carries host now sends none (RFC 9114 4.3.1), and the second request does that. Each test was run against a mutation of the code it guards and failed there. Comments are cut to one line.
…he arena check goes; tests guard the pool's bounds The check covered a body aliasing the arena and not its header values, for a handler breaking the request's documented lifetime. The two HTTP/2 stacks have no such check. Tests guard the 4 KiB arena limit, the 64-line limit at its boundary, and the cap of 32.
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
Every request stream got a new set of objects: about 1.66 KB per request.
ReqStream, with a 16-byte carry bufferHttp3Request, with its two lists and their arraysArray.Resizefrom empty on the first fieldThe fix
The same approach #486 takes for
ioxide.http2:_requestsat dispatch. Once its handler is done and its response submitted,Recycleresets theReqStreamand keeps it on the connection (_reqPool), with itsHttp3Request, arena, lists and carry.Feedtakes one from there for the next request stream.CompleteBufferedAsync) and streamed response (ServeAsync)._requestsuntil its stream ends, so it is left alone, as before._pendingBodies.Behaviour
Http3Requestis reused for a later stream on the same connection once its handler is done, when its body arrived whole.Http3Response's doc says so now. The two HTTP/2 stacks pool their arenas on main already, and have no check for it.MemoryStream, so "none of its objects" holds for the bodyless case.API
None public.
Tests
New in the E2E suite (
Http3RequestReuseTests.cs), through the bare HTTP/3 test client:h3 request (ioxide.http3, synchronous | completed | parked | streamed handler): the next stream's request holds nothing of the one beforehostand no:authority(RFC 9114 4.3.1 allows either), then a second POST with a content-length, on one connection.:authoritywould show on the GET, and a staleContentLengthLeftwould make the second content-length read as a repeated one.:authorityon a request that carrieshost; nothing else usedhostwith it.h3 request (ioxide.http3): headers past 4 KiB leave no large arena for the next streamh3 request (ioxide.http3): a request with 64 header lines is kept for the next stream, and with 65 is noth3 request (ioxide.http3): a connection keeps at most 32 requests for later streamsH3ErrorCodeTests, with hand-framed requests: they take no time, and the test client sends at most 64 fields.h3 request (ioxide.http3): a warm connection's request allocates none of the request objectsAgainst main's
ioxide.http3(the tests only):The two that pass check what is not kept, and main keeps nothing.
Each test added after the first review was also run against a mutation of the code it guards and failed there: the ValueTask read twice, the pseudo-header ranges not reset, the recycle on the completed path removed, a large arena kept, the 64-line limit removed or moved, and the cap removed.
With the fix:
Review
An 8-agent review of this PR and its siblings found the reuse sound. It found the following, fixed in e2c7129:
pending.Resultwas read twice.Submit(sid, pending.Result); Recycle(rs, pending.Result)consumed a ValueTask twice. A handler whose ValueTask comes from a pooled source threw on the second read and took the connection down. It is read once now.ContentLengthLeft, and took 62-83 s in CI.A later round removed a check that kept an arena out of the pool when the response body pointed into it (c3e440c). It covered the body and not the header values, for a handler outside the documented lifetime, and the HTTP/2 stacks have none.
Bench
Re-measured after the review changes: allocation per request is unchanged.
ManagedBufferedallocates 186 B (base 1,882 B, control 1,885 B), andManagedStreamedBothis unchanged, as before. The tables below are the first run.Setup: the same as #486.
h2load -c 32 -m 16 -t 1(with--alpn-list=h3for HTTP/3) pinned to CPU 2, on separate physical cores.Http3/ManagedBufferedHttp3/ManagedStreamedBothManagedStreamedBothstreams its request body, which this PR leaves alone, so it is unchanged, as expected.Allocations, from
dotnet-countersover 20 s under the same load:ManagedBuffered, B per requestManagedStreamedBoth, B per requestManagedBufferedis the QUIC send queue's per-stream state, 185 B, which PR ngtcp2: a connection keeps a finished stream's send state for the next stream, so a warm HTTP/3 response allocates none of it #490 removes.Fixes #488.