From f02ed22c581641498be67bebc606fb26d3ae9989 Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Mon, 10 Aug 2026 00:52:27 +0100 Subject: [PATCH 1/4] feat(http3): streamed response bodies, pushed rather than pulled ioxide.http3 could only return a finished Http3Response, so a large body cost its own size in memory and an endless one could not be expressed at all. The request direction already streamed through Http3Request.BodyReader; this is the other half. Owning the framing is what makes it simple. A chunk is just [0x00][varint length][payload] handed to the QUIC stream, so there is no data-reader callback to answer, nothing to defer out of a library call, and no buffer whose lifetime something else dictates. Compare the nghttp3 version: nghttp3 pulls, which needs WOULDBLOCK, resume_stream, a deferred resume to avoid re-entering writev, and native buffers it holds pointers into. Http3ResponseWriter IBufferWriter, so a serializer or a response sink writes into it unchanged. Pooled with its buffer. RunStreamedResponseAsync dispatches through its own path, inside the pass that made the request ready, so a writer's sends flush with that pass exactly as a buffered Submit does. Backpressure is the connection's send retention: FlushAsync waits while CanQueueSend is false, so a producer cannot outrun a peer that stopped reading. 8 x 1KiB, 2 reactors, 16 conns, 8 streams nghttp3 streamed 92689 req/s 724 MB/s pure C# streamed 122083 req/s 954 MB/s 1.32x Playground/Http3/ManagedStreamed shows both directions at once: uploads pulled a chunk at a time through BodyReader, downloads pushed through the writer, and an endless /feed that buffering cannot express. Unit 29, E2E 46, Http 35, Tls 15, File 4, Chaos 37 pass. 990134 requests with zero failures, and an endless /feed whose client disappears mid-stream followed by 348393 more requests served. --- .../Playground.Http3.ManagedStreamed.csproj | 20 ++ Playground/Http3/ManagedStreamed/Program.cs | 133 +++++++++++ ioxide.slnx | 1 + .../ioxide.http3/Http3Connection.Streamed.cs | 141 ++++++++++++ src/protocols/ioxide.http3/Http3Connection.cs | 7 +- .../ioxide.http3/Http3ResponseWriter.cs | 213 ++++++++++++++++++ 6 files changed, 514 insertions(+), 1 deletion(-) create mode 100644 Playground/Http3/ManagedStreamed/Playground.Http3.ManagedStreamed.csproj create mode 100644 Playground/Http3/ManagedStreamed/Program.cs create mode 100644 src/protocols/ioxide.http3/Http3Connection.Streamed.cs create mode 100644 src/protocols/ioxide.http3/Http3ResponseWriter.cs diff --git a/Playground/Http3/ManagedStreamed/Playground.Http3.ManagedStreamed.csproj b/Playground/Http3/ManagedStreamed/Playground.Http3.ManagedStreamed.csproj new file mode 100644 index 00000000..c444b268 --- /dev/null +++ b/Playground/Http3/ManagedStreamed/Playground.Http3.ManagedStreamed.csproj @@ -0,0 +1,20 @@ + + + + Exe + net11.0 + enable + enable + true + Playground.Http3.ManagedStreamed + Playground.Http3.ManagedStreamed + + + + + + + + + + diff --git a/Playground/Http3/ManagedStreamed/Program.cs b/Playground/Http3/ManagedStreamed/Program.cs new file mode 100644 index 00000000..586cf86f --- /dev/null +++ b/Playground/Http3/ManagedStreamed/Program.cs @@ -0,0 +1,133 @@ +using System.Text; +using ioxide; +using ioxide.http3; +using ioxide.ngtcp2; +using Playground.Shared; + +// ───────────────────────────────────────────────────────────────────────────────────────────── +// http3-managed-streamed - HTTP/3 in pure C# with BOTH directions streamed. +// +// The request body arrives through Http3Request.BodyReader, pulled a chunk at a time under +// flow control, so a large upload is never held whole. The response body goes out through an +// Http3ResponseWriter, one DATA frame per flush, so a large download is never built whole. +// +// Because ioxide.http3 owns the framing, sending is a push: build [0x00][varint len][payload] +// and hand it to the QUIC stream. There is no data-reader callback to answer and nothing to +// defer - which is the difference from the nghttp3 version of this in Playground/Http3/Streamed. +// +// dotnet run -c Release --project Playground/Http3/ManagedStreamed +// curl --http3-only -k https://127.0.0.1:8443/ # chunked download +// curl --http3-only -kN https://127.0.0.1:8443/feed # endless; ctrl-c to stop +// curl --http3-only -k --data-binary @big.bin https://127.0.0.1:8443/upload +// +// Needs: ioxide, ioxide.ngtcp2, ioxide.http3 +// ───────────────────────────────────────────────────────────────────────────────────────────── + +// ── Knobs ──────────────────────────────────────────────────────────────────────────────────── +// Edit these. That is the whole mechanism - there is no config file and nothing else to find. + +ushort quicPort = 8443; +int reactors = Environment.ProcessorCount; + +Env.OverrideQuic(ref quicPort, ref reactors); + +// Chunks written per response on "/", and the size of each. Their product is never held at once. +int chunkCount = 64; +int chunkBytes = 16 * 1024; + +Env.Override(ref chunkCount, "PLAYGROUND_CHUNKS"); +Env.Override(ref chunkBytes, "PLAYGROUND_CHUNK_BYTES"); + +int udpRecvSlots = 16; + +string? certOverride = null; +string? keyOverride = null; + +Env.OverrideCert(ref certOverride, ref keyOverride); +// ───────────────────────────────────────────────────────────────────────────────────────────── + +(string certPath, string keyPath) = QuicCert.Ensure(certOverride, keyOverride); + +using var engine = new QuicEngine(certPath, keyPath, cidLength: 8, alpn: ["h3"]); + +var config = new ServerConfig +{ + ReactorCount = reactors, + Tcp = null, + Udp = new UdpOptions { RecvSlots = udpRecvSlots }, + Quic = new QuicOptions + { + Port = quicPort, + LocalCidLength = 8, + ConnectionFactory = engine.CreateFactory(), + }, +}; + +byte[] chunk = Encoding.ASCII.GetBytes(new string('x', chunkBytes - 1) + "\n"); + +var threads = new Thread[config.ReactorCount]; + +for (int i = 0; i < threads.Length; i++) +{ + var reactor = new Reactor(i, config); + + reactor.QuicHandle = (r, conn) => + new Http3Connection(conn).RunStreamedResponseAsync(async (request, writer) => + { + bool endless = request.Path.Span.SequenceEqual("/feed"u8); + bool upload = request.Path.Span.SequenceEqual("/upload"u8); + + if (upload) + { + // The other direction: pull the body a chunk at a time rather than waiting for + // all of it, so memory is bound by one chunk however large the upload is. + long total = 0; + if (request.BodyReader is { } body) + { + while (true) + { + ReadOnlyMemory part = await body.ReadAsync(); + if (part.IsEmpty) break; + total += part.Length; + } + } + + writer.WriteHeaders(Plain()); + Encoding.ASCII.GetBytes($"{total}\n").CopyTo(writer.GetSpan(24)); + writer.Advance(Encoding.ASCII.GetByteCount($"{total}\n")); + await writer.FlushAsync(); + return; + } + + writer.WriteHeaders(Plain(endless)); + + for (int n = 0; endless || n < chunkCount; n++) + { + chunk.CopyTo(writer.GetSpan(chunk.Length)); + writer.Advance(chunk.Length); + + // Returns once the chunk is queued, and waits when the connection is at its + // send-retention high-water. That await IS the backpressure. + await writer.FlushAsync(); + } + }); + + threads[i] = new Thread(reactor.Run) { Name = $"reactor-{i}" }; + threads[i].Start(); +} + +Console.WriteLine($"[h3-managed-streamed] {config.ReactorCount} reactors, h3 on udp :{config.Quic!.Port} " + + $"(pure C#), {chunkCount} x {chunkBytes}-byte chunks, cert {certPath}"); + +foreach (Thread thread in threads) +{ + thread.Join(); +} + +static Http3Response Plain(bool eventStream = false) +{ + var response = new Http3Response { Status = 200 }; + response.Headers.Add(("content-type"u8.ToArray(), + eventStream ? "text/event-stream"u8.ToArray() : "text/plain"u8.ToArray())); + return response; +} diff --git a/ioxide.slnx b/ioxide.slnx index 616a6d3c..8bef36e2 100644 --- a/ioxide.slnx +++ b/ioxide.slnx @@ -66,6 +66,7 @@ + diff --git a/src/protocols/ioxide.http3/Http3Connection.Streamed.cs b/src/protocols/ioxide.http3/Http3Connection.Streamed.cs new file mode 100644 index 00000000..4aa6b769 --- /dev/null +++ b/src/protocols/ioxide.http3/Http3Connection.Streamed.cs @@ -0,0 +1,141 @@ +using System.Buffers; + +namespace ioxide.http3; + +/// +/// The STREAMED-RESPONSE half of the pure-C# HTTP/3 server: headers go out first and the body +/// follows as DATA frames, pushed by the handler through an . +/// +/// Owning the framing is what makes this simple. There is no data-reader callback to answer, so +/// nothing has to be deferred out of a library call, no buffer lifetime is dictated from outside, +/// and a chunk is sent the moment it exists rather than when something asks for it. +/// +public sealed partial class Http3Connection +{ + private readonly Stack _writerPool = new(); + private readonly List _capacityWaiters = []; + + /// True once the connection can no longer make progress; a parked writer gives up. + internal bool IsBroken => _fatal; + + /// + /// Serve this connection with each response body produced through a writer rather than + /// returned whole. The handler owns its stream until it completes the writer. + /// + public async Task RunStreamedResponseAsync(Func handler) + { + _quicConnection.OnSendCapacityAvailable = ReleaseCapacityWaiters; + + try + { + // Its own dispatch, rather than tunnelling through the buffered one: a streamed + // response has no Http3Response to return, and pretending otherwise meant handing + // back a sentinel that Submit then had to recognise and skip. + _streamedResponseHandler = handler; + await RunAsync(NoBufferedHandler); + } + finally + { + ReleaseCapacityWaiters(); + while (_writerPool.TryPop(out _)) { } + } + } + + private Func? _streamedResponseHandler; + + // Never invoked: DispatchReady hands streamed requests to the writer path before it would be + // reached. It exists only because RunAsync's signature demands a handler. + private static Http3Response NoBufferedHandler(Http3Request _) + => throw new InvalidOperationException("A streamed connection dispatches through its writer."); + + /// + /// The streamed dispatch, called from the pass that made the request ready - so the writer's + /// sends happen inside that pass, like a buffered Submit does. + /// + private bool TryDispatchStreamedResponse(Http3Request request) + { + if (_streamedResponseHandler is null) + { + return false; + } + + Http3ResponseWriter writer = RentWriter(request.StreamId); + _ = ServeAsync(_streamedResponseHandler, request, writer); + return true; + } + + private async Task ServeAsync(Func handler, + Http3Request request, Http3ResponseWriter writer) + { + try + { + await handler(request, writer); + await writer.CompleteAsync(); + } + catch (Exception exception) + { + Console.Error.WriteLine($"[ioxide.http3] request handler faulted: {exception.GetBaseException().Message}"); + await writer.CompleteAsync(); + } + finally + { + _writerPool.Push(writer); + } + } + + private Http3ResponseWriter RentWriter(long streamId) + { + if (_writerPool.TryPop(out Http3ResponseWriter? pooled)) + { + pooled.Reset(streamId); + return pooled; + } + return new Http3ResponseWriter(this, _quicConnection, streamId); + } + + /// Encode and send the HEADERS frame of a streamed response - no content-length. + internal void SendStreamedHeaders(long streamId, Http3Response response) + { + byte[] fields = Qpack.EncodeResponseFields(response, out int fieldsLen); + byte[] head = ArrayPool.Shared.Rent(fieldsLen + 16); + + int w = Varint.Write(head.AsSpan(), 0x1); // HEADERS + w += Varint.Write(head.AsSpan(w), fieldsLen); + fields.AsSpan(0, fieldsLen).CopyTo(head.AsSpan(w)); + w += fieldsLen; + + _quicConnection.SendStream(streamId, head.AsSpan(0, w), fin: false); + + ArrayPool.Shared.Return(fields); + ArrayPool.Shared.Return(head); + } + + /// Completes when the connection can accept more sends. + internal Task WaitForSendCapacityAsync() + { + if (IsBroken) + { + return Task.CompletedTask; + } + + var waiter = new TaskCompletionSource(); + _capacityWaiters.Add(waiter); + return waiter.Task; + } + + private void ReleaseCapacityWaiters() + { + if (_capacityWaiters.Count == 0) + { + return; + } + + TaskCompletionSource[] waiting = _capacityWaiters.ToArray(); + _capacityWaiters.Clear(); + + foreach (TaskCompletionSource waiter in waiting) + { + waiter.TrySetResult(); + } + } +} diff --git a/src/protocols/ioxide.http3/Http3Connection.cs b/src/protocols/ioxide.http3/Http3Connection.cs index 033be0d9..6bf7d11a 100644 --- a/src/protocols/ioxide.http3/Http3Connection.cs +++ b/src/protocols/ioxide.http3/Http3Connection.cs @@ -15,7 +15,7 @@ namespace ioxide.http3; /// Everything runs on the reactor thread; wakes for parked body reads are deferred to the end of /// each drain pass (the same fire-after-unwind discipline the engine uses). /// -public sealed class Http3Connection +public sealed partial class Http3Connection { private readonly QuicConnection _quicConnection; private bool _fatal; @@ -489,6 +489,11 @@ private void DispatchReady(Func handler) } rs.Request.Freeze(); + if (TryDispatchStreamedResponse(rs.Request)) + { + continue; // the writer owns this stream now + } + Http3Response resp; try { diff --git a/src/protocols/ioxide.http3/Http3ResponseWriter.cs b/src/protocols/ioxide.http3/Http3ResponseWriter.cs new file mode 100644 index 00000000..b847bef0 --- /dev/null +++ b/src/protocols/ioxide.http3/Http3ResponseWriter.cs @@ -0,0 +1,213 @@ +using System.Buffers; + +namespace ioxide.http3; + +/// +/// The write half of a STREAMED response: the handler pushes body bytes as it produces them, and +/// each flush becomes a DATA frame on the wire. +/// +/// This is a push, not a pull. Owning the framing means a chunk can simply be sent - build +/// [0x00][varint length][payload] and hand it to the QUIC stream - with no data-reader +/// callback to answer, nothing to defer, and no buffer whose lifetime a library dictates. QUIC +/// streams are independent, so nothing has to be interleaved with other responses either. +/// +/// It is an on purpose: that is the shape a serializer, a file +/// copy or a framework's response sink already writes into, so streaming through it needs no +/// adapter. +/// +/// Backpressure is the connection's send retention. returns once the +/// chunk is queued, and waits when the connection is at its high-water, so a producer cannot +/// outrun a peer that has stopped reading. +/// +/// Reactor thread only, like everything else on the connection. +public sealed class Http3ResponseWriter : IBufferWriter +{ + private const int DefaultChunk = 16 * 1024; + private const long FrameData = 0x0; + + private readonly Http3Connection _connection; + private readonly QuicConnection _quic; + private long _streamId; + + private byte[] _staging = []; + private int _staged; + + private bool _headersSent; + private bool _completed; + + internal Http3ResponseWriter(Http3Connection connection, QuicConnection quic, long streamId) + { + _connection = connection; + _quic = quic; + _streamId = streamId; + } + + /// The stream this response belongs to. + public long StreamId => _streamId; + + /// True once the body has been finished and the stream closed. + public bool IsCompleted => _completed; + + /// + /// Send the response headers. Exactly once, before any body byte - HTTP/3 puts HEADERS ahead + /// of DATA and there is no correcting that later. + /// + /// No content-length is written: a streamed response does not know its length yet, and for an + /// endless one there is no length to know. HTTP/3 needs none - each DATA frame carries its own. + /// + public void WriteHeaders(Http3Response response) + { + ArgumentNullException.ThrowIfNull(response); + + if (_headersSent) + { + throw new InvalidOperationException("Response headers have already been written for this stream."); + } + if (!response.Body.IsEmpty) + { + throw new ArgumentException( + "A streamed response carries its body through the writer; leave Response.Body empty.", + nameof(response)); + } + + _headersSent = true; + _connection.SendStreamedHeaders(_streamId, response); + } + + /// + public Span GetSpan(int sizeHint = 0) + { + EnsureStaging(sizeHint <= 0 ? 1 : sizeHint); + return _staging.AsSpan(_staged); + } + + /// + public Memory GetMemory(int sizeHint = 0) + { + EnsureStaging(sizeHint <= 0 ? 1 : sizeHint); + return _staging.AsMemory(_staged); + } + + /// + public void Advance(int count) + { + ArgumentOutOfRangeException.ThrowIfNegative(count); + if (_staged + count > _staging.Length) + { + throw new InvalidOperationException("Advanced past the end of the span handed out by GetSpan."); + } + _staged += count; + } + + /// + /// Send everything staged so far as one DATA frame. Waits first when the connection is at its + /// send-retention high-water: that wait is the backpressure, and it is what keeps memory bound + /// to one chunk rather than to the whole response. + /// + public ValueTask FlushAsync() => FlushCore(fin: false); + + /// + /// Send what is left and close the stream. A handler that returns without calling this still + /// gets it called for it - the peer is owed an end either way. + /// + public async ValueTask CompleteAsync() + { + if (_completed) + { + return; + } + + if (!_headersSent) + { + WriteHeaders(new Http3Response { Status = 500 }); + } + + await FlushCore(fin: true); + _completed = true; + + if (_staging.Length > 0) + { + ArrayPool.Shared.Return(_staging); + _staging = []; + } + } + + private async ValueTask FlushCore(bool fin) + { + if (!_headersSent) + { + throw new InvalidOperationException("Write the response headers before flushing a body chunk."); + } + + if (_staged == 0 && !fin) + { + return; + } + + // The peer has stopped reading and the connection is holding all it is willing to. Wait + // rather than queue: unbounded queueing here is exactly what streaming exists to avoid. + while (!_quic.CanQueueSend && !_connection.IsBroken) + { + await _connection.WaitForSendCapacityAsync(); + } + + if (_connection.IsBroken) + { + return; + } + + if (_staged == 0) + { + _quic.SendStream(_streamId, ReadOnlySpan.Empty, fin: true); + return; + } + + // [0x00][varint length][payload], sent as one call - the header is tiny and splitting it + // from its payload would cost a second trip through the QUIC send path per chunk. + Span header = stackalloc byte[16]; + int h = Varint.Write(header, FrameData); + h += Varint.Write(header[h..], _staged); + + byte[] frame = ArrayPool.Shared.Rent(h + _staged); + header[..h].CopyTo(frame); + _staging.AsSpan(0, _staged).CopyTo(frame.AsSpan(h)); + + _quic.SendStream(_streamId, frame.AsSpan(0, h + _staged), fin); + ArrayPool.Shared.Return(frame); + + _staged = 0; + } + + private void EnsureStaging(int sizeHint) + { + int needed = _staged + sizeHint; + if (_staging.Length >= needed) + { + return; + } + + byte[] grown = ArrayPool.Shared.Rent(Math.Max(needed, DefaultChunk)); + if (_staged > 0) + { + _staging.AsSpan(0, _staged).CopyTo(grown); + } + if (_staging.Length > 0) + { + ArrayPool.Shared.Return(_staging); + } + _staging = grown; + } + + /// + /// Take this writer for another stream, keeping its buffer. A writer and its staging block + /// carry nothing stream-specific once reset, and allocating both per response is what made the + /// nghttp3 streamed path heavier than its buffered one. + /// + internal void Reset(long streamId) + { + _streamId = streamId; + _staged = 0; + _headersSent = false; + _completed = false; + } +} From dd621e8b964df416aa9f54e8d637f8004bb87a31 Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Mon, 10 Aug 2026 01:21:12 +0100 Subject: [PATCH 2/4] bench+docs: register ManagedStreamed and generate its pane The sample, its baseline and its pane land together, rather than the sample arriving now and the other two being noticed later - which is the state Http3/Managed and Http3/Streamed were in until an hour ago. The pane's footer deliberately does not link the nghttp3 streamed tab: that tab comes from a different open PR, and a cross-PR label would be a dead link in whichever merges first. Http3/ManagedStreamed 28906 req/s 68.95us (new baseline) That cell is a 64 x 16 KiB response - a megabyte per request, so the low req/s is the body size, not a regression. Registered with the same 8 x 1 KiB chunking the nghttp3 streamed cell uses. --- bench/results/20260810T001948Z.json | 10 +++++ bench/results/latest.json | 6 +-- bench/samples.tsv | 1 + docs/assets/style.css | 3 ++ docs/index.html | 67 +++++++++++++++++++++++++++++ scripts/gen-docs-panes.py | 15 +++++++ 6 files changed, 99 insertions(+), 3 deletions(-) create mode 100644 bench/results/20260810T001948Z.json diff --git a/bench/results/20260810T001948Z.json b/bench/results/20260810T001948Z.json new file mode 100644 index 00000000..5b61c4d1 --- /dev/null +++ b/bench/results/20260810T001948Z.json @@ -0,0 +1,10 @@ +{ + "stamp": "20260810T001948Z", + "host": "diogo-System-Product-Name", + "kernel": "6.17.0-22-generic", + "reactors": 2, "conns": 64, "threads": 8, "seconds": 10, + "commit": "3c9a208", + "samples": [ + {"sample":"Http3/ManagedStreamed","proto":"h3","rps":28906,"cpu_us_per_req":68.95,"util_pct":100,"note":""} + ] +} diff --git a/bench/results/latest.json b/bench/results/latest.json index a45037c7..5b61c4d1 100644 --- a/bench/results/latest.json +++ b/bench/results/latest.json @@ -1,10 +1,10 @@ { - "stamp": "20260810T001150Z", + "stamp": "20260810T001948Z", "host": "diogo-System-Product-Name", "kernel": "6.17.0-22-generic", "reactors": 2, "conns": 64, "threads": 8, "seconds": 10, - "commit": "96137b9", + "commit": "3c9a208", "samples": [ - {"sample":"Http3/Nghttp3","proto":"h3","rps":480371,"cpu_us_per_req":4.17,"util_pct":100,"note":""},{"sample":"Http3/Buffered","proto":"h3","rps":516707,"cpu_us_per_req":3.87,"util_pct":100,"note":""},{"sample":"Http3/Managed","proto":"h3","rps":665936,"cpu_us_per_req":2.96,"util_pct":98,"note":""},{"sample":"Http3/Streamed","proto":"h3","rps":89906,"cpu_us_per_req":22.03,"util_pct":99,"note":""} + {"sample":"Http3/ManagedStreamed","proto":"h3","rps":28906,"cpu_us_per_req":68.95,"util_pct":100,"note":""} ] } diff --git a/bench/samples.tsv b/bench/samples.tsv index 76185786..dfa9843d 100644 --- a/bench/samples.tsv +++ b/bench/samples.tsv @@ -43,6 +43,7 @@ Http3/Nghttp3 h3 8443 / - - Http3/Buffered h3 8443 / - - Http3/Managed h3 8443 / - - Http3/Streamed h3 8443 / - PLAYGROUND_CHUNKS=8 PLAYGROUND_CHUNK_BYTES=1024 +Http3/ManagedStreamed h3 8443 / - PLAYGROUND_CHUNKS=8 PLAYGROUND_CHUNK_BYTES=1024 Quic/Alpn echo 8443 / - - Quic/Pipe echo 8443 / - - diff --git a/docs/assets/style.css b/docs/assets/style.css index 7d810087..78e2c29b 100644 --- a/docs/assets/style.css +++ b/docs/assets/style.css @@ -221,6 +221,7 @@ nav.top .links a.gh svg { display: block; } #tab-h2bcl:checked ~ .ex-menu label[for="tab-h2bcl"], #tab-h3cs:checked ~ .ex-menu label[for="tab-h3cs"], #tab-h3stream:checked ~ .ex-menu label[for="tab-h3stream"], +#tab-h3csstream:checked ~ .ex-menu label[for="tab-h3csstream"], #tab-h3buf:checked ~ .ex-menu label[for="tab-h3buf"], #tab-quicalpn:checked ~ .ex-menu label[for="tab-quicalpn"], #tab-qclient:checked ~ .ex-menu label[for="tab-qclient"], @@ -350,6 +351,7 @@ nav.top .links a.gh svg { display: block; } #tab-h2bcl:checked ~ .pane-h2bcl { display: block; } #tab-h3cs:checked ~ .pane-h3cs { display: block; } #tab-h3stream:checked ~ .pane-h3stream { display: block; } +#tab-h3csstream:checked ~ .pane-h3csstream { display: block; } #tab-h3buf:checked ~ .pane-h3buf { display: block; } #tab-quicalpn:checked ~ .pane-quicalpn { display: block; } #tab-qclient:checked ~ .pane-qclient { display: block; } @@ -484,6 +486,7 @@ nav.top .links a.gh svg { display: block; } #tab-h2bcl:checked ~ .ex-menu label[for="tab-h2bcl"], #tab-h3cs:checked ~ .ex-menu label[for="tab-h3cs"], #tab-h3stream:checked ~ .ex-menu label[for="tab-h3stream"], + #tab-h3csstream:checked ~ .ex-menu label[for="tab-h3csstream"], #tab-h3buf:checked ~ .ex-menu label[for="tab-h3buf"], #tab-quicalpn:checked ~ .ex-menu label[for="tab-quicalpn"], #tab-qclient:checked ~ .ex-menu label[for="tab-qclient"], diff --git a/docs/index.html b/docs/index.html index 7d6b3d1c..c81a00b8 100644 --- a/docs/index.html +++ b/docs/index.html @@ -47,6 +47,7 @@ + @@ -115,6 +116,7 @@ +
@@ -1100,6 +1102,9 @@

