Skip to content

playground: the nghttp3 sample for both directions streamed at once - #217

Merged
MDA2AV merged 1 commit into
mainfrom
feat/h3-nghttp3-streamed-both
Aug 30, 2026
Merged

playground: the nghttp3 sample for both directions streamed at once#217
MDA2AV merged 1 commit into
mainfrom
feat/h3-nghttp3-streamed-both

Conversation

@MDA2AV

@MDA2AV MDA2AV commented Aug 30, 2026

Copy link
Copy Markdown
Owner

The nghttp3 HTTP/3 samples covered three of the four corners and not the fourth:

response buffered response streamed
request buffered Http3/Nghttp3Buffered Http3/Nghttp3Response
request streamed Http3/Nghttp3Request (nothing)

The pure-C# stack has that corner as Http3/ManagedStreamedBoth, and its pane on the site 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.

The sample

Same routes as the managed twin, so the two can be diffed line for line:

curl --http3-only -k https://127.0.0.1:8443/                                   # chunked down
curl --http3-only -k --data-binary @big.bin https://127.0.0.1:8443/upload      # pulled up
curl --http3-only -k --data-binary @big.bin https://127.0.0.1:8443/echo        # both at once

/echo is the point: read a chunk, write a chunk, never hold more than one. Neither side can outrun the other, because ReadAsync waits on the peer and FlushAsync waits on nghttp3.

Underneath, the mechanism is the opposite of the managed twin's, which is what makes having both worth it. 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; the managed writer stages a DATA frame the moment you flush.

Registered in ioxide.slnx, Playground/README.md and bench/samples.tsv, and on the site as h3 · request + response streamed (nghttp3), generated from the sample like every other pane.

Verified

Against the running sample with a single listener asserted (no leaked co-bound servers):

result
/ at 64 × 16 KiB 200, full 1,048,576 bytes
/upload of 64 MiB 200, counted 67,108,864 exactly
/echo of 64 MiB 200, byte-identical round trip in 0.37 s
server RSS across that echo 51 → 57 MB
after all of it still serving, reactors back to idle

No /feed here, unlike the managed twin

An endless response does not work on this stack, and this is worth its own issue rather than a workaround in a sample. On the shipped Http3/Nghttp3Response, one listener, nothing else bound:

/ (finite) /feed (endless) after the reader disconnects
ioxide.nghttp3 200, full body no headers, 0 bytes, client times out a reactor spins at ~100% CPU, every later request fails
ioxide.http3 200, full body 200, text/event-stream, 2.1 GB in 3 s keeps serving, back to idle

Likely mechanism, offered as a hypothesis rather than a finding: DrainStreamedCore returns only when the producer stops or PumpEgress reports nothing moved. An endless handler keeps staging chunks that nghttp3 keeps accepting, so produced never goes false, the loop never hands the thread back, and the reactor never gets to send — which is also why no headers arrive. The if (!produced) return; guard is commented as covering exactly this case and does not fire.

That is not this sample's to solve, and not its place to repeat, so every route here is bounded. It does mean Http3/Nghttp3Response's banner and its site pane currently tell you to run a request that wedges the server — worth fixing in one direction or the other, separately from this.

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.
@MDA2AV
MDA2AV merged commit 3390e0d into main Aug 30, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant