fix(quic): rotate DCID off failed CID on path validation timeout

Pre-fix, PathValidator.checkValidationTimeout queued a
RETIRE_CONNECTION_ID for the failed sequence number while leaving
QuicConnection.destinationConnectionId stamping that same CID. The
strict servers (quic-go / picoquic / msquic / mvfst) processed the
RETIRE, dropped their routing entry, then closed us with
PROTOCOL_VIOLATION the next time a packet arrived stamped with
the just-retired CID — visible in qlog as two RETIRE_CONNECTION_ID
frames within ~500 ms followed by "retired connection ID N, which
was used as the Destination Connection ID on this packet".

Reproducer: setting proactiveDcidRotationMillis=4_000L on
QuicConnection drove the trigger every 4 s; rebind-port vs quic-go
then failed at ~10 s with the close above. Reverted in this commit;
the experiment fields are gone.

The fix splits the timeout outcome into three cases:

  - NotTimedOut         — budget hasn't elapsed.
  - RecoveredOnSpare    — rotated active to the lowest spare CID;
                          queued RETIRE for the failed seq; the
                          QuicConnection wrapper synchronously
                          updates destinationConnectionId so the
                          next outbound stamps the new CID, atomic
                          under streamsLock with the validator
                          state change.
  - StuckOnFailedCid    — no spare available; KEEP the failed seq
                          active and DO NOT queue retire. The
                          writer keeps stamping the unvalidated
                          CID; if the path is genuinely dead the
                          connection idles out, and once a
                          NEW_CONNECTION_ID arrives the next
                          trigger rotates cleanly.