HTTP/2 · over SslStream

HTTP/3 · pure C#

+
+
+

HTTP/3 · streamed both ways

ioxide + ioxide.ngtcp2 + ioxide.http3
// dotnet add package ioxide
@@ -1107,6 +1112,10 @@ 

HTTP/3 · pure C#

// dotnet add package ioxide.http3 // curl --http3-only -k https://127.0.0.1:8443/ +// curl --http3-only -kN https://127.0.0.1:8443/feed # never ends +// curl --http3-only -k --data-binary @big.bin https://127.0.0.1:8443/upload + +using System.Text; using ioxide; using ioxide.http3; using ioxide.ngtcp2; @@ -1205,6 +1214,19 @@

HTTP/3 · streamed response

// UDP receive slots per reactor: how many datagrams the ring can have outstanding at once. int udpRecvSlots = 16; +ushort quicPort = 8443; +int reactors = Environment.ProcessorCount; + + +// Chunks written per response on "/", and the size of each. Their product is never held at once. +int chunkCount = 64; +int chunkBytes = 16 * 1024; + + +int udpRecvSlots = 16; + +string? certOverride = null; +string? keyOverride = null; // ───────────────────────────────────────────────────────────────────────────────────────────── const string certPath = "cert.pem"; // any PEM pair @@ -1216,6 +1238,7 @@

