From 4b1f07d873895313a752631c5e33a3572c49a6fa Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Sun, 30 Aug 2026 20:58:33 +0100 Subject: [PATCH] playground: the nghttp3 sample for both directions streamed at once The nghttp3 set had three of the four corners - buffered both ways, request streamed, response streamed - and no sample doing both at once. The pure-C# stack has one (Http3/ManagedStreamedBoth) and its pane points at the nghttp3 side for the contrast, so the thing being contrasted did not exist. Nothing new was needed to make it work: RunStreamedResponseAsync already sets _streaming, so the request arrives through Nghttp3Request.BodyReader at end-of-headers while the response goes out through the writer. One call is both directions; no sample showed it. Same routes as the managed twin so the two can be diffed - "/" chunked down, "/upload" pulled up, "/echo" both at once, which is the shape a proxy needs. Verified against the running sample, one listener asserted: "/" returns the full 1 MiB, "/upload" of 64 MiB is counted exactly, and "/echo" of 64 MiB comes back byte-identical in 0.37s while the server's RSS moves 51 -> 57 MB. It keeps serving afterwards and the reactors go back to idle. No "/feed" here, unlike the managed twin, because an endless response does not work on this stack: no headers reach the peer at all, and after the client goes away a reactor spins and the connection serves nothing further. That reproduces on the SHIPPED Http3/Nghttp3Response, whose banner and site pane both tell you to run it, so it is not this sample's problem to solve and not this sample's place to repeat. Reported separately. --- ...layground.Http3.Nghttp3StreamedBoth.csproj | 20 ++ .../Http3/Nghttp3StreamedBoth/Program.cs | 190 ++++++++++++++++++ Playground/README.md | 1 + bench/samples.tsv | 1 + docs/assets/style.css | 3 + docs/index.html | 168 ++++++++++++++++ ioxide.slnx | 1 + scripts/gen-docs-panes.py | 20 ++ 8 files changed, 404 insertions(+) create mode 100644 Playground/Http3/Nghttp3StreamedBoth/Playground.Http3.Nghttp3StreamedBoth.csproj create mode 100644 Playground/Http3/Nghttp3StreamedBoth/Program.cs diff --git a/Playground/Http3/Nghttp3StreamedBoth/Playground.Http3.Nghttp3StreamedBoth.csproj b/Playground/Http3/Nghttp3StreamedBoth/Playground.Http3.Nghttp3StreamedBoth.csproj new file mode 100644 index 00000000..7088b4f3 --- /dev/null +++ b/Playground/Http3/Nghttp3StreamedBoth/Playground.Http3.Nghttp3StreamedBoth.csproj @@ -0,0 +1,20 @@ + + + + Exe + net11.0 + enable + enable + true + Playground.Http3.Nghttp3StreamedBoth + Playground.Http3.Nghttp3StreamedBoth + + + + + + + + + + diff --git a/Playground/Http3/Nghttp3StreamedBoth/Program.cs b/Playground/Http3/Nghttp3StreamedBoth/Program.cs new file mode 100644 index 00000000..4db9e92b --- /dev/null +++ b/Playground/Http3/Nghttp3StreamedBoth/Program.cs @@ -0,0 +1,190 @@ +using System.Text; +using ioxide; +using ioxide.nghttp3; +using ioxide.ngtcp2; +using Playground.Shared; + +// ───────────────────────────────────────────────────────────────────────────────────────────── +// nghttp3-streamed-both - HTTP/3 on nghttp3 with BOTH directions streamed, the fourth corner +// the other three nghttp3 samples leave empty. +// +// The request body arrives through Nghttp3Request.BodyReader, pulled a chunk at a time under +// flow control; the response goes out through an Nghttp3ResponseWriter, one flush at a time. +// One call does both: RunStreamedResponseAsync dispatches at end-of-headers, so the handler is +// running while the upload is still on the wire. +// +// "/echo" runs the two at once - read a chunk, write a chunk - which is what a proxy does. +// Memory stays flat however large the exchange is, because each side blocks the other. +// +// Diff it against Playground/Http3/ManagedStreamedBoth: same routes, same shape, and underneath +// the opposite mechanism. nghttp3 owns the framing, so it PULLS body bytes when it is ready to +// emit DATA - a flush here means nghttp3 has taken the chunk, not that it is on the wire. +// +// No "/feed" here, unlike the managed twin: an endless response does not work on this stack - +// nothing reaches the wire and the reactor stops serving. Every route below is bounded. +// +// dotnet run -c Release --project Playground/Http3/Nghttp3StreamedBoth +// curl --http3-only -k https://127.0.0.1:8443/ # chunked download +// 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.nghttp3 +// ───────────────────────────────────────────────────────────────────────────────────────────── + +// ── 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"); + +// Multishot recv slots per reactor. QPACK capacity 4096 advertises a decode-side dynamic table; +// 0 is static-only, nghttp3's default. +int udpRecvSlots = 16; +long qpackCapacity = 0; + +Env.OverrideH3(ref udpRecvSlots, ref qpackCapacity); + +// A real PEM pair, or null to generate a self-signed localhost cert on first run. +string? certOverride = null; +string? keyOverride = null; + +Env.OverrideCert(ref certOverride, ref keyOverride); +// ───────────────────────────────────────────────────────────────────────────────────────────── + +(string certPath, string keyPath) = QuicCert.Ensure(certOverride, keyOverride); + +// The last argument bounds what one connection may retain unacknowledged, which is what keeps a +// streamed response streaming instead of quietly buffering whole. See Playground/Http3/Nghttp3Buffered +// for the full QUIC/h3 knob set. +using var engine = new QuicEngine(certPath, keyPath, cidLength: 8, alpn: ["h3"], maxSendRetentionBytes: 16L << 20); + +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(), + // Where a moved client's packets go when several reactors share the port. Forward costs + // nothing until a client actually changes address; KernelFilter has the kernel route by + // connection id instead, which costs a little on every packet. See /how-ioxide-does-h3. + Routing = QuicRouting.Forward, + }, +}; + +var h3Options = new Nghttp3Options +{ + QpackDynamicTableCapacity = qpackCapacity, // 0 (default) = headers stay literal + QpackBlockedStreams = qpackCapacity > 0 ? 100 : 0, // raise both together for the dynamic table +}; + +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, h3Options).RunStreamedResponseAsync(async (request, writer) => + { + 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 needs: read a chunk, write + // a chunk, and never hold more than one. Neither side can run away from the other + // - ReadAsync waits on the peer, FlushAsync waits on nghttp3 - so memory stays + // flat however large the exchange is. + writer.WriteHeaders(Plain()); + + while (true) + { + ReadOnlyMemory part = await request.BodyReader!.ReadAsync(); + if (part.IsEmpty) + { + break; // end of the request body + } + + part.Span.CopyTo(writer.GetSpan(part.Length)); + writer.Advance(part.Length); + await writer.FlushAsync(); + } + + await writer.CompleteAsync(); + return; + } + + if (upload) + { + // Read side only: 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. Every read + // credits the peer's flow-control window, which is what throttles a fast sender. + long total = 0; + while (true) + { + ReadOnlyMemory part = await request.BodyReader!.ReadAsync(); + if (part.IsEmpty) break; + total += part.Length; // a real app would parse or store the chunk here + } + + writer.WriteHeaders(Plain()); + + byte[] count = Encoding.ASCII.GetBytes($"{total}\n"); + count.CopyTo(writer.GetSpan(count.Length)); + writer.Advance(count.Length); + await writer.FlushAsync(); + await writer.CompleteAsync(); + return; + } + + // Headers first and once: HTTP/3 puts HEADERS before DATA and there is no correcting + // it later. No content-length - the length is not known when they go out. A GET + // arrives with an already-ended body reader, so there is nothing to drain. + writer.WriteHeaders(Plain()); + + for (int n = 0; n < chunkCount; n++) + { + chunk.CopyTo(writer.GetSpan(chunk.Length)); + writer.Advance(chunk.Length); + + // Returns once nghttp3 has taken the chunk. That await IS the backpressure - + // nothing queues up behind a peer that has stopped reading. + await writer.FlushAsync(); + } + + await writer.CompleteAsync(); + }); + + threads[i] = new Thread(reactor.Run) { Name = $"reactor-{i}" }; + threads[i].Start(); +} + +Console.WriteLine($"[nghttp3-streamed-both] {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(); +} + +static Nghttp3Response Plain() +{ + var response = new Nghttp3Response { Status = 200 }; + response.Headers.Add("content-type"u8.ToArray(), "text/plain"u8.ToArray()); + return response; +} diff --git a/Playground/README.md b/Playground/README.md index 6578e164..91a193a6 100644 --- a/Playground/README.md +++ b/Playground/README.md @@ -100,6 +100,7 @@ reference implementation. QUIC itself is always ngtcp2 + picotls, bundled as one | [`Http3.Nghttp3Request`](Http3/Nghttp3Request/Program.cs) | 220 | HTTP/3 on nghttp3 with **streamed** dispatch, and a `SIGTERM` GOAWAY drain. | `ioxide.ngtcp2`, `ioxide.nghttp3` | | [`Http3.Nghttp3Buffered`](Http3/Nghttp3Buffered/Program.cs) | 205 | The same server with **buffered** dispatch - one method call is the whole difference. | `ioxide.ngtcp2`, `ioxide.nghttp3` | | [`Http3.Nghttp3Response`](Http3/Nghttp3Response/Program.cs) | 120 | The other direction: a response body produced over time. | `ioxide.ngtcp2`, `ioxide.nghttp3` | +| [`Http3.Nghttp3StreamedBoth`](Http3/Nghttp3StreamedBoth/Program.cs) | 190 | Both directions at once on nghttp3 - `/echo` reads a chunk and writes a chunk. The fourth corner the other three leave empty. | `ioxide.ngtcp2`, `ioxide.nghttp3` | | [`Http3.Sni`](Http3/Sni/Program.cs) | 118 | A certificate per host name on QUIC, registered before the engine starts serving. | `ioxide.ngtcp2`, `ioxide.http3` | | [`Http3.Rotate`](Http3/Rotate/Program.cs) | 195 | Renewal on QUIC: one shared engine, so a single call covers every reactor - the contrast with `Http2/Rotate`. | `ioxide.ngtcp2`, `ioxide.http3` | | [`Http3.MutualTls`](Http3/MutualTls/Program.cs) | 122 | The client proves who it is during the QUIC handshake, and the handler is told which peer it got. | `ioxide.ngtcp2`, `ioxide.http3` | diff --git a/bench/samples.tsv b/bench/samples.tsv index dc55d2df..4168b1c1 100644 --- a/bench/samples.tsv +++ b/bench/samples.tsv @@ -55,6 +55,7 @@ Http2/Rotate h2 8443 / - PLAY Http3/Nghttp3Request h3 8443 / - - Http3/Nghttp3Buffered h3 8443 / - - Http3/Nghttp3Response h3 8443 / - PLAYGROUND_CHUNKS=8 PLAYGROUND_CHUNK_BYTES=1024 +Http3/Nghttp3StreamedBoth h3 8443 / - PLAYGROUND_CHUNKS=8 PLAYGROUND_CHUNK_BYTES=1024 Http3/ManagedBuffered h3 8443 / - - Http3/ManagedStreamedBoth h3 8443 / - PLAYGROUND_CHUNKS=8 PLAYGROUND_CHUNK_BYTES=1024 Http3/Sni h3 8443 / - - diff --git a/docs/assets/style.css b/docs/assets/style.css index 0df966c6..23c9a96e 100644 --- a/docs/assets/style.css +++ b/docs/assets/style.css @@ -231,6 +231,7 @@ nav.top .links a.gh svg { display: block; } #tab-h3mtls:checked ~ .ex-menu label[for="tab-h3mtls"], #tab-h3cs:checked ~ .ex-menu label[for="tab-h3cs"], #tab-h3stream:checked ~ .ex-menu label[for="tab-h3stream"], +#tab-h3ngboth:checked ~ .ex-menu label[for="tab-h3ngboth"], #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"], @@ -373,6 +374,7 @@ nav.top .links a.gh svg { display: block; } #tab-h3mtls:checked ~ .pane-h3mtls { display: block; } #tab-h3cs:checked ~ .pane-h3cs { display: block; } #tab-h3stream:checked ~ .pane-h3stream { display: block; } +#tab-h3ngboth:checked ~ .pane-h3ngboth { display: block; } #tab-h3buf:checked ~ .pane-h3buf { display: block; } #tab-quicalpn:checked ~ .pane-quicalpn { display: block; } #tab-qclient:checked ~ .pane-qclient { display: block; } @@ -520,6 +522,7 @@ nav.top .links a.gh svg { display: block; } #tab-h3mtls:checked ~ .ex-menu label[for="tab-h3mtls"], #tab-h3cs:checked ~ .ex-menu label[for="tab-h3cs"], #tab-h3stream:checked ~ .ex-menu label[for="tab-h3stream"], +#tab-h3ngboth:checked ~ .ex-menu label[for="tab-h3ngboth"], #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 f9ba4ed9..4e5218d4 100644 --- a/docs/index.html +++ b/docs/index.html @@ -50,6 +50,7 @@ + @@ -138,6 +139,7 @@ +
@@ -2535,6 +2537,172 @@

