fix(quic): audit fixes for client path validation + DCID rotation

Addresses seven bugs surfaced by post-landing audit of the path
validation feature.

Spec fixes (RFC 9000 §9):
  - Bug 1: PATH_CHALLENGE was going out on the OLD DCID because the
    writer reads conn.destinationConnectionId per packet and the
    rotation only happened on PATH_RESPONSE arrival. Now rotate the
    DCID inside triggerPathMigrationLocked (abrupt-migration model
    appropriate for the "old path looks dead" trigger condition).
    Fixes the headline feature — without this the challenge cannot
    actually exercise the new path.
  - Bug 2: 3 * PTO timeout dropped the failed CID without queuing a
    RETIRE_CONNECTION_ID. The peer kept the routing entry forever.
    checkValidationTimeout now queues the failed sequence per §5.1.2.
  - Bug 3: RETIRE_CONNECTION_ID for seq 0 was silently honored. We
    have no replacement SCID to give the peer (we don't issue our
    own NEW_CONNECTION_ID frames), so the connection is unusable.
    Close with INTERNAL_ERROR instead.
  - Bug 4: triggerPathMigration had no handshake-confirmed gate;
    §9.1 forbids migration before handshake confirmation. Returns
    new PathMigrationResult.NotConnected when status != CONNECTED.

Implementation fixes:
  - Bug 5: driver was calling Clock.System.now() directly instead
    of conn.nowMillis(), breaking virtual-clock tests.
  - Bug 6: PTO threshold check ran BEFORE the consecutive-PTO
    counter increment, so threshold=2 actually required 3 PTOs.
    Increment first; threshold semantics now match the constant.
  - Bug 7: applyPeerPathResponseLocked didn't reset
    consecutivePtoCount on successful validation; the next sleep
    inherited a stale exponential-backoff multiplier even though
    the peer just proved liveness.

Code quality:
  - Rename ValidationOutcome.Validated.newConnectionIdBytes →
    connectionId; PathValidationState.Validating.newCidBytes →
    newConnectionId. The "Bytes" suffix was redundant.
  - Drop unused PathValidator(initialActiveCidSequence) parameter.
  - Drop dead coerceAtLeast(2) in pool size calculation.
  - Make pendingChallenges and pendingRetireSequences internal.
  - Fix stale KDoc references (activatePendingValidatedCid,
    forceRetireActiveIfNeeded, "retirePriorTo decreased" — none
    survived the §19.15 clamp fix).
  - PathValidator.RecordResult: drop RetirePriorToRegressed
    enum value (clamped, never returned).
  - Surface qlogObserver.onConnectionIdRetired in both the
    success and timeout paths.

Tests:
  - ClientPathMigrationTest: existing fullMigrationRoundTrip
    test now asserts DCID rotates AT challenge time, not on
    PATH_RESPONSE.
  - New retireConnectionIdForSequenceZeroClosesConnection.
  - New pathResponseSuccessResetsConsecutivePtoCount.
  - New triggerPathMigrationBeforeHandshakeReturnsNotConnected.
  - PathValidatorTest:
    validationTimeoutAfter3PtoTransitionsToFailedAndRetiresFailedCid
    now asserts the failed sequence is queued for retire.
  - retirePriorToRegressionIsRejected → renamed to
    retirePriorToRegressionIsClampedNotRejected.

All :quic:jvmTest and :nestsClient:jvmTest pass.

