From 4e7cd90ec255a8421ff3dee87a141ed3fc3ac022 Mon Sep 17 00:00:00 2001 From: deadcafe Date: Mon, 5 Oct 2026 01:17:10 +0530 Subject: [PATCH 1/2] QUIC: cap queued RETIRE_CONNECTION_ID frames - Close with CONNECTION_ID_LIMIT_ERROR in `processNewConnectionIDFrame` once twice the active connection ID limit of RETIRE_CONNECTION_ID frames are queued, as RFC 9000 section 5.1.2 allows - Add a test covering the cap --- .../SwiftNetwork/QUIC/QUICConnection.swift | 22 +++++++ .../QUICTests/ConnectionIDRotationTests.swift | 63 +++++++++++++++++++ 2 files changed, 85 insertions(+) diff --git a/Sources/SwiftNetwork/QUIC/QUICConnection.swift b/Sources/SwiftNetwork/QUIC/QUICConnection.swift index f729c6b0..eb52d2ec 100644 --- a/Sources/SwiftNetwork/QUIC/QUICConnection.swift +++ b/Sources/SwiftNetwork/QUIC/QUICConnection.swift @@ -6926,6 +6926,28 @@ extension QUICConnection { return false } + // RFC 9000 ยง5.1.2: + // + // An endpoint SHOULD allow for sending and tracking a number of + // RETIRE_CONNECTION_ID frames of at least twice the value of the + // active_connection_id_limit transport parameter. An endpoint MUST + // NOT forget a connection ID without retiring it, though it MAY choose + // to treat having connection IDs in need of retirement that exceed this + // limit as a connection error of type CONNECTION_ID_LIMIT_ERROR. + // + // Every frame that gets this far can queue RETIRE_CONNECTION_ID frames + // below, and those only leave the queue once they are sent. + let queuedRetireCount = withPendingItemsForKeyState { $0.retireConnectionIDs.count } + if queuedRetireCount >= 2 * remoteCIDs.activeConnectionIDLimit { + log.error("Received NEW_CONNECTION_ID frame with \(queuedRetireCount) RETIRE_CONNECTION_ID frames queued") + close( + with: .connectionIDLimitError, + "NEW_CONNECTION_ID: too many connection IDs to retire", + in: &eventContext + ) + return false + } + // RFC9000: // "Upon receipt of an increased Retire Prior To field, the peer MUST // stop using the corresponding connection IDs and retire them with diff --git a/Tests/QUICTests/ConnectionIDRotationTests.swift b/Tests/QUICTests/ConnectionIDRotationTests.swift index 641608a8..ba0d6964 100644 --- a/Tests/QUICTests/ConnectionIDRotationTests.swift +++ b/Tests/QUICTests/ConnectionIDRotationTests.swift @@ -72,6 +72,69 @@ final class ConnectionIDRotationTests: XCTestCase { return path } + // A RETIRE_CONNECTION_ID frame only leaves the queue once it is sent, and nothing is sent + // here. A peer that keeps supplying NEW_CONNECTION_ID frames below its own Retire Prior To + // gets one queued per frame, so the connection has to close once the queue reaches twice the + // active connection ID limit instead of letting it grow with the peer's frame count. + func testRetireConnectionIDQueueIsCapped() { + let expectation = XCTestExpectation() + connection.context.async { + let oldCID = QUICConnectionID([0xA1, 0xA2, 0xA3, 0xA4])! + let newCID = QUICConnectionID([0xB1, 0xB2, 0xB3, 0xB4])! + + let path = self.makePath(dcid: oldCID, sequenceNumber: 0, used: true) + self.connection.currentPath = path + + let retireLimit = 2 * self.connection.remoteCIDs.activeConnectionIDLimit + let frameCount = UInt64(4 * retireLimit) + + let maxQueued = self.connection.fromExternal { eventContext in + // Raise the retire threshold above every sequence number sent below. + let rotation = FrameNewConnectionID( + sequence: frameCount + 1, + retirePriorToSequence: frameCount + 1, + connectionID: newCID, + statelessResetToken: QUICStatelessResetToken() + ) + _ = self.connection.processNewConnectionIDFrame(rotation, in: &eventContext) + + var maxQueued = 0 + for sequence in 1...frameCount { + let frame = FrameNewConnectionID( + sequence: sequence, + retirePriorToSequence: 0, + connectionID: QUICConnectionID([0xC0, UInt8(sequence >> 8), UInt8(sequence & 0xFF)])!, + statelessResetToken: QUICStatelessResetToken() + ) + guard self.connection.processNewConnectionIDFrame(frame, in: &eventContext) else { + break + } + maxQueued = max( + maxQueued, + self.connection.withPendingItemsForKeyState { $0.retireConnectionIDs.count } + ) + } + return maxQueued + } + + XCTAssertEqual( + self.connection.closeError?.code, + QUICTransportError.QUICTransportErrorCode.connectionIDLimitError.rawValue, + "Connection should close with CONNECTION_ID_LIMIT_ERROR once the queue reaches the limit" + ) + // The check runs before a frame queues anything, so the frame that arrives with the + // queue at the limit closes the connection instead of being queued. + XCTAssertEqual( + maxQueued, + retireLimit, + "Queue should stop growing at the limit" + ) + + expectation.fulfill() + } + wait(for: [expectation], timeout: 5.0) + } + // The peer issues seq 1-3, but loss drops those NEW_CONNECTION_ID frames, so remoteCIDs holds // only the in-use seq 0 when the rotation frame (seq=4, retirePriorTo=1) arrives. Retiring // seq 0 leaves only the CID carried by that frame, so the path has to move to it instead of From baee899b3ed2006f40ab3add1ab0144cad800b25 Mon Sep 17 00:00:00 2001 From: deadcafe Date: Wed, 7 Oct 2026 02:49:38 +0530 Subject: [PATCH 2/2] QUIC: count what a NEW_CONNECTION_ID frame retires before capping - Close with CONNECTION_ID_LIMIT_ERROR only when the frame being processed would take the RETIRE_CONNECTION_ID queue past twice the active connection ID limit, so the queue cannot overshoot the limit - Accept a frame that retires nothing even with the queue at the limit - Add tests covering both cases --- .../SwiftNetwork/QUIC/QUICConnection.swift | 19 ++- .../QUICTests/ConnectionIDRotationTests.swift | 110 ++++++++++++++++++ 2 files changed, 125 insertions(+), 4 deletions(-) diff --git a/Sources/SwiftNetwork/QUIC/QUICConnection.swift b/Sources/SwiftNetwork/QUIC/QUICConnection.swift index eb52d2ec..a91a33e3 100644 --- a/Sources/SwiftNetwork/QUIC/QUICConnection.swift +++ b/Sources/SwiftNetwork/QUIC/QUICConnection.swift @@ -6935,11 +6935,22 @@ extension QUICConnection { // to treat having connection IDs in need of retirement that exceed this // limit as a connection error of type CONNECTION_ID_LIMIT_ERROR. // - // Every frame that gets this far can queue RETIRE_CONNECTION_ID frames - // below, and those only leave the queue once they are sent. + // RETIRE_CONNECTION_ID frames only leave the queue once they are sent. + // Count what this frame would queue below before queueing any of it: + // one for each connection ID under its Retire Prior To, and one for + // its own connection ID if an earlier frame already retired that + // sequence number. A frame that queues nothing is always accepted. let queuedRetireCount = withPendingItemsForKeyState { $0.retireConnectionIDs.count } - if queuedRetireCount >= 2 * remoteCIDs.activeConnectionIDLimit { - log.error("Received NEW_CONNECTION_ID frame with \(queuedRetireCount) RETIRE_CONNECTION_ID frames queued") + var newRetireCount = remoteCIDs.managedConnectionIDs.count(where: { + $0.sequenceNumber < frame.retirePriorToSequence + }) + if frame.sequence < retiredRemoteCIDSequenceNumberThreshold { + newRetireCount += 1 + } + if newRetireCount > 0 && queuedRetireCount + newRetireCount > 2 * remoteCIDs.activeConnectionIDLimit { + log.error( + "Received NEW_CONNECTION_ID frame retiring \(newRetireCount) connection IDs with \(queuedRetireCount) RETIRE_CONNECTION_ID frames queued" + ) close( with: .connectionIDLimitError, "NEW_CONNECTION_ID: too many connection IDs to retire", diff --git a/Tests/QUICTests/ConnectionIDRotationTests.swift b/Tests/QUICTests/ConnectionIDRotationTests.swift index ba0d6964..21b6e46f 100644 --- a/Tests/QUICTests/ConnectionIDRotationTests.swift +++ b/Tests/QUICTests/ConnectionIDRotationTests.swift @@ -135,6 +135,116 @@ final class ConnectionIDRotationTests: XCTestCase { wait(for: [expectation], timeout: 5.0) } + // Queues `count` RETIRE_CONNECTION_ID frames: one for the DCID in use, retired by a frame that + // raises Retire Prior To to `threshold`, and one for each later frame below `threshold`. + private func queueRetireConnectionIDs( + count: Int, + threshold: UInt64, + in eventContext: inout NetworkContext.EventContext + ) { + let rotation = FrameNewConnectionID( + sequence: threshold, + retirePriorToSequence: threshold, + connectionID: QUICConnectionID([0xB1, 0xB2, 0xB3, 0xB4])!, + statelessResetToken: QUICStatelessResetToken() + ) + _ = connection.processNewConnectionIDFrame(rotation, in: &eventContext) + for sequence in 1..