diff --git a/Sources/SwiftNetwork/Endpoint/AddressEndpoint.swift b/Sources/SwiftNetwork/Endpoint/AddressEndpoint.swift index 413dce9..acb2236 100644 --- a/Sources/SwiftNetwork/Endpoint/AddressEndpoint.swift +++ b/Sources/SwiftNetwork/Endpoint/AddressEndpoint.swift @@ -24,9 +24,9 @@ internal import os @_spi(Essentials) @available(Network 0.1.0, *) -public struct AddressEndpoint: EndpointProtocol, EndpointCommonProtocol { +public struct AddressEndpoint: Sendable, EndpointProtocol, EndpointCommonProtocol { public var common: EndpointCommon - public enum AddressEndpointType: Equatable { + public enum AddressEndpointType: Sendable, Equatable { case v4(IPv4Address, UInt16) case v6(IPv6Address, UInt16) case unix(String) diff --git a/Sources/SwiftNetwork/Endpoint/EndpointCommon.swift b/Sources/SwiftNetwork/Endpoint/EndpointCommon.swift index 71b1aca..6228740 100644 --- a/Sources/SwiftNetwork/Endpoint/EndpointCommon.swift +++ b/Sources/SwiftNetwork/Endpoint/EndpointCommon.swift @@ -91,7 +91,7 @@ protocol EndpointProtocol: CustomStringConvertible { @_spi(Essentials) @available(Network 0.1.0, *) -public struct EndpointCommon: Equatable, Hashable { +public struct EndpointCommon: Sendable, Equatable, Hashable { let interface: Interface? #if NETWORK_PRIVATE let commonPrivate: EndpointCommon_Private? diff --git a/Sources/SwiftNetwork/Endpoint/EthernetAddress.swift b/Sources/SwiftNetwork/Endpoint/EthernetAddress.swift index 6b59629..9f0ab92 100644 --- a/Sources/SwiftNetwork/Endpoint/EthernetAddress.swift +++ b/Sources/SwiftNetwork/Endpoint/EthernetAddress.swift @@ -13,7 +13,7 @@ //===----------------------------------------------------------------------===// @available(Network 0.1.0, *) -public struct EthernetAddress: Hashable, CustomDebugStringConvertible { +public struct EthernetAddress: Sendable, Hashable, CustomDebugStringConvertible { public static var broadcast: EthernetAddress { EthernetAddress(EthernetAddressStorage(repeating: 0xff)) diff --git a/Sources/SwiftNetwork/Protocols/HarnessProtocols.swift b/Sources/SwiftNetwork/Protocols/HarnessProtocols.swift index d3c2984..f3c5de8 100644 --- a/Sources/SwiftNetwork/Protocols/HarnessProtocols.swift +++ b/Sources/SwiftNetwork/Protocols/HarnessProtocols.swift @@ -759,6 +759,8 @@ public class NewFlowHarness Void)? var newFlow = Deque<(() -> Void)>() public var error: ((NetworkError) -> Void)? // invoked when error detected + public var pathChanged: ((QUICPathInfo) -> Void)? + public var pathValidated: ((QUICPathInfo) -> Void)? public init() {} } public var completions: Completions = .init() @@ -818,6 +820,14 @@ public class NewFlowHarness Bool { + lhs.local == rhs.local && lhs.remote == rhs.remote + } +} + // MARK: Implementations @available(Network 0.1.0, *) diff --git a/Sources/SwiftNetwork/Protocols/NetworkEvents.swift b/Sources/SwiftNetwork/Protocols/NetworkEvents.swift index 484c032..e831f4f 100644 --- a/Sources/SwiftNetwork/Protocols/NetworkEvents.swift +++ b/Sources/SwiftNetwork/Protocols/NetworkEvents.swift @@ -19,6 +19,14 @@ public struct NetworkEventDomain: Sendable, Hashable, CustomStringConvertible { public var description: String { domain } } +@_spi(ProtocolProvider) +@available(Network 0.1.0, *) +public struct QUICPathInfo: Sendable, Equatable { + public let isValidated: Bool + public let remote: AddressEndpoint + public let local: AddressEndpoint +} + @_spi(ProtocolProvider) @available(Network 0.1.0, *) /// An extensible event that a lower protocol reports to upper protocols. @@ -226,6 +234,8 @@ public enum QUICEvent: DomainSpecificNetworkProtocolEvent { case maxStreamsLimitUnidirectionalUpdated(maximumStreams: Int) case earlyDataRejected case receivedRemoteTransportParameters(transportParameters: [UInt8]) + case pathChanged(_ info: QUICPathInfo) + case pathValidated(_ info: QUICPathInfo) public var description: String { switch self { @@ -251,6 +261,12 @@ public enum QUICEvent: DomainSpecificNetworkProtocolEvent { return "QUIC: Early data rejected" case .receivedRemoteTransportParameters: return "QUIC: Received remote transport parameters" + case .pathChanged(let pathInfo): + return + "QUIC: Path changed local: \(pathInfo.local.description) remote: \(pathInfo.remote.description) validated: \(pathInfo.isValidated)" + case .pathValidated(let pathInfo): + return + "QUIC: Path validated local: \(pathInfo.local.description) remote: \(pathInfo.remote.description)" } } } diff --git a/Sources/SwiftNetwork/Protocols/ProtocolControlHandlers.swift b/Sources/SwiftNetwork/Protocols/ProtocolControlHandlers.swift index 1ec3151..0edb912 100644 --- a/Sources/SwiftNetwork/Protocols/ProtocolControlHandlers.swift +++ b/Sources/SwiftNetwork/Protocols/ProtocolControlHandlers.swift @@ -895,6 +895,18 @@ extension ProtocolInstanceReference { } } + public func getPathEndpoints( + _ from: ProtocolInstanceReference + ) -> DatagramPathEndpoints? { + guard !isNone else { return nil } + return self.handleCallFromUpperProtocol { + switch self.reference { + case .udp(let index): return context.udpInstances[index].pathEndpoints() + default: return nil + } + } + } + public func getMetrics( _ from: ProtocolInstanceReference, requestedNetworkMetric: RequestedNetworkMetrics diff --git a/Sources/SwiftNetwork/Protocols/ProtocolLinkage.swift b/Sources/SwiftNetwork/Protocols/ProtocolLinkage.swift index 085ff0a..c497996 100644 --- a/Sources/SwiftNetwork/Protocols/ProtocolLinkage.swift +++ b/Sources/SwiftNetwork/Protocols/ProtocolLinkage.swift @@ -284,6 +284,12 @@ public struct OutboundDatagramLinkage: OutboundDataLinkage { ) throws(NetworkError) { try reference.sendDatagrams(from, datagrams: datagrams) } + + public func invokeGetPathEndpoints( + _ from: ProtocolInstanceReference + ) -> DatagramPathEndpoints? { + reference.getPathEndpoints(from) + } } @_spi(ProtocolProvider) diff --git a/Sources/SwiftNetwork/Protocols/UDPProtocol.swift b/Sources/SwiftNetwork/Protocols/UDPProtocol.swift index 7e8d7ab..a4def88 100644 --- a/Sources/SwiftNetwork/Protocols/UDPProtocol.swift +++ b/Sources/SwiftNetwork/Protocols/UDPProtocol.swift @@ -485,6 +485,20 @@ public struct UDPProtocol: NetworkProtocol { snapshot.receivedTransportByteCount = UInt64(receiveByteCount) snapshot.sentTransportByteCount = UInt64(transmitByteCount) } + + func pathEndpoints() -> DatagramPathEndpoints? { + if isIPv4 { + return DatagramPathEndpoints( + local: AddressEndpoint(address: ipv4Local, port: localPort), + remote: AddressEndpoint(address: ipv4Remote, port: remotePort) + ) + } else { + return DatagramPathEndpoints( + local: AddressEndpoint(address: ipv6Local, port: localPort), + remote: AddressEndpoint(address: ipv6Remote, port: remotePort) + ) + } + } } public init() {} diff --git a/Sources/SwiftNetwork/QUIC/Migration.swift b/Sources/SwiftNetwork/QUIC/Migration.swift index 578436d..3c74dea 100644 --- a/Sources/SwiftNetwork/QUIC/Migration.swift +++ b/Sources/SwiftNetwork/QUIC/Migration.swift @@ -213,6 +213,15 @@ extension QUICConnection { "Path \(path.identifier) \(path.state) over \(path.interface?.description ?? "nil")" ) } + // Notify the stack about a path change event + if let datagramPathEndpoints = path.lower.invokeGetPathEndpoints(path.reference) { + let pathInfo = QUICPathInfo( + isValidated: path.isValidated, + remote: datagramPathEndpoints.remote, + local: datagramPathEndpoints.local + ) + deliverNetworkProtocolEvent(flow: .allFlows, event: .init(quicEvent: .pathChanged(pathInfo))) + } // This is a new primary path. Migrate to it if we are the client. if !isServer, path != currentPath, isPrimary, path.isRouteEstablished { diff --git a/Sources/SwiftNetwork/QUIC/QUICPath.swift b/Sources/SwiftNetwork/QUIC/QUICPath.swift index f36b470..e2653c7 100644 --- a/Sources/SwiftNetwork/QUIC/QUICPath.swift +++ b/Sources/SwiftNetwork/QUIC/QUICPath.swift @@ -618,6 +618,18 @@ public final class QUICPath: MultiplexingDatagramPath, Equatable // Initialize RTT based on the PATH_RESPONSE duration so that we have a proper RTT estimate when we reset the timers. rtt.processNewSample(ackDuration: responseDuration, packetAckedTime: now, ackDelay: .zero) parentProtocol.migration.resetTimer(connection: parentProtocol) + // Notify the stack about the path becoming validated + if let datagramPathEndpoints = lower.invokeGetPathEndpoints(reference) { + let pathInfo = QUICPathInfo( + isValidated: self.isValidated, + remote: datagramPathEndpoints.remote, + local: datagramPathEndpoints.local + ) + parentProtocol.deliverNetworkProtocolEvent( + flow: .allFlows, + event: .init(quicEvent: .pathValidated(pathInfo)) + ) + } if migrationPending { migrationPending = false parentProtocol.migration.migrate(to: self, connection: parentProtocol) diff --git a/Tests/SwiftNetworkTests/SwiftNetworkQUICStackTests.swift b/Tests/SwiftNetworkTests/SwiftNetworkQUICStackTests.swift index 62a6a26..5dd0f38 100644 --- a/Tests/SwiftNetworkTests/SwiftNetworkQUICStackTests.swift +++ b/Tests/SwiftNetworkTests/SwiftNetworkQUICStackTests.swift @@ -550,6 +550,221 @@ final class SwiftNetworkQUICStackTests: NetTestCase { ) } + func testQUICPathEvents() { + // 10.0.0.99 - new client address simulating the client switching network interfaces + // So this test would be client initiated migration + let newLocalIPv4Address: [UInt8] = [0x0a, 0x00, 0x00, 0x63] + + let clientEndpoint = Endpoint( + address: IPv4Address(SwiftNetworkQUICStackTests.localIPv4Address)!, + port: 1234 + ) + let serverAddress = IPv4Address(SwiftNetworkQUICStackTests.localIPv4Address)! + let serverPort: UInt16 = UInt16(8080) + let serverEndpoint = Endpoint( + address: serverAddress, + port: serverPort + ) + // The client gets a new source address; the server address stays fixed. + let newClientAddress = IPv4Address(newLocalIPv4Address)! + let newCLientPort = UInt16(4321) + let newClientEndpoint = Endpoint( + address: newClientAddress, + port: newCLientPort + ) + + let clientParameters = Parameters() + let context = clientParameters.context + + let handshakeExpectation = XCTestExpectation(description: "QUIC handshake complete") + let pathChangedExpectation = XCTestExpectation(description: "pathChanged event received") + let pathValidatedExpectation = XCTestExpectation(description: "pathValidated event received") + + var clientQUICReference: ProtocolInstanceReference? + var serverQUICReference: ProtocolInstanceReference? + var clientUpperHarness: StreamUpperHarness? + var serverUpperHarness: NewStreamFlowHarness? + var pairedPathsArray = [PairedUDPIPPaths]() + + var receivedPathChangedInfo: QUICPathInfo? + var receivedPathValidatedInfo: QUICPathInfo? + + context.async { + defer { handshakeExpectation.fulfill() } + + let initialPaths = PairedUDPIPPaths( + context: context, + identifier: "1", + clientEndpoint: clientEndpoint, + serverEndpoint: serverEndpoint + ) + pairedPathsArray.append(initialPaths) + + let clientQUICOptions = self.createQUICTestOptions( + server: false, + sourceConnectionIDLength: 8 + ) + clientQUICOptions.setLogID(prefix: "C", parent: "pathEvents", protocolLogIDNumber: 1) + let clientQUIC = QUICProtocol.instance(context: context) + clientQUICOptions.setProtocolInstance(clientQUIC) + clientQUICReference = clientQUIC + + clientParameters.defaultStack.prepend(applicationProtocol: .quic(clientQUICOptions)) + + let clientPath = PathProperties(parameters: clientParameters) + let clientListenerLinkage = StreamListenerLinkage(reference: clientQUIC) + clientUpperHarness = StreamUpperHarness( + identifier: "Client", + local: clientEndpoint, + remote: serverEndpoint, + parameters: clientParameters, + path: clientPath, + context: context, + listenerProtocol: clientListenerLinkage + ) + XCTAssertNotNil(clientUpperHarness) + guard let clientUpperHarness else { return } + + try! clientQUIC.attachLowerDatagramProtocolForNewPath( + initialPaths.clientTop, + remote: serverEndpoint, + local: clientEndpoint, + parameters: clientParameters, + path: clientPath + ) + + var serverParameters = Parameters() + serverParameters.isServer = true + let serverPath = PathProperties(parameters: serverParameters) + let serverQUICOptions = self.createQUICTestOptions(server: true) + serverQUICOptions.setLogID(prefix: "L", parent: "pathEvents", protocolLogIDNumber: 1) + let serverQUIC = QUICProtocol.instance(context: context) + serverQUICOptions.setProtocolInstance(serverQUIC) + serverQUICReference = serverQUIC + + serverParameters.defaultStack.prepend(applicationProtocol: .quic(serverQUICOptions)) + + let serverListenerLinkage = StreamListenerLinkage(reference: serverQUIC) + serverUpperHarness = NewStreamFlowHarness( + identifier: "Server", + local: serverEndpoint, + remote: clientEndpoint, + parameters: serverParameters, + path: serverPath, + context: context, + listenerProtocol: serverListenerLinkage + ) + XCTAssertNotNil(serverUpperHarness) + guard let serverUpperHarness else { return } + + try! serverQUIC.attachLowerDatagramProtocolForNewPath( + initialPaths.serverTop, + remote: clientEndpoint, + local: serverEndpoint, + parameters: serverParameters, + path: serverPath + ) + + // Register path event callbacks on the server-side connection harness + serverUpperHarness.completions.pathChanged = { pathInfo in + receivedPathChangedInfo = pathInfo + pathChangedExpectation.fulfill() + } + serverUpperHarness.completions.pathValidated = { pathInfo in + receivedPathValidatedInfo = pathInfo + pathValidatedExpectation.fulfill() + } + + var serverConnected = false + serverUpperHarness.start { connected in + serverConnected = true + XCTAssertTrue(connected) + handshakeExpectation.fulfill() + } + + clientUpperHarness.start { connected in + XCTAssertTrue(connected) + } + + while !serverConnected { + if initialPaths.transferPackets() == 0 { break } + } + } + + wait(for: [handshakeExpectation], timeout: 10.0) + XCTAssertNotNil(clientQUICReference) + XCTAssertNotNil(serverQUICReference) + guard let clientQUICReference, let serverQUICReference else { return } + + // Migration: client switches to a new source address, server address is unchanged + let migrationExpectation = XCTestExpectation(description: "migration path complete") + context.async { + defer { migrationExpectation.fulfill() } + + // The server endpoint is the same — only the clients source address changes. + let migrationPaths = PairedUDPIPPaths( + context: context, + identifier: "2", + clientEndpoint: newClientEndpoint, + serverEndpoint: serverEndpoint, + maximumDatagramSize: 1500 + ) + pairedPathsArray.append(migrationPaths) + + let clientMigrationParameters = Parameters() + let serverMigrationParameters = Parameters() + + try! clientQUICReference.attachLowerDatagramProtocolForNewPath( + migrationPaths.clientTop, + remote: serverEndpoint, + local: newClientEndpoint, + parameters: clientMigrationParameters, + path: PathProperties(parameters: clientMigrationParameters) + ) + try! serverQUICReference.attachLowerDatagramProtocolForNewPath( + migrationPaths.serverTop, + remote: newClientEndpoint, + local: serverEndpoint, + parameters: serverMigrationParameters, + path: PathProperties(parameters: serverMigrationParameters) + ) + + migrationPaths.server.deliverPathIsNotPrimary() + migrationPaths.client.deliverPathIsPrimary() + + for _ in 0..<10 { + if migrationPaths.transferPackets() == 0 { break } + } + } + + wait(for: [migrationExpectation], timeout: 10.0) + wait(for: [pathChangedExpectation, pathValidatedExpectation], timeout: 10.0) + + XCTAssertNotNil(receivedPathChangedInfo, "Expected pathChanged event") + XCTAssertNotNil(receivedPathValidatedInfo, "Expected pathValidated event") + + // This validations are for the server side so the remote should change here + if let pathInfo = receivedPathValidatedInfo { + XCTAssertTrue(pathInfo.isValidated) + XCTAssertTrue(pathInfo.local == AddressEndpoint(address: serverAddress, port: serverPort)) + XCTAssertTrue(pathInfo.remote == AddressEndpoint(address: newClientAddress, port: newCLientPort)) + } + if let pathInfo = receivedPathChangedInfo { + XCTAssertTrue(pathInfo.local == AddressEndpoint(address: serverAddress, port: serverPort)) + XCTAssertTrue(pathInfo.remote == AddressEndpoint(address: newClientAddress, port: newCLientPort)) + } + + let stopExpectation = XCTestExpectation(description: "stop") + context.async { + clientUpperHarness?.stop() + serverUpperHarness?.stop() + clientUpperHarness?.teardown() + serverUpperHarness?.teardown() + stopExpectation.fulfill() + } + wait(for: [stopExpectation], timeout: 10.0) + } + func testQUICStackMigrationDoesNotLeakPaths() { let ipv4Client = Endpoint(address: IPv4Address(SwiftNetworkQUICStackTests.localIPv4Address)!, port: 1234) let ipv4Server = Endpoint(address: IPv4Address(SwiftNetworkQUICStackTests.localIPv4Address)!, port: 8080)