HTTP/3 · streamed response

{ ReactorCount = reactors, Tcp = null, // QUIC only: no TCP listener is bound + Tcp = null, Udp = new UdpOptions { RecvSlots = udpRecvSlots }, Quic = new QuicOptions { @@ -1245,6 +1268,34 @@

HTTP/3 · streamed response

response.Headers.Add("content-type"u8.ToArray(), endless ? "text/event-stream"u8.ToArray() : "text/plain"u8.ToArray()); writer.WriteHeaders(response); + new Http3Connection(conn).RunStreamedResponseAsync(async (request, writer) => + { + bool endless = request.Path.Span.SequenceEqual("/feed"u8); + bool upload = request.Path.Span.SequenceEqual("/upload"u8); + + if (upload) + { + // The other direction: pull the body a chunk at a time rather than waiting for + // all of it, so memory is bound by one chunk however large the upload is. + long total = 0; + if (request.BodyReader is { } body) + { + while (true) + { + ReadOnlyMemory<byte> part = await body.ReadAsync(); + if (part.IsEmpty) break; + total += part.Length; + } + } + + writer.WriteHeaders(Plain()); + Encoding.ASCII.GetBytes($"{total}\n").CopyTo(writer.GetSpan(24)); + writer.Advance(Encoding.ASCII.GetByteCount($"{total}\n")); + await writer.FlushAsync(); + return; + } + + writer.WriteHeaders(Plain(endless)); for (int n = 0; endless || n < chunkCount; n++) { @@ -1259,6 +1310,10 @@

HTTP/3 · streamed response

// CompleteAsync ends the stream. The runner calls it too if a handler returns without // doing so, since the peer is owed an end either way. await writer.CompleteAsync(); + // Returns once the chunk is queued, and waits when the connection is at its + // send-retention high-water. That await IS the backpressure. + await writer.FlushAsync(); + } }); threads[i] = new Thread(reactor.Run) { Name = $"reactor-{i}" }; @@ -1267,12 +1322,24 @@

HTTP/3 · streamed response

Console.WriteLine($"[h3-streamed] {config.ReactorCount} reactors, h3 on udp :{config.Quic!.Port}, " + $"{chunkCount} x {chunkBytes}-byte chunks per response, cert {certPath}"); +Console.WriteLine($"[h3-managed-streamed] {config.ReactorCount} reactors, h3 on udp :{config.Quic!.Port} " + + $"(pure C#), {chunkCount} x {chunkBytes}-byte chunks, cert {certPath}"); foreach (Thread thread in threads) { thread.Join(); }

The response body produced OVER TIME instead of handed over whole - each flush becomes a DATA frame. That is what /feed demonstrates: an endless response has no final byte, so a buffered API cannot express it at all. Nghttp3ResponseWriter is an IBufferWriter<byte>, so a serializer or a framework's response sink writes into it unchanged, and FlushAsync returning only once nghttp3 has taken the chunk is what stops a producer outrunning a peer that has stopped reading. nghttp3 PULLS body bytes rather than accepting pushes, which is why this carries a resume and a drain the pure-C# writer does not need.

+} + +static Http3Response Plain(bool eventStream = false) +{ + var response = new Http3Response { Status = 200 }; + response.Headers.Add(("content-type"u8.ToArray(), + eventStream ? "text/event-stream"u8.ToArray() : "text/plain"u8.ToArray())); + return response; +} +

Both directions streamed, in pure C#. The request body is PULLED a chunk at a time through Http3Request.BodyReader, so a large upload is never held whole; the response is PUSHED through Http3ResponseWriter, one DATA frame per flush, so a large download is never built whole. Owning the framing is what makes the push side simple - a chunk is just [0x00][varint length][payload] handed to the QUIC stream, with no data-reader callback to answer and nothing to defer. Compare the nghttp3 streamed response, which carries a resume and a drain because nghttp3 pulls instead: this one measures 1.32× its throughput on the same 8×1 KiB response.

diff --git a/scripts/gen-docs-panes.py b/scripts/gen-docs-panes.py index 8464b199..807211f8 100644 --- a/scripts/gen-docs-panes.py +++ b/scripts/gen-docs-panes.py @@ -161,6 +161,21 @@ "the chunk is what stops a producer outrunning a peer that has stopped reading. nghttp3 " "PULLS body bytes rather than accepting pushes, which is why this carries a resume and a " "drain the pure-C# writer does not need."), + "h3csstream": ( + "Http3/ManagedStreamed", "HTTP/3 · streamed both ways", "ioxide + ioxide.ngtcp2 + ioxide.http3", + ["curl --http3-only -k https://127.0.0.1:8443/", + "curl --http3-only -kN https://127.0.0.1:8443/feed # never ends", + "curl --http3-only -k --data-binary @big.bin https://127.0.0.1:8443/upload"], + "Both directions streamed, in pure C#. The request body is PULLED a chunk at a time through " + "Http3Request.BodyReader, so a large upload is never held whole; the response " + "is PUSHED through Http3ResponseWriter, one DATA frame per flush, so a large " + "download is never built whole. " + "Owning the framing is what makes the push side simple - a chunk is just " + "[0x00][varint length][payload] handed to the QUIC stream, with no data-reader " + "callback to answer and nothing to defer. Compare " + "the nghttp3 streamed response, which " + "carries a resume and a drain because nghttp3 pulls instead: this one measures " + "1.32× its throughput on the same 8×1 KiB response."), "h3buf": ( "Http3/Buffered", "HTTP/3 · buffered dispatch", "ioxide + ioxide.ngtcp2 + ioxide.nghttp3", ["curl --http3-only -k https://127.0.0.1:8443/"], From 7a8eac60f44d2295dcad67f56230b4bcdabdee2e Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Mon, 10 Aug 2026 01:25:08 +0100 Subject: [PATCH 3/4] playground: an /echo route that streams BOTH directions at once The sample showed each direction separately - /upload pulled a request body, / pushed a response - but nothing exercised them together, which is the shape that actually matters: a proxy reads a chunk and writes a chunk, holding one at a time. /echo does that. Neither side can outrun the other, because ReadAsync waits on the peer and FlushAsync waits on the connection having room. Exercised with h3x --requests, which can carry a request body: 38-byte body 50 requests, 0 failed 1 MiB body 4330 requests, 0 failed, peak RSS 99 MB That RSS is the result worth reading. 4330 requests carrying a megabyte IN and a megabyte OUT, four concurrent, and the process never grew past 99 MB - buffering either direction would have shown gigabytes. The memory being flat is what says both halves stream. Still NOT verified byte-for-byte: h3x does not validate response payloads and the curl here is built without HTTP/3, so an echo that corrupted its payload would pass this. That wants a test using ioxide's own h3 client, which is the right home for the assertion anyway. --- Playground/Http3/ManagedStreamed/Program.cs | 33 +++++++++++++++++++++ docs/index.html | 28 +++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/Playground/Http3/ManagedStreamed/Program.cs b/Playground/Http3/ManagedStreamed/Program.cs index 586cf86f..fc9a5651 100644 --- a/Playground/Http3/ManagedStreamed/Program.cs +++ b/Playground/Http3/ManagedStreamed/Program.cs @@ -11,6 +11,10 @@ // flow control, so a large upload is never held whole. The response body goes out through an // Http3ResponseWriter, one DATA frame per flush, so a large download is never built whole. // +// "/echo" runs both at once - read a chunk, write a chunk - which is what a proxy does and the +// case that shows the two halves are independent. Memory stays flat regardless of size, because +// each side blocks the other: ReadAsync waits on the peer, FlushAsync waits on the connection. +// // Because ioxide.http3 owns the framing, sending is a push: build [0x00][varint len][payload] // and hand it to the QUIC stream. There is no data-reader callback to answer and nothing to // defer - which is the difference from the nghttp3 version of this in Playground/Http3/Streamed. @@ -19,6 +23,7 @@ // curl --http3-only -k https://127.0.0.1:8443/ # chunked download // curl --http3-only -kN https://127.0.0.1:8443/feed # endless; ctrl-c to stop // curl --http3-only -k --data-binary @big.bin https://127.0.0.1:8443/upload +// curl --http3-only -k --data-binary @big.bin https://127.0.0.1:8443/echo # both ways // // Needs: ioxide, ioxide.ngtcp2, ioxide.http3 // ───────────────────────────────────────────────────────────────────────────────────────────── @@ -76,6 +81,34 @@ { bool endless = request.Path.Span.SequenceEqual("/feed"u8); bool upload = request.Path.Span.SequenceEqual("/upload"u8); + bool echo = request.Path.Span.SequenceEqual("/echo"u8); + + if (echo) + { + // BOTH directions at once, which is the shape a proxy actually needs: read a + // chunk, write a chunk, and never hold more than one. Neither side can run away + // from the other - ReadAsync waits for the peer to send, FlushAsync waits for the + // connection to have room - so memory stays flat however large the exchange is. + writer.WriteHeaders(Plain()); + + if (request.BodyReader is { } duplex) + { + while (true) + { + ReadOnlyMemory part = await duplex.ReadAsync(); + if (part.IsEmpty) + { + break; // end of the request body + } + + part.Span.CopyTo(writer.GetSpan(part.Length)); + writer.Advance(part.Length); + await writer.FlushAsync(); + } + } + + return; + } if (upload) { diff --git a/docs/index.html b/docs/index.html index c81a00b8..e66dd2bc 100644 --- a/docs/index.html +++ b/docs/index.html @@ -1272,6 +1272,34 @@

HTTP/3 · streamed response

{ bool endless = request.Path.Span.SequenceEqual("/feed"u8); bool upload = request.Path.Span.SequenceEqual("/upload"u8); + bool echo = request.Path.Span.SequenceEqual("/echo"u8); + + if (echo) + { + // BOTH directions at once, which is the shape a proxy actually needs: read a + // chunk, write a chunk, and never hold more than one. Neither side can run away + // from the other - ReadAsync waits for the peer to send, FlushAsync waits for the + // connection to have room - so memory stays flat however large the exchange is. + writer.WriteHeaders(Plain()); + + if (request.BodyReader is { } duplex) + { + while (true) + { + ReadOnlyMemory<byte> part = await duplex.ReadAsync(); + if (part.IsEmpty) + { + break; // end of the request body + } + + part.Span.CopyTo(writer.GetSpan(part.Length)); + writer.Advance(part.Length); + await writer.FlushAsync(); + } + } + + return; + } if (upload) { From f5755410e516c29e3147df78895d38ba7fc21bcf Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Mon, 10 Aug 2026 01:32:44 +0100 Subject: [PATCH 4/4] docs: reapply the ManagedStreamed pane cleanly after the rebase Resolving the docs conflict by unioning both sides was wrong for HTML: it left the new pane div nested inside the previous pane's head, which the generator then silently overwrote while rewriting its neighbour. Taking main's file and reapplying the three additions - tab, label, CSS - produces the same result without the interleaving. Verified: 41 samples, 41 registered, 41 generated; no duplicate tabs or panes; tabs, labels, panes and CSS agree; generators idempotent; the pane compiles standalone. --- docs/assets/style.css | 8 +-- docs/index.html | 121 ++++++++++++++++++++++++++++---------- scripts/gen-docs-panes.py | 19 +++--- 3 files changed, 103 insertions(+), 45 deletions(-) diff --git a/docs/assets/style.css b/docs/assets/style.css index 78e2c29b..7b2667e9 100644 --- a/docs/assets/style.css +++ b/docs/assets/style.css @@ -219,9 +219,9 @@ nav.top .links a.gh svg { display: block; } #tab-taskrun:checked ~ .ex-menu label[for="tab-taskrun"], #tab-h2cstls:checked ~ .ex-menu label[for="tab-h2cstls"], #tab-h2bcl:checked ~ .ex-menu label[for="tab-h2bcl"], +#tab-h3csstream:checked ~ .ex-menu label[for="tab-h3csstream"], #tab-h3cs:checked ~ .ex-menu label[for="tab-h3cs"], #tab-h3stream:checked ~ .ex-menu label[for="tab-h3stream"], -#tab-h3csstream:checked ~ .ex-menu label[for="tab-h3csstream"], #tab-h3buf:checked ~ .ex-menu label[for="tab-h3buf"], #tab-quicalpn:checked ~ .ex-menu label[for="tab-quicalpn"], #tab-qclient:checked ~ .ex-menu label[for="tab-qclient"], @@ -349,9 +349,9 @@ nav.top .links a.gh svg { display: block; } #tab-taskrun:checked ~ .pane-taskrun { display: block; } #tab-h2cstls:checked ~ .pane-h2cstls { display: block; } #tab-h2bcl:checked ~ .pane-h2bcl { display: block; } +#tab-h3csstream:checked ~ .pane-h3csstream { display: block; } #tab-h3cs:checked ~ .pane-h3cs { display: block; } #tab-h3stream:checked ~ .pane-h3stream { display: block; } -#tab-h3csstream:checked ~ .pane-h3csstream { display: block; } #tab-h3buf:checked ~ .pane-h3buf { display: block; } #tab-quicalpn:checked ~ .pane-quicalpn { display: block; } #tab-qclient:checked ~ .pane-qclient { display: block; } @@ -484,9 +484,9 @@ nav.top .links a.gh svg { display: block; } #tab-taskrun:checked ~ .ex-menu label[for="tab-taskrun"], #tab-h2cstls:checked ~ .ex-menu label[for="tab-h2cstls"], #tab-h2bcl:checked ~ .ex-menu label[for="tab-h2bcl"], - #tab-h3cs:checked ~ .ex-menu label[for="tab-h3cs"], -#tab-h3stream:checked ~ .ex-menu label[for="tab-h3stream"], #tab-h3csstream:checked ~ .ex-menu label[for="tab-h3csstream"], +#tab-h3cs:checked ~ .ex-menu label[for="tab-h3cs"], +#tab-h3stream:checked ~ .ex-menu label[for="tab-h3stream"], #tab-h3buf:checked ~ .ex-menu label[for="tab-h3buf"], #tab-quicalpn:checked ~ .ex-menu label[for="tab-quicalpn"], #tab-qclient:checked ~ .ex-menu label[for="tab-qclient"], diff --git a/docs/index.html b/docs/index.html index e66dd2bc..3556e8b6 100644 --- a/docs/index.html +++ b/docs/index.html @@ -1102,9 +1102,6 @@

HTTP/2 · over SslStream

HTTP/3 · pure C#

-
-
-

HTTP/3 · streamed both ways

ioxide + ioxide.ngtcp2 + ioxide.http3
// dotnet add package ioxide
@@ -1112,10 +1109,6 @@ 

HTTP/3 · streamed both ways

// dotnet add package ioxide.http3 // curl --http3-only -k https://127.0.0.1:8443/ -// curl --http3-only -kN https://127.0.0.1:8443/feed # never ends -// curl --http3-only -k --data-binary @big.bin https://127.0.0.1:8443/upload - -using System.Text; using ioxide; using ioxide.http3; using ioxide.ngtcp2; @@ -1214,6 +1207,94 @@

HTTP/3 · streamed response

// UDP receive slots per reactor: how many datagrams the ring can have outstanding at once. int udpRecvSlots = 16; +// ───────────────────────────────────────────────────────────────────────────────────────────── + +const string certPath = "cert.pem"; // any PEM pair +const string keyPath = "key.pem"; + +using var engine = new QuicEngine(certPath, keyPath, cidLength: 8, alpn: ["h3"]); + +var config = new ServerConfig +{ + ReactorCount = reactors, + Tcp = null, // QUIC only: no TCP listener is bound + Udp = new UdpOptions { RecvSlots = udpRecvSlots }, + Quic = new QuicOptions + { + Port = quicPort, + LocalCidLength = 8, + ConnectionFactory = engine.CreateFactory(), + }, +}; + +byte[] chunk = Encoding.ASCII.GetBytes(new string('x', chunkBytes - 1) + "\n"); + +var threads = new Thread[config.ReactorCount]; + +for (int i = 0; i < threads.Length; i++) +{ + var reactor = new Reactor(i, config); + + reactor.QuicHandle = (r, conn) => + new Nghttp3Connection(conn).RunStreamedResponseAsync(async (request, writer) => + { + bool endless = request.Path.Span.SequenceEqual("/feed"u8); + + // Headers first and once: HTTP/3 puts HEADERS before DATA and there is no correcting + // it later. No Content-Length here - the length is not known yet, and for /feed never + // will be. + var response = new Nghttp3Response { Status = 200 }; + response.Headers.Add("content-type"u8.ToArray(), + endless ? "text/event-stream"u8.ToArray() : "text/plain"u8.ToArray()); + writer.WriteHeaders(response); + + for (int n = 0; endless || n < chunkCount; n++) + { + chunk.CopyTo(writer.GetSpan(chunk.Length)); + writer.Advance(chunk.Length); + + // Returns once nghttp3 has taken it. That await IS the backpressure - nothing + // queues up behind a peer that has stopped reading. + await writer.FlushAsync(); + } + + // CompleteAsync ends the stream. The runner calls it too if a handler returns without + // doing so, since the peer is owed an end either way. + await writer.CompleteAsync(); + }); + + threads[i] = new Thread(reactor.Run) { Name = $"reactor-{i}" }; + threads[i].Start(); +} + +Console.WriteLine($"[h3-streamed] {config.ReactorCount} reactors, h3 on udp :{config.Quic!.Port}, " + + $"{chunkCount} x {chunkBytes}-byte chunks per response, cert {certPath}"); + +foreach (Thread thread in threads) +{ + thread.Join(); +}
+

The response body produced OVER TIME instead of handed over whole - each flush becomes a DATA frame. That is what /feed demonstrates: an endless response has no final byte, so a buffered API cannot express it at all. Nghttp3ResponseWriter is an IBufferWriter<byte>, so a serializer or a framework's response sink writes into it unchanged, and FlushAsync returning only once nghttp3 has taken the chunk is what stops a producer outrunning a peer that has stopped reading. nghttp3 PULLS body bytes rather than accepting pushes, which is why this carries a resume and a drain the pure-C# writer does not need.

+
+
+
+

HTTP/3 · streamed both ways

+ ioxide + ioxide.ngtcp2 + ioxide.http3 +
+
// dotnet add package ioxide
+// dotnet add package ioxide.ngtcp2
+// dotnet add package ioxide.http3
+//   curl --http3-only -k https://127.0.0.1:8443/
+//   curl --http3-only -kN https://127.0.0.1:8443/feed        # never ends
+//   curl --http3-only -k --data-binary @big.bin https://127.0.0.1:8443/echo
+
+using System.Text;
+using ioxide;
+using ioxide.http3;
+using ioxide.ngtcp2;
+
+// ── Knobs ────────────────────────────────────────────────────────────────────────────────────
+
 ushort quicPort = 8443;
 int    reactors = Environment.ProcessorCount;
 
@@ -1237,7 +1318,6 @@ 

HTTP/3 · streamed response

var config = new ServerConfig { ReactorCount = reactors, - Tcp = null, // QUIC only: no TCP listener is bound Tcp = null, Udp = new UdpOptions { RecvSlots = udpRecvSlots }, Quic = new QuicOptions @@ -1257,17 +1337,6 @@

HTTP/3 · streamed response

var reactor = new Reactor(i, config); reactor.QuicHandle = (r, conn) => - new Nghttp3Connection(conn).RunStreamedResponseAsync(async (request, writer) => - { - bool endless = request.Path.Span.SequenceEqual("/feed"u8); - - // Headers first and once: HTTP/3 puts HEADERS before DATA and there is no correcting - // it later. No Content-Length here - the length is not known yet, and for /feed never - // will be. - var response = new Nghttp3Response { Status = 200 }; - response.Headers.Add("content-type"u8.ToArray(), - endless ? "text/event-stream"u8.ToArray() : "text/plain"u8.ToArray()); - writer.WriteHeaders(response); new Http3Connection(conn).RunStreamedResponseAsync(async (request, writer) => { bool endless = request.Path.Span.SequenceEqual("/feed"u8); @@ -1330,14 +1399,6 @@

HTTP/3 · streamed response

chunk.CopyTo(writer.GetSpan(chunk.Length)); writer.Advance(chunk.Length); - // Returns once nghttp3 has taken it. That await IS the backpressure - nothing - // queues up behind a peer that has stopped reading. - await writer.FlushAsync(); - } - - // CompleteAsync ends the stream. The runner calls it too if a handler returns without - // doing so, since the peer is owed an end either way. - await writer.CompleteAsync(); // Returns once the chunk is queued, and waits when the connection is at its // send-retention high-water. That await IS the backpressure. await writer.FlushAsync(); @@ -1348,16 +1409,12 @@

HTTP/3 · streamed response

threads[i].Start(); } -Console.WriteLine($"[h3-streamed] {config.ReactorCount} reactors, h3 on udp :{config.Quic!.Port}, " - + $"{chunkCount} x {chunkBytes}-byte chunks per response, cert {certPath}"); Console.WriteLine($"[h3-managed-streamed] {config.ReactorCount} reactors, h3 on udp :{config.Quic!.Port} " + $"(pure C#), {chunkCount} x {chunkBytes}-byte chunks, cert {certPath}"); foreach (Thread thread in threads) { thread.Join(); -}
-

The response body produced OVER TIME instead of handed over whole - each flush becomes a DATA frame. That is what /feed demonstrates: an endless response has no final byte, so a buffered API cannot express it at all. Nghttp3ResponseWriter is an IBufferWriter<byte>, so a serializer or a framework's response sink writes into it unchanged, and FlushAsync returning only once nghttp3 has taken the chunk is what stops a producer outrunning a peer that has stopped reading. nghttp3 PULLS body bytes rather than accepting pushes, which is why this carries a resume and a drain the pure-C# writer does not need.

} static Http3Response Plain(bool eventStream = false) @@ -1367,7 +1424,7 @@

HTTP/3 · streamed response

eventStream ? "text/event-stream"u8.ToArray() : "text/plain"u8.ToArray())); return response; } -

Both directions streamed, in pure C#. The request body is PULLED a chunk at a time through Http3Request.BodyReader, so a large upload is never held whole; the response is PUSHED through Http3ResponseWriter, one DATA frame per flush, so a large download is never built whole. Owning the framing is what makes the push side simple - a chunk is just [0x00][varint length][payload] handed to the QUIC stream, with no data-reader callback to answer and nothing to defer. Compare the nghttp3 streamed response, which carries a resume and a drain because nghttp3 pulls instead: this one measures 1.32× its throughput on the same 8×1 KiB response.

+

Both directions streamed, in pure C#. The request body is PULLED a chunk at a time through Http3Request.BodyReader, so a large upload is never held whole; the response is PUSHED through Http3ResponseWriter, one DATA frame per flush, so a large download is never built whole. /echo runs both at once - read a chunk, write a chunk - which is what a proxy does. Owning the framing is what makes the push side simple: a chunk is just [0x00][varint length][payload] handed to the QUIC stream, with no data-reader callback to answer and nothing to defer. carries a resume and a drain because nghttp3 pulls instead; this measures 1.32× its throughput on the same 8×1 KiB response.

diff --git a/scripts/gen-docs-panes.py b/scripts/gen-docs-panes.py index 807211f8..8dc50b1e 100644 --- a/scripts/gen-docs-panes.py +++ b/scripts/gen-docs-panes.py @@ -165,17 +165,18 @@ "Http3/ManagedStreamed", "HTTP/3 · streamed both ways", "ioxide + ioxide.ngtcp2 + ioxide.http3", ["curl --http3-only -k https://127.0.0.1:8443/", "curl --http3-only -kN https://127.0.0.1:8443/feed # never ends", - "curl --http3-only -k --data-binary @big.bin https://127.0.0.1:8443/upload"], + "curl --http3-only -k --data-binary @big.bin https://127.0.0.1:8443/echo"], "Both directions streamed, in pure C#. The request body is PULLED a chunk at a time through " - "Http3Request.BodyReader, so a large upload is never held whole; the response " - "is PUSHED through Http3ResponseWriter, one DATA frame per flush, so a large " - "download is never built whole. " - "Owning the framing is what makes the push side simple - a chunk is just " + "Http3Request.BodyReader, so a large upload is never held whole; the response is " + "PUSHED through Http3ResponseWriter, one DATA frame per flush, so a large " + "download is never built whole. /echo runs both at once - read a chunk, write a " + "chunk - which is what a proxy does. " + "Owning the framing is what makes the push side simple: a chunk is just " "[0x00][varint length][payload] handed to the QUIC stream, with no data-reader " - "callback to answer and nothing to defer. Compare " - "the nghttp3 streamed response, which " - "carries a resume and a drain because nghttp3 pulls instead: this one measures " - "1.32× its throughput on the same 8×1 KiB response."), + "callback to answer and nothing to defer. " + " carries a " + "resume and a drain because nghttp3 pulls instead; this measures 1.32× its " + "throughput on the same 8×1 KiB response."), "h3buf": ( "Http3/Buffered", "HTTP/3 · buffered dispatch", "ioxide + ioxide.ngtcp2 + ioxide.nghttp3", ["curl --http3-only -k https://127.0.0.1:8443/"],