From 4bd56013c8a4c778e755d8420b9880592ed291b5 Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Wed, 4 Nov 2026 16:40:20 +0000 Subject: [PATCH 1/4] http3: a connection reuses a finished request stream for its next stream, 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. --- .../ioxide.http3/Http3Connection.Streamed.cs | 1 + src/protocols/ioxide.http3/Http3Connection.cs | 51 ++++++++- src/protocols/ioxide.http3/Http3Request.cs | 26 ++++- tests/Ioxide.Tests.E2E/Program.cs | 1 + .../Protocols/Http3RequestReuseTests.cs | 106 ++++++++++++++++++ 5 files changed, 183 insertions(+), 2 deletions(-) create mode 100644 tests/Ioxide.Tests.E2E/Protocols/Http3RequestReuseTests.cs diff --git a/src/protocols/ioxide.http3/Http3Connection.Streamed.cs b/src/protocols/ioxide.http3/Http3Connection.Streamed.cs index cca6c881..b6468869 100644 --- a/src/protocols/ioxide.http3/Http3Connection.Streamed.cs +++ b/src/protocols/ioxide.http3/Http3Connection.Streamed.cs @@ -102,6 +102,7 @@ private async Task ServeAsync(Func { _writers.Remove(writer.StreamId); _writerPool.Push(writer); + Recycle(rs, response: null); // what it wrote was copied out as it went } } diff --git a/src/protocols/ioxide.http3/Http3Connection.cs b/src/protocols/ioxide.http3/Http3Connection.cs index 0b7e3fc5..93e84471 100644 --- a/src/protocols/ioxide.http3/Http3Connection.cs +++ b/src/protocols/ioxide.http3/Http3Connection.cs @@ -1,6 +1,7 @@ using System.Buffers; using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; using ioxide; namespace ioxide.http3; @@ -56,6 +57,10 @@ public sealed partial class Http3Connection // client uni (id % 4 == 2) = control / QPACK / push streams. private readonly Dictionary _requests = new(); private readonly Dictionary _unis = new(); + + // Request streams whose body arrived whole, kept with their Http3Request, arena and lists for the + // next stream (#488). + private readonly Stack _reqPool = new(); private readonly List _ready = []; private readonly List _bodyWakes = []; @@ -93,6 +98,20 @@ private sealed class ReqStream // RetireBody unpaced the stream, because bytes delivered while it was still paced can be // in the ring then - over-crediting the ones that arrived after is harmless. public bool Paced; + + /// Fresh for the next stream, after . + public void Reset() + { + Request.Reset(); + Sink = null; + State = ParseState.FrameHeader; + CarryLen = 0; + Remaining = 0; + HeadersLen = 0; + HeadersDone = false; + Finished = false; + Paced = false; + } } private sealed class UniStream @@ -294,7 +313,7 @@ private void Feed(in QuicRecvRing.Delivery item) _quicConnection.ResetStream(item.StreamId, H3RequestIncomplete); return; } - rs = new ReqStream(); + rs = _reqPool.TryPop(out ReqStream? pooled) ? pooled : new ReqStream(); rs.Request.StreamId = item.StreamId; _requests[item.StreamId] = rs; } @@ -657,6 +676,7 @@ private void DispatchReady(Func handler) } Submit(rs.Request.StreamId, resp); + Recycle(rs, resp); } _ready.Clear(); } @@ -687,6 +707,7 @@ private void DispatchReadyAsync(Func> han Console.Error.WriteLine($"[ioxide.http3] request handler faulted: {e.GetBaseException().Message}"); RetireBody(rs); Submit(sid, new Http3Response { Status = 500 }); + Recycle(rs, response: null); continue; } @@ -694,6 +715,7 @@ private void DispatchReadyAsync(Func> han { RetireBody(rs); Submit(sid, pending.Result); + Recycle(rs, pending.Result); } else { @@ -723,6 +745,33 @@ private async Task CompleteBufferedAsync(ValueTask pending, ReqSt return; } Submit(rs.Request.StreamId, resp); + Recycle(rs, resp); + } + + /// + /// Done with a request once its handler is done and its response submitted. One whose body arrived whole + /// left _requests at dispatch, so nothing reaches it by its id any more and it goes back for the next + /// stream. A streamed body's stays in _requests until its stream ends, so it is left alone. + /// + private void Recycle(ReqStream rs, Http3Response? response) + { + if (_streamBodies) + { + return; + } + + // A body still waiting for send capacity is read later, so an arena the response points into (an echo of + // a header, say) goes with the response, and the next stream starts a fresh one. + if (response is not null + && MemoryMarshal.TryGetArray(response.Body, out ArraySegment body) + && ReferenceEquals(body.Array, rs.Request.Arena)) + { + rs.Request.Arena = []; + } + + ReleaseParseBuffers(rs); + rs.Reset(); + _reqPool.Push(rs); } // The handler is done with the body: credit what it left unread and let the engine credit the diff --git a/src/protocols/ioxide.http3/Http3Request.cs b/src/protocols/ioxide.http3/Http3Request.cs index 11435dbc..a3b9aa4b 100644 --- a/src/protocols/ioxide.http3/Http3Request.cs +++ b/src/protocols/ioxide.http3/Http3Request.cs @@ -5,7 +5,8 @@ namespace ioxide.http3; /// /// One HTTP/3 request - post-QPACK BYTES throughout, mirroring the ioxide.nghttp3 surface: route /// by byte compare (req.Path.Span.SequenceEqual("/x"u8)) or decode at the edge. Every -/// memory slices a per-request buffer valid only until the handler returns. +/// memory slices a per-request buffer valid only until the handler returns, and a request whose +/// body arrived whole is itself reused for a later stream on the same connection after that. /// public sealed class Http3Request { @@ -103,6 +104,29 @@ internal void Freeze() } private ReadOnlyMemory Slice((int Off, int Len) r) => r.Len < 0 ? default : Arena.AsMemory(r.Off, r.Len); + + // An arena past this is not kept for the next stream: a peer sending large headers would otherwise + // leave a large array on the connection for the rest of its life. + private const int MaxPooledArena = 4 * 1024; + + /// Fresh for the next stream, keeping the arena and both lists' capacity. + internal void Reset() + { + StreamId = 0; + Method = Path = Scheme = Authority = Body = default; + Headers.Clear(); + BodyReader = null; + + if (Arena.Length > MaxPooledArena) + { + Arena = []; + } + ArenaUsed = 0; + MethodR = PathR = SchemeR = AuthorityR = (0, -1); + HeaderRanges.Clear(); + BodyBuffer = null; + ContentLengthLeft = -1; + } } /// diff --git a/tests/Ioxide.Tests.E2E/Program.cs b/tests/Ioxide.Tests.E2E/Program.cs index e0c353db..729d3c4f 100644 --- a/tests/Ioxide.Tests.E2E/Program.cs +++ b/tests/Ioxide.Tests.E2E/Program.cs @@ -64,6 +64,7 @@ private static int Main() StreamedPeerGoneTests.Register(runner); QuicStreamingCreditTests.Register(runner); Http3BodyTests.Register(runner); + Http3RequestReuseTests.Register(runner); H3BodyModeTests.Register(runner); H3BufferedFaultTests.Register(runner); H3BodyLimitTests.Register(runner); diff --git a/tests/Ioxide.Tests.E2E/Protocols/Http3RequestReuseTests.cs b/tests/Ioxide.Tests.E2E/Protocols/Http3RequestReuseTests.cs new file mode 100644 index 00000000..769804b0 --- /dev/null +++ b/tests/Ioxide.Tests.E2E/Protocols/Http3RequestReuseTests.cs @@ -0,0 +1,106 @@ +using System.Text; +using ioxide.http3; +using ioxide.ngtcp2; + +namespace Ioxide.Tests; + +/// +/// ioxide.http3 keeps a request stream whose body arrived whole, with its request, arena and lists, for the next +/// stream: the next request must come back holding nothing of the one before, and a warm connection's requests +/// allocate none of those objects on the reactor. +/// +internal static class Http3RequestReuseTests +{ + public static void Register(Runner runner) + { + runner.Test("h3 request (ioxide.http3): the next stream's request holds nothing of the one before", () => + { + var seen = new List<(Http3Request Request, string Fields)>(); + int udpPort = Start(request => + { + seen.Add((request, Describe(request))); // on the reactor's thread + return Http3Response.Text("ok"); + }); + + using H3TestClient client = Connect(udpPort); + Assert.Equal(200, client.Request("POST", "/first", "hello"u8.ToArray(), [("x-one", "1"), ("x-two", "2")], timeoutMs: 5000).Status); + Assert.Equal(200, client.Get("/", timeoutMs: 5000).Status); + + Assert.Equal(2, seen.Count); + Assert.Equal("POST /first [x-one=1 x-two=2] hello", seen[0].Fields); + Assert.Equal("GET / [] -", seen[1].Fields); + Assert.True(ReferenceEquals(seen[0].Request, seen[1].Request), "the second stream reuses the first one's request"); + }); + + runner.Test("h3 request (ioxide.http3): a warm connection's request allocates none of the request objects", () => + { + const int warm = 16; + const int measured = 32; + Http3Response response = Http3Response.Text("ok"); // shared, so the handler allocates nothing + long[] marks = new long[2]; + int served = 0; + + int udpPort = Start(_ => + { + // On the reactor's thread: what it allocated between these two requests. + int n = ++served; + if (n == warm) + { + marks[0] = GC.GetAllocatedBytesForCurrentThread(); + } + else if (n == warm + measured) + { + marks[1] = GC.GetAllocatedBytesForCurrentThread(); + } + return response; + }); + + using H3TestClient client = Connect(udpPort); + for (int i = 0; i < warm + measured; i++) + { + (int status, string body) = client.Request("GET", $"/{i}", null, [("x-n", $"{i}")], timeoutMs: 5000); + Assert.Equal(200, status); + Assert.Equal("ok", body); + } + + // Before reuse, every request allocated about 1.8 KB of these. What may remain is the QUIC send queue's + // per-stream state, about 185 B, which the engine reuses as well once that change is in. + long perRequest = (marks[1] - marks[0]) / measured; + Assert.True(perRequest < 400, $"a request on a warm connection allocated {perRequest} B on the reactor"); + }); + } + + private static int Start(Func handler) + { + (string certPath, string keyPath) = TestCert.Ensure(); + var engine = TestServer.Own(new QuicEngine(certPath, keyPath, cidLength: 8, alpn: ["h3"])); + + (_, int udpPort) = TestServer.StartDatagram( + onDatagram: null, + quicFactory: engine.CreateFactory(), + quicHandle: (_, conn) => new Http3Connection(conn).RunAsync(handler)); + return udpPort; + } + + private static H3TestClient Connect(int udpPort) + { + var client = new H3TestClient("127.0.0.1", udpPort); + client.Connect(); + Assert.True(client.CompleteHandshake(timeoutMs: 5000), "handshake did not complete"); + return client; + } + + private static string Describe(Http3Request request) + { + var fields = new List(); + foreach ((ReadOnlyMemory name, ReadOnlyMemory value) in request.Headers) + { + fields.Add($"{Ascii(name)}={Ascii(value)}"); + } + + return $"{Ascii(request.Method)} {Ascii(request.Path)} [{string.Join(' ', fields)}] " + + (request.Body.IsEmpty ? "-" : Ascii(request.Body)); + } + + private static string Ascii(ReadOnlyMemory bytes) => Encoding.ASCII.GetString(bytes.Span); +} From e2c7129625e7c46f07f1a1e0d2fec452fa5d3bc1 Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Wed, 4 Nov 2026 19:09:30 +0000 Subject: [PATCH 2/4] http3: read a parked-but-complete handler's result once; the request 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. --- src/protocols/ioxide.http3/Http3Connection.cs | 18 ++-- src/protocols/ioxide.http3/Http3Request.cs | 8 +- .../Protocols/Http3RequestReuseTests.cs | 101 +++++++++++++----- 3 files changed, 90 insertions(+), 37 deletions(-) diff --git a/src/protocols/ioxide.http3/Http3Connection.cs b/src/protocols/ioxide.http3/Http3Connection.cs index 93e84471..daac3255 100644 --- a/src/protocols/ioxide.http3/Http3Connection.cs +++ b/src/protocols/ioxide.http3/Http3Connection.cs @@ -59,8 +59,9 @@ public sealed partial class Http3Connection private readonly Dictionary _unis = new(); // Request streams whose body arrived whole, kept with their Http3Request, arena and lists for the - // next stream (#488). + // next stream (#488). Capped, so a burst of streams leaves no more than this on an idle connection. private readonly Stack _reqPool = new(); + private const int MaxPooledRequests = 32; private readonly List _ready = []; private readonly List _bodyWakes = []; @@ -714,8 +715,9 @@ private void DispatchReadyAsync(Func> han if (pending.IsCompletedSuccessfully) { RetireBody(rs); - Submit(sid, pending.Result); - Recycle(rs, pending.Result); + Http3Response response = pending.Result; // once: a ValueTask is consumed by reading it + Submit(sid, response); + Recycle(rs, response); } else { @@ -755,13 +757,17 @@ private async Task CompleteBufferedAsync(ValueTask pending, ReqSt /// private void Recycle(ReqStream rs, Http3Response? response) { - if (_streamBodies) + // Only on the reactor, the one thread that rents, and never one whose lists a peer's thousands of header + // lines grew: those would stay that size on the connection. + if (_streamBodies || _reqPool.Count >= MaxPooledRequests || OffReactor || !rs.Request.IsSmall) { return; } - // A body still waiting for send capacity is read later, so an arena the response points into (an echo of - // a header, say) goes with the response, and the next stream starts a fresh one. + // A response can outlive its request: a handler may hand the same response object out again later. So an + // arena the response points into (an echo of a header, say) goes with it, and the next stream starts a + // fresh one. A body that waits for send capacity needs nothing more: only bodies past 4 KiB wait, and an + // arena that large is never kept (Http3Request.MaxPooledArena). if (response is not null && MemoryMarshal.TryGetArray(response.Body, out ArraySegment body) && ReferenceEquals(body.Array, rs.Request.Arena)) diff --git a/src/protocols/ioxide.http3/Http3Request.cs b/src/protocols/ioxide.http3/Http3Request.cs index a3b9aa4b..4615e628 100644 --- a/src/protocols/ioxide.http3/Http3Request.cs +++ b/src/protocols/ioxide.http3/Http3Request.cs @@ -106,8 +106,12 @@ internal void Freeze() private ReadOnlyMemory Slice((int Off, int Len) r) => r.Len < 0 ? default : Arena.AsMemory(r.Off, r.Len); // An arena past this is not kept for the next stream: a peer sending large headers would otherwise - // leave a large array on the connection for the rest of its life. - private const int MaxPooledArena = 4 * 1024; + // leave a large array on the connection for the rest of its life. At most the inline-body limit, so a + // response body that waits for send capacity never points into a kept arena. + internal const int MaxPooledArena = 4 * 1024; + + // Grew nothing a pooled request should keep: lists past this were a peer's thousands of header lines. + internal bool IsSmall => Headers.Capacity <= 64 && HeaderRanges.Capacity <= 64; /// Fresh for the next stream, keeping the arena and both lists' capacity. internal void Reset() diff --git a/tests/Ioxide.Tests.E2E/Protocols/Http3RequestReuseTests.cs b/tests/Ioxide.Tests.E2E/Protocols/Http3RequestReuseTests.cs index 769804b0..da0df5dc 100644 --- a/tests/Ioxide.Tests.E2E/Protocols/Http3RequestReuseTests.cs +++ b/tests/Ioxide.Tests.E2E/Protocols/Http3RequestReuseTests.cs @@ -1,58 +1,64 @@ using System.Text; +using ioxide; using ioxide.http3; using ioxide.ngtcp2; +using ioxide.timer; namespace Ioxide.Tests; /// /// ioxide.http3 keeps a request stream whose body arrived whole, with its request, arena and lists, for the next -/// stream: the next request must come back holding nothing of the one before, and a warm connection's requests -/// allocate none of those objects on the reactor. +/// stream: on every dispatch path the next request must come back holding nothing of the one before, and a warm +/// connection's requests allocate none of those objects on the reactor. /// internal static class Http3RequestReuseTests { + private enum Shape { Synchronous, Parked, Streamed } + public static void Register(Runner runner) { - runner.Test("h3 request (ioxide.http3): the next stream's request holds nothing of the one before", () => + foreach (Shape shape in new[] { Shape.Synchronous, Shape.Parked, Shape.Streamed }) { - var seen = new List<(Http3Request Request, string Fields)>(); - int udpPort = Start(request => + Shape s = shape; + runner.Test($"h3 request (ioxide.http3, {s.ToString().ToLowerInvariant()} handler): the next stream's request holds nothing of the one before", () => { - seen.Add((request, Describe(request))); // on the reactor's thread - return Http3Response.Text("ok"); - }); + var seen = new List<(Http3Request Request, string Fields)>(); + int udpPort = Start(s, request => seen.Add((request, Describe(request)))); // on the reactor's thread - using H3TestClient client = Connect(udpPort); - Assert.Equal(200, client.Request("POST", "/first", "hello"u8.ToArray(), [("x-one", "1"), ("x-two", "2")], timeoutMs: 5000).Status); - Assert.Equal(200, client.Get("/", timeoutMs: 5000).Status); + using H3TestClient client = Connect(udpPort); + Assert.Equal(200, client.Request("POST", "/first", "hello"u8.ToArray(), + [("x-one", "1"), ("x-two", "2"), ("content-length", "5")], timeoutMs: 5000).Status); + Assert.Equal(200, client.Get("/", timeoutMs: 5000).Status); + // A content-length the request before it already counted down must not read as a repeated one. + Assert.Equal(200, client.Request("POST", "/again", "world"u8.ToArray(), [("content-length", "5")], timeoutMs: 5000).Status); - Assert.Equal(2, seen.Count); - Assert.Equal("POST /first [x-one=1 x-two=2] hello", seen[0].Fields); - Assert.Equal("GET / [] -", seen[1].Fields); - Assert.True(ReferenceEquals(seen[0].Request, seen[1].Request), "the second stream reuses the first one's request"); - }); + Assert.Equal(3, seen.Count); + Assert.Equal("POST /first [x-one=1 x-two=2 content-length=5] hello", seen[0].Fields); + Assert.Equal("GET / [] -", seen[1].Fields); + Assert.Equal("POST /again [content-length=5] world", seen[2].Fields); + Assert.True(ReferenceEquals(seen[0].Request, seen[1].Request) && ReferenceEquals(seen[1].Request, seen[2].Request), + "each stream reuses the request of the one before"); + }); + } runner.Test("h3 request (ioxide.http3): a warm connection's request allocates none of the request objects", () => { - const int warm = 16; - const int measured = 32; - Http3Response response = Http3Response.Text("ok"); // shared, so the handler allocates nothing + const int warm = 4; + const int measured = 12; long[] marks = new long[2]; + int[] threads = new int[2]; int served = 0; - int udpPort = Start(_ => + int udpPort = Start(Shape.Synchronous, _ => { // On the reactor's thread: what it allocated between these two requests. int n = ++served; - if (n == warm) - { - marks[0] = GC.GetAllocatedBytesForCurrentThread(); - } - else if (n == warm + measured) + int mark = n == warm ? 0 : n == warm + measured ? 1 : -1; + if (mark >= 0) { - marks[1] = GC.GetAllocatedBytesForCurrentThread(); + marks[mark] = GC.GetAllocatedBytesForCurrentThread(); + threads[mark] = Environment.CurrentManagedThreadId; } - return response; }); using H3TestClient client = Connect(udpPort); @@ -63,6 +69,8 @@ public static void Register(Runner runner) Assert.Equal("ok", body); } + Assert.True(threads[0] == threads[1] && marks[1] >= marks[0], "both marks come from the reactor's thread"); + // Before reuse, every request allocated about 1.8 KB of these. What may remain is the QUIC send queue's // per-stream state, about 185 B, which the engine reuses as well once that change is in. long perRequest = (marks[1] - marks[0]) / measured; @@ -70,7 +78,11 @@ public static void Register(Runner runner) }); } - private static int Start(Func handler) + private static readonly Http3Response Ok = Http3Response.Text("ok"); // shared, so the handler allocates nothing + + // One connection's server, answering "ok" through the handler shape asked for: a synchronous handler, one that + // parks on a ring timer before it answers, or one that streams its response. + private static int Start(Shape shape, Action observe) { (string certPath, string keyPath) = TestCert.Ensure(); var engine = TestServer.Own(new QuicEngine(certPath, keyPath, cidLength: 8, alpn: ["h3"])); @@ -78,7 +90,38 @@ private static int Start(Func handler) (_, int udpPort) = TestServer.StartDatagram( onDatagram: null, quicFactory: engine.CreateFactory(), - quicHandle: (_, conn) => new Http3Connection(conn).RunAsync(handler)); + quicHandle: (reactor, conn) => + { + var connection = new Http3Connection(conn); + switch (shape) + { + case Shape.Synchronous: + return connection.RunAsync(request => + { + observe(request); + return Ok; + }); + + case Shape.Parked: + var timer = new RingTimer(reactor); + return connection.RunAsync(async request => + { + observe(request); + await timer.DelayAsync(1); + return Ok; + }); + + default: + return connection.RunAsync(async (request, writer) => + { + observe(request); + writer.WriteHeaders(new Http3Response { Status = 200 }); + "ok"u8.CopyTo(writer.GetSpan(2)); + writer.Advance(2); + await writer.FlushAsync(); + }); + } + }); return udpPort; } From 8acf932079d968b25ddea52035dabe534da063d6 Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Thu, 5 Nov 2026 00:21:34 +0000 Subject: [PATCH 3/4] http3: the reuse tests cover a handler's completed ValueTask, a kept 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. --- src/protocols/ioxide.http3/Http3Connection.cs | 17 ++--- src/protocols/ioxide.http3/Http3Request.cs | 4 +- .../Protocols/Http3RequestReuseTests.cs | 68 ++++++++++++++----- tests/Ioxide.Tests.Harness/H3TestClient.cs | 11 ++- 4 files changed, 62 insertions(+), 38 deletions(-) diff --git a/src/protocols/ioxide.http3/Http3Connection.cs b/src/protocols/ioxide.http3/Http3Connection.cs index daac3255..50ece08e 100644 --- a/src/protocols/ioxide.http3/Http3Connection.cs +++ b/src/protocols/ioxide.http3/Http3Connection.cs @@ -58,8 +58,7 @@ public sealed partial class Http3Connection private readonly Dictionary _requests = new(); private readonly Dictionary _unis = new(); - // Request streams whose body arrived whole, kept with their Http3Request, arena and lists for the - // next stream (#488). Capped, so a burst of streams leaves no more than this on an idle connection. + // Request streams whose body arrived whole, kept for the next stream; capped, so a burst leaves little behind. private readonly Stack _reqPool = new(); private const int MaxPooledRequests = 32; private readonly List _ready = []; @@ -750,24 +749,16 @@ private async Task CompleteBufferedAsync(ValueTask pending, ReqSt Recycle(rs, resp); } - /// - /// Done with a request once its handler is done and its response submitted. One whose body arrived whole - /// left _requests at dispatch, so nothing reaches it by its id any more and it goes back for the next - /// stream. A streamed body's stays in _requests until its stream ends, so it is left alone. - /// + /// Back for the next stream once the handler is done and the response submitted. private void Recycle(ReqStream rs, Http3Response? response) { - // Only on the reactor, the one thread that rents, and never one whose lists a peer's thousands of header - // lines grew: those would stay that size on the connection. + // A streamed body's request stays in _requests until its stream ends; only the reactor rents. if (_streamBodies || _reqPool.Count >= MaxPooledRequests || OffReactor || !rs.Request.IsSmall) { return; } - // A response can outlive its request: a handler may hand the same response object out again later. So an - // arena the response points into (an echo of a header, say) goes with it, and the next stream starts a - // fresh one. A body that waits for send capacity needs nothing more: only bodies past 4 KiB wait, and an - // arena that large is never kept (Http3Request.MaxPooledArena). + // A handler may send this response object again later, so an arena its body points into goes with it. if (response is not null && MemoryMarshal.TryGetArray(response.Body, out ArraySegment body) && ReferenceEquals(body.Array, rs.Request.Arena)) diff --git a/src/protocols/ioxide.http3/Http3Request.cs b/src/protocols/ioxide.http3/Http3Request.cs index 4615e628..7e6ef710 100644 --- a/src/protocols/ioxide.http3/Http3Request.cs +++ b/src/protocols/ioxide.http3/Http3Request.cs @@ -105,9 +105,7 @@ internal void Freeze() private ReadOnlyMemory Slice((int Off, int Len) r) => r.Len < 0 ? default : Arena.AsMemory(r.Off, r.Len); - // An arena past this is not kept for the next stream: a peer sending large headers would otherwise - // leave a large array on the connection for the rest of its life. At most the inline-body limit, so a - // response body that waits for send capacity never points into a kept arena. + // Not above the inline-body limit, so a response body that waits for send capacity never points into a kept arena. internal const int MaxPooledArena = 4 * 1024; // Grew nothing a pooled request should keep: lists past this were a peer's thousands of header lines. diff --git a/tests/Ioxide.Tests.E2E/Protocols/Http3RequestReuseTests.cs b/tests/Ioxide.Tests.E2E/Protocols/Http3RequestReuseTests.cs index da0df5dc..3f199b04 100644 --- a/tests/Ioxide.Tests.E2E/Protocols/Http3RequestReuseTests.cs +++ b/tests/Ioxide.Tests.E2E/Protocols/Http3RequestReuseTests.cs @@ -1,4 +1,5 @@ using System.Text; +using System.Threading.Tasks.Sources; using ioxide; using ioxide.http3; using ioxide.ngtcp2; @@ -7,17 +8,15 @@ namespace Ioxide.Tests; /// -/// ioxide.http3 keeps a request stream whose body arrived whole, with its request, arena and lists, for the next -/// stream: on every dispatch path the next request must come back holding nothing of the one before, and a warm -/// connection's requests allocate none of those objects on the reactor. +/// ioxide.http3 hands a finished request stream, with its request, to the next stream on the connection. /// internal static class Http3RequestReuseTests { - private enum Shape { Synchronous, Parked, Streamed } + private enum Shape { Synchronous, Completed, Parked, Streamed } public static void Register(Runner runner) { - foreach (Shape shape in new[] { Shape.Synchronous, Shape.Parked, Shape.Streamed }) + foreach (Shape shape in new[] { Shape.Synchronous, Shape.Completed, Shape.Parked, Shape.Streamed }) { Shape s = shape; runner.Test($"h3 request (ioxide.http3, {s.ToString().ToLowerInvariant()} handler): the next stream's request holds nothing of the one before", () => @@ -28,19 +27,29 @@ public static void Register(Runner runner) using H3TestClient client = Connect(udpPort); Assert.Equal(200, client.Request("POST", "/first", "hello"u8.ToArray(), [("x-one", "1"), ("x-two", "2"), ("content-length", "5")], timeoutMs: 5000).Status); - Assert.Equal(200, client.Get("/", timeoutMs: 5000).Status); + Assert.Equal(200, client.Request("GET", "/", null, [("host", "b.example")], timeoutMs: 5000).Status); // A content-length the request before it already counted down must not read as a repeated one. Assert.Equal(200, client.Request("POST", "/again", "world"u8.ToArray(), [("content-length", "5")], timeoutMs: 5000).Status); Assert.Equal(3, seen.Count); - Assert.Equal("POST /first [x-one=1 x-two=2 content-length=5] hello", seen[0].Fields); - Assert.Equal("GET / [] -", seen[1].Fields); - Assert.Equal("POST /again [content-length=5] world", seen[2].Fields); + Assert.Equal("POST localhost /first [x-one=1 x-two=2 content-length=5] hello", seen[0].Fields); + Assert.Equal("GET - / [host=b.example] -", seen[1].Fields); + Assert.Equal("POST localhost /again [content-length=5] world", seen[2].Fields); Assert.True(ReferenceEquals(seen[0].Request, seen[1].Request) && ReferenceEquals(seen[1].Request, seen[2].Request), "each stream reuses the request of the one before"); }); } + runner.Test("h3 request (ioxide.http3): a response that points into its request keeps its bytes when it is sent again", () => + { + Http3Response? kept = null; + int udpPort = Start(Shape.Synchronous, _ => { }, request => kept ??= new Http3Response { Body = request.Path }); + + using H3TestClient client = Connect(udpPort); + Assert.Equal("/aaaa", client.Get("/aaaa", timeoutMs: 5000).Body); + Assert.Equal("/aaaa", client.Get("/bbbb", timeoutMs: 5000).Body); // the same response object, sent again + }); + runner.Test("h3 request (ioxide.http3): a warm connection's request allocates none of the request objects", () => { const int warm = 4; @@ -71,8 +80,7 @@ public static void Register(Runner runner) Assert.True(threads[0] == threads[1] && marks[1] >= marks[0], "both marks come from the reactor's thread"); - // Before reuse, every request allocated about 1.8 KB of these. What may remain is the QUIC send queue's - // per-stream state, about 185 B, which the engine reuses as well once that change is in. + // Allows for the QUIC layer's per-stream send state, about 185 B, which this connection does not own. long perRequest = (marks[1] - marks[0]) / measured; Assert.True(perRequest < 400, $"a request on a warm connection allocated {perRequest} B on the reactor"); }); @@ -80,9 +88,8 @@ public static void Register(Runner runner) private static readonly Http3Response Ok = Http3Response.Text("ok"); // shared, so the handler allocates nothing - // One connection's server, answering "ok" through the handler shape asked for: a synchronous handler, one that - // parks on a ring timer before it answers, or one that streams its response. - private static int Start(Shape shape, Action observe) + // One connection's server, answering "ok" (or what respond makes) through the handler shape asked for. + private static int Start(Shape shape, Action observe, Func? respond = null) { (string certPath, string keyPath) = TestCert.Ensure(); var engine = TestServer.Own(new QuicEngine(certPath, keyPath, cidLength: 8, alpn: ["h3"])); @@ -99,7 +106,14 @@ private static int Start(Shape shape, Action observe) return connection.RunAsync(request => { observe(request); - return Ok; + return respond?.Invoke(request) ?? Ok; + }); + + case Shape.Completed: + return connection.RunAsync(request => + { + observe(request); + return new ValueTask(new ReadOnce(Ok), 0); }); case Shape.Parked: @@ -141,9 +155,31 @@ private static string Describe(Http3Request request) fields.Add($"{Ascii(name)}={Ascii(value)}"); } - return $"{Ascii(request.Method)} {Ascii(request.Path)} [{string.Join(' ', fields)}] " + string authority = request.Authority.IsEmpty ? "-" : Ascii(request.Authority); + return $"{Ascii(request.Method)} {authority} {Ascii(request.Path)} [{string.Join(' ', fields)}] " + (request.Body.IsEmpty ? "-" : Ascii(request.Body)); } + // Complete before it is returned and readable once, as a pooled source is: a second read throws. + private sealed class ReadOnce(Http3Response response) : IValueTaskSource + { + private bool _read; + + public Http3Response GetResult(short token) + { + if (_read) + { + throw new InvalidOperationException("the handler's ValueTask was read twice"); + } + _read = true; + return response; + } + + public ValueTaskSourceStatus GetStatus(short token) => ValueTaskSourceStatus.Succeeded; + + public void OnCompleted(Action continuation, object? state, short token, ValueTaskSourceOnCompletedFlags flags) + => continuation(state); + } + private static string Ascii(ReadOnlyMemory bytes) => Encoding.ASCII.GetString(bytes.Span); } diff --git a/tests/Ioxide.Tests.Harness/H3TestClient.cs b/tests/Ioxide.Tests.Harness/H3TestClient.cs index 93c3f6b0..7e67b076 100644 --- a/tests/Ioxide.Tests.Harness/H3TestClient.cs +++ b/tests/Ioxide.Tests.Harness/H3TestClient.cs @@ -219,13 +219,12 @@ private void Submit(string method, string path, byte[]? body, (string Name, stri PumpIn(); } - var headerList = new List<(string, string)> + var headerList = new List<(string, string)> { (":method", method), (":scheme", "https") }; + if (extraHeaders?.Any(h => h.Name == "host") != true) { - (":method", method), - (":scheme", "https"), - (":authority", "localhost"), - (":path", path), - }; + headerList.Add((":authority", "localhost")); // a request carrying host sends no :authority (RFC 9114 4.3.1) + } + headerList.Add((":path", path)); if (extraHeaders is not null) { headerList.AddRange(extraHeaders.Select(h => (h.Name, h.Value))); From c3e440c0dede735affea6c6f194bb95678a5dfb5 Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Thu, 5 Nov 2026 20:43:30 +0000 Subject: [PATCH 4/4] http3: a response kept past its handler has to own its memories, so the 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. --- .../ioxide.http3/Http3Connection.Streamed.cs | 2 +- src/protocols/ioxide.http3/Http3Connection.cs | 19 +-- src/protocols/ioxide.http3/Http3Request.cs | 1 + .../Protocols/Http3RequestReuseTests.cs | 109 ++++++++++++++++-- 4 files changed, 107 insertions(+), 24 deletions(-) diff --git a/src/protocols/ioxide.http3/Http3Connection.Streamed.cs b/src/protocols/ioxide.http3/Http3Connection.Streamed.cs index b6468869..9ec3cec3 100644 --- a/src/protocols/ioxide.http3/Http3Connection.Streamed.cs +++ b/src/protocols/ioxide.http3/Http3Connection.Streamed.cs @@ -102,7 +102,7 @@ private async Task ServeAsync(Func { _writers.Remove(writer.StreamId); _writerPool.Push(writer); - Recycle(rs, response: null); // what it wrote was copied out as it went + Recycle(rs); } } diff --git a/src/protocols/ioxide.http3/Http3Connection.cs b/src/protocols/ioxide.http3/Http3Connection.cs index 50ece08e..991df01c 100644 --- a/src/protocols/ioxide.http3/Http3Connection.cs +++ b/src/protocols/ioxide.http3/Http3Connection.cs @@ -1,7 +1,6 @@ using System.Buffers; using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; using ioxide; namespace ioxide.http3; @@ -676,7 +675,7 @@ private void DispatchReady(Func handler) } Submit(rs.Request.StreamId, resp); - Recycle(rs, resp); + Recycle(rs); } _ready.Clear(); } @@ -707,7 +706,7 @@ private void DispatchReadyAsync(Func> han Console.Error.WriteLine($"[ioxide.http3] request handler faulted: {e.GetBaseException().Message}"); RetireBody(rs); Submit(sid, new Http3Response { Status = 500 }); - Recycle(rs, response: null); + Recycle(rs); continue; } @@ -716,7 +715,7 @@ private void DispatchReadyAsync(Func> han RetireBody(rs); Http3Response response = pending.Result; // once: a ValueTask is consumed by reading it Submit(sid, response); - Recycle(rs, response); + Recycle(rs); } else { @@ -746,11 +745,11 @@ private async Task CompleteBufferedAsync(ValueTask pending, ReqSt return; } Submit(rs.Request.StreamId, resp); - Recycle(rs, resp); + Recycle(rs); } /// Back for the next stream once the handler is done and the response submitted. - private void Recycle(ReqStream rs, Http3Response? response) + private void Recycle(ReqStream rs) { // A streamed body's request stays in _requests until its stream ends; only the reactor rents. if (_streamBodies || _reqPool.Count >= MaxPooledRequests || OffReactor || !rs.Request.IsSmall) @@ -758,14 +757,6 @@ private void Recycle(ReqStream rs, Http3Response? response) return; } - // A handler may send this response object again later, so an arena its body points into goes with it. - if (response is not null - && MemoryMarshal.TryGetArray(response.Body, out ArraySegment body) - && ReferenceEquals(body.Array, rs.Request.Arena)) - { - rs.Request.Arena = []; - } - ReleaseParseBuffers(rs); rs.Reset(); _reqPool.Push(rs); diff --git a/src/protocols/ioxide.http3/Http3Request.cs b/src/protocols/ioxide.http3/Http3Request.cs index 7e6ef710..7691bd4b 100644 --- a/src/protocols/ioxide.http3/Http3Request.cs +++ b/src/protocols/ioxide.http3/Http3Request.cs @@ -134,6 +134,7 @@ internal void Reset() /// /// One HTTP/3 response: status, headers, in-memory body - bytes throughout. Everything is encoded /// and handed to the QUIC engine synchronously at submit, so the memories can be pooled or static. +/// A response kept past its handler has to own them: a request's are reused for a later stream. /// public sealed class Http3Response { diff --git a/tests/Ioxide.Tests.E2E/Protocols/Http3RequestReuseTests.cs b/tests/Ioxide.Tests.E2E/Protocols/Http3RequestReuseTests.cs index 3f199b04..e380b68e 100644 --- a/tests/Ioxide.Tests.E2E/Protocols/Http3RequestReuseTests.cs +++ b/tests/Ioxide.Tests.E2E/Protocols/Http3RequestReuseTests.cs @@ -1,3 +1,6 @@ +using System.Collections; +using System.Reflection; +using System.Runtime.InteropServices; using System.Text; using System.Threading.Tasks.Sources; using ioxide; @@ -40,14 +43,57 @@ public static void Register(Runner runner) }); } - runner.Test("h3 request (ioxide.http3): a response that points into its request keeps its bytes when it is sent again", () => + runner.Test("h3 request (ioxide.http3): headers past 4 KiB leave no large arena for the next stream", () => { - Http3Response? kept = null; - int udpPort = Start(Shape.Synchronous, _ => { }, request => kept ??= new Http3Response { Body = request.Path }); + var arenas = new List(); + ServeInTurn([Get(lines: 1, value: 5 * 1024), Get(lines: 0)], request => + { + Assert.True(MemoryMarshal.TryGetArray(request.Method, out ArraySegment arena), "the method is in the arena"); + arenas.Add(arena.Array!.Length); + }); - using H3TestClient client = Connect(udpPort); - Assert.Equal("/aaaa", client.Get("/aaaa", timeoutMs: 5000).Body); - Assert.Equal("/aaaa", client.Get("/bbbb", timeoutMs: 5000).Body); // the same response object, sent again + Assert.Equal(2, arenas.Count); + Assert.True(arenas[0] > 5 * 1024, $"the first request's arena was {arenas[0]} B"); + Assert.True(arenas[1] < 4 * 1024, $"the next stream started on an arena of {arenas[1]} B"); + }); + + foreach ((int lines, bool kept) in new[] { (64, true), (65, false) }) + { + int count = lines; + bool expected = kept; + runner.Test($"h3 request (ioxide.http3): a request with {count} header lines {(expected ? "is" : "is not")} kept for the next stream", () => + { + var seen = new List(); + ServeInTurn([Get(count), Get(lines: 0)], seen.Add); + + Assert.Equal(2, seen.Count); + Assert.Equal(expected, ReferenceEquals(seen[0], seen[1])); + }); + } + + runner.Test("h3 request (ioxide.http3): a connection keeps at most 32 requests for later streams", () => + { + var quic = new H3ErrorCodeTests.RecordingQuic(); + var connection = new Http3Connection(quic); + int served = 0; + Task run = connection.RunAsync(_ => + { + served++; + return Ok; + }); + + for (int i = 0; i < 40; i++) // one read, so all 40 are open before the first retires + { + Assert.True(quic.EnqueueStreamData(i * 4, Get(lines: 0), fin: true), "the recv ring rejected the item"); + } + quic.SignalDataArrived(); + int pooled = ((ICollection)typeof(Http3Connection) + .GetField("_reqPool", BindingFlags.NonPublic | BindingFlags.Instance)!.GetValue(connection)!).Count; + quic.MarkClosed(); + + Assert.True(run.IsCompleted, "the run loop should have finished inline"); + Assert.Equal(40, served); + Assert.Equal(32, pooled); }); runner.Test("h3 request (ioxide.http3): a warm connection's request allocates none of the request objects", () => @@ -88,8 +134,8 @@ public static void Register(Runner runner) private static readonly Http3Response Ok = Http3Response.Text("ok"); // shared, so the handler allocates nothing - // One connection's server, answering "ok" (or what respond makes) through the handler shape asked for. - private static int Start(Shape shape, Action observe, Func? respond = null) + // One connection's server, answering "ok" through the handler shape asked for. + private static int Start(Shape shape, Action observe) { (string certPath, string keyPath) = TestCert.Ensure(); var engine = TestServer.Own(new QuicEngine(certPath, keyPath, cidLength: 8, alpn: ["h3"])); @@ -106,7 +152,7 @@ private static int Start(Shape shape, Action observe, Func { observe(request); - return respond?.Invoke(request) ?? Ok; + return Ok; }); case Shape.Completed: @@ -139,6 +185,51 @@ private static int Start(Shape shape, Action observe, Func observe) + { + var quic = new H3ErrorCodeTests.RecordingQuic(); + Task run = new Http3Connection(quic).RunAsync(request => + { + observe(request); + return Ok; + }); + + for (int i = 0; i < requests.Length; i++) + { + Assert.True(quic.EnqueueStreamData(i * 4, requests[i], fin: true), "the recv ring rejected the item"); + quic.SignalDataArrived(); + } + quic.MarkClosed(); + Assert.True(run.IsCompleted, "the run loop should have finished inline"); + } + + // A GET framed by hand, as the test client sends at most 64 fields: the static table, then literal "x-a" lines. + private static byte[] Get(int lines, int value = 1) + { + var block = new List { 0x00, 0x00, 0xD1, 0xD7, 0xC1, 0xC0 }; + for (int i = 0; i < lines; i++) + { + block.AddRange([0x23, (byte)'x', (byte)'-', (byte)'a']); + if (value < 127) + { + block.Add((byte)value); + } + else + { + block.Add(0x7F); + int rest = value - 127; + for (; rest >= 128; rest >>= 7) + { + block.Add((byte)(rest | 0x80)); + } + block.Add((byte)rest); + } + block.AddRange(Enumerable.Repeat((byte)'v', value)); + } + return [0x01, (byte)(0x40 | (block.Count >> 8)), (byte)block.Count, .. block]; + } + private static H3TestClient Connect(int udpPort) { var client = new H3TestClient("127.0.0.1", udpPort);