HTTP/3 · response streamed (nghttp3)

}

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 · request + response streamed (nghttp3)

+ ioxide + ioxide.ngtcp2 + ioxide.nghttp3 +
+
// dotnet add package ioxide
+// dotnet add package ioxide.ngtcp2
+// dotnet add package ioxide.nghttp3
+//   curl --http3-only -k https://127.0.0.1:8443/
+//   curl --http3-only -k --data-binary @big.bin https://127.0.0.1:8443/echo
+
+using System.Text;
+using ioxide;
+using ioxide.nghttp3;
+using ioxide.ngtcp2;
+
+// ── Knobs ────────────────────────────────────────────────────────────────────────────────────
+
+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;
+
+
+// Multishot recv slots per reactor. QPACK capacity 4096 advertises a decode-side dynamic table;
+// 0 is static-only, nghttp3's default.
+int  udpRecvSlots  = 16;
+long qpackCapacity = 0;
+
+
+// ─────────────────────────────────────────────────────────────────────────────────────────────
+
+const string certPath = "cert.pem";   // any PEM pair
+const string keyPath  = "key.pem";
+
+// The last argument bounds what one connection may retain unacknowledged, which is what keeps a
+// streamed response streaming instead of quietly buffering whole. See Playground/Http3/Nghttp3Buffered
+// for the full QUIC/h3 knob set.
+using var engine = new QuicEngine(certPath, keyPath, cidLength: 8, alpn: ["h3"], maxSendRetentionBytes: 16L << 20);
+
+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(),
+        // Where a moved client's packets go when several reactors share the port. Forward costs
+        // nothing until a client actually changes address; KernelFilter has the kernel route by
+        // connection id instead, which costs a little on every packet. See /how-ioxide-does-h3.
+        Routing = QuicRouting.Forward,
+    },
+};
+
+var h3Options = new Nghttp3Options
+{
+    QpackDynamicTableCapacity = qpackCapacity,                // 0 (default) = headers stay literal
+    QpackBlockedStreams       = qpackCapacity > 0 ? 100 : 0,  // raise both together for the dynamic table
+};
+
+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, h3Options).RunStreamedResponseAsync(async (request, writer) =>
+        {
+            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 needs: read a chunk, write
+                // a chunk, and never hold more than one. Neither side can run away from the other
+                // - ReadAsync waits on the peer, FlushAsync waits on nghttp3 - so memory stays
+                // flat however large the exchange is.
+                writer.WriteHeaders(Plain());
+
+                while (true)
+                {
+                    ReadOnlyMemory<byte> part = await request.BodyReader!.ReadAsync();
+                    if (part.IsEmpty)
+                    {
+                        break;   // end of the request body
+                    }
+
+                    part.Span.CopyTo(writer.GetSpan(part.Length));
+                    writer.Advance(part.Length);
+                    await writer.FlushAsync();
+                }
+
+                await writer.CompleteAsync();
+                return;
+            }
+
+            if (upload)
+            {
+                // Read side only: 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. Every read
+                // credits the peer's flow-control window, which is what throttles a fast sender.
+                long total = 0;
+                while (true)
+                {
+                    ReadOnlyMemory<byte> part = await request.BodyReader!.ReadAsync();
+                    if (part.IsEmpty) break;
+                    total += part.Length;   // a real app would parse or store the chunk here
+                }
+
+                writer.WriteHeaders(Plain());
+
+                byte[] count = Encoding.ASCII.GetBytes($"{total}\n");
+                count.CopyTo(writer.GetSpan(count.Length));
+                writer.Advance(count.Length);
+                await writer.FlushAsync();
+                await writer.CompleteAsync();
+                return;
+            }
+
+            // Headers first and once: HTTP/3 puts HEADERS before DATA and there is no correcting
+            // it later. No content-length - the length is not known when they go out. A GET
+            // arrives with an already-ended body reader, so there is nothing to drain.
+            writer.WriteHeaders(Plain());
+
+            for (int n = 0; n < chunkCount; n++)
+            {
+                chunk.CopyTo(writer.GetSpan(chunk.Length));
+                writer.Advance(chunk.Length);
+
+                // Returns once nghttp3 has taken the chunk. That await IS the backpressure -
+                // nothing queues up behind a peer that has stopped reading.
+                await writer.FlushAsync();
+            }
+
+            await writer.CompleteAsync();
+        });
+
+    threads[i] = new Thread(reactor.Run) { Name = $"reactor-{i}" };
+    threads[i].Start();
+}
+
+Console.WriteLine($"[nghttp3-streamed-both] {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();
+}
+
+static Nghttp3Response Plain()
+{
+    var response = new Nghttp3Response { Status = 200 };
+    response.Headers.Add("content-type"u8.ToArray(), "text/plain"u8.ToArray());
+    return response;
+}
+

