Repository navigation
fix(quic): close state lives in one phase per transport - #8208
Conversation
Quest-Claim: 1958a8b567ff3ca3
The reconnect state keeps one Lifecycle enum instead of a status, an ended flag, a session, an error, and a failure, so ending the loop no longer leaves a trailing Disconnected for status() to report before the terminal error. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The endpoint keeps one Lifecycle (Running, Closed, DriverLost) instead of a driver_lost flag beside a close option, and every entry point checks it. Endpoint::rebind is test-only: after a close it bound a port nothing read. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
moq-tokio's driver latches the terminal error like moq-uring and qmux: a local close resolves Connection::closed only once its CONNECTION_CLOSE is on the socket, and a driver that stops publishes why. moq-net's lite revoke guards count a close this side sent as the session ending, rather than waiting for a backend to report its close delivered. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The closed latch, the dead flag, the cached local close, and the driver's close_staged flag become one End enum (Open, Closed, Dead); a staged CONNECTION_CLOSE publishes at once and the reason is read from moq-quic. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A STOP_SENDING that beat `finish` left `SendStream.fin` set, so the drop skipped the RESET_STREAM that RFC 9000 section 3.5 requires. The peer never learned the final size and never returned the stream's credit: every such stream leaked one slot of the peer's MAX_STREAMS. The drop now echoes a stop's code like moq-tokio and moq-uring do, and the send and receive handles each keep one end enum instead of two flags. The session no longer caches a local close beside the connection, which already keeps its first reason: the driver publishes `conn.error()` once the close is delivered. The close deadline lives in the driver's phase, not a timer field beside it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The QMux connection kept its end in `error`, `close`, `close_sent`, and a
borrowed QUIC `State` it set to `Established` while still handshaking,
only so streams could ask whether it closed. `End { Open, Closing {
error, frame }, Closed }` replaces the four. The peer's parameters stay
an `Option` beside it: they outlive the end, which still reads them for
the record budget and the datagram size.
Streams now take the one bit they read, `closed`, rather than a
reference to a connection state.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`Serving` kept `counted` beside `closed`, and `closed()` moved the session
from open to ending on every call, so a second call underflowed the open
tally. One `Count { Uncounted, Open, Ending(Instant) }` replaces both, and
only the first close counts.
The cluster's `DialersPhase` kept its open state and its shutdown in two
options, exactly one of them set; it is now an enum of the two.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
moq-tokio's WebTransport `Session` kept the session ID, the accept state,
the stream and datagram headers, the settings, the CONNECT stream, the
request, and the response as `Option`s or empty values, all set for
WebTransport and all unset for raw QUIC, with the raw ALPN beside them.
`Mode { Raw { alpn }, Web(Arc<Web>) }` replaces them.
moq-uring already grouped most of it in `Web`; the request and response
move in too, and the raw ALPN is read from the connection, which keeps it.
The web `poll_closed` doc no longer contrasts with `Connection::closed`,
which now also waits for a local close to be sent.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…together
`PathData` kept `challenge: Option<u64>` beside `challenge_pending`, and
sending a previous path's challenge expected the token whenever the bit
was set. `Option<Challenge { token, pending }>` makes a pending packet
without a token unrepresentable.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The connection kept `close: bool` beside its `State`, meaningful only in `Closed` and `Draining`, so every move into `Draining` inherited it silently. `Closed` and `Draining` now carry `send_close`, and each move says whether a close is due: the reply to a peer's close, the one left over from closing, or none. `draining_reported` stays: a server's first Initial can carry a CONNECTION_CLOSE and reach `Draining` without the report, so the state cannot stand in for it. Its doc now says so. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A receive stream kept `stopped` on the stream and `evicted` on its lease,
an eviction setting both. One `Reader { Reading, Evicted, Stopped }` on
the stream replaces them, and the lease's credit accounting takes the
eviction from the caller.
A send stream kept `fin_pending` beside `SendState`, set only while
`DataSent`; it is now a field of `DataSent`. The peer's `stop_reason` and
the application's `released` stay beside the state: either can come in
any of our states, and the stop outlives our reset to report
`End::Stopped`.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts: # rs/moq-quic/src/fuzz.rs
Clippy's large_enum_variant: `Offline` held the predecessor session inline. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
moq-tokio inferred that a sent datagram was the CONNECTION_CLOSE from the connection's error, so retrying a datagram the socket held from before a local close resolved closed() while the close itself was still unsent. The buffered transmit now records whether it carries the close. moq-uring's publish woke the driver even for an end already published, and the driver republishes on every poll, so a remote close spun it through the drain. Only the first publish wakes anyone now. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
transmit sent the PATH_CHALLENGE a migration owed the previous path before it checked for a close, so a connection closed right after migrating sent the probe first. RFC 9000 §10.2.1 limits a closing endpoint to CONNECTION_CLOSE, and the drivers mark a transmit made after close as the close itself, so moq-tokio and moq-uring resolved closed() on the probe while the close could still sit unsent behind a blocked socket. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Pins the quest's goal through the public entry points: once closed, or once its driver is gone, an endpoint neither dials nor accepts again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Warning Review limit reachedYou'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 26 seconds. View limit details
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. |
kixelated
left a comment
There was a problem hiding this comment.
Automated review by review (OpenAI)
Reviewed head 8150dc6 against base 4a86602.
No new actionable findings from static review of the lifecycle transitions, delayed local-close publication, buffered-transmit tagging, stopped-stream reset/credit handling, and relay close accounting. The added regression tests cover the identified close-ordering and double-close cases.
Verification limits: GitHub source/diff review only; I did not run builds, tests, simulations, or benchmarks. The PR reports an unrelated qlog accounting-test flake, which this review does not independently resolve or verify.
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
- Reconnect loop (
rs/moq-tokio/src/connection.rs):Lifecycle { Connecting, Live, Offline, Closed }replaces the status/ended/error/session/version/transport/predecessor cluster, so a loop that gives up publishesClosedand its error without a trailingDisconnected;StatusandObservationnow derive from it. - QUIC endpoint (
rs/moq-tokio/src/quic/driver/endpoint.rs):Lifecycle { Running, Closed, DriverLost }gatesaccept,connect,rebind, andclose_now;rebindand itsSlot/event plumbing are test-only, which is where the released-port bug came from. - QUIC connection close latch (
rs/moq-tokio/src/quic/driver/connection.rs): a driver-publishedclosedlatch resolvespoll_closedonly once the CONNECTION_CLOSE is on the socket (or the drain ends for a withheld close), publishes why a stopped driver ended, and keeps a buffered pre-close datagram from counting as the close. moq-quicconnection and streams:Closed/Drainingcarrysend_close, a path's challenge carriestoken/pending, a receive stream has oneReaderphase, andtransmitdrops a pending PATH_CHALLENGE once closing (RFC 9000 §10.2.1), fixing both drivers at the source.- QMux mux connection and
rs/qmux:Endreplaceserror/close/close_sent/the borrowedConnectionState;SendEnd/RecvEndreplace the FIN/reset flags, and dropping a peer-stopped stream now echoes its code in RESET_STREAM so the peer returns the credit; the driver'sPhaseabsorbsTimer.closing. moq-uring,moq-tokioweb sessions,moq-relay,moq-net:End { Open, Closed, Dead }; oneModefor raw vs WebTransport;Countso a second close no longer underflows the drain tally; andpoll_endingso a local close this side sent reads as ending before a backend reports delivery.- Quest:
transport-close.mdis deleted, its## Requiredline is gone from the epic README, and therebinddecision now records test-only as done (consistent withjust api-diff rust).
I traced the close paths across the three backends (sent vs staged vs drained), the evicted/stopped lease accounting, the QMux stop-then-finish credit path, and the migration/predecessor transitions; the new tests pin each fixed bug, and the mocked-clock/real-socket split matches REVIEW.md.
deepseek-v4.1-flash-1017 (free via Pullfrog for OSS) | 𝕏

