Skip to content

http3: closing or resetting the peer's control or QPACK stream ends the connection with H3_CLOSED_CRITICAL_STREAM instead of being ignored - #382

Open
MDA2AV wants to merge 1 commit into
mainfrom
fix/351-h3-control-stream-close
Open

MDA2AV wants to merge 1 commit into
mainfrom
fix/351-h3-control-stream-close

Conversation

@MDA2AV

@MDA2AV MDA2AV commented Oct 17, 2026 •

Copy link
Copy Markdown
Owner

Fixes #351.

What was wrong. ioxide.http3 kept serving after the client closed or reset one of its critical streams. RFC 9114 6.2.1 makes closing a control stream "at any point" a connection error of type H3_CLOSED_CRITICAL_STREAM. RFC 9204 4.2 says the same of the QPACK encoder and decoder streams. Two paths swallowed it:

  • FeedUni treated the control stream's FIN as the end of its record (_unis.Remove) and kept serving. A QPACK stream's FIN did not even get that far: the non-control branch returned first.
  • Feed's lifecycle branch dropped the record on a RESET_STREAM the same way.

What changed. UniStream gets one bool, IsCritical, set when the stream type is read: control (0x00), QPACK encoder (0x02) or decoder (0x03). Two places now call Fatal("critical stream closed", H3_CLOSED_CRITICAL_STREAM) for a critical stream:

  • a FIN in FeedUni;
  • any lifecycle item in Feed.

So the run loop ends and Close(0x104) reaches the peer. The if (fin) _unis.Remove(sid) at the end of FeedUni is gone. Only the control stream ever reached it, and its FIN is now fatal before that point.

A reserved-type (grease) stream may still close or reset. So may a stream that ends before its type arrives, which RFC 9114 6.2 requires tolerating.

Proof. H3ErrorCodeTests had 17 tests. Its critical-stream group moves to a new H3CriticalStreamTests, which leaves it at 15. The new file reuses the recording transport: RecordingQuic, StartPure, AssertClosedWith, Served, WellFormedGet and the code constant go from private to internal.

  • http3/codes: closing the peer's control stream is a connection error H3_CLOSED_CRITICAL_STREAM: the Pending, promoted.
  • http3/codes: resetting the peer's control stream is a connection error H3_CLOSED_CRITICAL_STREAM: new.
  • http3/codes: closing or resetting a QPACK encoder or decoder stream is a connection error H3_CLOSED_CRITICAL_STREAM: new, four cases.
  • Controls:
    • control - a client control stream is parsed and requests still serve (moved).
    • control - a uni stream of another type may close or reset, and requests still serve (new). It covers a grease FIN, a grease reset, and a FIN inside the type varint; the GET is still served and nothing is closed.
  • h3/codes: nghttp3 - closing or resetting the peer's control stream ends the connection with H3_CLOSED_CRITICAL_STREAM: pins the other stack (below).

Run both ways:

  • Without the fix (source reverted, tests kept): the three critical-stream tests fail with expected the connection to close with H3_CLOSED_CRITICAL_STREAM (0x104), got no Close at all. Both controls and the nghttp3 test pass.
  • With the fix: all pass. The E2E output has exactly six protocol error: critical stream closed lines, all from those tests. No other test produced one, so a normal connection teardown does not trip it.

nghttp3 was already right, and this pins it. Its read_stream returns NGHTTP3_ERR_H3_CLOSED_CRITICAL_STREAM for a FIN on any of the three streams. For a reset, Nghttp3Connection passes on the Closed item that follows it, and nghttp3_conn_close_stream refuses any typed uni stream with the same error. Both reach FailProtocol, which closes with 0x104.

Suites.

  • E2E: 292 passed, 5 pending on main → 297 passed, 0 failed, 0 skipped, 4 pending.
  • Http: 88 passed, same as main.
  • Chaos: 79 passed, 1 pending, same as main.
  • ioxide.http3 builds for net10.0 and net11.0 with no warnings.

Overlap. Merges cleanly with #383 (issue #350). Both touch Http3Connection.cs, in different hunks. #381 (issue #352) also touches H3ErrorCodeTests.cs: it adds an nghttp3 ALPN test there, while this PR moves the critical-stream group out and makes the shared helpers internal. The two still merge cleanly, and H3ErrorCodeTests ends at 16 tests once both land. Checked with git merge-tree against every other PR of this batch (#378, #379, #381, #376, #377, #380): all merge cleanly. #350 + #351 + #352 merged together build, and E2E passes there: 305 passed, 0 failed, 2 pending (the #195 and #349 tests).

All eight together. This batch's PRs (#376 to #383) were merged onto main 71aab91 in one scratch tree. The only conflict is the binary libioxide_ngtcp2.so between #376 and #377, rebuilt there from the merged shim with the script's defaults. Everything builds, and Unit 102, E2E 309, Http 88, Chaos 80, Tls 169 (14 kTLS cases skipped) and File 5 pass, with 0 failed and 0 pending. The repo's only pending test left is the Redis suite's (#336).

Noticed, not changed:

  • A STOP_SENDING for our own control stream is not treated as closing it. The connection does not keep that stream's id.
  • None of these is refused:
    • a second control stream or a second QPACK encoder/decoder stream (H3_STREAM_CREATION_ERROR, RFC 9114 6.2.1 and RFC 9204 4.2);
    • a client-opened push stream (H3_STREAM_CREATION_ERROR, RFC 9114 6.2.2);
    • a control stream whose first frame is not SETTINGS (H3_MISSING_SETTINGS, RFC 9114 6.2.1).

…he connection with H3_CLOSED_CRITICAL_STREAM instead of being ignored

This branch has not been deployed

No deployments
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.

http3: closing or resetting the peer's control stream is ignored instead of H3_CLOSED_CRITICAL_STREAM

1 participant