fix(quic): RFC 9000 §10.2.2 DRAINING state for peer CONNECTION_CLOSE
Peer's CONNECTION_CLOSE now transitions the connection through DRAINING
for 3 * PTO before flipping to CLOSED, instead of going straight to
CLOSED. While DRAINING:
- the writer emits no packets (drainOutbound returns null);
- the parser drops late inbound silently at the top of feedDatagramInner;
- close()-awaiters unblock immediately via closingDrainSignal so the
transition isn't gated on the 3*PTO grace.
After 3 * PTO the driver's send-loop timer fires
markClosedExternally("draining period elapsed") which transitions to
CLOSED. The §10.2.2 grace period gives the peer's last retransmits a
chance to converge before we discard state.
Implementation:
* `Status.DRAINING` enum value with kdoc tying it to §10.2.2.
* `QuicConnection.drainingDeadlineMs` + `enterDraining(reason, nowMs)`
(sets status, computes `now + max(3*pto, MIN_DRAINING_PERIOD_MS)`,
completes closingDrainSignal, fires qlog) + `isDrainingExpired`.
* Parser's CONNECTION_CLOSE handler routes through `enterDraining`
instead of `markClosedExternally`.
* Driver folds `drainingDeadlineMs` into its send-loop sleep
`withTimeoutOrNull` and transitions to CLOSED on expiry.
* Writer short-circuits drainOutbound for DRAINING (returns null —
spec MUST NOT send).
* Parser drops late inbound at the top of feedDatagramInner.
Updated `FrameRoutingTest.connection_close_from_peer_short_circuits_remaining_frames`
to expect DRAINING (was CLOSED). Other status=CLOSED assertions in the
test suite cover OUR-side closes (markClosedExternally on protocol
violations / TRANSPORT_PARAMETER_ERROR / FLOW_CONTROL_ERROR) which
still go directly to CLOSED.
Test: `DrainingStateTest` (7 cases) covers the peer-close → DRAINING
transition, late-inbound drop, deadline math, the
MIN_DRAINING_PERIOD_MS floor, retransmit no-op semantics, and the
fresh-connection invariant.
Plan updated to mark all 🟡 Medium items resolved. The 2026-05-09
audit's complete spec-compliance close-out: 2 of 2 High + 6 of 6
Medium fixed in seven commits.
https://claude.ai/code/session_01XGmmaVuy2wsSZQx2AqN2ik
This commit is contained in:
@@ -193,7 +193,7 @@ gates on the audio-rooms completion plan
|
||||
| 8 | 2026-05-06 | Path validation pass 1 + 2 | DCID rotation, retire-prior-to monotonicity, abrupt migration | all fixed |
|
||||
| 9 | 2026-05-07 | DoS hardening + reserved-bit checks + TLS bounds + SendBuffer shrink | bound peer-controlled buffers and channels | all fixed |
|
||||
| 10 | 2026-05-08 | Lock-split design + close-under-load + key-update PN gate | streamsLock + lifecycleLock split | all fixed; see [`2026-05-08-lock-split-design.md`](2026-05-08-lock-split-design.md) |
|
||||
| 11 | 2026-05-09 | Four-RFC compliance audit (4 parallel agents: 9000, 9001, 9002, 9221+9114+9204) | ~15 items, mostly receive-side validation gaps | catalogued in [§ RFC compliance gaps](#rfc-compliance-gaps-2026-05-09) below; both 🔴 High items (fixed-bit, idle timeout) fixed same-day |
|
||||
| 11 | 2026-05-09 | Four-RFC compliance audit (4 parallel agents: 9000, 9001, 9002, 9221+9114+9204) | ~15 items, mostly receive-side validation gaps | catalogued in [§ RFC compliance gaps](#rfc-compliance-gaps-2026-05-09) below; **all 🔴 High and all 🟡 Medium items resolved same-day across 7 commits** |
|
||||
|
||||
Every fix carries an inline `audit-N #M` reference comment so the regression
|
||||
test → fix → comment chain is auditable. The whole audit corpus is in the
|
||||
@@ -287,29 +287,29 @@ file:line evidence. Severity:
|
||||
|
||||
| RFC § | Sev | Gap | Evidence |
|
||||
|---|---|---|---|
|
||||
| RFC 9000 §17.2 + §17.3 | ~~🔴~~ | ~~**Inbound fixed-bit (0x40) not validated** on long- or short-header receive.~~ **Resolved 2026-05-09** — both `LongHeaderPacket.parseAndDecrypt`, `ShortHeaderPacket.parseAndDecrypt`, and `ShortHeaderPacket.peekKeyPhase` now reject fixed-bit=0 packets per RFC §17.2 / §17.3 (silent discard). Tests in `FixedBitValidationTest`. |
|
||||
| RFC 9000 §10.1 | ~~🔴~~ | ~~**`max_idle_timeout` not enforced.**~~ **Resolved 2026-05-09** — `QuicConnection.lastActivityMs` updated on inbound packet receipt and outbound ack-eliciting send; `effectiveIdleTimeoutMs()` computes min(local, peer) with 3*PTO floor per §10.1; driver send-loop folds the deadline into its `withTimeoutOrNull` and silently closes via `markClosedExternally` per §10.2.1 on expiry. Tests in `IdleTimeoutTest` (8 cases). |
|
||||
| RFC 9000 §4.1 | 🟡 | **Connection-level inbound flow control missing.** Per-stream `receiveLimit` IS enforced (`QuicConnectionParser.kt:655`) — peer overshoot closes the connection. But the aggregate connection-level cap (matching what we advertised in `initial_max_data`) is not tracked on the receive side. | `QuicConnectionParser.kt:735-743` (only updates send-side credit on MAX_DATA) |
|
||||
| RFC 9000 §3 | 🟡 | **Stream state machine implicit, not validated.** The 10-state machine (Ready / Send / Data Sent / Data Recvd / Reset Sent / Reset Recvd × directions) is encoded as flags + buffer content. STREAM frames arriving after a RESET_STREAM are silently absorbed instead of raising STREAM_STATE_ERROR. (FIN-size mismatch *is* caught — `QuicConnectionParser.kt:672-685`.) | `QuicStream.kt:35-51` (no explicit state field) |
|
||||
| RFC 9000 §10.2 | 🟡 | **No DRAINING state.** Closing transitions go CONNECTED → CLOSING → CLOSED without the 3 × PTO hold the spec mandates. Late inbound packets after our own CONNECTION_CLOSE may not get a re-emitted close. | `QuicConnection.kt:301,1441-1531` (Status enum has no DRAINING) |
|
||||
| RFC 9000 §10.3 | 🟡 | **Stateless reset detection missing.** Tokens are stored in `PeerConnectionIdEntry` but no inbound matcher compares fail-to-decrypt datagrams against the token list. Spoofed peer-side resets look like noise. | `PathValidator.kt:35-43`; no consumer in parser |
|
||||
| RFC 9001 §6.6 | 🟡 | **AEAD invocation limit not tracked.** AES-128-GCM is bounded at 2^23 invocations (~8M packets). Past the limit, key rotation is mandatory. We don't count, don't rotate, don't close with AEAD_LIMIT_REACHED. ChaCha20 is effectively unbounded so less critical. | `Aead.kt`, `JcaAesGcmAead.kt` (no counter) |
|
||||
| RFC 9000 §17.2 + §17.3 | ~~🔴~~ | ~~**Inbound fixed-bit (0x40) not validated.**~~ **Resolved 2026-05-09** — both `LongHeaderPacket.parseAndDecrypt`, `ShortHeaderPacket.parseAndDecrypt`, and `ShortHeaderPacket.peekKeyPhase` now reject fixed-bit=0 packets per RFC §17.2 / §17.3 (silent discard). Tests in `FixedBitValidationTest`. |
|
||||
| RFC 9000 §10.1 | ~~🔴~~ | ~~**`max_idle_timeout` not enforced.**~~ **Resolved 2026-05-09** — `QuicConnection.lastActivityMs` updated on inbound packet receipt and outbound ack-eliciting send; `effectiveIdleTimeoutMs()` computes min(local, peer) with 3 × PTO floor per §10.1; driver send-loop folds the deadline into its `withTimeoutOrNull` and silently closes via `markClosedExternally` per §10.2.1 on expiry. Tests in `IdleTimeoutTest` (8 cases). |
|
||||
| RFC 9000 §4.1 | ~~🟡~~ | ~~**Connection-level inbound flow control missing.**~~ **Resolved 2026-05-09** — `QuicStream.receiveHighestOffset` + `QuicConnection.connectionInboundOffsetSum` track the §4.1 spec quantity (sum of largest-received-offset across streams). Parser closes with FLOW_CONTROL_ERROR when the sum exceeds `advertisedMaxData`. RESET_STREAM finalSize counted toward the limit per §4.5. Tests in `ConnectionLevelFlowControlTest`. |
|
||||
| RFC 9000 §3 | ~~🟡~~ | ~~**Stream state machine implicit, not validated.**~~ **Resolved 2026-05-09** — `QuicStream.peerResetReceived` latches when a RESET_STREAM arrives; the parser closes with STREAM_STATE_ERROR on any subsequent STREAM frame for the same id. (Other state transitions — FIN-size mismatch, illegal peer-initiated opens — were already caught.) Tests in `StreamAfterResetTest`. |
|
||||
| RFC 9000 §10.2 | ~~🟡~~ | ~~**No DRAINING state.**~~ **Resolved 2026-05-09** — `Status.DRAINING` added; parser routes peer's CONNECTION_CLOSE through `enterDraining` (sets status + 3 × PTO deadline) instead of immediate CLOSED. Driver folds the deadline into its send-loop sleep and flips to CLOSED on expiry. Late inbound during DRAINING is silently dropped at the top of `feedDatagramInner`. Tests in `DrainingStateTest`. |
|
||||
| RFC 9000 §10.3 | ~~🟡~~ | ~~**Stateless reset detection missing.**~~ **Resolved 2026-05-09** — `QuicConnection.isStatelessReset` checks the trailing 16 bytes of every short-header-form datagram against the peer's `statelessResetToken` AND every unused entry in `pathValidator`'s pool, in constant time per §10.3.1. On match, silently transitions to CLOSED. Tests in `StatelessResetDetectionTest`. |
|
||||
| RFC 9001 §6.6 | ~~🟡~~ | ~~**AEAD invocation limit not tracked.**~~ **Resolved 2026-05-09** — `Aead.confidentialityLimit` / `integrityLimit` properties surface the §B.1 values; `QuicConnection.aeadEncryptCount` / `aeadDecryptFailureCount` track per-key usage and reset on rotation. Writer triggers a key update at half the confidentiality limit and closes with AEAD_LIMIT_REACHED if rotation can't complete; parser closes if decrypt failures hit the integrity limit. Tests in `AeadInvocationLimitTest`. |
|
||||
|
||||
### Transport-parameter bounds
|
||||
|
||||
| RFC § | Sev | Gap | Evidence |
|
||||
|---|---|---|---|
|
||||
| RFC 9000 §18.2 | 🟡 | **`max_udp_payload_size < 1200` not rejected.** Spec says minimum 1200; smaller values MUST close with TRANSPORT_PARAMETER_ERROR. | `TransportParameters.kt:165` (decode-only) |
|
||||
| RFC 9000 §18.2 | 🟡 | **`ack_delay_exponent > 20` not rejected.** Spec maximum is 20. | `TransportParameters.kt:166` (decode-only) |
|
||||
| RFC 9000 §18.2 | 🟡 | **`active_connection_id_limit < 2` not rejected.** Spec minimum is 2. (We already cap our own pool at the peer's value via `PathValidator.maxUnusedCids`, so the runtime risk is bounded — just no explicit error.) | `TransportParameters.kt:168`; `QuicConnection.kt:582` |
|
||||
| RFC 9000 §13.2.5 | 🟦 | **`ack_delay_exponent` decode uses our config, not peer's advertised value.** Default (3) matches both ends in practice, but a peer that advertises a non-default exponent gets misinterpreted ACK delays. The shift IS overflow-safe (`QuicConnectionParser.kt:540-553`), just keyed off the wrong side's parameter. | `QuicConnectionParser.kt:547` (uses `conn.config.ackDelayExponent`) |
|
||||
| RFC 9000 §18.2 | ~~🟡~~ | ~~**`max_udp_payload_size < 1200` not rejected.**~~ **Resolved 2026-05-09** — `applyPeerTransportParameters` closes with TRANSPORT_PARAMETER_ERROR. Tests in `TransportParameterBoundsTest`. |
|
||||
| RFC 9000 §18.2 | ~~🟡~~ | ~~**`ack_delay_exponent > 20` not rejected.**~~ **Resolved 2026-05-09** — same path. |
|
||||
| RFC 9000 §18.2 | ~~🟡~~ | ~~**`active_connection_id_limit < 2` not rejected.**~~ **Resolved 2026-05-09** — same path. |
|
||||
| RFC 9000 §13.2.5 | ~~🟦~~ | ~~**`ack_delay_exponent` decode uses our config, not peer's.**~~ **Resolved 2026-05-09** — parser now uses `peerTransportParameters?.ackDelayExponent` with the §18.2 default of 3 as fallback; defensive 0..20 coercion preserved. |
|
||||
|
||||
### HTTP/3 + DATAGRAM gaps
|
||||
|
||||
| RFC § | Sev | Gap | Evidence |
|
||||
|---|---|---|---|
|
||||
| RFC 9114 §7.2.4.1 | 🟡 | **HTTP/2 reserved SETTINGS ids 0x02/0x03/0x04/0x05 not rejected.** RFC says receipt MUST close with H3_SETTINGS_ERROR. We accept and store. | `Http3Settings.kt:85-121` |
|
||||
| RFC 9221 §3 | 🟡 | **Outbound DATAGRAM size not gated by peer's `max_datagram_frame_size`.** Encoder will send oversize frames; spec-conformant peers will close with PROTOCOL_VIOLATION. | `Frame.kt:278-291` (no size check on send) |
|
||||
| RFC 9114 §7.2.4.1 | ~~🟡~~ | ~~**HTTP/2 reserved SETTINGS ids 0x02/0x03/0x04/0x05 not rejected.**~~ **Resolved 2026-05-09** — `Http3Settings.decodeBody` raises H3_SETTINGS_ERROR for ids 0x02..0x05. Tests in `Http3ReservedSettingsTest`. |
|
||||
| RFC 9221 §3 | ~~🟡~~ | ~~**Outbound DATAGRAM size not gated.**~~ **Resolved 2026-05-09** — writer drops outbound DATAGRAM frames when peer didn't advertise `max_datagram_frame_size` or when the encoded frame would exceed the advertised value. Diagnostic via `qlogObserver.onPacketDropped`. |
|
||||
| RFC 9114 §6.2.1 | 🟦 | **Closing the control stream surfaces a flag rather than auto-closing.** Spec says closing the control stream is H3_CLOSED_CRITICAL_STREAM. Caller must poll `peerH3ProtocolError` and close. | `Http3FrameReader.kt`; `WtPeerStreamDemux.kt` |
|
||||
|
||||
### TLS / error reporting cosmetics
|
||||
|
||||
@@ -298,7 +298,30 @@ class QuicConnection(
|
||||
var peerTransportParameters: TransportParameters? = null
|
||||
internal set
|
||||
|
||||
enum class Status { HANDSHAKING, CONNECTED, CLOSING, CLOSED }
|
||||
enum class Status {
|
||||
HANDSHAKING,
|
||||
CONNECTED,
|
||||
|
||||
/**
|
||||
* RFC 9000 §10.2.1 closing period. We've sent a
|
||||
* CONNECTION_CLOSE (or are about to); the writer flushes the
|
||||
* close datagram and then the connection holds until the
|
||||
* peer's last responses drain out of flight.
|
||||
*/
|
||||
CLOSING,
|
||||
|
||||
/**
|
||||
* RFC 9000 §10.2.2 draining period. We received a
|
||||
* CONNECTION_CLOSE from the peer; we MUST NOT send any further
|
||||
* packets and SHOULD discard inbound packets without
|
||||
* processing. The state MUST persist for at least 3 * PTO
|
||||
* before transitioning to fully [CLOSED] so the peer's
|
||||
* retransmits can converge.
|
||||
*/
|
||||
DRAINING,
|
||||
|
||||
CLOSED,
|
||||
}
|
||||
|
||||
/**
|
||||
* Lock-split refactor (2026-05-08): @Volatile so concurrent loops can
|
||||
@@ -333,6 +356,16 @@ class QuicConnection(
|
||||
@Volatile
|
||||
internal var lastActivityMs: Long = nowMillis()
|
||||
|
||||
/**
|
||||
* RFC 9000 §10.2.2 draining-period deadline. Set by
|
||||
* [enterDraining] to `now + 3 * PTO`; the driver's send loop
|
||||
* folds it into the timeout and transitions to [Status.CLOSED]
|
||||
* via [markClosedExternally] when it elapses. Null whenever the
|
||||
* connection is not in [Status.DRAINING].
|
||||
*/
|
||||
@Volatile
|
||||
internal var drainingDeadlineMs: Long? = null
|
||||
|
||||
/**
|
||||
* Non-suspend monitor protecting the atomic transition of
|
||||
* [status] / [closeReason] / [closeErrorCode] from a non-CLOSED to a
|
||||
@@ -2488,6 +2521,60 @@ class QuicConnection(
|
||||
return nowMillis - lastActivityMs >= timeoutMs
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC 9000 §10.2.2: enter the draining period after receiving a
|
||||
* peer's CONNECTION_CLOSE. Sets [status] to [Status.DRAINING],
|
||||
* records a `now + 3 * PTO` deadline at which the driver flips
|
||||
* the connection to [Status.CLOSED]. While draining, the writer
|
||||
* MUST NOT emit any packets; the read loop drops late inbound
|
||||
* silently.
|
||||
*
|
||||
* For the application this is functionally equivalent to "closed"
|
||||
* — `awaitIncoming*` returns null, new streams can't be opened —
|
||||
* but the explicit DRAINING state lets observability tools (qlog,
|
||||
* tests) see the spec-mandated grace period.
|
||||
*
|
||||
* Per §10.2.2 caller transitions ONLY from CONNECTED / HANDSHAKING.
|
||||
* A second call (peer retransmits its CONNECTION_CLOSE) is a
|
||||
* harmless no-op.
|
||||
*/
|
||||
internal fun enterDraining(
|
||||
reason: String,
|
||||
nowMillis: Long,
|
||||
) {
|
||||
val firstTransition =
|
||||
synchronized(closeStateMonitor) {
|
||||
if (status == Status.CLOSED || status == Status.DRAINING) return@synchronized false
|
||||
closeReason = reason
|
||||
status = Status.DRAINING
|
||||
true
|
||||
}
|
||||
if (!firstTransition) return
|
||||
// 3 * PTO deadline. Use the connection's `lossDetection.ptoBaseMs`
|
||||
// for the post-handshake case (peer params have arrived); fall
|
||||
// back to a small floor pre-handshake.
|
||||
val maxAckDelay = peerTransportParameters?.maxAckDelay ?: 0L
|
||||
val ptoBaseMs = lossDetection.ptoBaseMs(maxAckDelay).coerceAtLeast(1L)
|
||||
drainingDeadlineMs = nowMillis + (ptoBaseMs * 3L).coerceAtLeast(MIN_DRAINING_PERIOD_MS)
|
||||
// Wake any close()-awaiter so they don't sit on the
|
||||
// `closingDrainSignal` for 3*PTO unnecessarily.
|
||||
closingDrainSignal.complete(Unit)
|
||||
qlogObserver.onConnectionClosed("remote", closeErrorCode, reason)
|
||||
if (!handshakeComplete) {
|
||||
signalHandshakeFailed(QuicConnectionClosedException("connection closed externally: $reason"))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC 9000 §10.2.2 draining-deadline check. Returns true once
|
||||
* the 3 * PTO grace period has elapsed and the connection should
|
||||
* be flipped to fully closed.
|
||||
*/
|
||||
internal fun isDrainingExpired(nowMillis: Long): Boolean {
|
||||
val deadline = drainingDeadlineMs ?: return false
|
||||
return status == Status.DRAINING && nowMillis >= deadline
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC 9000 §10.3 stateless-reset detection. A peer that has lost
|
||||
* connection state (crash, restart, route change) signals so by
|
||||
@@ -2822,6 +2909,14 @@ class QuicConnection(
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* RFC 9000 §10.2.2 floor on the draining-period grace. The
|
||||
* spec mandates "at least 3 * PTO" which can be vanishingly
|
||||
* small pre-handshake. Floor at 100 ms so qlog observers and
|
||||
* tests can see the DRAINING window.
|
||||
*/
|
||||
const val MIN_DRAINING_PERIOD_MS: Long = 100L
|
||||
|
||||
/**
|
||||
* Bound on the inbound datagram queue depth. RFC 9221 datagrams are
|
||||
* outside connection-level flow control, so without this cap a peer
|
||||
|
||||
+24
-8
@@ -298,11 +298,19 @@ class QuicConnectionDriver(
|
||||
idleTimeoutMs?.let {
|
||||
(connection.lastActivityMs + it - nowForLossTimer).coerceAtLeast(0L)
|
||||
}
|
||||
// RFC 9000 §10.2.2 draining-period deadline. Set when
|
||||
// peer's CONNECTION_CLOSE arrives; we hold in DRAINING
|
||||
// until 3 * PTO elapses, then transition to CLOSED.
|
||||
val drainingDelta =
|
||||
connection.drainingDeadlineMs?.let {
|
||||
(it - nowForLossTimer).coerceAtLeast(0L)
|
||||
}
|
||||
val sleepMillis =
|
||||
minOf(
|
||||
ptoMillis,
|
||||
nextLossDelta ?: Long.MAX_VALUE,
|
||||
idleDelta ?: Long.MAX_VALUE,
|
||||
drainingDelta ?: Long.MAX_VALUE,
|
||||
)
|
||||
// Suspend until either: a wakeup arrives, or the timer expires.
|
||||
val woke =
|
||||
@@ -311,14 +319,22 @@ class QuicConnectionDriver(
|
||||
Unit
|
||||
}
|
||||
if (woke == null) {
|
||||
// Distinguish idle-timeout / loss-timer / PTO expiry. An
|
||||
// idle-timeout wake silently closes the connection per
|
||||
// RFC 9000 §10.2.1 ("the connection enters the closing
|
||||
// state silently — discarding the connection state
|
||||
// without sending a CONNECTION_CLOSE frame"). A
|
||||
// loss-timer wake just runs `detectAndRemoveLost`. A
|
||||
// PTO wake additionally bumps the consecutive-PTO
|
||||
// counter and arms the probe budget.
|
||||
// Distinguish draining-deadline / idle-timeout /
|
||||
// loss-timer / PTO expiry. RFC 9000 §10.2.2: the
|
||||
// draining period transitions to fully CLOSED once
|
||||
// 3 * PTO has elapsed since the peer's
|
||||
// CONNECTION_CLOSE — gives the peer's last
|
||||
// retransmits a chance to converge before we discard
|
||||
// state. Idle-timeout fires per §10.2.1 ("silently
|
||||
// closes — discarding the connection state without
|
||||
// sending a CONNECTION_CLOSE frame"). A loss-timer
|
||||
// wake just runs `detectAndRemoveLost`. A PTO wake
|
||||
// additionally bumps the consecutive-PTO counter and
|
||||
// arms the probe budget.
|
||||
if (connection.isDrainingExpired(nowMillis())) {
|
||||
connection.markClosedExternally("draining period elapsed")
|
||||
continue
|
||||
}
|
||||
if (idleTimeoutMs != null && connection.isIdleTimedOut(nowMillis())) {
|
||||
connection.markClosedExternally(
|
||||
"idle timeout ($idleTimeoutMs ms with no activity)",
|
||||
|
||||
+15
-1
@@ -91,6 +91,13 @@ private fun feedDatagramInner(
|
||||
datagram: ByteArray,
|
||||
nowMillis: Long,
|
||||
) {
|
||||
// RFC 9000 §10.2.2: while in the DRAINING state, late inbound
|
||||
// packets MUST be discarded silently. Same goes for CLOSED.
|
||||
if (conn.status == QuicConnection.Status.DRAINING ||
|
||||
conn.status == QuicConnection.Status.CLOSED
|
||||
) {
|
||||
return
|
||||
}
|
||||
// RFC 9000 §10.3 stateless-reset detection. A peer that has lost
|
||||
// connection state signals so by sending a datagram whose trailing
|
||||
// 16 bytes equal a stateless_reset_token we previously received.
|
||||
@@ -1032,7 +1039,14 @@ private fun dispatchFrames(
|
||||
// Audit-4 #13: any frames following CONNECTION_CLOSE in the
|
||||
// same payload MUST NOT be dispatched — they could create
|
||||
// streams or deliver bytes on an already-closed connection.
|
||||
conn.markClosedExternally("peer CONNECTION_CLOSE: ${frame.reason}")
|
||||
//
|
||||
// RFC 9000 §10.2.2: enter the DRAINING state for 3 * PTO
|
||||
// before transitioning to CLOSED. During draining we
|
||||
// MUST NOT send packets and SHOULD discard inbound
|
||||
// silently. The driver's send loop transitions to
|
||||
// CLOSED when [QuicConnection.drainingDeadlineMs]
|
||||
// elapses.
|
||||
conn.enterDraining("peer CONNECTION_CLOSE: ${frame.reason}", nowMillis)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -93,6 +93,11 @@ fun drainOutbound(
|
||||
conn.closingDrainSignal.complete(Unit)
|
||||
return datagram
|
||||
}
|
||||
// RFC 9000 §10.2.2: in the draining state we MUST NOT send any
|
||||
// further packets. The driver's send-loop timer transitions the
|
||||
// status to CLOSED once the 3 * PTO grace elapses; until then
|
||||
// drainOutbound returns null on every call.
|
||||
if (conn.status == QuicConnection.Status.DRAINING) return null
|
||||
|
||||
// Bug-A fix: drive the §8.2.4 3*PTO validation budget on every
|
||||
// drain, not just on PTO expiration. The peer ACKs PATH_CHALLENGE
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quic.connection
|
||||
|
||||
import com.vitorpamplona.quic.frame.ConnectionCloseFrame
|
||||
import com.vitorpamplona.quic.frame.StreamFrame
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* RFC 9000 §10.2.2 draining state.
|
||||
*
|
||||
* When an endpoint receives a CONNECTION_CLOSE frame from its peer it
|
||||
* MUST enter the draining state. While draining:
|
||||
* - the endpoint MUST NOT send further packets;
|
||||
* - the endpoint SHOULD discard inbound packets without processing;
|
||||
* - the state MUST persist for at least 3 * PTO before transitioning
|
||||
* to fully closed (so the peer's last retransmits can converge).
|
||||
*
|
||||
* Pre-fix the parser called `markClosedExternally` directly on inbound
|
||||
* CONNECTION_CLOSE which transitioned straight to CLOSED. There was no
|
||||
* grace period, no observable DRAINING phase, and the read loop didn't
|
||||
* explicitly drop subsequent late packets (it just exited on CLOSED —
|
||||
* functional equivalence, but the spec-mandated state was missing
|
||||
* from observability).
|
||||
*/
|
||||
class DrainingStateTest {
|
||||
@Test
|
||||
fun peer_connection_close_transitions_to_draining() {
|
||||
val (client, pipe) = newConnectedClient()
|
||||
val close =
|
||||
ConnectionCloseFrame(
|
||||
errorCode = 0L,
|
||||
frameType = 0L, // transport-close (non-null frameType)
|
||||
reason = "test peer close",
|
||||
)
|
||||
feedDatagram(client, pipe.buildServerApplicationDatagram(listOf(close))!!, nowMillis = 0L)
|
||||
assertEquals(QuicConnection.Status.DRAINING, client.status)
|
||||
assertNotNull(client.drainingDeadlineMs, "deadline must be set")
|
||||
assertTrue(client.drainingDeadlineMs!! > 0L)
|
||||
val reason = client.closeReason
|
||||
assertNotNull(reason)
|
||||
assertTrue(reason.contains("peer CONNECTION_CLOSE"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun late_inbound_during_draining_is_dropped() {
|
||||
val (client, pipe) = newConnectedClient()
|
||||
val close =
|
||||
ConnectionCloseFrame(
|
||||
errorCode = 0L,
|
||||
frameType = 0L,
|
||||
reason = "draining drop test",
|
||||
)
|
||||
feedDatagram(client, pipe.buildServerApplicationDatagram(listOf(close))!!, nowMillis = 0L)
|
||||
assertEquals(QuicConnection.Status.DRAINING, client.status)
|
||||
|
||||
// Feed a STREAM frame after the peer-close. Per §10.2.2 the
|
||||
// packet MUST be discarded; the connection-level
|
||||
// bookkeeping (e.g. `connectionInboundOffsetSum`) MUST stay
|
||||
// put.
|
||||
val before = client.connectionInboundOffsetSum
|
||||
val streamFrame = StreamFrame(streamId = 3L, offset = 0L, data = ByteArray(64), fin = false)
|
||||
val datagram = pipe.buildServerApplicationDatagram(listOf(streamFrame))
|
||||
// The pipe build may legitimately succeed (server keys are
|
||||
// separate from client state) — test only matters if it does.
|
||||
if (datagram != null) {
|
||||
feedDatagram(client, datagram, nowMillis = 0L)
|
||||
assertEquals(before, client.connectionInboundOffsetSum, "draining must drop without bookkeeping update")
|
||||
}
|
||||
// Status remains DRAINING — late packet did not flip us to
|
||||
// CLOSED prematurely.
|
||||
assertEquals(QuicConnection.Status.DRAINING, client.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun isDrainingExpired_false_before_deadline() {
|
||||
val (client, _) = newConnectedClient()
|
||||
client.enterDraining("test", nowMillis = 0L)
|
||||
// Just before the deadline.
|
||||
val deadline = client.drainingDeadlineMs!!
|
||||
assertFalse(client.isDrainingExpired(deadline - 1L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun isDrainingExpired_true_at_deadline() {
|
||||
val (client, _) = newConnectedClient()
|
||||
client.enterDraining("test", nowMillis = 0L)
|
||||
val deadline = client.drainingDeadlineMs!!
|
||||
assertTrue(client.isDrainingExpired(deadline))
|
||||
assertTrue(client.isDrainingExpired(deadline + 100L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun draining_floor_at_least_minimum_period() {
|
||||
// Pre-handshake the PTO is small (smoothed_rtt floor only),
|
||||
// so the §10.2.2 "3 * PTO" target could be vanishingly tiny.
|
||||
// The implementation floors at MIN_DRAINING_PERIOD_MS so qlog
|
||||
// observers and tests can see the DRAINING window.
|
||||
val (client, _) = newConnectedClient()
|
||||
client.enterDraining("test", nowMillis = 0L)
|
||||
val deadline = client.drainingDeadlineMs!!
|
||||
assertTrue(deadline >= QuicConnection.MIN_DRAINING_PERIOD_MS, "expected ≥ ${QuicConnection.MIN_DRAINING_PERIOD_MS}, got $deadline")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun second_connection_close_during_draining_is_no_op() {
|
||||
val (client, pipe) = newConnectedClient()
|
||||
val close =
|
||||
ConnectionCloseFrame(
|
||||
errorCode = 0L,
|
||||
frameType = 0L,
|
||||
reason = "first close",
|
||||
)
|
||||
feedDatagram(client, pipe.buildServerApplicationDatagram(listOf(close))!!, nowMillis = 0L)
|
||||
val firstReason = client.closeReason
|
||||
val firstDeadline = client.drainingDeadlineMs
|
||||
|
||||
// Peer retransmits its CONNECTION_CLOSE — we MUST NOT reset
|
||||
// the draining deadline (would extend the grace indefinitely
|
||||
// under retransmits) or change the recorded reason.
|
||||
val secondClose =
|
||||
ConnectionCloseFrame(
|
||||
errorCode = 0L,
|
||||
frameType = 0L,
|
||||
reason = "second close",
|
||||
)
|
||||
feedDatagram(client, pipe.buildServerApplicationDatagram(listOf(secondClose))!!, nowMillis = 50L)
|
||||
assertEquals(firstReason, client.closeReason, "first-call wins on closeReason")
|
||||
assertEquals(firstDeadline, client.drainingDeadlineMs, "deadline should not advance on retransmit")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fresh_connection_has_no_draining_deadline() {
|
||||
val (client, _) = newConnectedClient()
|
||||
assertEquals(QuicConnection.Status.CONNECTED, client.status)
|
||||
assertNull(client.drainingDeadlineMs)
|
||||
}
|
||||
}
|
||||
@@ -187,11 +187,13 @@ class FrameRoutingTest {
|
||||
// Order matters: CCF first, stream frame second.
|
||||
val packet = pipe.buildServerApplicationDatagram(listOf(ccf, streamFrame))!!
|
||||
feedDatagram(client, packet, nowMillis = 0L)
|
||||
assertEquals(QuicConnection.Status.CLOSED, client.status)
|
||||
// The dispatcher MUST have stopped at CCF; no peer stream materialised.
|
||||
// (We can't easily query this directly, but the behavioural assertion
|
||||
// is the close-status — pre-fix the StreamFrame would have created
|
||||
// a phantom stream after close.)
|
||||
// RFC 9000 §10.2.2: peer's CONNECTION_CLOSE puts us in DRAINING
|
||||
// (was CLOSED pre-DRAINING-implementation; the spec-mandated
|
||||
// 3 * PTO grace is held by the driver's send loop and flips to
|
||||
// CLOSED once it elapses). The "no phantom stream" assertion
|
||||
// still holds — DRAINING blocks frame dispatch the same way
|
||||
// CLOSED did.
|
||||
assertEquals(QuicConnection.Status.DRAINING, client.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
Reference in New Issue
Block a user