https://claude.ai/code/session_01PVVhSQXvw4K4oQ46FzpgaT
This commit is contained in:
Claude
2026-05-08 19:37:33 +00:00
parent 435c49bae9
commit 9b9ede2e1e
5 changed files with 284 additions and 95 deletions
@@ -83,7 +83,7 @@ sealed class PathValidationState {
data class Validating(
val challengeData: ByteArray,
val newCidSequence: Long,
val newCidBytes: ByteArray,
val newConnectionId: ByteArray,
val priorCidSequence: Long,
val startedAtMillis: Long,
val priorPtoMillis: Long,
@@ -93,7 +93,7 @@ sealed class PathValidationState {
if (other !is Validating) return false
return challengeData.contentEquals(other.challengeData) &&
newCidSequence == other.newCidSequence &&
newCidBytes.contentEquals(other.newCidBytes) &&
newConnectionId.contentEquals(other.newConnectionId) &&
priorCidSequence == other.priorCidSequence &&
startedAtMillis == other.startedAtMillis &&
priorPtoMillis == other.priorPtoMillis
@@ -102,7 +102,7 @@ sealed class PathValidationState {
override fun hashCode(): Int {
var h = challengeData.contentHashCode()
h = 31 * h + newCidSequence.hashCode()
h = 31 * h + newCidBytes.contentHashCode()
h = 31 * h + newConnectionId.contentHashCode()
h = 31 * h + priorCidSequence.hashCode()
h = 31 * h + startedAtMillis.hashCode()
h = 31 * h + priorPtoMillis.hashCode()
@@ -137,6 +137,14 @@ enum class PathMigrationResult {
* pool. Caller should wait for fresh peer offers.
*/
NoSpareCid,
/**
* Connection is not yet in CONNECTED state — RFC 9000 §9.1
* forbids initiating migration before the handshake is
* confirmed. Caller should drop the trigger; a fresh handshake
* doesn't need migration.
*/
NotConnected,
}
/**
@@ -171,15 +179,6 @@ enum class PathMigrationResult {
* are plain mutable collections.
*/
class PathValidator(
/**
* Sequence number of the CID the writer is currently stamping
* into outbound short-header packets. Initial value 0 per RFC
* 9000 §5.1.1 — the DCID negotiated at handshake. After a
* successful validation [activeCidSequence] is the new
* sequence; the prior sequence is in `pendingRetireSequences`
* waiting to ride out as a RETIRE_CONNECTION_ID.
*/
initialActiveCidSequence: Long = 0L,
/**
* Cap on the number of unused CIDs we'll buffer. Defaults to
* the peer's `active_connection_id_limit` transport parameter,
@@ -222,7 +221,15 @@ class PathValidator(
var retirePriorToWatermark: Long = 0L
private set
var activeCidSequence: Long = initialActiveCidSequence
/**
* Sequence number of the CID the writer is currently stamping
* into outbound short-header packets. Starts at 0 per RFC 9000
* §5.1.1 — the DCID negotiated at handshake is implicitly
* sequence 0. Bumped to the new sequence after a successful
* [applyPathResponse]; the prior sequence is queued for
* RETIRE_CONNECTION_ID via [pendingRetireSequences].
*/
var activeCidSequence: Long = 0L
private set
/**
@@ -231,7 +238,7 @@ class PathValidator(
* next outbound application packet. Re-populated by the loss
* dispatcher if our RETIRE_CONNECTION_ID was lost.
*/
val pendingRetireSequences: ArrayDeque<Long> = ArrayDeque()
internal val pendingRetireSequences: ArrayDeque<Long> = ArrayDeque()
/**
* Outbound `PATH_CHALLENGE` payloads the writer should drain on
@@ -240,7 +247,7 @@ class PathValidator(
* (the writer also records a [com.vitorpamplona.quic.connection.recovery.RecoveryToken.PathChallenge]
* so the loss dispatcher can decide to retransmit or abandon).
*/
val pendingChallenges: ArrayDeque<ByteArray> = ArrayDeque()
internal val pendingChallenges: ArrayDeque<ByteArray> = ArrayDeque()
var state: PathValidationState = PathValidationState.Idle
private set
@@ -276,25 +283,31 @@ class PathValidator(
*
* The semantics:
* - If [retirePriorTo] is greater than [sequenceNumber], that's
* a §19.15 violation (the peer can't tell us to retire its
* own brand-new CID).
* - If [retirePriorTo] decreased, that's a §5.1.2 violation.
* a §19.15 frame-encoding error (the peer can't tell us to
* retire its own brand-new CID).
* - If [retirePriorTo] is smaller than the current
* [retirePriorToWatermark], it's clamped to the watermark
* per §19.15 ("MUST be treated as the largest one it has
* seen") — common with reordered NEW_CONNECTION_ID arrivals,
* not a peer error.
* - If the new entry would push the pool past [maxUnusedCids],
* return [Result.PoolFull] — the peer over-issued.
* return [RecordResult.PoolFull] — the peer over-issued.
* - If [sequenceNumber] is below the current
* [retirePriorToWatermark], the offer is already-retired and
* we silently drop it (the caller MUST still queue a
* RETIRE_CONNECTION_ID for it per §5.1.2).
* [retirePriorToWatermark] (after clamp), the offer is
* already-retired and we drop it after queuing a
* RETIRE_CONNECTION_ID for it (§5.1.2).
* - Duplicate sequence number with matching CID/token is
* idempotent ([Result.Duplicate]); a duplicate sequence with
* DIFFERENT bytes is a §19.15 violation.
* idempotent ([RecordResult.Duplicate]); a duplicate sequence
* with DIFFERENT bytes is a §19.15 violation.
*
* Side effect on success: any cached entry whose sequence
* number is now below the new [retirePriorTo] is moved into
* [pendingRetireSequences] (we owe the peer a retire) and
* dropped from [unusedCids]. If the active CID itself is
* forced-retired, the active sequence is bumped (the caller
* must rotate the active CID — see [forceRetireActiveIfNeeded]).
* number is now below the new [retirePriorToWatermark] is moved
* into [pendingRetireSequences] and dropped from [unusedCids].
* Note that this routine does NOT force-retire the active CID
* if the watermark moves past [activeCidSequence] — that's the
* caller's responsibility (it requires picking a replacement
* from the pool and rotating the writer's DCID). See
* [QuicConnection.applyPeerNewConnectionIdLocked].
*/
fun recordPeerNewConnectionId(
sequenceNumber: Long,
@@ -385,15 +398,23 @@ class PathValidator(
}
/**
* Begin a fresh path-validation attempt. Picks the lowest-sequence
* unused CID, generates a random 8-byte challenge, queues both,
* and transitions [state] to [PathValidationState.Validating].
* Begin a fresh path-validation attempt. Picks the lowest-
* sequence unused CID, generates a random 8-byte challenge,
* queues the challenge in [pendingChallenges], and transitions
* [state] to [PathValidationState.Validating]. The selected
* CID is removed from the unused pool; on validation success
* [applyPathResponse] commits it (callers swap the writer's
* DCID), and on timeout [checkValidationTimeout] queues it
* for RETIRE_CONNECTION_ID so the peer can drop the routing
* entry.
*
* Caller is responsible for waking the writer so the challenge
* actually goes out. The writer also stamps the new DCID into
* the next outbound short-header packet — see
* [QuicConnection.activatePendingValidatedCid] for the post-
* response promotion path.
* Caller is responsible for two follow-ups when this returns
* [PathMigrationResult.Started]:
* 1. Rotate the connection's `destinationConnectionId` to
* [PathValidationState.Validating.newConnectionId] so the
* PATH_CHALLENGE packet (and subsequent traffic, per the
* abrupt-migration model) goes out on the new path.
* 2. Wake the send loop so the challenge actually leaves.
*/
fun tryStartValidation(
nowMillis: Long,
@@ -411,7 +432,7 @@ class PathValidator(
PathValidationState.Validating(
challengeData = payload,
newCidSequence = seq,
newCidBytes = entry.connectionId,
newConnectionId = entry.connectionId,
priorCidSequence = activeCidSequence,
startedAtMillis = nowMillis,
priorPtoMillis = currentPtoMillis,
@@ -442,7 +463,7 @@ class PathValidator(
successfulValidations += 1
return ValidationOutcome.Validated(
newSequence = current.newCidSequence,
newConnectionIdBytes = current.newCidBytes,
connectionId = current.newConnectionId,
retiredSequence = priorSeq,
)
}
@@ -454,20 +475,20 @@ class PathValidator(
data class Validated(
val newSequence: Long,
val newConnectionIdBytes: ByteArray,
val connectionId: ByteArray,
val retiredSequence: Long,
) : ValidationOutcome() {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is Validated) return false
return newSequence == other.newSequence &&
newConnectionIdBytes.contentEquals(other.newConnectionIdBytes) &&
connectionId.contentEquals(other.connectionId) &&
retiredSequence == other.retiredSequence
}
override fun hashCode(): Int {
var h = newSequence.hashCode()
h = 31 * h + newConnectionIdBytes.contentHashCode()
h = 31 * h + connectionId.contentHashCode()
h = 31 * h + retiredSequence.hashCode()
return h
}
@@ -481,12 +502,21 @@ class PathValidator(
* surface a qlog event / decide whether to attempt another
* rotation), or null if no validation is in progress / the
* timer hasn't fired yet.
*
* On abandonment the new CID's sequence is queued for
* RETIRE_CONNECTION_ID. We probed with that CID — even though
* the peer never echoed our challenge, our challenge packet
* may have reached them and they've stamped routing state
* against the CID. Retiring it tells them they can drop that
* state. RFC 9000 §5.1.2: a connection ID that was used and
* then abandoned MUST be retired.
*/
fun checkValidationTimeout(nowMillis: Long): PathValidationState.Validating? {
val current = state as? PathValidationState.Validating ?: return null
val elapsed = nowMillis - current.startedAtMillis
val budget = (current.priorPtoMillis * VALIDATION_PTO_BUDGET_MULTIPLIER).coerceAtLeast(MIN_VALIDATION_BUDGET_MS)
if (elapsed < budget) return null
queueRetireSequence(current.newCidSequence)
state = PathValidationState.Failed
failedValidations += 1
return current
@@ -519,11 +519,15 @@ class QuicConnection(
*/
internal val pathValidator: PathValidator =
PathValidator(
initialActiveCidSequence = 0L,
// Cap the unused-CID buffer at the smaller of our own
// [activeConnectionIdLimit] (which we advertised to the
// peer in transport parameters) and the validator's hard
// [PathValidator.DEFAULT_MAX_UNUSED_CIDS] memory cap. A
// peer that issues more CIDs than we said we'd accept
// hits the cap and excess offers are dropped.
maxUnusedCids =
config.activeConnectionIdLimit
.coerceAtMost(PathValidator.DEFAULT_MAX_UNUSED_CIDS.toLong())
.coerceAtLeast(2L)
.toInt(),
)
@@ -2000,60 +2004,99 @@ class QuicConnection(
}
is PathValidator.ValidationOutcome.Validated -> {
destinationConnectionId = ConnectionId(outcome.newConnectionIdBytes)
// Bug-7 fix: a valid PATH_RESPONSE proves the peer
// can reach us on the new path. Reset the PTO
// backoff so the send loop returns to baseline
// pacing instead of carrying a stale "many PTOs
// expired" multiplier into healthy traffic.
consecutivePtoCount = 0
qlogObserver.onPathValidationSucceeded(outcome.newSequence)
qlogObserver.onConnectionIdActivated("peer", outcome.newSequence, outcome.newConnectionIdBytes)
qlogObserver.onConnectionIdActivated("peer", outcome.newSequence, outcome.connectionId)
qlogObserver.onConnectionIdRetired("peer", outcome.retiredSequence)
pathValidator.acknowledgeTerminal()
// Note: the writer's `destinationConnectionId` was
// already swapped to [outcome.connectionId] inside
// [triggerPathMigrationLocked] (abrupt-migration
// model — the challenge itself MUST go out on the
// new path per RFC 9000 §9.3). On success there's
// nothing more to swap; we just confirm the
// promotion is durable by re-stamping (idempotent).
destinationConnectionId = ConnectionId(outcome.connectionId)
}
}
}
/**
* RFC 9000 §19.16: peer asked us to retire one of our own
* source CIDs. Today we only ever issue one source CID at
* connection start (sequence 0), so any RETIRE_CONNECTION_ID
* the peer sends about a sequence > 0 references a CID we
* never issued — RFC 9000 says this is PROTOCOL_VIOLATION.
* Sequence 0 retire is also a violation per §19.16: "Receipt
* of a RETIRE_CONNECTION_ID frame containing a sequence number
* greater than any previously sent to the peer MUST be
* treated as a connection error of type PROTOCOL_VIOLATION."
* 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.
*
* Caller must hold [streamsLock].
*/
internal fun applyPeerRetireConnectionIdLocked(sequenceNumber: Long) {
// We only issue sequence 0 today; any retire request beyond
// that references a CID we never advertised. This is a
// peer-side violation per §19.16.
if (sequenceNumber > 0L) {
markClosedExternally(
"PROTOCOL_VIOLATION: RETIRE_CONNECTION_ID seq=$sequenceNumber > any we issued (0)",
)
return
}
// Sequence 0 retire today means "stop using your initial
// SCID." We don't model client-issued CID rotation, so the
// request is silently honored at the protocol level (we
// never re-use the SCID in any new long-header packet —
// long headers are gone after handshake completion).
// Sequence 0: peer wants us off our initial SCID, but we
// haven't issued any replacements. Close — see kdoc above.
markClosedExternally(
"INTERNAL_ERROR: peer asked to retire our initial SCID but we have no replacement to offer",
)
}
/**
* Try to start client-initiated path validation + DCID rotation
* (RFC 9000 §9). Picks the lowest-sequence unused CID from the
* pool, queues a fresh PATH_CHALLENGE, and records the
* outstanding state. Returns the result so the caller can
* decide whether to retry later (e.g. on the next PTO if the
* pool was empty when this fired).
* pool, queues a fresh PATH_CHALLENGE, **swaps the writer's
* outbound DCID to the new CID**, and records the outstanding
* state. Returns the result so the caller can decide whether
* to retry later (e.g. on the next PTO if the pool was empty
* when this fired).
*
* Caller must hold [streamsLock].
* Bug-1 fix — abrupt-migration model: the writer stamps a
* single connection-level [destinationConnectionId] onto every
* outbound short-header packet. To put the PATH_CHALLENGE on
* the new path (RFC 9000 §9.3) we rotate the DCID immediately
* here, not on validation success. The trigger condition
* ("path probably dead — N consecutive PTOs without ACKs")
* makes abrupt migration appropriate; we don't gain anything
* by holding the old DCID alongside. If validation later fails
* (3 * PTO timeout), [checkPathValidationTimeoutLocked]
* abandons the new CID without rolling back — by then the
* connection is in trouble and the next PTO either picks
* another spare CID or the connection idles out.
*
* Caller must hold [streamsLock]. Refuses to start before
* handshake confirmation per RFC 9000 §9.1 (returns
* [PathMigrationResult.NotConnected]).
*/
internal fun triggerPathMigrationLocked(
nowMillis: Long,
currentPtoMillis: Long,
): PathMigrationResult {
if (!handshakeComplete || status != Status.CONNECTED) {
return PathMigrationResult.NotConnected
}
val result = pathValidator.tryStartValidation(nowMillis, currentPtoMillis)
if (result == PathMigrationResult.Started) {
val validating = pathValidator.state as PathValidationState.Validating
destinationConnectionId = ConnectionId(validating.newConnectionId)
qlogObserver.onPathValidationStarted(validating.newCidSequence)
}
return result
@@ -2073,16 +2116,22 @@ class QuicConnection(
/**
* Check whether an outstanding PATH_CHALLENGE has exceeded the
* 3 * PTO budget (RFC 9000 §8.2.4). Called from the driver's
* PTO timer path. Returns true when validation just timed out
* — caller may surface that to qlog and decide to retry with
* another CID, or close the connection if the budget for
* retries is exhausted.
* PTO timer path. Returns true when validation just timed out.
*
* On timeout the validator queues the failed CID's sequence
* number into [PathValidator.pendingRetireSequences] (Bug-2
* fix) so the writer's next drain emits a
* RETIRE_CONNECTION_ID. Without this the peer keeps the
* routing entry forever — we promised by sending the challenge
* that we might use the CID, and §5.1.2 obligates us to
* retire it once we abandon it.
*
* Caller must hold [streamsLock].
*/
internal fun checkPathValidationTimeoutLocked(nowMillis: Long): Boolean {
val abandoned = pathValidator.checkValidationTimeout(nowMillis) ?: return false
qlogObserver.onPathValidationFailed(abandoned.newCidSequence)
qlogObserver.onConnectionIdRetired("peer", abandoned.newCidSequence)
pathValidator.acknowledgeTerminal()
return true
}
@@ -350,6 +350,12 @@ internal suspend fun handlePtoFired(conn: QuicConnection) {
if (conn.initial.sendProtection != null && !conn.initial.keysDiscarded) {
conn.requeueAllInflightCrypto(EncryptionLevel.INITIAL)
}
// Bug-6 fix: increment BEFORE the threshold check. With the
// pre-fix ordering and threshold=2, the rotation actually fired
// on the 3rd PTO (count went 0→1→2 before check). Now the count
// matches the constant's natural reading: "after 2 consecutive
// PTOs with no progress, trigger migration on the 2nd PTO firing."
conn.consecutivePtoCount = (conn.consecutivePtoCount + 1).coerceAtMost(6)
// Once 1-RTT keys are installed, PTO must also retransmit application
// data — STREAM bytes that were sent but never ACK'd. Without this,
// a single corrupted/lost 1-RTT packet (especially the first one
@@ -379,16 +385,14 @@ internal suspend fun handlePtoFired(conn: QuicConnection) {
// also dead — abandon and let the next PTO try with
// another CID (or surface the failure to the higher
// layer).
val nowMillis =
kotlin.time.Clock.System
.now()
.toEpochMilliseconds()
val maxAckDelayMs =
if (conn.application.sendProtection != null) {
conn.peerTransportParameters?.maxAckDelay ?: 0L
} else {
0L
}
//
// Bug-5 fix: use [conn.nowMillis] so a test-injected
// virtual clock takes effect. The pre-fix shape called
// [Clock.System.now()] directly, ignoring the
// connection's clock supplier and breaking timing
// assertions in unit tests.
val nowMillis = conn.nowMillis()
val maxAckDelayMs = conn.peerTransportParameters?.maxAckDelay ?: 0L
val ptoBaseMs = conn.lossDetection.ptoBaseMs(maxAckDelayMs).coerceAtLeast(1L)
conn.checkPathValidationTimeoutLocked(nowMillis)
if (conn.consecutivePtoCount >= PATH_PROBE_PTO_THRESHOLD) {
@@ -396,7 +400,6 @@ internal suspend fun handlePtoFired(conn: QuicConnection) {
}
}
}
conn.consecutivePtoCount = (conn.consecutivePtoCount + 1).coerceAtMost(6)
}
/**
@@ -409,6 +412,11 @@ internal suspend fun handlePtoFired(conn: QuicConnection) {
* (single dropped packet trips a rotation); 4+ is too late (user
* notices the silence).
*
* The check in [handlePtoFired] runs AFTER the consecutive-PTO
* counter is incremented, so the threshold value is the count of
* PTOs that fired without an inbound ACK arriving in between.
* With value 2 migration triggers on the 2nd consecutive PTO.
*
* Once the threshold is crossed, [handlePtoFired] calls into
* [QuicConnection.triggerPathMigrationLocked] which is itself
* idempotent — a second crossing while validation is already in
@@ -150,13 +150,21 @@ class ClientPathMigrationTest {
drainOutbound(client, nowMillis = 0L)
// Step 2 — application triggers migration. The validator picks
// sequence 1 and queues a PATH_CHALLENGE.
// sequence 1, queues a PATH_CHALLENGE, AND swaps the writer's
// outbound DCID immediately so the challenge goes out on the
// new path (Bug-1 fix — abrupt migration model per RFC 9000
// §9.3).
val triggered = client.triggerPathMigration(nowMillis = 100L, currentPtoMillis = 1_000L)
assertEquals(PathMigrationResult.Started, triggered)
assertTrue(client.pathValidator.state is PathValidationState.Validating)
assertContentEquals(
newCidBytes,
client.destinationConnectionId.bytes,
"DCID must rotate at challenge time so PATH_CHALLENGE goes out on the new path",
)
// Step 3 — the next outbound application packet must carry a
// PATH_CHALLENGE.
// PATH_CHALLENGE stamped with the NEW DCID.
val challengeOut = drainOutbound(client, nowMillis = 100L)
assertTrue(challengeOut != null, "client must emit a packet carrying PATH_CHALLENGE")
val outboundFrames = pipe.decryptClientApplicationFrames(challengeOut)
@@ -171,9 +179,11 @@ class ClientPathMigrationTest {
)!!
feedDatagram(client, response, nowMillis = 200L)
// Step 5 — DCID must have rotated to the new bytes; the prior
// sequence (0) must be queued for RETIRE_CONNECTION_ID.
assertContentEquals(newCidBytes, client.destinationConnectionId.bytes, "DCID must rotate to new bytes")
// Step 5 — validation succeeded; DCID is still the new bytes
// (already rotated at step 2), the prior sequence (0) is
// queued for RETIRE_CONNECTION_ID, and activeCidSequence now
// reflects the new sequence.
assertContentEquals(newCidBytes, client.destinationConnectionId.bytes)
assertEquals(1L, client.pathValidator.activeCidSequence)
assertTrue(
client.pathValidator.pendingRetireSequences.contains(0L),
@@ -197,36 +207,43 @@ class ClientPathMigrationTest {
}
@Test
fun pathResponseWithMismatchingPayloadDoesNotRotateDcid() =
fun pathResponseWithMismatchingPayloadKeepsValidatingAndDcid() =
runBlocking {
val (client, pipe) = newConnectedClient()
val originalDcid = client.destinationConnectionId
// Seed a spare CID and trigger migration.
// Seed a spare CID and trigger migration. Under abrupt
// migration the DCID rotates at trigger time, NOT at
// PATH_RESPONSE — so the post-trigger state is "DCID is
// already the new bytes; we're awaiting validation."
val newCid = ByteArray(8) { 0x01 }
val offer =
pipe.buildServerApplicationDatagram(
listOf(
NewConnectionIdFrame(
sequenceNumber = 1L,
retirePriorTo = 0L,
connectionId = ByteArray(8) { 0x01 },
connectionId = newCid,
statelessResetToken = ByteArray(16) { 0x10 },
),
),
)!!
feedDatagram(client, offer, nowMillis = 0L)
client.triggerPathMigration(nowMillis = 100L, currentPtoMillis = 1_000L)
assertContentEquals(newCid, client.destinationConnectionId.bytes, "DCID rotates at challenge time")
drainOutbound(client, nowMillis = 100L) // emit challenge, drop content
// Server replies with WRONG payload — must not rotate.
// Server replies with WRONG payload — validation must NOT
// succeed and the validator state must stay in Validating
// until the 3 * PTO timeout (or a correct response).
val bogusResponse =
pipe.buildServerApplicationDatagram(
listOf(PathResponseFrame(ByteArray(8) { 0xFF.toByte() })),
)!!
feedDatagram(client, bogusResponse, nowMillis = 200L)
assertEquals(originalDcid, client.destinationConnectionId, "mismatched response must NOT rotate DCID")
assertContentEquals(newCid, client.destinationConnectionId.bytes, "mismatched response must not undo the rotation")
assertTrue(client.pathValidator.state is PathValidationState.Validating, "still validating")
assertEquals(0L, client.pathValidator.activeCidSequence, "active sequence still old until validation succeeds")
}
@Test
@@ -257,4 +274,80 @@ class ClientPathMigrationTest {
"got ${client.status}",
)
}
@Test
fun retireConnectionIdForSequenceZeroClosesConnection() =
runBlocking {
// Bug-3 regression: peer asking us to retire our initial
// SCID (seq 0) leaves us with no replacement to give them.
// We close rather than silently honoring — see
// [QuicConnection.applyPeerRetireConnectionIdLocked].
val (client, pipe) = newConnectedClient()
val packet = pipe.buildServerApplicationDatagram(listOf(RetireConnectionIdFrame(sequenceNumber = 0L)))!!
feedDatagram(client, packet, nowMillis = 0L)
assertTrue(
client.status == QuicConnection.Status.CLOSING || client.status == QuicConnection.Status.CLOSED,
"got ${client.status}; seq=0 retire must close because we have no replacement SCID",
)
}
@Test
fun pathResponseSuccessResetsConsecutivePtoCount() =
runBlocking {
// Bug-7 regression: a successful PATH_RESPONSE proves the
// peer can reach us on the new path. The consecutive-PTO
// counter must reset so the send loop's exponential
// backoff returns to baseline pacing instead of carrying
// a stale "many PTOs expired" multiplier.
val (client, pipe) = newConnectedClient()
val newCid = ByteArray(8) { 0x55 }
val token = ByteArray(16) { 0x66 }
feedDatagram(
client,
pipe.buildServerApplicationDatagram(
listOf(NewConnectionIdFrame(1L, 0L, newCid, token)),
)!!,
nowMillis = 0L,
)
drainOutbound(client, nowMillis = 0L)
// Pretend we burned through 4 PTOs without progress.
client.consecutivePtoCount = 4
client.triggerPathMigration(nowMillis = 100L, currentPtoMillis = 1_000L)
val challengeOut = drainOutbound(client, nowMillis = 100L)!!
val challenge =
pipe.decryptClientApplicationFrames(challengeOut)!!.first { it is PathChallengeFrame }
as PathChallengeFrame
// PATH_RESPONSE arrives with matching payload — Bug-7 fix
// resets the counter inside applyPeerPathResponseLocked.
feedDatagram(
client,
pipe.buildServerApplicationDatagram(listOf(PathResponseFrame(challenge.data.copyOf())))!!,
nowMillis = 200L,
)
assertEquals(0, client.consecutivePtoCount, "successful path validation must reset consecutivePtoCount")
}
@Test
fun triggerPathMigrationBeforeHandshakeReturnsNotConnected() =
runBlocking {
// Bug-4 regression: RFC 9000 §9.1 forbids initiating
// migration before the handshake is confirmed. The
// public API must refuse rather than silently misbehave.
val client =
QuicConnection(
serverName = "example.test",
config = QuicConnectionConfig(),
tlsCertificateValidator =
com.vitorpamplona.quic.tls
.PermissiveCertificateValidator(),
)
// Connection is in HANDSHAKING state; no transport
// parameters have been parsed yet.
assertEquals(QuicConnection.Status.HANDSHAKING, client.status)
val result = client.triggerPathMigration(nowMillis = 0L, currentPtoMillis = 100L)
assertEquals(PathMigrationResult.NotConnected, result)
}
}
@@ -166,7 +166,7 @@ class PathValidatorTest {
assertTrue(outcome is PathValidator.ValidationOutcome.Validated, "got $outcome")
assertEquals(1L, outcome.newSequence)
assertEquals(0L, outcome.retiredSequence)
assertContentEquals(ByteArray(8) { 0xAA.toByte() }, outcome.newConnectionIdBytes)
assertContentEquals(ByteArray(8) { 0xAA.toByte() }, outcome.connectionId)
assertEquals(1L, v.activeCidSequence)
assertTrue(v.state is PathValidationState.Succeeded)
assertTrue(v.pendingRetireSequences.contains(0L), "old sequence must be queued for RETIRE_CONNECTION_ID")
@@ -191,7 +191,12 @@ class PathValidatorTest {
}
@Test
fun validationTimeoutAfter3PtoTransitionsToFailed() {
fun validationTimeoutAfter3PtoTransitionsToFailedAndRetiresFailedCid() {
// Bug-2 regression: on 3 * PTO timeout the failed CID's
// sequence MUST be queued for RETIRE_CONNECTION_ID.
// Without this the peer keeps the routing entry forever
// because we sent a packet with that DCID (the challenge)
// but never told them we abandoned it.
val v = PathValidator(challengePayloadFactory = { ByteArray(8) { 0x44 } })
v.recordPeerNewConnectionId(1L, 0L, ByteArray(8) { 0x01 }, ByteArray(16))
val pto = 1_000L
@@ -203,6 +208,10 @@ class PathValidatorTest {
assertEquals(1L, abandoned.newCidSequence)
assertTrue(v.state is PathValidationState.Failed)
assertEquals(1L, v.failedValidations)
assertTrue(
v.pendingRetireSequences.contains(1L),
"abandoned CID sequence must be queued for RETIRE_CONNECTION_ID — pending=${v.pendingRetireSequences}",
)
}
@Test