From 3d907bc09980e2a9dc9035095ee800747c146988 Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Wed, 4 Nov 2026 16:26:37 +0000 Subject: [PATCH 1/4] ngtcp2: a connection keeps a finished stream's send state for the next stream, so a warm HTTP/3 response allocates none of it Every stream QuicEngineConnection sent on got a new OutStream with its own Queue, and the queue's array grew as chunks queued: about 185 B per HTTP/3 response, all of ioxide.nghttp3's garbage. PurgeOutStream now resets it and keeps it on the connection, and SendStream takes one from there. A queue that grew past 64 slots is trimmed first, so a stream of many small chunks leaves no large array behind. --- .../QuicEngineConnection.SendStream.cs | 25 +++++++- tests/Ioxide.Tests.E2E/Program.cs | 1 + .../Protocols/QuicSendStreamTests.cs | 60 +++++++++++++++++++ 3 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 tests/Ioxide.Tests.E2E/Protocols/QuicSendStreamTests.cs diff --git a/src/protocols/ioxide.ngtcp2/Connection/QuicEngineConnection.SendStream.cs b/src/protocols/ioxide.ngtcp2/Connection/QuicEngineConnection.SendStream.cs index 4b377a19..54806f99 100644 --- a/src/protocols/ioxide.ngtcp2/Connection/QuicEngineConnection.SendStream.cs +++ b/src/protocols/ioxide.ngtcp2/Connection/QuicEngineConnection.SendStream.cs @@ -23,9 +23,23 @@ private sealed class OutStream public bool FinSent; public bool Dead; // reset/refused: stop feeding; chunks freed at stream close public bool Pending; // in the replay list + + public void Reset() + { + Base = Sent = End = 0; + Fin = FinSent = Dead = Pending = false; + } } private readonly Dictionary _outStreams = new(); + + // Streams whose send side is done, kept for the next stream that sends: the object and its queue + // otherwise cost every HTTP/3 response about 185 B of garbage (#487). + private readonly Stack _outStreamPool = new(); + + // A pooled queue keeps at most this many slots, so a stream that queued many small chunks does not + // leave a large array on the connection for the rest of its life. + private const int MaxPooledChunks = 64; private readonly List _outPending = []; private long _outRetained; @@ -76,7 +90,7 @@ public override void SendStream(long streamId, ReadOnlySpan data, bool fin if (!_outStreams.TryGetValue(streamId, out OutStream? os)) { - os = new OutStream(); + os = _outStreamPool.TryPop(out OutStream? pooled) ? pooled : new OutStream(); _outStreams[streamId] = os; } if (os.Dead || (os.Fin && !fin && data.Length > 0)) @@ -354,5 +368,14 @@ private void PurgeOutStream(long sid) NativeMemory.Free((void*)p); _outRetained -= l; } + + // Everything reaches an OutStream through _outStreams by its id, so the old id finds nothing from here + // on; PumpOut, still holding it after a purge, sees a stream with nothing left to send. + os.Reset(); + if (os.Chunks.Capacity > MaxPooledChunks) + { + os.Chunks.TrimExcess(); + } + _outStreamPool.Push(os); } } diff --git a/tests/Ioxide.Tests.E2E/Program.cs b/tests/Ioxide.Tests.E2E/Program.cs index e0c353db..77fe72cd 100644 --- a/tests/Ioxide.Tests.E2E/Program.cs +++ b/tests/Ioxide.Tests.E2E/Program.cs @@ -47,6 +47,7 @@ private static int Main() QuicUnansweredStreamTests.Register(runner); QuicDeferredFaultTests.Register(runner); QuicStreamAllowanceTests.Register(runner); + QuicSendStreamTests.Register(runner); QuicTimerTests.Register(runner); QuicSniHostileTests.Register(runner); QuicDemuxRoutingTests.Register(runner); diff --git a/tests/Ioxide.Tests.E2E/Protocols/QuicSendStreamTests.cs b/tests/Ioxide.Tests.E2E/Protocols/QuicSendStreamTests.cs new file mode 100644 index 00000000..0844e39b --- /dev/null +++ b/tests/Ioxide.Tests.E2E/Protocols/QuicSendStreamTests.cs @@ -0,0 +1,60 @@ +using ioxide.nghttp3; +using ioxide.ngtcp2; + +namespace Ioxide.Tests; + +/// +/// The QUIC engine's per-stream send state: a connection keeps the state of a stream whose send side is done for +/// the next stream that sends, so a warm connection's responses allocate none of it. ioxide.nghttp3 makes no +/// other garbage per request, so what the reactor allocates across its responses is that state alone. +/// +internal static class QuicSendStreamTests +{ + public static void Register(Runner runner) + { + runner.Test("quic: a warm connection's responses allocate no send-stream state (nghttp3)", () => + { + (string certPath, string keyPath) = TestCert.Ensure(); + var engine = TestServer.Own(new QuicEngine(certPath, keyPath, cidLength: 8, alpn: ["h3"])); + + const int warm = 16; + const int measured = 32; + Nghttp3Response response = Nghttp3Response.Text("ok"); // shared, so the handler allocates nothing + long[] marks = new long[2]; + int served = 0; + + (_, int udpPort) = TestServer.StartDatagram( + onDatagram: null, + quicFactory: engine.CreateFactory(), + quicHandle: (_, conn) => new Nghttp3Connection(conn).RunAsync(_ => + { + // 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 var client = new H3TestClient("127.0.0.1", udpPort); + client.Connect(); + Assert.True(client.CompleteHandshake(timeoutMs: 5000), "handshake did not complete"); + + // One stream after another on one connection, so each reuses the state the one before it left. + for (int i = 0; i < warm + measured; i++) + { + (int status, string body) = client.Get($"/{i}", timeoutMs: 5000); + Assert.Equal(200, status); + Assert.Equal("ok", body); + } + + long perResponse = (marks[1] - marks[0]) / measured; + Assert.True(perResponse < 64, $"a response on a warm connection allocated {perResponse} B on the reactor"); + }); + } +} From 79bb5ee9e3f6774704b37efaae51abedbe09e506 Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Wed, 4 Nov 2026 19:13:52 +0000 Subject: [PATCH 2/4] ngtcp2: the send-state pool is capped and trims queues to 64 slots; its test runs in a quarter of the time From review: - The pool keeps at most 32 OutStreams, so a burst of up to 1024 concurrent streams no longer stays on an idle connection, and Destroy clears it. - A queue past 64 slots is trimmed to 64 rather than to nothing, as the comment says, so the next large stream grows it once rather than from empty. - The test makes 16 requests instead of 48: the test client spends about 1.5 s on each, and the 48 took 61-75 s in CI against a 120 s watchdog. It checks that both marks come from the reactor's thread. Main still fails it at 184 B per response. --- .../QuicEngineConnection.SendStream.cs | 10 ++++++++-- .../Connection/QuicEngineConnection.cs | 1 + .../Protocols/QuicSendStreamTests.cs | 18 ++++++++++-------- 3 files changed, 19 insertions(+), 10 deletions(-) diff --git a/src/protocols/ioxide.ngtcp2/Connection/QuicEngineConnection.SendStream.cs b/src/protocols/ioxide.ngtcp2/Connection/QuicEngineConnection.SendStream.cs index 54806f99..ed0d2d2e 100644 --- a/src/protocols/ioxide.ngtcp2/Connection/QuicEngineConnection.SendStream.cs +++ b/src/protocols/ioxide.ngtcp2/Connection/QuicEngineConnection.SendStream.cs @@ -34,8 +34,10 @@ public void Reset() private readonly Dictionary _outStreams = new(); // Streams whose send side is done, kept for the next stream that sends: the object and its queue - // otherwise cost every HTTP/3 response about 185 B of garbage (#487). + // otherwise cost every HTTP/3 response about 185 B of garbage (#487). Capped, so a burst of streams + // leaves no more than this on an idle connection. private readonly Stack _outStreamPool = new(); + private const int MaxPooledStreams = 32; // A pooled queue keeps at most this many slots, so a stream that queued many small chunks does not // leave a large array on the connection for the rest of its life. @@ -371,10 +373,14 @@ private void PurgeOutStream(long sid) // Everything reaches an OutStream through _outStreams by its id, so the old id finds nothing from here // on; PumpOut, still holding it after a purge, sees a stream with nothing left to send. + if (_outStreamPool.Count >= MaxPooledStreams) + { + return; + } os.Reset(); if (os.Chunks.Capacity > MaxPooledChunks) { - os.Chunks.TrimExcess(); + os.Chunks.TrimExcess(MaxPooledChunks); } _outStreamPool.Push(os); } diff --git a/src/protocols/ioxide.ngtcp2/Connection/QuicEngineConnection.cs b/src/protocols/ioxide.ngtcp2/Connection/QuicEngineConnection.cs index f9293919..0fc2257e 100644 --- a/src/protocols/ioxide.ngtcp2/Connection/QuicEngineConnection.cs +++ b/src/protocols/ioxide.ngtcp2/Connection/QuicEngineConnection.cs @@ -449,6 +449,7 @@ private void Destroy() } } _outStreams.Clear(); + _outStreamPool.Clear(); _outPending.Clear(); if (_conn != 0) { diff --git a/tests/Ioxide.Tests.E2E/Protocols/QuicSendStreamTests.cs b/tests/Ioxide.Tests.E2E/Protocols/QuicSendStreamTests.cs index 0844e39b..5e4fdf5b 100644 --- a/tests/Ioxide.Tests.E2E/Protocols/QuicSendStreamTests.cs +++ b/tests/Ioxide.Tests.E2E/Protocols/QuicSendStreamTests.cs @@ -17,10 +17,12 @@ public static void Register(Runner runner) (string certPath, string keyPath) = TestCert.Ensure(); var engine = TestServer.Own(new QuicEngine(certPath, keyPath, cidLength: 8, alpn: ["h3"])); - const int warm = 16; - const int measured = 32; + // Few requests: the test client spends about 1.5 s on each. + const int warm = 4; + const int measured = 12; Nghttp3Response response = Nghttp3Response.Text("ok"); // shared, so the handler allocates nothing long[] marks = new long[2]; + int[] threads = new int[2]; int served = 0; (_, int udpPort) = TestServer.StartDatagram( @@ -30,13 +32,11 @@ public static void Register(Runner runner) { // On the reactor's thread: what it allocated between these two requests. int n = ++served; - if (n == warm) + int mark = n == warm ? 0 : n == warm + measured ? 1 : -1; + if (mark >= 0) { - marks[0] = GC.GetAllocatedBytesForCurrentThread(); - } - else if (n == warm + measured) - { - marks[1] = GC.GetAllocatedBytesForCurrentThread(); + marks[mark] = GC.GetAllocatedBytesForCurrentThread(); + threads[mark] = Environment.CurrentManagedThreadId; } return response; })); @@ -53,6 +53,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"); + long perResponse = (marks[1] - marks[0]) / measured; Assert.True(perResponse < 64, $"a response on a warm connection allocated {perResponse} B on the reactor"); }); From 2294fc319a856df7b2020972e63ce0332175edc3 Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Thu, 5 Nov 2026 00:15:49 +0000 Subject: [PATCH 3/4] ngtcp2: the reuse test fails when a single response misses the pool, and takes half as long It allowed under 64 B per response over 12 responses, so four of them could build fresh send state and it still passed. It now allows under 184 B in total, one stream's send state, over 4 measured responses: main fails it at 736 B, and the test takes about 13 s instead of 27 s. Also from review: the purge comment moves to the reset it explains and names the callers that can still hold the stream (SendStream and ReplayOut, not PumpOut); comments are cut to one line. --- .../Connection/QuicEngineConnection.SendStream.cs | 13 +++---------- .../Protocols/QuicSendStreamTests.cs | 12 ++++++------ 2 files changed, 9 insertions(+), 16 deletions(-) diff --git a/src/protocols/ioxide.ngtcp2/Connection/QuicEngineConnection.SendStream.cs b/src/protocols/ioxide.ngtcp2/Connection/QuicEngineConnection.SendStream.cs index ed0d2d2e..5e4797b3 100644 --- a/src/protocols/ioxide.ngtcp2/Connection/QuicEngineConnection.SendStream.cs +++ b/src/protocols/ioxide.ngtcp2/Connection/QuicEngineConnection.SendStream.cs @@ -33,15 +33,10 @@ public void Reset() private readonly Dictionary _outStreams = new(); - // Streams whose send side is done, kept for the next stream that sends: the object and its queue - // otherwise cost every HTTP/3 response about 185 B of garbage (#487). Capped, so a burst of streams - // leaves no more than this on an idle connection. + // Closed streams' send state, for the next stream that sends; capped, so a burst leaves little on an idle connection. private readonly Stack _outStreamPool = new(); private const int MaxPooledStreams = 32; - - // A pooled queue keeps at most this many slots, so a stream that queued many small chunks does not - // leave a large array on the connection for the rest of its life. - private const int MaxPooledChunks = 64; + private const int MaxPooledChunks = 64; // slots a pooled queue keeps private readonly List _outPending = []; private long _outRetained; @@ -371,13 +366,11 @@ private void PurgeOutStream(long sid) _outRetained -= l; } - // Everything reaches an OutStream through _outStreams by its id, so the old id finds nothing from here - // on; PumpOut, still holding it after a purge, sees a stream with nothing left to send. if (_outStreamPool.Count >= MaxPooledStreams) { return; } - os.Reset(); + os.Reset(); // SendStream and ReplayOut can still hold it after PumpOut's purge; reset, it has nothing to send if (os.Chunks.Capacity > MaxPooledChunks) { os.Chunks.TrimExcess(MaxPooledChunks); diff --git a/tests/Ioxide.Tests.E2E/Protocols/QuicSendStreamTests.cs b/tests/Ioxide.Tests.E2E/Protocols/QuicSendStreamTests.cs index 5e4fdf5b..96f63300 100644 --- a/tests/Ioxide.Tests.E2E/Protocols/QuicSendStreamTests.cs +++ b/tests/Ioxide.Tests.E2E/Protocols/QuicSendStreamTests.cs @@ -4,9 +4,7 @@ namespace Ioxide.Tests; /// -/// The QUIC engine's per-stream send state: a connection keeps the state of a stream whose send side is done for -/// the next stream that sends, so a warm connection's responses allocate none of it. ioxide.nghttp3 makes no -/// other garbage per request, so what the reactor allocates across its responses is that state alone. +/// A QUIC connection hands a closed stream's send state to the next stream that sends. /// internal static class QuicSendStreamTests { @@ -19,7 +17,8 @@ public static void Register(Runner runner) // Few requests: the test client spends about 1.5 s on each. const int warm = 4; - const int measured = 12; + const int measured = 4; + const int oneStreamState = 184; // an OutStream, its Queue and the queue's first array Nghttp3Response response = Nghttp3Response.Text("ok"); // shared, so the handler allocates nothing long[] marks = new long[2]; int[] threads = new int[2]; @@ -55,8 +54,9 @@ public static void Register(Runner runner) Assert.True(threads[0] == threads[1] && marks[1] >= marks[0], "both marks come from the reactor's thread"); - long perResponse = (marks[1] - marks[0]) / measured; - Assert.True(perResponse < 64, $"a response on a warm connection allocated {perResponse} B on the reactor"); + long allocated = marks[1] - marks[0]; // nghttp3 makes no other garbage per request + Assert.True(allocated < oneStreamState, + $"{measured} responses on a warm connection allocated {allocated} B on the reactor, a stream's send state or more"); }); } } From b13236dac78c1615493f40e7ed8f26947a9f7cfd Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Thu, 5 Nov 2026 20:50:28 +0000 Subject: [PATCH 4/4] ngtcp2: a test guards the send-state pool's cap and its queue trim --- .../Protocols/QuicSendStreamTests.cs | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/tests/Ioxide.Tests.E2E/Protocols/QuicSendStreamTests.cs b/tests/Ioxide.Tests.E2E/Protocols/QuicSendStreamTests.cs index 96f63300..55b1f7de 100644 --- a/tests/Ioxide.Tests.E2E/Protocols/QuicSendStreamTests.cs +++ b/tests/Ioxide.Tests.E2E/Protocols/QuicSendStreamTests.cs @@ -1,3 +1,7 @@ +using System.Collections; +using System.Reflection; +using ioxide; +using ioxide.http3; using ioxide.nghttp3; using ioxide.ngtcp2; @@ -58,5 +62,66 @@ public static void Register(Runner runner) Assert.True(allocated < oneStreamState, $"{measured} responses on a warm connection allocated {allocated} B on the reactor, a stream's send state or more"); }); + + runner.Test("quic: a burst leaves the send state of at most 32 streams, with no queue past 64 slots", () => + { + (string certPath, string keyPath) = TestCert.Ensure(); + var engine = TestServer.Own(new QuicEngine(certPath, keyPath, cidLength: 8, alpn: ["h3"])); + + const int burst = 40; + const int chunks = 100; + (int Count, int LargestQueue) kept = default; + + (_, int udpPort) = TestServer.StartDatagram( + onDatagram: null, + quicFactory: engine.CreateFactory(), + quicHandle: (_, conn) => new Http3Connection(conn).RunAsync(async (request, writer) => + { + bool after = request.Path.Span.SequenceEqual("/after"u8); + if (after) + { + kept = Pooled(conn); // before this stream's headers take one + } + writer.WriteHeaders(new Http3Response { Status = 200 }); + for (int i = 0; i < (after ? 0 : chunks); i++) // no ack can land before this returns, so each stays in the queue + { + writer.GetSpan(1)[0] = (byte)'x'; + writer.Advance(1); + await writer.FlushAsync(); + } + })); + + using var client = new H3TestClient("127.0.0.1", udpPort); + client.Connect(); + Assert.True(client.CompleteHandshake(timeoutMs: 5000), "handshake did not complete"); + + string[] paths = [.. Enumerable.Range(0, burst).Select(i => $"/{i}")]; + foreach ((int status, long length) in client.GetConcurrent(paths, timeoutMs: 15000)) + { + Assert.Equal(200, status); + Assert.Equal((long)chunks, length); + } + Assert.Equal(200, client.Get("/after", timeoutMs: 5000).Status); // long enough for the burst's acks to close its streams + + Assert.Equal(32, kept.Count); + Assert.Equal(64, kept.LargestQueue); // each grew past it, to 128 + }); + } + + // On the reactor's thread. OutStream is private to the connection, so by reflection. + private static (int Count, int LargestQueue) Pooled(QuicConnection conn) + { + var pool = (IEnumerable)typeof(QuicEngineConnection) + .GetField("_outStreamPool", BindingFlags.NonPublic | BindingFlags.Instance)!.GetValue(conn)!; + + int count = 0; + int largest = 0; + foreach (object state in pool) + { + object queue = state.GetType().GetField("Chunks")!.GetValue(state)!; + largest = Math.Max(largest, (int)queue.GetType().GetProperty("Capacity")!.GetValue(queue)!); + count++; + } + return (count, largest); } }