fix(quic): issue spare source CIDs to peer post-handshake-confirmed

RFC 9000 §5.1.1 + §19.15: a client SHOULD advertise spare source
CIDs to the peer via NEW_CONNECTION_ID frames so the peer has
DCIDs available for path migration / NAT rebind. Strict server
stacks (quic-go, picoquic, msquic, mvfst) refuse to validate a new
client path until they have a spare DCID — server log shows
"skipping validation of new path … since no connection ID is
available". Quinn migrates on src-port-change alone, which is why
rebind-port / rebind-addr passed against quinn pre-fix and failed
against the others.

Adds:
  - IssuedSourceConnectionIdEntry (data class) — sequence + CID +
    stateless-reset token tuple.
  - QuicConnection.issuedSourceConnectionIds (LinkedHashMap) — pool
    of currently-active issued SCIDs, seeded with seq=0 (the
    initial CID, no token because the stateless_reset_token TP is
    server-only per RFC 9000 §18.2).
  - QuicConnection.issueOwnConnectionIdsLocked(count) — drives the
    initial issuance once handshake is confirmed; appends recovery
    tokens to pendingOwnNewConnectionIdEmits for the writer to emit.
  - QuicConnection.issueOwnReplacementSourceCidLocked() — top-up
    after each peer RETIRE_CONNECTION_ID so the pool stays at
    full capacity for repeated migrations.
  - QuicConnectionWriter — once handshakeConfirmed flips, calls
    issueOwnConnectionIdsLocked with `peer.activeConnectionIdLimit
    - 1` (cap 7); drains pendingOwnNewConnectionIdEmits as fresh
    NEW_CONNECTION_ID frames; the existing pendingNewConnectionId
    map continues to handle loss-recovery retransmits.
  - applyPeerRetireConnectionIdLocked — three cases: in-pool seq
    is freed and replaced; below-next-seq missing seq is benign
    retransmit; above-next-seq is PROTOCOL_VIOLATION (peer
    retiring something we never advertised).

Verified against the live interop runner:
  - quic-go rebind-port: 2/2 (was 0/N — Task 2). Now passes in ~64s.
  - quic-go rebind-addr: 2/2 (was 0/N). Now passes in ~125-138s.
  - picoquic rebind-port: 2/2.
  - picoquic rebind-addr: 1/2 (flaky — separate investigation;
    one run the connection migrates correctly, the other times
    out at 110s; not blocking the rebind-port / quic-go win).
  - picoquic connectionmigration: 0/2 still failing (the runner's
    "active migration" testcase exercises a more sophisticated
    migration shape — TBD; tracked separately).