Finishes quest/m1/explicit-state/transport-close.md, a child of the explicit-state epic, and deletes it.
Problem
The Rust connection and QUIC transport code kept close state spread across flags and options beside each other, the epic's implicit-state shapes. That hid real bugs:
Disconnected. Once moq-tokio's reconnectingConnectiongave up,status()yieldedDisconnectedbefore the terminal error:Shared::endwroteDisconnectedin the same revision that setended, contradictingpoll_status's doc.rebindafter close. moq-tokio's QUICEndpoint::rebindchecked neither a lost driver nor a close, and refilled a released socket slot, binding a port nothing reads.closed()disagreed across backends. For a local close, moq-tokio resolvedclosed()at once while moq-uring and qmux waited until the CONNECTION_CLOSE was staged. A moq-tokio caller that stopped its runtime the momentclosed()resolved could leave the close unsent, so the peer waited for its idle timeout.finish()setfin, so the drop skipped the RESET_STREAM that RFC 9000 §3.5 requires. The peer never learned the final size and never returned the credit, so every such stream leaked a slot of the peer's MAX_STREAMS.Serving::closed()moved a session from open to ending on every call, so a second call underflowed the drain tally.Approach
Each piece of close state now lives in one enum, and every bug above is fixed with a regression test.
connection.rs):Lifecycle { Connecting { failure }, Live, Offline, Closed }replaces the status,endedflag, session, error, and failure.StatusandObservationderive from it, so ending the loop goes straight toClosed. The draining predecessor is boxed (clippy'slarge_enum_variant).Lifecycle { Running, Closed { error_code, reason }, DriverLost }replacesdriver_lostbeside a close option, and every entry point checks it.rebindis crate-private, as the maintainer decided, and test-only.Mode { Raw { alpn }, Web(Arc<Web>) }replaces the WebTransport-only fields, kept asOptions or empty values that were all set or all unset together.End { Open, Closed, Dead }replaces the closed latch, the dead flag, the cached local close, andclose_staged. Only the first publish wakes anyone. The WebTransport request and response move intoWeb, and the raw ALPN is read from the connection.SendEnd { Open, Finished, Reset }andRecvEnd { Open, Reset(code), Stopped }replace thefinandresetflags. A stopped stream's drop echoes the stop's code in a RESET_STREAM, like moq-tokio and moq-uring. The session no longer caches a local close beside the connection, and the driver'sPhase { Open, Flushing, Closing, Done }holds the close deadline thatTimer.closingduplicated.mux):End { Open, Closing { error, frame }, Closed }replaceserror,close,close_sent, and a borrowed QUICStatethat claimedEstablishedwhile handshaking. Streams take the oneclosedbit they read.ClosedandDrainingcarrysend_close, replacing aclose: boolthat every move intoDraininginherited silently.Option<Challenge { token, pending }>.Reader { Reading, Evicted, Stopped }replacesstoppedand the lease'sevicted.fin_pendingmoves intoSendState::DataSent.transmitskips a pending PATH_CHALLENGE once closed, which fixes both drivers at once.Count { Uncounted, Open, Ending(Instant) }replacescountedbesideclosed, so only the first close counts. The cluster'sDialersPhaseis an enum of its open state and its shutdown.Impact
Connection::closed()/poll_closed: for a local close, these now resolve once the CONNECTION_CLOSE is on the socket rather than at once, matching moq-uring and qmux. A close held behind a blocked socket still resolves by the end of the closing period. Stream and datagram operations still fail as soon asclose()is called.Connection::status()/poll_status/state_changed, and moq-ffi'sMoqConnectionStatederived from them: a loop that gives up reportsClosedand its error next, never a trailingDisconnected.Endpoint:rebindis test-only. It was added after the last release, sojust api-diff rustshows no break. A closed or driverless endpoint still refusesconnectandaccept, and a secondclosekeeps the first close's code and reason.SendStream: dropping a stream the peer stopped now sends RESET_STREAM with the stop's code, which returns the peer's stream credit.finish()after a stop still returnsStreamStop.just api-diff rust, and no wire format change.Alternatives
closed()at once on every backend (moq-tokio's old behavior). Simpler, but a caller that exits on resolve can drop the CONNECTION_CLOSE, so the peer waits out its idle timeout.rebindafter close. The maintainer chose making it test-only instead (2026-11-07).Phase { Handshake, Established(Params), Closing, Closed }. The peer's parameters outlive the end: the record budget andmax_datagram_size()read them during a close. Folding them intoEstablishedwould change moq-net's datagram size mid-close.Lifecycle { Running, Closing, Released, DriverLost }. The socket slot already records its own release, and withrebindtest-only nothing refills it, so oneClosedcovers both closing and released.Choices made without asking
closed()latches on every backend: a local close resolves once the close is sent or staged. The quest asked to settle on one behavior, and this is the one that never drops the close.rebindstays in this PR rather than splitting into an m2 quest. It is unreleased (absent fromjust api-diff rust), so it breaks no published surface. The epic's decision bullet now says so.peer: Option<Params>besideEnd, andparams_sent, which only records whether our own parameters went out.draining_reported: a server's first Initial can carry a CONNECTION_CLOSE and reachDrainingwithout the report, so the state cannot stand in for it.stop_reasonandreleasedbesideSendState: either can arrive in any of our states, and the stop outlives our reset soEnd::Stoppedcan report it.validatedbeside its challenge: a path is validated by the handshake or a PATH_RESPONSE, and stays validated after its challenge clears.Lifecycle, not the quest'sPhase, sincePhaseis already the public observation enum.rebindmention is unchanged: it describes a fork prototype, not this API.Caused by
Disconnected: moq-dev/moq#6487, whoseShared::endcalledstate.offline()in the ending revision.rebindrefilling a released slot: moq-dev/moq#7045, which made the endpoint own a releasable socket slot.rebinditself dates from moq-dev/moq#5664.closed()disagreement: moq-dev/moq#5664, which gave moq-uring a staged latch while moq-tokio resolved at once.finishsetfinon a stop.Serving::closed.Validation
just check origin/mainpassed at 4f0838c: 12750 tests, clippy, fmt, and the text and TOML checks. The one commit after it (8150dc6) only adds a test; moq-tokio's 579 tests and clippy pass on it.a_stopped_loop_reports_its_error_next,closed_waits_for_the_close_to_be_sent,closed_waits_past_a_datagram_held_from_before_the_close(sim), anda_closed_or_driverless_endpoint_stays_closed.an_end_published_again_wakes_nobody.a_stopped_stream_resets_even_after_finish.a_session_closed_twice_ends_once.a_close_drops_a_pending_path_challenge.a_closed_or_driverless_endpoint_stays_closedpins the quest's third goal through the public entry points.closed()after sending a datagram held from before the close) and the PATH_CHALLENGE bug. All three are fixed, each with a test above that fails without its fix. Round 3 found nothing.moq-uring quic::qlog::tests::an_open_trace_accounts_for_staging_and_closefailed once while moq-tokio and moq-uring ran in parallel, then passed 5 of 5 alone. This PR does not touch it; see Follow-ups.Follow-ups
an_open_trace_accounts_for_staging_and_closeinrs/moq-uring/src/quic/qlog.rs. It spins a fixed 10,000yield_nowcalls waiting for a background thread's memory accounting, so a loaded machine can run out of spins first. Recommendation: plan it (XS). Wait on the accounting itself instead of a spin count.Review
Rewritten by
quest reviewafter each round.01a1b8df-c564-74b1-9e01-e2d734b7d1c1, round 4The incremental change adds a focused regression test, and source inspection found no issues; builds and tests were not run.Models
Size: L
Implementer: Claude Code, claude-opus-5-5, xhigh effort
(Written by Claude Opus 5.5 with xhigh)
🤖 Generated with Claude Code