Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions Sources/SwiftNetwork/QUIC/QUICConnection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6926,6 +6926,39 @@ 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.
//
// 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 }
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",
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
Expand Down
173 changes: 173 additions & 0 deletions Tests/QUICTests/ConnectionIDRotationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,179 @@ 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)
}

// 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..<UInt64(count) {
let frame = FrameNewConnectionID(
sequence: sequence,
retirePriorToSequence: 0,
connectionID: QUICConnectionID([0xC0, UInt8(sequence)])!,
statelessResetToken: QUICStatelessResetToken()
)
_ = connection.processNewConnectionIDFrame(frame, in: &eventContext)
}
}

// With the queue at the limit, a NEW_CONNECTION_ID frame that retires nothing adds nothing
// to it, so it has to be accepted instead of closing the connection.
func testFrameRetiringNothingIsAcceptedAtRetireLimit() {
let expectation = XCTestExpectation()
connection.context.async {
let oldCID = QUICConnectionID([0xA1, 0xA2, 0xA3, 0xA4])!
let path = self.makePath(dcid: oldCID, sequenceNumber: 0, used: true)
self.connection.currentPath = path

let retireLimit = 2 * self.connection.remoteCIDs.activeConnectionIDLimit
let threshold = UInt64(retireLimit) + 1

let accepted = self.connection.fromExternal { eventContext in
self.queueRetireConnectionIDs(count: retireLimit, threshold: threshold, in: &eventContext)
let frame = FrameNewConnectionID(
sequence: threshold + 1,
retirePriorToSequence: threshold,
connectionID: QUICConnectionID([0xD1, 0xD2, 0xD3, 0xD4])!,
statelessResetToken: QUICStatelessResetToken()
)
return self.connection.processNewConnectionIDFrame(frame, in: &eventContext)
}

XCTAssertTrue(accepted, "A frame that retires nothing should be accepted")
XCTAssertNil(self.connection.closeError, "Connection should stay open")
XCTAssertEqual(
self.connection.withPendingItemsForKeyState { $0.retireConnectionIDs.count },
retireLimit,
"Queue should stay at the limit"
)

expectation.fulfill()
}
wait(for: [expectation], timeout: 5.0)
}

// With the queue one below the limit, a NEW_CONNECTION_ID frame that retires two connection
// IDs would take it past the limit, so the connection has to close before queueing either.
func testFrameRetiringSeveralCannotOvershootRetireLimit() {
let expectation = XCTestExpectation()
connection.context.async {
let oldCID = QUICConnectionID([0xA1, 0xA2, 0xA3, 0xA4])!
let path = self.makePath(dcid: oldCID, sequenceNumber: 0, used: true)
self.connection.currentPath = path

let retireLimit = 2 * self.connection.remoteCIDs.activeConnectionIDLimit
let threshold = UInt64(retireLimit) + 1

let accepted = self.connection.fromExternal { eventContext in
self.queueRetireConnectionIDs(count: retireLimit - 1, threshold: threshold, in: &eventContext)
// A second active connection ID, next to the one the rotation frame supplied.
let second = FrameNewConnectionID(
sequence: threshold + 1,
retirePriorToSequence: threshold,
connectionID: QUICConnectionID([0xD1, 0xD2, 0xD3, 0xD4])!,
statelessResetToken: QUICStatelessResetToken()
)
_ = self.connection.processNewConnectionIDFrame(second, in: &eventContext)
let retiringBoth = FrameNewConnectionID(
sequence: threshold + 3,
retirePriorToSequence: threshold + 2,
connectionID: QUICConnectionID([0xE1, 0xE2, 0xE3, 0xE4])!,
statelessResetToken: QUICStatelessResetToken()
)
return self.connection.processNewConnectionIDFrame(retiringBoth, in: &eventContext)
}

XCTAssertFalse(accepted, "A frame that would take the queue past the limit should be rejected")
XCTAssertEqual(
self.connection.closeError?.code,
QUICTransportError.QUICTransportErrorCode.connectionIDLimitError.rawValue,
"Connection should close with CONNECTION_ID_LIMIT_ERROR"
)
// Closing drops the queue, so check the connection IDs the frame would have retired.
XCTAssertEqual(
self.connection.remoteCIDs.count,
2,
"Connection should close before retiring any connection ID"
)

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
Expand Down
Loading