After the fix, rebind-port vs quic-go fails at the 60 s test
timeout with server-side trigger=idle_timeout (the Task 2 issue —
strict servers gate on fresh DCID at new src port, which we can't
synchronize with the sim's rebind cadence) instead of the
PROTOCOL_VIOLATION close.

Tests:
  - PathValidatorTest:
      validationTimeoutAfter3PtoTransitionsToFailedAndRetiresFailedCidWithSpare
      timeoutWithoutSpareKeepsFailedSeqActiveAndDoesNotRetire
      twoConsecutiveFailedValidationsRetireAllAbandonedSequencesWithSpare
  - ClientPathMigrationTest:
      backToBackSuccessfulMigrationsRetireExactlyOnePerRotationAndStampNewDcid
      validationTimeoutWithSpareRotatesDcidAndRetiresFailedSeq
      validationTimeoutWithoutSpareKeepsActiveCidAndDoesNotRetire

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-05-08 17:49:11 -04:00
parent f927e24fd4
commit 707fcee5b6
4 changed files with 466 additions and 54 deletions
@@ -589,28 +589,125 @@ class PathValidator(
/** /**
* RFC 9000 §8.2.4: abandon validation if more than 3 * PTO has * RFC 9000 §8.2.4: abandon validation if more than 3 * PTO has
* elapsed since the challenge went out without a matching * elapsed since the challenge went out without a matching
* response. Returns the abandoned context (so the caller can * response.
* 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 * On abandonment we probed with the new CID — even though the
* RETIRE_CONNECTION_ID. We probed with that CID — even though * peer never echoed our challenge, our challenge packet may
* the peer never echoed our challenge, our challenge packet * have reached them and they've stamped routing state against
* may have reached them and they've stamped routing state * the CID. So we'd like to retire it (RFC 9000 §5.1.2: a
* against the CID. Retiring it tells them they can drop that * connection ID that was used and then abandoned MUST be
* state. RFC 9000 §5.1.2: a connection ID that was used and * retired). BUT — under the abrupt-migration model, the
* then abandoned MUST be retired. * writer's outbound DCID is currently STAMPING the failed
* CID. If we queue a RETIRE_CONNECTION_ID for it without
* rotating the writer to a different CID, the next outbound
* packet carries both the RETIRE frame AND the now-retired
* CID as its destination — quic-go / picoquic / msquic /
* mvfst correctly read that as PROTOCOL_VIOLATION (see
* `quic/plans/2026-05-08-rebind-port-bug.md`).
*
* So on timeout we have two cases:
*
* - **Spare available** ([TimeoutOutcome.RecoveredOnSpare]):
* pick the lowest-sequence unused CID, rotate
* [activeCidSequence] to it, and queue
* `RETIRE_CONNECTION_ID` for the failed sequence. The
* caller MUST swap the connection's
* `destinationConnectionId` to
* [RecoveredOnSpare.newConnectionId] atomically with the
* state change so the next outbound stamps the rotated
* CID, not the retired one.
*
* - **No spare** ([TimeoutOutcome.StuckOnFailedCid]): keep
* [activeCidSequence] on the failed sequence and do NOT
* queue retire. The validation didn't succeed but the CID
* itself is still "ours" until we explicitly retire it —
* the peer hasn't been told to drop it. The writer keeps
* using it; if the path actually still works, traffic
* resumes; if it really is dead, the next idle-timeout or
* application-layer close handles the connection. Once a
* spare arrives via NEW_CONNECTION_ID we'll rotate on the
* next trigger.
*
* Returns [TimeoutOutcome.NotTimedOut] if no validation is in
* progress or the budget hasn't elapsed yet.
*/ */
fun checkValidationTimeout(nowMillis: Long): PathValidationState.Validating? { fun checkValidationTimeout(nowMillis: Long): TimeoutOutcome {
val current = state as? PathValidationState.Validating ?: return null val current = state as? PathValidationState.Validating ?: return TimeoutOutcome.NotTimedOut
val elapsed = nowMillis - current.startedAtMillis val elapsed = nowMillis - current.startedAtMillis
val budget = (current.priorPtoMillis * VALIDATION_PTO_BUDGET_MULTIPLIER).coerceAtLeast(MIN_VALIDATION_BUDGET_MS) val budget = (current.priorPtoMillis * VALIDATION_PTO_BUDGET_MULTIPLIER).coerceAtLeast(MIN_VALIDATION_BUDGET_MS)
if (elapsed < budget) return null if (elapsed < budget) return TimeoutOutcome.NotTimedOut
queueRetireSequence(current.newCidSequence) // Drop any PATH_CHALLENGE we hadn't yet drained for this
// attempt — the writer mustn't emit them onto an abandoned
// validation cycle.
pendingChallenges.removeAll { it.contentEquals(current.challengeData) }
state = PathValidationState.Failed state = PathValidationState.Failed
failedValidations += 1 failedValidations += 1
return current val sparePair = unusedCids.entries.firstOrNull()
if (sparePair == null) {
// No spare to rotate into. KEEP the failed seq active —
// queuing a retire here would have us stamping a retired
// CID on every subsequent packet (the very bug we're
// guarding against). The CID is still valid from the
// peer's perspective until we explicitly retire it.
return TimeoutOutcome.StuckOnFailedCid(abandoned = current)
}
val (spareSeq, spareEntry) = sparePair
unusedCids.remove(spareSeq)
// Atomic with the rotation: queue retire AND bump active to
// the spare. The caller (connection wrapper) MUST also
// update its `destinationConnectionId` to the new bytes
// before releasing the lock.
queueRetireSequence(current.newCidSequence)
activeCidSequence = spareSeq
return TimeoutOutcome.RecoveredOnSpare(
abandoned = current,
newSequence = spareSeq,
newConnectionId = spareEntry.connectionId,
)
}
sealed class TimeoutOutcome {
/** No validation in progress, or budget hasn't elapsed yet. */
object NotTimedOut : TimeoutOutcome()
/**
* Validation timed out and we rotated [activeCidSequence]
* to a fresh spare. The caller MUST update the
* connection's `destinationConnectionId` to
* [newConnectionId] atomically with this transition.
* `RETIRE_CONNECTION_ID` for the abandoned seq has been
* queued for the next outbound.
*/
data class RecoveredOnSpare(
val abandoned: PathValidationState.Validating,
val newSequence: Long,
val newConnectionId: ByteArray,
) : TimeoutOutcome() {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is RecoveredOnSpare) return false
return abandoned == other.abandoned &&
newSequence == other.newSequence &&
newConnectionId.contentEquals(other.newConnectionId)
}
override fun hashCode(): Int {
var h = abandoned.hashCode()
h = 31 * h + newSequence.hashCode()
h = 31 * h + newConnectionId.contentHashCode()
return h
}
}
/**
* Validation timed out but no spare CID is available. The
* failed sequence is KEPT as active (no retire queued) so
* the writer doesn't stamp a CID it has already retired —
* see [checkValidationTimeout] kdoc for the rationale.
*/
data class StuckOnFailedCid(
val abandoned: PathValidationState.Validating,
) : TimeoutOutcome()
} }
/** /**
@@ -2676,25 +2676,57 @@ class QuicConnection(
/** /**
* Check whether an outstanding PATH_CHALLENGE has exceeded the * Check whether an outstanding PATH_CHALLENGE has exceeded the
* 3 * PTO budget (RFC 9000 §8.2.4). Called from the driver's * 3 * PTO budget (RFC 9000 §8.2.4). Called from the driver's
* PTO timer path. Returns true when validation just timed out. * PTO timer path AND from [drainOutbound]. Returns true when
* validation just timed out.
* *
* On timeout the validator queues the failed CID's sequence * Two outcomes (see [PathValidator.checkValidationTimeout]):
* number into [PathValidator.pendingRetireSequences] (Bug-2 *
* fix) so the writer's next drain emits a * - [PathValidator.TimeoutOutcome.RecoveredOnSpare] — the
* RETIRE_CONNECTION_ID. Without this the peer keeps the * validator picked a fresh spare and rotated
* routing entry forever — we promised by sending the challenge * `activeCidSequence` onto it; we MUST swap
* that we might use the CID, and §5.1.2 obligates us to * [destinationConnectionId] to the new bytes synchronously
* retire it once we abandon it. * here, before the lock is released. Otherwise the next
* drain would stamp the just-retired CID on the wire (the
* bug from `quic/plans/2026-05-08-rebind-port-bug.md` —
* quic-go / picoquic / msquic / mvfst correctly close us
* with PROTOCOL_VIOLATION when they see "RETIRE(seq=N) +
* DCID=seq=N bytes" in the same packet).
* - [PathValidator.TimeoutOutcome.StuckOnFailedCid] — no
* spare available, validator kept the failed seq active
* and DID NOT queue retire. We stay on that CID; if the
* path is genuinely dead the connection idles out, and if
* a spare arrives later the next trigger will rotate
* cleanly.
* *
* Caller must hold [streamsLock]. * Caller must hold [streamsLock].
*/ */
internal fun checkPathValidationTimeoutLocked(nowMillis: Long): Boolean { internal fun checkPathValidationTimeoutLocked(nowMillis: Long): Boolean =
val abandoned = pathValidator.checkValidationTimeout(nowMillis) ?: return false when (val outcome = pathValidator.checkValidationTimeout(nowMillis)) {
qlogObserver.onPathValidationFailed(abandoned.newCidSequence) PathValidator.TimeoutOutcome.NotTimedOut -> {
qlogObserver.onConnectionIdRetired("peer", abandoned.newCidSequence) false
pathValidator.acknowledgeTerminal() }
return true
} is PathValidator.TimeoutOutcome.StuckOnFailedCid -> {
qlogObserver.onPathValidationFailed(outcome.abandoned.newCidSequence)
pathValidator.acknowledgeTerminal()
true
}
is PathValidator.TimeoutOutcome.RecoveredOnSpare -> {
qlogObserver.onPathValidationFailed(outcome.abandoned.newCidSequence)
qlogObserver.onConnectionIdRetired("peer", outcome.abandoned.newCidSequence)
// Atomic with the validator's queued RETIRE: rotate
// the writer off the abandoned CID before any drain
// can run. Both this method and the writer drain
// hold streamsLock, so no packet builds between
// here and the next outbound — the swap is
// synchronous from the wire's perspective.
destinationConnectionId = ConnectionId(outcome.newConnectionId)
qlogObserver.onConnectionIdActivated("peer", outcome.newSequence, outcome.newConnectionId)
pathValidator.acknowledgeTerminal()
true
}
}
/** /**
* Queue a PATH_RESPONSE for the given [challengeData]. Called by * Queue a PATH_RESPONSE for the given [challengeData]. Called by
@@ -27,9 +27,11 @@ import com.vitorpamplona.quic.frame.RetireConnectionIdFrame
import com.vitorpamplona.quic.frame.decodeFrames import com.vitorpamplona.quic.frame.decodeFrames
import com.vitorpamplona.quic.frame.encodeFrames import com.vitorpamplona.quic.frame.encodeFrames
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.withLock
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertContentEquals import kotlin.test.assertContentEquals
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertNotEquals
import kotlin.test.assertTrue import kotlin.test.assertTrue
/** /**
@@ -384,6 +386,220 @@ class ClientPathMigrationTest {
) )
} }
@Test
fun backToBackSuccessfulMigrationsRetireExactlyOnePerRotationAndStampNewDcid() =
runBlocking {
// Rebind-port regression: prior to the 2026-05-08 fix, two
// back-to-back rotations could leak a packet stamped with
// a just-retired DCID — quic-go / picoquic / msquic /
// mvfst correctly close that as PROTOCOL_VIOLATION. This
// test drives the validator through TWO completed
// rotations and asserts:
// 1. exactly ONE RETIRE_CONNECTION_ID frame is queued
// per rotation (the prior active seq);
// 2. EVERY post-rotation outbound packet is stamped
// with the new DCID (no stray packet leaks the
// retired seq).
val (client, pipe) = newConnectedClient()
val cidSeq1 = ByteArray(8) { (0xA0 or it).toByte() }
val cidSeq2 = ByteArray(8) { (0xB0 or it).toByte() }
val token1 = ByteArray(16) { 0x71 }
val token2 = ByteArray(16) { 0x72 }
// Server offers two spare CIDs up front so we can do two
// rotations without waiting for replenishment.
feedDatagram(
client,
pipe.buildServerApplicationDatagram(
listOf(
NewConnectionIdFrame(1L, 0L, cidSeq1, token1),
NewConnectionIdFrame(2L, 0L, cidSeq2, token2),
),
)!!,
nowMillis = 0L,
)
assertEquals(2, client.pathValidator.unusedCount())
// Drain the ACK so subsequent assertions see only post-rotation traffic.
drainOutbound(client, nowMillis = 0L)
// ── Rotation #1 ────────────────────────────────────────
assertEquals(PathMigrationResult.Started, client.triggerPathMigration(100L, 1_000L))
assertContentEquals(cidSeq1, client.destinationConnectionId.bytes)
val out1 = drainOutbound(client, nowMillis = 100L)!!
val frames1 = pipe.decryptClientApplicationFrames(out1)!!
val challenge1 = frames1.first { it is PathChallengeFrame } as PathChallengeFrame
// Exactly ONE RETIRE_CONNECTION_ID frame in this packet,
// for the prior active seq (0). No retire of seq 1.
val retires1 = frames1.filterIsInstance<RetireConnectionIdFrame>()
assertEquals(1, retires1.size, "rotation #1 must queue exactly one RETIRE — got ${retires1.map { it.sequenceNumber }}")
assertEquals(0L, retires1.single().sequenceNumber)
// PATH_RESPONSE arrives — validation succeeds.
feedDatagram(
client,
pipe.buildServerApplicationDatagram(listOf(PathResponseFrame(challenge1.data.copyOf())))!!,
nowMillis = 200L,
)
assertContentEquals(cidSeq1, client.destinationConnectionId.bytes)
assertEquals(1L, client.pathValidator.activeCidSequence)
assertEquals(1L, client.pathValidator.successfulValidations)
// Drain the ACK so the next outbound is rotation-specific.
drainOutbound(client, nowMillis = 200L)
// ── Rotation #2 ────────────────────────────────────────
assertEquals(PathMigrationResult.Started, client.triggerPathMigration(300L, 1_000L))
assertContentEquals(cidSeq2, client.destinationConnectionId.bytes)
val out2 = drainOutbound(client, nowMillis = 300L)!!
val frames2 = pipe.decryptClientApplicationFrames(out2)!!
val challenge2 = frames2.first { it is PathChallengeFrame } as PathChallengeFrame
assertNotEquals(
challenge1.data.toList(),
challenge2.data.toList(),
"second rotation must emit a fresh random PATH_CHALLENGE payload",
)
// Exactly ONE RETIRE_CONNECTION_ID frame for seq=1 (the
// prior active). Critically NOT also seq=0 — that was
// already retired in rotation #1 and the writer must
// not re-emit a stale entry.
val retires2 = frames2.filterIsInstance<RetireConnectionIdFrame>()
assertEquals(1, retires2.size, "rotation #2 must queue exactly one RETIRE — got ${retires2.map { it.sequenceNumber }}")
assertEquals(1L, retires2.single().sequenceNumber)
// PATH_RESPONSE arrives — second validation succeeds.
feedDatagram(
client,
pipe.buildServerApplicationDatagram(listOf(PathResponseFrame(challenge2.data.copyOf())))!!,
nowMillis = 400L,
)
assertContentEquals(cidSeq2, client.destinationConnectionId.bytes)
assertEquals(2L, client.pathValidator.activeCidSequence)
assertEquals(2L, client.pathValidator.successfulValidations)
assertEquals(QuicConnection.Status.CONNECTED, client.status)
}
@Test
fun validationTimeoutWithSpareRotatesDcidAndRetiresFailedSeq() =
runBlocking {
// Rebind-port regression: when validation times out and
// a spare CID exists, the writer's destinationConnectionId
// MUST rotate to that spare BEFORE the next outbound is
// built. Otherwise we'd queue RETIRE for the failed seq
// and stamp the same retired seq on the same packet —
// exactly the PROTOCOL_VIOLATION the strict servers
// close us on.
val (client, pipe) = newConnectedClient()
val cidSeq1 = ByteArray(8) { (0xA0 or it).toByte() }
val cidSeq2 = ByteArray(8) { (0xB0 or it).toByte() }
feedDatagram(
client,
pipe.buildServerApplicationDatagram(
listOf(
NewConnectionIdFrame(1L, 0L, cidSeq1, ByteArray(16) { 0x11 }),
NewConnectionIdFrame(2L, 0L, cidSeq2, ByteArray(16) { 0x22 }),
),
)!!,
nowMillis = 0L,
)
drainOutbound(client, nowMillis = 0L)
// Trigger validation. tryStart consumes seq=1; pool keeps seq=2.
val pto = 1_000L
assertEquals(PathMigrationResult.Started, client.triggerPathMigration(100L, pto))
assertContentEquals(cidSeq1, client.destinationConnectionId.bytes)
// Drain the PATH_CHALLENGE so any subsequent build is
// post-timeout traffic only.
drainOutbound(client, nowMillis = 100L)
// Force the 3*PTO timeout. checkPathValidationTimeoutLocked
// is the same path the driver / writer take, so this
// exercises the production wrapper.
val timedOut =
client.streamsLock.withLock {
client.checkPathValidationTimeoutLocked(nowMillis = 100L + pto * 4L)
}
assertTrue(timedOut)
// Validator rotated active to the spare; connection swapped DCID.
assertEquals(2L, client.pathValidator.activeCidSequence)
assertContentEquals(
cidSeq2,
client.destinationConnectionId.bytes,
"DCID must rotate to the spare before any outbound runs",
)
assertTrue(
client.pathValidator.pendingRetireSequences.contains(1L),
"failed seq=1 must be queued for RETIRE alongside the rotation",
)
// Next outbound must stamp the SPARE seq (cidSeq2), NOT
// the failed seq (cidSeq1) — the bug we're guarding
// against. The packet carries RETIRE(1) on a DCID of
// seq=2's bytes.
val out = drainOutbound(client, nowMillis = 100L + pto * 4L)!!
val frames = pipe.decryptClientApplicationFrames(out)!!
val retire = frames.firstOrNull { it is RetireConnectionIdFrame } as? RetireConnectionIdFrame
assertTrue(retire != null, "outbound must carry RETIRE for the failed seq — got ${frames.map { it::class.simpleName }}")
assertEquals(1L, retire.sequenceNumber)
// The pipe parser uses serverScid.length to peel the
// DCID off the short header. We verify the rotation
// structurally via destinationConnectionId rather than
// reparsing the wire bytes — but the pipe successfully
// decrypted the packet, which already requires the DCID
// to match a recognized issued CID on the server side.
}
@Test
fun validationTimeoutWithoutSpareKeepsActiveCidAndDoesNotRetire() =
runBlocking {
// Rebind-port regression (no-spare branch): when
// validation times out and NO spare exists, the
// validator must NOT queue retire for the failed seq —
// doing so would have us stamping a CID we just told
// the peer to retire. Instead activeCidSequence stays
// on the failed seq; the writer keeps using it; the
// next NEW_CONNECTION_ID arrival lets a fresh trigger
// rotate cleanly.
val (client, pipe) = newConnectedClient()
val cidSeq1 = ByteArray(8) { (0xC0 or it).toByte() }
feedDatagram(
client,
pipe.buildServerApplicationDatagram(
listOf(NewConnectionIdFrame(1L, 0L, cidSeq1, ByteArray(16) { 0x33 })),
)!!,
nowMillis = 0L,
)
drainOutbound(client, nowMillis = 0L)
val pto = 1_000L
assertEquals(PathMigrationResult.Started, client.triggerPathMigration(100L, pto))
assertContentEquals(cidSeq1, client.destinationConnectionId.bytes)
// Pool is now empty — only seq=1 was offered, and it's
// the active.
assertEquals(0, client.pathValidator.unusedCount())
drainOutbound(client, nowMillis = 100L)
val timedOut =
client.streamsLock.withLock {
client.checkPathValidationTimeoutLocked(nowMillis = 100L + pto * 4L)
}
assertTrue(timedOut)
// Active stays on the failed seq; DCID unchanged.
assertEquals(1L, client.pathValidator.activeCidSequence)
assertContentEquals(
cidSeq1,
client.destinationConnectionId.bytes,
"no spare available — DCID must stay on the failed seq, not be cleared",
)
// Crucially: no retire queued. The writer doesn't have
// a way to stamp anything else, so retiring would put
// us into the bug.
assertTrue(
!client.pathValidator.pendingRetireSequences.contains(1L),
"failed seq must NOT be queued for retire when no spare exists — pending=${client.pathValidator.pendingRetireSequences}",
)
assertEquals(QuicConnection.Status.CONNECTED, client.status, "stuck-on-failed-cid must not close the connection")
}
@Test @Test
fun triggerPathMigrationBeforeHandshakeReturnsNotConnected() = fun triggerPathMigrationBeforeHandshakeReturnsNotConnected() =
runBlocking { runBlocking {
@@ -23,7 +23,6 @@ package com.vitorpamplona.quic.connection
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertContentEquals import kotlin.test.assertContentEquals
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue import kotlin.test.assertTrue
/** /**
@@ -191,31 +190,88 @@ class PathValidatorTest {
} }
@Test @Test
fun twoConsecutiveFailedValidationsRetireAllAbandonedSequences() { fun twoConsecutiveFailedValidationsRetireAllAbandonedSequencesWithSpare() {
// Bug-B regression: prior to the fix, two failed validations // Bug-B regression (original): prior to the priorSeq fix,
// would leave the original seq=0 unretired (the priorSeq // two failed validations would leave seq=0 unretired forever
// queue entry was scheduled inside applyPathResponse, which // (the priorSeq retire was scheduled inside applyPathResponse
// never runs for failed validations). // which never ran for failed validations).
//
// 2026-05-08 update: timeout no longer queues retire for the
// failed seq UNLESS a spare is available to rotate the writer
// onto. Otherwise we'd be stamping a CID we just retired
// (the rebind-port bug). With a spare per attempt the
// original Bug-B contract still holds — every abandoned seq
// gets retired.
val v = PathValidator(challengePayloadFactory = { ByteArray(8) { 0x22 } }) val v = PathValidator(challengePayloadFactory = { ByteArray(8) { 0x22 } })
// Need 4 spares: each failed validation consumes one (the
// tryStart pick) and rotates to another (the timeout
// recovery pick). Two failed cycles → 4 spares total.
v.recordPeerNewConnectionId(1L, 0L, ByteArray(8) { 0x01 }, ByteArray(16) { 0x10 }) v.recordPeerNewConnectionId(1L, 0L, ByteArray(8) { 0x01 }, ByteArray(16) { 0x10 })
v.recordPeerNewConnectionId(2L, 0L, ByteArray(8) { 0x02 }, ByteArray(16) { 0x20 }) v.recordPeerNewConnectionId(2L, 0L, ByteArray(8) { 0x02 }, ByteArray(16) { 0x20 })
v.recordPeerNewConnectionId(3L, 0L, ByteArray(8) { 0x03 }, ByteArray(16) { 0x30 })
v.recordPeerNewConnectionId(4L, 0L, ByteArray(8) { 0x04 }, ByteArray(16) { 0x40 })
val pto = 1_000L val pto = 1_000L
// First attempt fails by timeout. // First attempt: tryStart consumes seq=1, queues retire(0),
// active=1. Timeout consumes seq=2 (spare), queues retire(1),
// active=2.
v.tryStartValidation(0L, pto) v.tryStartValidation(0L, pto)
v.checkValidationTimeout(nowMillis = pto * 4L) val first = v.checkValidationTimeout(nowMillis = pto * 4L)
assertTrue(first is PathValidator.TimeoutOutcome.RecoveredOnSpare, "got $first")
assertEquals(2L, first.newSequence)
v.acknowledgeTerminal() v.acknowledgeTerminal()
// Second attempt also fails. // Second attempt: tryStart consumes seq=3, queues retire(2),
// active=3. Timeout consumes seq=4 (spare), queues retire(3),
// active=4.
v.tryStartValidation(nowMillis = 10_000L, currentPtoMillis = pto) v.tryStartValidation(nowMillis = 10_000L, currentPtoMillis = pto)
v.checkValidationTimeout(nowMillis = 10_000L + pto * 4L) val second = v.checkValidationTimeout(nowMillis = 10_000L + pto * 4L)
assertTrue(second is PathValidator.TimeoutOutcome.RecoveredOnSpare, "got $second")
assertEquals(4L, second.newSequence)
// All three sequences abandoned along the way (the original // Every abandoned sequence (0, 1, 2, 3) MUST be queued for
// seq=0 plus seq=1 plus seq=2) MUST be queued for retire. // retire. seq=4 is the new active and is NOT queued.
for (seq in listOf(0L, 1L, 2L)) { for (seq in listOf(0L, 1L, 2L, 3L)) {
assertTrue( assertTrue(
v.pendingRetireSequences.contains(seq), v.pendingRetireSequences.contains(seq),
"seq=$seq abandoned but not queued for retire — pending=${v.pendingRetireSequences}", "seq=$seq abandoned but not queued for retire — pending=${v.pendingRetireSequences}",
) )
} }
assertTrue(
!v.pendingRetireSequences.contains(4L),
"seq=4 is the new active CID — must NOT be queued for retire",
)
}
@Test
fun timeoutWithoutSpareKeepsFailedSeqActiveAndDoesNotRetire() {
// Rebind-port regression: queuing a RETIRE_CONNECTION_ID for
// the failed seq while the writer is still stamping that
// same seq as DCID is a PROTOCOL_VIOLATION on the wire (the
// strict server retires its routing entry as soon as it
// sees our RETIRE, then closes us when subsequent packets
// arrive stamped with the just-retired CID). When no spare
// is available, the validator must NOT queue retire and
// must keep the failed seq as activeCidSequence so the
// writer doesn't stamp a retired CID.
val v = PathValidator(challengePayloadFactory = { ByteArray(8) { 0x55 } })
v.recordPeerNewConnectionId(1L, 0L, ByteArray(8) { 0x01 }, ByteArray(16) { 0x10 })
val pto = 1_000L
v.tryStartValidation(nowMillis = 0L, currentPtoMillis = pto)
// After tryStartValidation pool is empty (the only spare was
// consumed). Now timeout with no spare to recover onto.
val outcome = v.checkValidationTimeout(nowMillis = pto * 4L)
assertTrue(outcome is PathValidator.TimeoutOutcome.StuckOnFailedCid, "got $outcome")
assertEquals(1L, outcome.abandoned.newCidSequence)
assertTrue(v.state is PathValidationState.Failed)
assertEquals(1L, v.failedValidations)
// Critical: seq=1 is the writer's active and must NOT be
// queued for retire.
assertTrue(
!v.pendingRetireSequences.contains(1L),
"failed seq must NOT be queued for retire when no spare exists — pending=${v.pendingRetireSequences}",
)
// activeCidSequence stays on the failed seq so the writer
// continues to stamp it.
assertEquals(1L, v.activeCidSequence)
} }
@Test @Test
@@ -291,27 +347,38 @@ class PathValidatorTest {
} }
@Test @Test
fun validationTimeoutAfter3PtoTransitionsToFailedAndRetiresFailedCid() { fun validationTimeoutAfter3PtoTransitionsToFailedAndRetiresFailedCidWithSpare() {
// Bug-2 regression: on 3 * PTO timeout the failed CID's // Bug-2 regression (updated 2026-05-08): on 3 * PTO timeout
// sequence MUST be queued for RETIRE_CONNECTION_ID. // the failed CID's sequence MUST be queued for
// Without this the peer keeps the routing entry forever // RETIRE_CONNECTION_ID **and** the writer must rotate off
// because we sent a packet with that DCID (the challenge) // that CID. The pre-2026-05-08 shape queued retire alone,
// but never told them we abandoned it. // leaving the writer stamping the just-retired CID — see
// [timeoutWithoutSpareKeepsFailedSeqActiveAndDoesNotRetire]
// for the no-spare branch and the rebind-port plan for the
// PROTOCOL_VIOLATION the strict servers raised.
val v = PathValidator(challengePayloadFactory = { ByteArray(8) { 0x44 } }) val v = PathValidator(challengePayloadFactory = { ByteArray(8) { 0x44 } })
v.recordPeerNewConnectionId(1L, 0L, ByteArray(8) { 0x01 }, ByteArray(16)) v.recordPeerNewConnectionId(1L, 0L, ByteArray(8) { 0x01 }, ByteArray(16))
v.recordPeerNewConnectionId(2L, 0L, ByteArray(8) { 0x02 }, ByteArray(16) { 0x22 })
val pto = 1_000L val pto = 1_000L
v.tryStartValidation(nowMillis = 0L, currentPtoMillis = pto) v.tryStartValidation(nowMillis = 0L, currentPtoMillis = pto)
assertNull(v.checkValidationTimeout(nowMillis = pto * 2L), "still within budget") assertEquals(
val abandoned = v.checkValidationTimeout(nowMillis = pto * 4L) PathValidator.TimeoutOutcome.NotTimedOut,
assertTrue(abandoned != null, "validation must time out at 3*PTO") v.checkValidationTimeout(nowMillis = pto * 2L),
assertEquals(1L, abandoned.newCidSequence) "still within budget",
)
val outcome = v.checkValidationTimeout(nowMillis = pto * 4L)
assertTrue(outcome is PathValidator.TimeoutOutcome.RecoveredOnSpare, "got $outcome")
assertEquals(1L, outcome.abandoned.newCidSequence)
assertEquals(2L, outcome.newSequence)
assertContentEquals(ByteArray(8) { 0x02 }, outcome.newConnectionId)
assertTrue(v.state is PathValidationState.Failed) assertTrue(v.state is PathValidationState.Failed)
assertEquals(1L, v.failedValidations) assertEquals(1L, v.failedValidations)
assertTrue( assertTrue(
v.pendingRetireSequences.contains(1L), v.pendingRetireSequences.contains(1L),
"abandoned CID sequence must be queued for RETIRE_CONNECTION_ID — pending=${v.pendingRetireSequences}", "abandoned CID sequence must be queued for RETIRE_CONNECTION_ID — pending=${v.pendingRetireSequences}",
) )
assertEquals(2L, v.activeCidSequence, "active must rotate to the spare so writer stops stamping the retired CID")
} }
@Test @Test