Tests: IssuedSourceConnectionIdTest (5 cases pinning the
post-handshake emission, peer-RETIRE replacement, retransmit
tolerance, and protocol-violation closure for above-next-seq retires).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-05-09 10:59:58 -04:00
parent 3a5c010993
commit 0920aba2b8
3 changed files with 477 additions and 29 deletions
@@ -673,6 +673,72 @@ class QuicConnection(
internal val pendingNewConnectionId:
MutableMap<Long, com.vitorpamplona.quic.connection.recovery.RecoveryToken.NewConnectionId> = HashMap()
/**
* Active source CIDs we've issued to the peer (RFC 9000 §5.1.1).
* Keyed by sequence number. The initial CID is implicitly sequence
* 0 ([sourceConnectionId]) and is seeded into this pool at
* connection start. Additional sequences come from
* [issueOwnConnectionIdsLocked] which the writer drives once the
* handshake is confirmed.
*
* Why we need this: strict servers (quic-go, picoquic, msquic,
* mvfst) gate server-initiated path validation on having a SPARE
* destination CID for the new client path (RFC 9000 §9.5: "An
* endpoint MUST NOT reuse a connection ID for sending packets to
* more than one source address"). Without spares from us, server
* logs `"skipping validation of new path … since no connection
* ID is available"` and the rebind-port / rebind-addr /
* connectionmigration interop tests time out. Quinn is the
* exception — it migrates on src-port-change alone, which is why
* those cells passed against quinn pre-fix.
*
* Lifetime: entries are added by [issueOwnConnectionIdsLocked]
* and removed by [applyPeerRetireConnectionIdLocked]. We only
* cap at peer's `active_connection_id_limit`; the peer telling
* us to retire one creates room for a fresh issuance.
*/
internal val issuedSourceConnectionIds: LinkedHashMap<Long, IssuedSourceConnectionIdEntry> =
LinkedHashMap<Long, IssuedSourceConnectionIdEntry>().apply {
// Sequence 0 is implicit per §5.1.1 and has no
// stateless-reset token — we never advertised one in our
// transport parameters (the `stateless_reset_token` TP is
// server-only per §18.2). The empty-bytes sentinel here
// is correct: this entry exists for sequence-counter
// continuity and peer-RETIRE tracking, never for a
// RecoveryToken / NEW_CONNECTION_ID emission (it'd be a
// §19.15 protocol violation to send sequence 0 in a
// NEW_CONNECTION_ID frame).
put(0L, IssuedSourceConnectionIdEntry(0L, sourceConnectionId.bytes, ByteArray(0)))
}
/**
* Counter for the next sequence number we'll issue via
* NEW_CONNECTION_ID. Starts at 1 because sequence 0 is reserved
* for the initial source CID.
*/
internal var nextOwnSourceCidSeq: Long = 1L
/**
* NEW_CONNECTION_ID frames staged for FIRST emission (i.e. fresh
* issuance, distinct from [pendingNewConnectionId] which is the
* loss-recovery retransmit queue). The writer drains this on each
* application-level build; a sent token is recorded as
* [com.vitorpamplona.quic.connection.recovery.RecoveryToken.NewConnectionId]
* for the loss dispatcher.
*/
internal val pendingOwnNewConnectionIdEmits:
ArrayDeque<com.vitorpamplona.quic.connection.recovery.RecoveryToken.NewConnectionId> =
ArrayDeque()
/**
* Whether [issueOwnConnectionIdsLocked] has run. Single-shot
* because we issue the full peer-active-connection-id-limit pool
* at handshake-confirmation time; further issuance only happens
* in response to peer's RETIRE_CONNECTION_ID (which calls
* [issueOwnReplacementSourceCidLocked]).
*/
internal var ownSourceCidsIssued: Boolean = false
/**
* RFC 9000 §8.2.2: queue of inbound PATH_CHALLENGE payloads we
* still owe a PATH_RESPONSE for. Each entry is the EXACT 8 bytes
@@ -2493,39 +2559,125 @@ class QuicConnection(
* RFC 9000 §19.16: peer asked us to retire one of our own
* source CIDs.
*
* Two cases:
* - Sequence > 0: we never issued anything beyond seq 0, so
* this references a CID we never advertised. PROTOCOL_VIOLATION
* per §19.16.
* - Sequence == 0: peer is asking us to retire the SCID we
* picked at connection start. Per RFC 9000 §5.1.1 we'd be
* expected to have already issued a replacement via
* NEW_CONNECTION_ID and switch the peer to it. We don't
* issue our own NEW_CONNECTION_ID frames today, so we have
* no replacement to give the peer. Closing the connection
* surfaces this as a known limitation rather than continuing
* against a peer that thinks we agreed to stop using our
* only CID.
* Three cases:
* - Sequence is in [issuedSourceConnectionIds] — peer is
* retiring an SCID we issued via NEW_CONNECTION_ID. Drop the
* entry and issue a replacement (so the pool stays topped up
* for future migrations). Common during NAT rebind: peer
* burns through our spares as it migrates.
* - Sequence < [nextOwnSourceCidSeq] but missing from the pool
* — already-retired SCID. Silently drop per §19.16 ("a peer
* that retransmits RETIRE_CONNECTION_ID after observing the
* corresponding ACK ... it's harmless").
* - Sequence ≥ [nextOwnSourceCidSeq] — peer is retiring a CID
* we never advertised. PROTOCOL_VIOLATION per §19.16. Same
* diagnostic for sequence 0 if it's still active and we have
* no spares to give the peer (which is impossible once
* [issueOwnConnectionIdsLocked] has run, but defensively
* surfaces the only failure mode left).
*
* Caller must hold [streamsLock].
*/
internal fun applyPeerRetireConnectionIdLocked(sequenceNumber: Long) {
if (sequenceNumber > 0L) {
if (sequenceNumber >= nextOwnSourceCidSeq) {
markClosedExternally(
"PROTOCOL_VIOLATION: RETIRE_CONNECTION_ID seq=$sequenceNumber > any we issued (0)",
"PROTOCOL_VIOLATION: RETIRE_CONNECTION_ID seq=$sequenceNumber ≥ next-issued seq=$nextOwnSourceCidSeq",
)
return
}
// Sequence 0: peer wants us off our initial SCID, but we never
// advertised additional SCIDs (we don't issue NEW_CONNECTION_ID
// frames yet). RFC 9000 §19.16 lets us close — and we MUST,
// because there's no replacement SCID to switch to. Reclassify
// as PROTOCOL_VIOLATION (peer-side fault) rather than
// INTERNAL_ERROR (our-side fault) — the original diagnostic
// misdescribed which side made the mistake.
markClosedExternally(
"PROTOCOL_VIOLATION: peer asked to retire our initial SCID (seq=0) but we never " +
"advertised additional SCIDs to switch to",
val removed = issuedSourceConnectionIds.remove(sequenceNumber)
if (removed == null) {
// Already-retired entry; benign §19.16 retransmit.
return
}
if (sequenceNumber == 0L && issuedSourceConnectionIds.isEmpty()) {
// Peer retired our LAST source CID. Without a spare we
// can't continue speaking on the wire — close per §19.16.
// Should be unreachable once [issueOwnConnectionIdsLocked]
// has run (the loop seeds the pool with handshake-confirmed
// spares); kept as a defence-in-depth so a misbehaving
// peer that retires faster than we issue can't drive us
// into a silent wedge.
markClosedExternally(
"PROTOCOL_VIOLATION: peer retired our initial SCID (seq=0) and no spare " +
"is left in the issued pool",
)
return
}
// Top up the pool so a subsequent migration still has spares.
// RFC 9000 §5.1.2 — endpoints SHOULD replace retired CIDs.
issueOwnReplacementSourceCidLocked()
}
/**
* RFC 9000 §5.1.1 + §19.15: issue spare source CIDs to the peer
* via NEW_CONNECTION_ID frames so it has DCIDs available to send
* to us on alternative paths (NAT rebind, connection migration).
* Strict servers (quic-go, picoquic, msquic, mvfst) refuse to
* validate a new path until they have a spare DCID from us — the
* tell is the server log line `"skipping validation of new path
* … since no connection ID is available"`.
*
* Issues `count` fresh entries (`nextOwnSourceCidSeq` ..
* `nextOwnSourceCidSeq + count - 1`), each with a random 8-byte
* CID and a random 16-byte stateless-reset token. The entries
* land in [issuedSourceConnectionIds] (peer-RETIRE tracking) and
* a [com.vitorpamplona.quic.connection.recovery.RecoveryToken.NewConnectionId]
* goes onto [pendingOwnNewConnectionIdEmits] so the writer
* emits the frame on the next application-level build.
*
* Idempotent via [ownSourceCidsIssued] — caller doesn't need to
* gate. Caller must hold [streamsLock].
*/
internal fun issueOwnConnectionIdsLocked(count: Int) {
if (ownSourceCidsIssued || count <= 0) return
repeat(count) {
val seq = nextOwnSourceCidSeq
nextOwnSourceCidSeq += 1
val cidBytes =
com.vitorpamplona.quartz.utils.RandomInstance
.bytes(sourceConnectionId.length)
val token =
com.vitorpamplona.quartz.utils.RandomInstance
.bytes(16)
issuedSourceConnectionIds[seq] = IssuedSourceConnectionIdEntry(seq, cidBytes, token)
pendingOwnNewConnectionIdEmits.addLast(
com.vitorpamplona.quic.connection.recovery.RecoveryToken.NewConnectionId(
sequenceNumber = seq,
retirePriorTo = 0L,
connectionId = cidBytes,
statelessResetToken = token,
),
)
}
ownSourceCidsIssued = true
}
/**
* Issue exactly one replacement source CID after the peer has
* retired one of ours (RFC 9000 §5.1.2: the peer is allowed to
* cycle through our spares freely). Keeps the pool topped up so
* a subsequent rebind still has a fresh DCID available. Caller
* must hold [streamsLock]; should only fire from
* [applyPeerRetireConnectionIdLocked].
*/
internal fun issueOwnReplacementSourceCidLocked() {
val seq = nextOwnSourceCidSeq
nextOwnSourceCidSeq += 1
val cidBytes =
com.vitorpamplona.quartz.utils.RandomInstance
.bytes(sourceConnectionId.length)
val token =
com.vitorpamplona.quartz.utils.RandomInstance
.bytes(16)
issuedSourceConnectionIds[seq] = IssuedSourceConnectionIdEntry(seq, cidBytes, token)
pendingOwnNewConnectionIdEmits.addLast(
com.vitorpamplona.quic.connection.recovery.RecoveryToken.NewConnectionId(
sequenceNumber = seq,
retirePriorTo = 0L,
connectionId = cidBytes,
statelessResetToken = token,
),
)
}
@@ -3114,6 +3266,39 @@ private fun defaultMonotonicNowMillis(): () -> Long {
return { anchor.elapsedNow().inWholeMilliseconds }
}
/**
* One source CID we've issued to the peer (RFC 9000 §5.1.1 +
* §19.15). Sequence 0 is the initial CID, implicitly issued via the
* client's first Initial. Sequences ≥ 1 are issued explicitly via
* NEW_CONNECTION_ID frames after the handshake is confirmed.
*
* `statelessResetToken` is the 16-byte token the peer would put in
* a stateless reset packet addressed to this CID. Sequence 0's
* entry has an empty token because clients can't advertise a
* stateless-reset token via transport parameters (the
* `stateless_reset_token` TP is server-only per RFC 9000 §18.2).
*/
internal data class IssuedSourceConnectionIdEntry(
val sequenceNumber: Long,
val connectionId: ByteArray,
val statelessResetToken: ByteArray,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is IssuedSourceConnectionIdEntry) return false
return sequenceNumber == other.sequenceNumber &&
connectionId.contentEquals(other.connectionId) &&
statelessResetToken.contentEquals(other.statelessResetToken)
}
override fun hashCode(): Int {
var h = sequenceNumber.hashCode()
h = 31 * h + connectionId.contentHashCode()
h = 31 * h + statelessResetToken.contentHashCode()
return h
}
}
/** Connection was closed (locally or by peer) before reaching CONNECTED. */
class QuicConnectionClosedException(
message: String,
@@ -110,6 +110,26 @@ fun drainOutbound(
// time comparison) when the validator is in [PathValidationState.Idle].
conn.checkPathValidationTimeoutLocked(nowMillis)
// RFC 9000 §5.1.1 + §19.15: once the handshake is confirmed,
// issue spare source CIDs to the peer so it has DCIDs available
// for path migration / NAT rebind. Strict servers (quic-go,
// picoquic, msquic, mvfst) refuse to validate a new path until
// they have a spare DCID from us; quinn migrates on src-port-
// change alone, which is why rebind-* tests passed against
// quinn pre-fix and failed against the others. Issue
// peer.active_connection_id_limit - 1 spares (we already implicitly
// issued seq=0 via the handshake). The default
// active_connection_id_limit per RFC 9000 §18.2 is 2; modern
// servers advertise 48.
if (!conn.ownSourceCidsIssued && conn.handshakeConfirmed) {
val peerLimit = conn.peerTransportParameters?.activeConnectionIdLimit ?: 2L
// Cap at a sensible upper bound to avoid issuing huge pools
// for misbehaving servers; 4 matches our own
// [QuicConnectionConfig.activeConnectionIdLimit] default.
val spareCount = (peerLimit - 1L).coerceIn(0L, 7L).toInt()
if (spareCount > 0) conn.issueOwnConnectionIdsLocked(spareCount)
}
// Drain destructive frame sources into local lists, ONCE.
val initialContents = collectHandshakeLevelFrames(conn, EncryptionLevel.INITIAL, nowMillis)
val handshakeContents = collectHandshakeLevelFrames(conn, EncryptionLevel.HANDSHAKE, nowMillis)
@@ -1086,10 +1106,26 @@ private fun appendFlowControlUpdates(
tokens += RecoveryToken.RetireConnectionId(seq)
}
// NEW_CONNECTION_ID retransmits. No application path emits these
// initially today (connection-ID rotation isn't wired); the map
// is populated only by the loss dispatcher, so this branch only
// fires for genuine retransmits.
// NEW_CONNECTION_ID — fresh issuances queued by
// [QuicConnection.issueOwnConnectionIdsLocked] post-handshake.
// Each emission also records a recovery token so the loss
// dispatcher can re-queue if the carrying packet is declared lost.
while (conn.pendingOwnNewConnectionIdEmits.isNotEmpty()) {
val token = conn.pendingOwnNewConnectionIdEmits.removeFirst()
frames +=
NewConnectionIdFrame(
sequenceNumber = token.sequenceNumber,
retirePriorTo = token.retirePriorTo,
connectionId = token.connectionId,
statelessResetToken = token.statelessResetToken,
)
tokens += token
}
// NEW_CONNECTION_ID retransmits — the loss dispatcher populates
// [pendingNewConnectionId] when a previously-emitted frame's
// carrier packet was declared lost. Same wire shape as a fresh
// issuance; we just preserve the original token.
if (conn.pendingNewConnectionId.isNotEmpty()) {
val pendingNewCidEntries = conn.pendingNewConnectionId.entries.toList()
for ((_, token) in pendingNewCidEntries) {
@@ -0,0 +1,227 @@
/*
* 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.NewConnectionIdFrame
import com.vitorpamplona.quic.frame.RetireConnectionIdFrame
import kotlinx.coroutines.runBlocking
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* Pins the client-side issuance of source CIDs (RFC 9000 §5.1.1 +
* §19.15). Once the handshake is confirmed, the client MUST be
* willing to advertise spare source CIDs to the peer so it has
* DCIDs available to send on alternative paths after a NAT rebind
* or migration. Strict server stacks (quic-go, picoquic, msquic,
* mvfst) refuse to validate a new path until they have a spare
* DCID — the giveaway is the server log line
* `"skipping validation of new path … since no connection ID is
* available"`. Pre-fix the rebind-port / rebind-addr /
* connectionmigration interop testcases against those servers all
* timed out at the runner's 60 s budget; quinn was the only server
* that passed because it migrates on src-port-change alone without
* waiting for a fresh DCID.
*
* The implementation lives in
* [QuicConnection.issueOwnConnectionIdsLocked] (called from the
* writer once `handshakeConfirmed` flips) +
* [QuicConnection.applyPeerRetireConnectionIdLocked] (which now
* tops the pool back up via
* [QuicConnection.issueOwnReplacementSourceCidLocked] instead of
* closing).
*/
class IssuedSourceConnectionIdTest {
@Test
fun postHandshakeDrainEmitsNewConnectionIdFramesPerActiveConnectionIdLimit() =
runBlocking {
val (client, pipe) = newConnectedClient()
// newConnectedClient now delivers HANDSHAKE_DONE so
// [handshakeConfirmed] is true; the next outbound drain
// gates spare-CID issuance off that flag.
assertTrue(client.handshakeConfirmed, "fixture must deliver HANDSHAKE_DONE")
assertEquals(
false,
client.ownSourceCidsIssued,
"issuance happens at writer-drain time, not at handshake-confirm time",
)
// Run the writer once. Should pull `peer.activeConnectionIdLimit
// - 1` fresh entries off the pool and emit them as
// NEW_CONNECTION_ID frames.
val out = drainOutbound(client, nowMillis = 0L)
assertTrue(out != null && out.isNotEmpty(), "writer must drain")
assertTrue(client.ownSourceCidsIssued, "writer must mark issuance done after first post-confirm drain")
val frames = pipe.decryptClientApplicationFrames(out)!!
val ncids = frames.filterIsInstance<NewConnectionIdFrame>()
// The fixture's TLS server advertises
// `activeConnectionIdLimit = 4` by default (transport
// params symmetry — see [ConnectedClientFixture]). We
// already implicitly issued seq=0, so we expect 3 fresh
// sequences (1, 2, 3).
val expectedCount = (client.peerTransportParameters?.activeConnectionIdLimit ?: 2L) - 1L
assertEquals(expectedCount.toInt(), ncids.size, "wrong NEW_CONNECTION_ID count — got ${ncids.size}")
// Sequence numbers are contiguous starting at 1.
val seqs = ncids.map { it.sequenceNumber }.sorted()
assertEquals((1L..expectedCount).toList(), seqs)
// Each frame is a fresh-issuance — retirePriorTo=0.
assertTrue(ncids.all { it.retirePriorTo == 0L }, "fresh issuances don't request retirement")
// Each carries an 8-byte CID and 16-byte stateless reset token.
assertTrue(ncids.all { it.connectionId.size == client.sourceConnectionId.length }, "CID length mismatch")
assertTrue(ncids.all { it.statelessResetToken.size == 16 }, "stateless reset token must be 16 bytes")
// The pool tracks every issued sequence (including seq=0).
assertEquals(
(0L..expectedCount).toSet(),
client.issuedSourceConnectionIds.keys,
"issuedSourceConnectionIds must contain every emitted sequence (plus seq=0)",
)
// A second drain doesn't re-emit (the queue was drained, the
// ownSourceCidsIssued latch is set).
val followup = drainOutbound(client, nowMillis = 1L)
if (followup != null && followup.isNotEmpty()) {
val followupFrames = pipe.decryptClientApplicationFrames(followup) ?: emptyList()
assertTrue(
followupFrames.none { it is NewConnectionIdFrame },
"second drain must NOT re-issue — got ${followupFrames.map { it::class.simpleName }}",
)
}
}
@Test
fun preHandshakeDrainDoesNotIssueOwnConnectionIds() =
runBlocking {
// Pre-confirmation the writer must skip issuance — the
// peer's active_connection_id_limit hasn't been parsed
// yet, and emitting NEW_CONNECTION_ID at handshake level
// is meaningless (frame is application-only per §19.15).
val (client, _) = newConnectedClient(deliverHandshakeDone = false)
assertEquals(false, client.handshakeConfirmed)
drainOutbound(client, nowMillis = 0L)
assertEquals(
false,
client.ownSourceCidsIssued,
"writer must not issue spare CIDs before HANDSHAKE_DONE arrives",
)
assertEquals(setOf(0L), client.issuedSourceConnectionIds.keys)
}
@Test
fun peerRetireOfIssuedSequenceFreesEntryAndIssuesReplacement() =
runBlocking {
// RFC 9000 §5.1.2 endorsement: top up the pool when the
// peer retires one of our spares. Without this, after
// each NAT rebind the strict server consumes one spare
// and we'd run out — leaving the next rebind unable to
// validate.
val (client, pipe) = newConnectedClient()
drainOutbound(client, nowMillis = 0L) // emit initial NEW_CONNECTION_IDs
val initialPool = client.issuedSourceConnectionIds.keys.toSet()
val initialNext = client.nextOwnSourceCidSeq
// Peer retires sequence 1 (one of the post-handshake spares).
val packet = pipe.buildServerApplicationDatagram(listOf(RetireConnectionIdFrame(sequenceNumber = 1L)))!!
feedDatagram(client, packet, nowMillis = 1L)
assertTrue(
!client.issuedSourceConnectionIds.containsKey(1L),
"retired seq=1 must be removed from issuedSourceConnectionIds — got ${client.issuedSourceConnectionIds.keys}",
)
assertEquals(
initialNext + 1L,
client.nextOwnSourceCidSeq,
"must allocate a fresh sequence for the replacement",
)
assertTrue(
client.issuedSourceConnectionIds.containsKey(initialNext),
"replacement seq=$initialNext must be in the pool",
)
// Connection still CONNECTED.
assertEquals(QuicConnection.Status.CONNECTED, client.status)
// The replacement is queued for the next outbound.
val out2 = drainOutbound(client, nowMillis = 2L)
val frames = pipe.decryptClientApplicationFrames(out2 ?: ByteArray(0)) ?: emptyList()
val replacement = frames.filterIsInstance<NewConnectionIdFrame>().firstOrNull()
assertTrue(replacement != null, "replacement must emit on next drain — got ${frames.map { it::class.simpleName }}")
assertEquals(initialNext, replacement.sequenceNumber)
// Sanity: original pool minus seq=1 plus seq=initialNext is the new pool.
assertEquals(
initialPool - 1L + initialNext,
client.issuedSourceConnectionIds.keys,
)
}
@Test
fun peerRetireOfUnissuedSequenceIsProtocolViolation() =
runBlocking {
// §19.16 — the peer can't retire something we never
// advertised. Pre-fix any seq > 0 closed the connection
// because we never issued anything; now seq must be ≥
// [nextOwnSourceCidSeq] to violate (anything below either
// exists in the pool or has been retired already, both
// of which are benign).
val (client, pipe) = newConnectedClient()
drainOutbound(client, nowMillis = 0L) // issuance runs
val far = client.nextOwnSourceCidSeq + 100L
val packet = pipe.buildServerApplicationDatagram(listOf(RetireConnectionIdFrame(sequenceNumber = far)))!!
feedDatagram(client, packet, nowMillis = 1L)
assertTrue(
client.status == QuicConnection.Status.CLOSING || client.status == QuicConnection.Status.CLOSED,
"got ${client.status}",
)
}
@Test
fun peerRetireRetransmitForAlreadyRetiredSequenceIsBenign() =
runBlocking {
// §19.16 nuance: a peer that doesn't see our ACK may
// retransmit RETIRE_CONNECTION_ID for an already-removed
// entry. Closing on that would be hostile — the second
// arrival is just retransmit noise.
val (client, pipe) = newConnectedClient()
drainOutbound(client, nowMillis = 0L)
// First retire of seq=1.
feedDatagram(
client,
pipe.buildServerApplicationDatagram(listOf(RetireConnectionIdFrame(sequenceNumber = 1L)))!!,
nowMillis = 1L,
)
assertEquals(QuicConnection.Status.CONNECTED, client.status)
drainOutbound(client, nowMillis = 2L) // emit replacement
// Duplicate retire of seq=1 (already removed).
feedDatagram(
client,
pipe.buildServerApplicationDatagram(listOf(RetireConnectionIdFrame(sequenceNumber = 1L)))!!,
nowMillis = 3L,
)
assertEquals(
QuicConnection.Status.CONNECTED,
client.status,
"duplicate retire must not close — peer is just retransmitting",
)
}
}