Skip to content

h3: tell the peer why an nghttp3 connection died, and use the codes RFC 9114 defines - #208

Merged
MDA2AV merged 2 commits into
mainfrom
fix/nghttp3-close-code
Aug 19, 2026
Merged

h3: tell the peer why an nghttp3 connection died, and use the codes RFC 9114 defines#208
MDA2AV merged 2 commits into
mainfrom
fix/nghttp3-close-code

Conversation

@MDA2AV

@MDA2AV MDA2AV commented Aug 19, 2026

Copy link
Copy Markdown
Owner

Closes #206.

A request to an HTTP/3 server on the native stack could hang forever. Nghttp3Connection had exactly one Close call, and it was gated on !_protocolFailed — so a protocol failure could never reach it by construction. The failure was recorded, the run loop broke, and the finally did DecRef and Dispose, neither of which puts anything on the wire.

Nothing rescued the client either. The shim sets no max_idle_timeout, and the transport's 60 s sweep is refreshed by any inbound datagram — including ones ngtcp2 discards — so a peer sending ACKs or PINGs holds a dead connection open indefinitely.

The pure-C# stack fixed this in 1214005. The stack the README calls its drop-in replacement did not get it, which is the recurring shape on this branch: one behaviour, two implementations, a fix landing on one side.

The code comes from nghttp3

nghttp3_err_infer_quic_app_error_code already owns the mapping — which of its errors are H3_FRAME_ERROR, H3_MESSAGE_ERROR, QPACK_DECOMPRESSION_FAILED, and that anything unrecognised is H3_INTERNAL_ERROR. The shim gained a four-line export for it rather than a table maintained here. We vendor these libraries; re-deriving what they already know is how the two wrong NGTCP2_ERR_* constants got introduced earlier in this release.

Eleven sites, not the two that were easy to find

The issue described PushToEngine. grep found eleven places that fail a connection. Four carry a library error and go through FailProtocol; five are our own failure — a control stream that would not open, a callback that threw — and take H3_INTERNAL_ERROR through FailInternal.

The twelfth site is deliberately untouched

This is the part worth reviewing. Streamed.cs sets _protocolFailed in a finally on a response that completed perfectly well, purely to unpark waiters — IsBroken is what makes them return rather than loop.

Keying the close on that flag would have sent an error code to every client that received a streamed body successfully. So what the peer is told is tracked separately as a nullable code, and the flag keeps meaning "stop looping". The comment at that site says so, because it looks like an oversight.

Two RFC codes were wrong, and two tests pinned them that way

RFC 9114 §8.1 splits the two by why a frame is wrong: H3_FRAME_UNEXPECTED (0x0105) is a frame not permitted in the current state or on the current stream; H3_FRAME_ERROR (0x0106) is one whose layout or size is invalid.

  • A DATA frame before HEADERS is well formed and merely too early. §4.1 names that exact case as H3_FRAME_UNEXPECTED.
  • SETTINGS on a request stream is a legal frame — on the control stream.

Both sent 0x0106, and both had passing tests asserting it. Both now send 0x0105, and both tests say why they changed rather than silently flipping a constant.

Verification

The nghttp3 test was already in the tree as a Pending from the review round. It flipped on the fix, the runner failed the run to say so, and it is now a Test.

Native rebuilt from the pinned refs with the BuildID verified into the test output before any result was believed. All suites: 420 passed, 0 failed, 19 pending.

MDA2AV added 2 commits August 19, 2026 14:00
…FC 9114 defines

Closes #206.

Nghttp3Connection had exactly one Close call, gated on `!_protocolFailed` - so a protocol failure
could never reach it by construction. PushToEngine recorded the failure, the run loop broke, and
the finally did DecRef and Dispose, neither of which puts anything on the wire. The client's
request simply hung: the shim sets no max_idle_timeout, and the transport's 60 s sweep is refreshed
by ANY inbound datagram including ones ngtcp2 discards, so a peer sending ACKs holds a dead
connection open indefinitely. The pure-C# stack fixed this in 1214005; the stack the README calls
its drop-in replacement did not get it.

The code comes from nghttp3 rather than from a table here. It already owns the mapping -
nghttp3_err_infer_quic_app_error_code knows which of its errors are H3_FRAME_ERROR,
H3_MESSAGE_ERROR or QPACK_DECOMPRESSION_FAILED, and returns H3_INTERNAL_ERROR for what it does not
recognise - so the shim gained a four-line export for it instead. Same principle as everywhere else
here: we vendor these libraries, we do not re-derive what they already know.

All ELEVEN sites that fail a connection are routed, not the two that were easiest to find. Four
carry a library error and go through FailProtocol; five are our own failure - a control stream that
would not open, a callback that threw - and take H3_INTERNAL_ERROR through FailInternal.

The twelfth site is deliberately left alone, and that is the part worth reading. Streamed.cs sets
_protocolFailed in a finally on a response that completed PERFECTLY WELL, purely to unpark waiters,
because IsBroken is what makes them return rather than loop. Closing on that flag would have sent
an error code to every client that received a streamed body successfully. So what the peer is told
is tracked separately, as a nullable code, and the flag keeps meaning "stop looping".

Two codes in the pure-C# stack were also wrong, both pinned by tests asserting the wrong value.
RFC 9114 section 8.1 splits them by WHY a frame is wrong: UNEXPECTED (0x0105) is one not permitted
in this state or on this stream, ERROR (0x0106) is one whose layout or size is invalid. A DATA
frame before HEADERS is well formed and merely too early - section 4.1 names that exact case as
H3_FRAME_UNEXPECTED - and SETTINGS on a request stream is legal on the control stream. Both now
send 0x0105, and both tests say why they changed.

The nghttp3 test for this was already in the tree as a Pending; it flipped on the fix and is now a
Test. All suites: 420 passed, 0 failed, 19 pending.
@MDA2AV
MDA2AV merged commit 8d8eb66 into main Aug 19, 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.

h3/nghttp3: a protocol error closes the connection without a code, so the peer hangs

1 participant