Skip to content

fix(quic): send a client's queued Finished ahead of its close - #5721

Merged
kixelated merged 8 commits into
mainfrom
quest/m1/test-flakes-2/uring-teardown-close
Oct 18, 2026
Merged

kixelated merged 8 commits into
mainfrom
quest/m1/test-flakes-2/uring-teardown-close

Conversation

@kixelated

@kixelated kixelated commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

a_published_close_has_already_left_the_client (moq-uring) hung until nextest's 60 s timeout under load (#5483's just check). Neither suspected cause was it: the close did leave the client, and no wake was lost.

The client QUIC stack raises Connected once it has processed the server's flight, before the server has the client's Finished (the last TLS handshake message, which the server needs to complete its side). The Finished may still be queued behind the pacer, or lost in flight. If the application closes in that gap, the close flight replaces the Finished. The server never completes its handshake and only reads the Initial and Handshake copies of the close, which RFC 9000 §10.2.3 strips of the application code (APPLICATION_ERROR, code 12). The incoming connection fails and the server's accept never resolves.

Reproduced under load on the pre-#5626 moq-uring worker, about once per 500 runs. Since #5664 both moq-uring and moq-tokio run on moq-quic, so both backends share the race.

Approach

In moq-quic: until a client discards its Handshake keys, a close carries every unacknowledged Handshake CRYPTO frame (queued or in flight, normally just the Finished) in its Handshake packet, in place of the Initial and Handshake closes. The server completes its handshake on the Finished and reads the 1-RTT close coalesced behind it, application code intact. Nothing is dequeued, so a close resent after a lost flight (in answer to the server's retransmitted handshake) carries it again.

It is all or nothing: the CRYPTO must fit in the close packet with room left to coalesce the 1-RTT close, or the client sends the plain Handshake close as before, since part of the flight would not let the server complete either. Nothing else about close changes: a close with no unacknowledged Finished, and every server close, still go out in every space as before.

The room for the 1-RTT close matters: without it, a client certificate flight that fit the packet but crowded out the close made every poll_transmit with a one-datagram budget (moq-tokio without GSO, a short moq-uring buffer) resend the Handshake packet and never reach the close, flooding the peer until the drain timer.

Regression

Deterministic moq-quic tests: the client processes the server's flight without transmitting, closes on Connected, and the server must see Connected then the application close.

  • close_before_the_client_finished_leaves: Finished still queued. Without the fix the server sees ConnectionClosed(APPLICATION_ERROR) and never connects.
  • close_after_the_client_finished_is_lost: the datagram carrying the Finished is dropped before the close. Fails if only the queued Finished is carried.
  • close_before_the_client_finished_leaves_survives_a_lost_flight: the first close flight is dropped. Fails if the Finished is only sent once.
  • close_before_a_client_certificate_leaves_ends: sweeps an mTLS client certificate's size across the close packet's room, polling with a one-datagram budget. Each close must leave in one datagram, and a server that completes must read the application close. Without the room check, a 512-byte padded certificate takes 3+ datagrams and never sends the 1-RTT close.

The first revision's moq-uring relay test (a_close_right_after_dialing_follows_the_handshake) is dropped: on current main, without any fix, it passes 5/5, because moq-quic's pacing no longer holds the Finished on that path. It no longer guards anything, and it depended on wall-clock sleeps.

Validation

Impact

  • Public API: none.
  • Wire: a client that closes before the server has its Finished now sends [Handshake: ACK, CRYPTO(Finished)] + [1-RTT: CONNECTION_CLOSE] instead of [Initial: CONNECTION_CLOSE] + [Handshake: CONNECTION_CLOSE] + [1-RTT: CONNECTION_CLOSE]. Standard QUIC; any peer handles it.

Alternatives / Decisions

Settled by the iterating agents on the recommended option (no maintainer prompt was available); reopen any you disagree with.

  1. Where to fix. feat(quic)!: run moq-tokio and moq-uring on moq-quic #5664 brought the QUIC stack in-repo, which reopened this.
    • ✅ (a) moq-quic: the close flight carries the unacknowledged Finished. Fixes moq-uring and moq-tokio at the source, and covers a lost Finished.
    • (b) Keep the moq-uring driver gate and port it to moq-tokio. Two copies of a workaround, and neither covers a lost Finished.
    • (c) Delay moq-quic's Connected event until the Finished is staged. Also misses a lost Finished, and the event would be raised from poll_transmit, which both drivers would need restructuring to notice.
  2. Regression.
    • ✅ (a) Deterministic moq-quic unit tests, dropping the relay test that no longer reproduces.
    • (b) Keep both.
  3. A client flight larger than one packet (an mTLS client certificate chain; both backends support client certificates).
    • ✅ (a) Fall back to the plain Handshake close, as before this PR. Such a client that closes before the server has its flight still loses the application code.
    • (b) Split the flight across several close packets. Covers mTLS, at the cost of a multi-packet close path.
  4. Conflict with feat(quic)!: run moq-tokio and moq-uring on moq-quic #5664 and refactor(quic)!: send options for streams and datagrams, private send domains #5872. Resolved: both merged first; this PR changes only moq-quic's close path and its tests.
  5. A flight that fits the packet but not the coalesced 1-RTT close (found while iterating).
    • ✅ (a) Fall back to the plain close, the same as 3(a). One size check, no new path.
    • (b) Carry the flight and send the 1-RTT close in a later datagram. Needs the close path to remember which spaces it already sent across poll_transmit calls.

Follow-ups

  • 3(b) if mTLS clients need the application code on an immediate close; 5(b) would come with it.
  • The same race likely exists in upstream quinn-proto, which moq-quic forked. Offering the fix upstream needs maintainer approval.

(Written by Claude Opus 5.5)

🤖 Generated with Claude Code

kixelated and others added 2 commits October 16, 2026 07:44
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
noq raises Connected on the client with its Finished still queued, and
paces it like any other packet. Under load the first RTT sample is long,
so the pacer can hold the Finished just as `connect` resolves. A close in
that gap replaced it: the server never completed its handshake, saw only
a handshake-level close without the application code, and its `accept`
never resolved. That is the 60 s hang in the teardown close test.

`connect` now resolves once the client has staged one more CRYPTO frame
than it had at Connected. A new teardown test reproduces the gap on an
idle host through a relay that slows the server-to-client leg.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kixelated

Copy link
Copy Markdown
Collaborator Author

Outcome: the 60 s hang was reproduced under load (on the pre-#5626 worker, about 1 in 500 runs). The cause was a close that took the place of the client's paced Finished, so the server's accept never resolved. Neither suspected cause was it: no CONNECTION_CLOSE was lost and no wake was missed. The fix gates connect on the staged Finished, and a deterministic relay-based regression covers it. Three open decisions are in the description; this stays a draft until they are settled.

(Written by Claude Opus 5.5)

kixelated and others added 2 commits October 16, 2026 17:50
# Conflicts:
#	quest/m1/test-flakes-2/README.md
#	quest/m1/test-flakes-2/uring-teardown-close.md
#	rs/moq-uring/src/quic/driver/connection.rs
A client is Connected with its Finished only queued, and pacing can hold
it there. A close in that gap replaced it: the server never completed
its handshake and only read an Initial or Handshake close, which carries
no application code, so its accept never resolved. That was the 60 s
hang in moq-uring's teardown close test, and moq-tokio shares it.

The close flight now carries the queued Finished in its Handshake packet
instead of a handshake-level close, and the server reads the 1-RTT close
behind it. This replaces the moq-uring driver gate, which only covered
one backend.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kixelated kixelated changed the title fix(uring): resolve a dial only once the client Finished is staged fix(quic): send a client's queued Finished ahead of its close Oct 17, 2026
@kixelated
kixelated marked this pull request as ready for review October 17, 2026 01:13
@coderabbitai

coderabbitai Bot commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 6 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used all 4 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: d33eaa86-5cde-4301-90c1-057f8b656d39
📥 Commits

Reviewing files that changed from the base of the PR and between cc5eb61 and 6827e4f.

📒 Files selected for processing: 5
  • quest/m1/test-flakes-2/README.md
  • quest/m1/test-flakes-2/uring-teardown-close.md
  • rs/moq-quic/src/connection/mod.rs
  • rs/moq-quic/src/connection/spaces.rs
  • rs/moq-quic/src/tests/mod.rs
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 17, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-18T04:29:53.709775Z 6827e4f New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2edc2a34ca

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread rs/moq-quic/src/connection/mod.rs Resolved
A close resent after the first flight was lost carried a Handshake close
instead, since the Finished had been taken off the queue. Leave it queued
so every close flight carries it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 059bd6d08a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread rs/moq-quic/src/connection/mod.rs Resolved
Comment thread rs/moq-quic/src/connection/mod.rs Outdated
Comment on lines +886 to +889
if buf.len() + frame::Crypto::SIZE_BOUND + frame.data.len()
> builder.max_size
{
break;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Fragment Finished data to fit the close packet

When a queued Handshake CRYPTO frame does not fit intact, this branch emits none of it and continues with the 1-RTT close. This occurs with a custom TLS client whose client-certificate flight exceeds one packet, and can also occur when the preceding ACK consumes the space that was reserved only for a CONNECTION_CLOSE; unlike populate_packet, this loop never fragments the CRYPTO frame. The server therefore still cannot complete its handshake, so this path must prioritize and split the queued CRYPTO data across packets.

Useful? React with 👍 / 👎.

@kixelated kixelated Oct 17, 2026 •

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partly agreed, fixed in c5136b6 without fragmenting. Including part of the flight is worse than today: the server still cannot complete its handshake, and it no longer gets a Handshake close either, so it would idle out. So it is now all or nothing: if the unacknowledged CRYPTO does not fit in the close packet after its ACK, the client sends the plain Handshake close as before.

That leaves one gap: an mTLS client (moq-tokio and moq-uring both support client certificates) whose Certificate flight exceeds one packet and that closes before the server has it still loses the application code, exactly as before this PR. Splitting it across several close packets is a larger change to the close path; I have left it to the maintainer as a follow-up decision rather than grow this PR.

(Written by Claude Opus 5.5)

kixelated and others added 3 commits October 16, 2026 18:47
A Finished already sent but lost left the same gap: the close went out
as a Handshake close the server cannot complete its handshake from. The
close flight now carries every unacknowledged Handshake CRYPTO frame,
queued or in flight, until the client discards its Handshake keys. A
flight too big for one packet keeps the plain close, since part of it
would not complete the handshake either.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A client flight that fit the close packet but not the coalesced 1-RTT
close made a one-datagram poll resend the Handshake packet forever.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts:
#	quest/m1/test-flakes-2/README.md
@kixelated

Copy link
Copy Markdown
Collaborator Author

Merging at 6827e4f: main merged after #5872 (one quest README conflict). Iterating found a close flood the head introduced, where a client flight fit the close packet but left no room to coalesce the 1-RTT close; fixed in 774045d by falling back to the plain close (decision 5a, approved by the maintainer), with a certificate-size sweep regression test. Codex thumbs-up after the push; CI green; moq-quic 521/521; moq-uring teardown 20/20 under stress.

(Written by Claude Opus 5.5)

@kixelated
kixelated added this pull request to the merge queue Oct 18, 2026
Merged via the queue into main with commit 54d36fc Oct 18, 2026
12 checks passed
@kixelated
kixelated deleted the quest/m1/test-flakes-2/uring-teardown-close branch October 18, 2026 05:19
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