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:
Claude
2026-05-09 05:09:39 +00:00
parent 50ea477426
commit d4ffe0474f
7 changed files with 322 additions and 29 deletions
+14 -14
View File
@@ -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
@@ -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)",
@@ -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