The fourth corner the other three nghttp3 samples leave empty: the request pulled through BodyReader while the response is pushed through the writer, both in one handler. One call arranges it - RunStreamedResponseAsync dispatches at end-of-headers, so the handler is running while the upload is still on the wire. /echo is the shape a proxy needs: read a chunk, write a chunk, never hold more than one, and neither side can outrun the other because ReadAsync waits on the peer and FlushAsync waits on nghttp3. Measured here on a 64 MiB echo: byte-identical out, and the server's RSS moved 51→57 MB - flat, in the sense that matters. Diff it against : same routes, opposite mechanism underneath. nghttp3 owns the framing and pulls body bytes when it has room to emit DATA, so a flush here means nghttp3 has taken the chunk, where the managed writer stages a DATA frame the moment you flush. That twin also serves an endless /feed; this one deliberately does not, because on this stack an endless response never reaches the wire.

+

HTTP/3 · request + response streamed

diff --git a/ioxide.slnx b/ioxide.slnx index 8939779d..702f4bc6 100644 --- a/ioxide.slnx +++ b/ioxide.slnx @@ -82,6 +82,7 @@ + diff --git a/scripts/gen-docs-panes.py b/scripts/gen-docs-panes.py index 8391ad27..3b7738af 100644 --- a/scripts/gen-docs-panes.py +++ b/scripts/gen-docs-panes.py @@ -239,6 +239,26 @@ " carries a " "resume and a drain because nghttp3 pulls instead; this measures 1.32× its " "throughput on the same 8×1 KiB response."), + "h3ngboth": ( + "Http3/Nghttp3StreamedBoth", "HTTP/3 · request + response streamed (nghttp3)", + "ioxide + ioxide.ngtcp2 + ioxide.nghttp3", + ["curl --http3-only -k https://127.0.0.1:8443/", + "curl --http3-only -k --data-binary @big.bin https://127.0.0.1:8443/echo"], + "The fourth corner the other three nghttp3 samples leave empty: the request pulled through " + "BodyReader while the response is pushed through the writer, both in one " + "handler. One call arranges it - RunStreamedResponseAsync dispatches at " + "end-of-headers, so the handler is running while the upload is still on the wire. " + "/echo is the shape a proxy needs: read a chunk, write a chunk, never hold " + "more than one, and neither side can outrun the other because ReadAsync waits " + "on the peer and FlushAsync waits on nghttp3. Measured here on a 64 MiB echo: " + "byte-identical out, and the server's RSS moved 51→57 MB - flat, in the sense that " + "matters. Diff it against " + ": same routes, " + "opposite mechanism underneath. nghttp3 owns the framing and pulls body bytes when " + "it has room to emit DATA, so a flush here means nghttp3 has taken the chunk, " + "where the managed writer stages a DATA frame the moment you flush. That twin also serves " + "an endless /feed; this one deliberately does not, because on this stack an " + "endless response never reaches the wire."), "h3buf": ( "Http3/Nghttp3Buffered", "HTTP/3 · buffered (nghttp3)", "ioxide + ioxide.ngtcp2 + ioxide.nghttp3", ["curl --http3-only -k https://127.0.0.1:8443/"],