From bc9f48e08e7a2853d0ca2237782c4a2a6c1f054f Mon Sep 17 00:00:00 2001 From: agnosticdev Date: Sat, 18 Jul 2026 14:23:19 -0700 Subject: [PATCH 1/4] SwiftQUIC: Provide path events when QUICPath changes or is validated --- .../Protocols/HarnessProtocols.swift | 10 + .../Protocols/NetworkEvents.swift | 36 +++ .../Protocols/ProtocolControlHandlers.swift | 11 + .../Protocols/ProtocolLinkage.swift | 6 + .../SwiftNetwork/Protocols/UDPProtocol.swift | 14 ++ Sources/SwiftNetwork/QUIC/Migration.swift | 8 + Sources/SwiftNetwork/QUIC/QUICPath.swift | 8 + .../SwiftNetworkQUICStackTests.swift | 215 ++++++++++++++++++ 8 files changed, 308 insertions(+) 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 (local: QUICPathInfo.PathAddress, remote: QUICPathInfo.PathAddress)? { + 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..f00681a 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 + ) -> (local: QUICPathInfo.PathAddress, remote: QUICPathInfo.PathAddress)? { + reference.getPathEndpoints(from) + } } @_spi(ProtocolProvider) diff --git a/Sources/SwiftNetwork/Protocols/UDPProtocol.swift b/Sources/SwiftNetwork/Protocols/UDPProtocol.swift index 7e8d7ab..76661c4 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() -> (local: QUICPathInfo.PathAddress, remote: QUICPathInfo.PathAddress)? { + if isIPv4 { + return ( + local: .v4(ipv4Local, port: localPort), + remote: .v4(ipv4Remote, port: remotePort) + ) + } else { + return ( + local: .v6(ipv6Local, port: localPort), + remote: .v6(ipv6Remote, port: remotePort) + ) + } + } } public init() {} diff --git a/Sources/SwiftNetwork/QUIC/Migration.swift b/Sources/SwiftNetwork/QUIC/Migration.swift index d5dec2a..d21ea75 100644 --- a/Sources/SwiftNetwork/QUIC/Migration.swift +++ b/Sources/SwiftNetwork/QUIC/Migration.swift @@ -217,6 +217,14 @@ extension QUICConnection { "Path \(path.identifier) \(path.state) over \(path.interface?.description ?? "nil")" ) } + // Notify the stack about a path change event + let endpoints = path.lower.invokeGetPathEndpoints(path.reference) + let pathInfo = QUICPathInfo( + local: endpoints?.local, + remote: endpoints?.remote, + isValidated: path.isValidated + ) + 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 77c6464..3cbf2ed 100644 --- a/Sources/SwiftNetwork/QUIC/QUICPath.swift +++ b/Sources/SwiftNetwork/QUIC/QUICPath.swift @@ -618,6 +618,14 @@ 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 + let endpoints = lower.invokeGetPathEndpoints(reference) + let pathInfo = QUICPathInfo( + local: endpoints?.local, + remote: endpoints?.remote, + isValidated: state.isValidated + ) + 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 d367726..e4800c4 100644 --- a/Tests/SwiftNetworkTests/SwiftNetworkQUICStackTests.swift +++ b/Tests/SwiftNetworkTests/SwiftNetworkQUICStackTests.swift @@ -538,6 +538,221 @@ final class SwiftNetworkQUICStackTests: NetTestCase { dataToSend: Array("Hello World!".utf8) ) } + + 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 serverEndpoint = Endpoint( + address: IPv4Address(SwiftNetworkQUICStackTests.localIPv4Address)!, + port: 8080 + ) + // The client gets a new source address; the server address stays fixed. + let newClientEndpoint = Endpoint( + address: IPv4Address(newLocalIPv4Address)!, + port: 4321 + ) + + 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) + XCTAssertNotNil(pathInfo.local!) + XCTAssertNotNil(pathInfo.remote!) + XCTAssertTrue(pathInfo.local! == serverEndpoint) + XCTAssertTrue(pathInfo.remote! == newClientEndpoint) + } + if let pathInfo = receivedPathChangedInfo { + XCTAssertNotNil(pathInfo.local!) + XCTAssertNotNil(pathInfo.remote!) + XCTAssertTrue(pathInfo.local! == serverEndpoint) + XCTAssertTrue(pathInfo.remote! == newClientEndpoint) + } + + let stopExpectation = XCTestExpectation(description: "stop") + context.async { + clientUpperHarness?.stop() + serverUpperHarness?.stop() + clientUpperHarness?.teardown() + serverUpperHarness?.teardown() + stopExpectation.fulfill() + } + wait(for: [stopExpectation], timeout: 10.0) + } } #endif #endif From 4087f10aa6b52354dc11cad5876a679ff7a62be4 Mon Sep 17 00:00:00 2001 From: agnosticdev Date: Sat, 18 Jul 2026 14:53:08 -0700 Subject: [PATCH 2/4] Safety checks --- .../Protocols/ProtocolControlHandlers.swift | 3 ++- Sources/SwiftNetwork/QUIC/Migration.swift | 15 ++++++++------- Sources/SwiftNetwork/QUIC/QUICPath.swift | 18 +++++++++++------- 3 files changed, 21 insertions(+), 15 deletions(-) diff --git a/Sources/SwiftNetwork/Protocols/ProtocolControlHandlers.swift b/Sources/SwiftNetwork/Protocols/ProtocolControlHandlers.swift index d53bec3..5f26fd8 100644 --- a/Sources/SwiftNetwork/Protocols/ProtocolControlHandlers.swift +++ b/Sources/SwiftNetwork/Protocols/ProtocolControlHandlers.swift @@ -898,7 +898,8 @@ extension ProtocolInstanceReference { public func getPathEndpoints( _ from: ProtocolInstanceReference ) -> (local: QUICPathInfo.PathAddress, remote: QUICPathInfo.PathAddress)? { - self.handleCallFromUpperProtocol { + guard !isNone else { return nil } + return self.handleCallFromUpperProtocol { switch self.reference { case .udp(let index): return context.udpInstances[index].pathEndpoints() default: return nil diff --git a/Sources/SwiftNetwork/QUIC/Migration.swift b/Sources/SwiftNetwork/QUIC/Migration.swift index d21ea75..b3541e2 100644 --- a/Sources/SwiftNetwork/QUIC/Migration.swift +++ b/Sources/SwiftNetwork/QUIC/Migration.swift @@ -218,13 +218,14 @@ extension QUICConnection { ) } // Notify the stack about a path change event - let endpoints = path.lower.invokeGetPathEndpoints(path.reference) - let pathInfo = QUICPathInfo( - local: endpoints?.local, - remote: endpoints?.remote, - isValidated: path.isValidated - ) - deliverNetworkProtocolEvent(flow: .allFlows, event: .init(quicEvent: .pathChanged(pathInfo))) + if let endpoints = path.lower.invokeGetPathEndpoints(path.reference) { + let pathInfo = QUICPathInfo( + local: endpoints.local, + remote: endpoints.remote, + isValidated: path.isValidated + ) + 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 3cbf2ed..8dca794 100644 --- a/Sources/SwiftNetwork/QUIC/QUICPath.swift +++ b/Sources/SwiftNetwork/QUIC/QUICPath.swift @@ -619,13 +619,17 @@ public final class QUICPath: MultiplexingDatagramPath, Equatable rtt.processNewSample(ackDuration: responseDuration, packetAckedTime: now, ackDelay: .zero) parentProtocol.migration.resetTimer(connection: parentProtocol) // Notify the stack about the path becoming validated - let endpoints = lower.invokeGetPathEndpoints(reference) - let pathInfo = QUICPathInfo( - local: endpoints?.local, - remote: endpoints?.remote, - isValidated: state.isValidated - ) - parentProtocol.deliverNetworkProtocolEvent(flow: .allFlows, event: .init(quicEvent: .pathValidated(pathInfo))) + if let endpoints = lower.invokeGetPathEndpoints(reference) { + let pathInfo = QUICPathInfo( + local: endpoints.local, + remote: endpoints.remote, + isValidated: state.isValidated + ) + parentProtocol.deliverNetworkProtocolEvent( + flow: .allFlows, + event: .init(quicEvent: .pathValidated(pathInfo)) + ) + } if migrationPending { migrationPending = false parentProtocol.migration.migrate(to: self, connection: parentProtocol) From c42008fe6b4a1d4f58da198f84a85b47736dd2c6 Mon Sep 17 00:00:00 2001 From: agnosticdev Date: Mon, 20 Jul 2026 09:07:38 -0700 Subject: [PATCH 3/4] Review feedback --- .../Protocols/ManyToManyProtocol.swift | 23 +++++++++++++++ .../Protocols/NetworkEvents.swift | 28 +++++-------------- .../Protocols/ProtocolControlHandlers.swift | 2 +- .../Protocols/ProtocolLinkage.swift | 2 +- .../SwiftNetwork/Protocols/UDPProtocol.swift | 6 ++-- Sources/SwiftNetwork/QUIC/Migration.swift | 8 ++---- Sources/SwiftNetwork/QUIC/QUICPath.swift | 8 ++---- .../SwiftNetworkQUICStackTests.swift | 12 +++----- 8 files changed, 43 insertions(+), 46 deletions(-) diff --git a/Sources/SwiftNetwork/Protocols/ManyToManyProtocol.swift b/Sources/SwiftNetwork/Protocols/ManyToManyProtocol.swift index 1c213b6..bd4dee3 100644 --- a/Sources/SwiftNetwork/Protocols/ManyToManyProtocol.swift +++ b/Sources/SwiftNetwork/Protocols/ManyToManyProtocol.swift @@ -253,6 +253,29 @@ public protocol MultiplexingPath: UpperProtocolHandler { public protocol MultiplexingDatapathPath: MultiplexingPath where LowerProtocol: OutboundDataLinkage, ParentProtocol: ManyToManyDatapathProtocol {} +@_spi(ProtocolProvider) +@available(Network 0.1.0, *) +public enum PathAddress: Sendable, Equatable { + case v4(IPv4Address, port: UInt16) + case v6(IPv6Address, port: UInt16) + var endpoint: Endpoint { + switch self { + case .v4(let address, let port): return Endpoint(address: address, port: port) + case .v6(let address, let port): return Endpoint(address: address, port: port) + } + } +} + +@_spi(ProtocolProvider) +@available(Network 0.1.0, *) +public struct DatagramPathEndpoints: Sendable, Equatable { + let local: PathAddress + let remote: PathAddress + public static func == (lhs: DatagramPathEndpoints, rhs: DatagramPathEndpoints) -> 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 aead60a..9a216a0 100644 --- a/Sources/SwiftNetwork/Protocols/NetworkEvents.swift +++ b/Sources/SwiftNetwork/Protocols/NetworkEvents.swift @@ -22,27 +22,13 @@ public struct NetworkEventDomain: Sendable, Hashable, CustomStringConvertible { @_spi(ProtocolProvider) @available(Network 0.1.0, *) public struct QUICPathInfo: Sendable, Equatable { - public enum PathAddress: Sendable, Equatable { - case v4(IPv4Address, port: UInt16) - case v6(IPv6Address, port: UInt16) - - var endpoint: Endpoint { - switch self { - case .v4(let address, let port): return Endpoint(address: address, port: port) - case .v6(let address, let port): return Endpoint(address: address, port: port) - } - } - } - private let _local: PathAddress? - private let _remote: PathAddress? + private var datagramPathEndpoints: DatagramPathEndpoints public let isValidated: Bool + public var remote: Endpoint { datagramPathEndpoints.remote.endpoint } + public var local: Endpoint { datagramPathEndpoints.local.endpoint } - public var local: Endpoint? { _local?.endpoint } - public var remote: Endpoint? { _remote?.endpoint } - - init(local: PathAddress?, remote: PathAddress?, isValidated: Bool) { - self._local = local - self._remote = remote + init(datagramPathEndpoints: DatagramPathEndpoints, isValidated: Bool) { + self.datagramPathEndpoints = datagramPathEndpoints self.isValidated = isValidated } } @@ -283,10 +269,10 @@ public enum QUICEvent: DomainSpecificNetworkProtocolEvent { return "QUIC: Received remote transport parameters" case .pathChanged(let pathInfo): return - "QUIC: Path changed local: \(pathInfo.local?.description ?? "N/A") remote: \(pathInfo.remote?.description ?? "N/A") validated: \(pathInfo.isValidated)" + "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 ?? "N/A") remote: \(pathInfo.remote?.description ?? "N/A")" + "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 5f26fd8..0edb912 100644 --- a/Sources/SwiftNetwork/Protocols/ProtocolControlHandlers.swift +++ b/Sources/SwiftNetwork/Protocols/ProtocolControlHandlers.swift @@ -897,7 +897,7 @@ extension ProtocolInstanceReference { public func getPathEndpoints( _ from: ProtocolInstanceReference - ) -> (local: QUICPathInfo.PathAddress, remote: QUICPathInfo.PathAddress)? { + ) -> DatagramPathEndpoints? { guard !isNone else { return nil } return self.handleCallFromUpperProtocol { switch self.reference { diff --git a/Sources/SwiftNetwork/Protocols/ProtocolLinkage.swift b/Sources/SwiftNetwork/Protocols/ProtocolLinkage.swift index f00681a..c497996 100644 --- a/Sources/SwiftNetwork/Protocols/ProtocolLinkage.swift +++ b/Sources/SwiftNetwork/Protocols/ProtocolLinkage.swift @@ -287,7 +287,7 @@ public struct OutboundDatagramLinkage: OutboundDataLinkage { public func invokeGetPathEndpoints( _ from: ProtocolInstanceReference - ) -> (local: QUICPathInfo.PathAddress, remote: QUICPathInfo.PathAddress)? { + ) -> DatagramPathEndpoints? { reference.getPathEndpoints(from) } } diff --git a/Sources/SwiftNetwork/Protocols/UDPProtocol.swift b/Sources/SwiftNetwork/Protocols/UDPProtocol.swift index 76661c4..b86e08b 100644 --- a/Sources/SwiftNetwork/Protocols/UDPProtocol.swift +++ b/Sources/SwiftNetwork/Protocols/UDPProtocol.swift @@ -486,14 +486,14 @@ public struct UDPProtocol: NetworkProtocol { snapshot.sentTransportByteCount = UInt64(transmitByteCount) } - func pathEndpoints() -> (local: QUICPathInfo.PathAddress, remote: QUICPathInfo.PathAddress)? { + func pathEndpoints() -> DatagramPathEndpoints? { if isIPv4 { - return ( + return DatagramPathEndpoints( local: .v4(ipv4Local, port: localPort), remote: .v4(ipv4Remote, port: remotePort) ) } else { - return ( + return DatagramPathEndpoints( local: .v6(ipv6Local, port: localPort), remote: .v6(ipv6Remote, port: remotePort) ) diff --git a/Sources/SwiftNetwork/QUIC/Migration.swift b/Sources/SwiftNetwork/QUIC/Migration.swift index b3541e2..7d04dd9 100644 --- a/Sources/SwiftNetwork/QUIC/Migration.swift +++ b/Sources/SwiftNetwork/QUIC/Migration.swift @@ -218,12 +218,8 @@ extension QUICConnection { ) } // Notify the stack about a path change event - if let endpoints = path.lower.invokeGetPathEndpoints(path.reference) { - let pathInfo = QUICPathInfo( - local: endpoints.local, - remote: endpoints.remote, - isValidated: path.isValidated - ) + if let datagramPathEndpoints = path.lower.invokeGetPathEndpoints(path.reference) { + let pathInfo = QUICPathInfo(datagramPathEndpoints: datagramPathEndpoints, isValidated: path.isValidated) deliverNetworkProtocolEvent(flow: .allFlows, event: .init(quicEvent: .pathChanged(pathInfo))) } diff --git a/Sources/SwiftNetwork/QUIC/QUICPath.swift b/Sources/SwiftNetwork/QUIC/QUICPath.swift index 8dca794..f374be7 100644 --- a/Sources/SwiftNetwork/QUIC/QUICPath.swift +++ b/Sources/SwiftNetwork/QUIC/QUICPath.swift @@ -619,12 +619,8 @@ public final class QUICPath: MultiplexingDatagramPath, Equatable rtt.processNewSample(ackDuration: responseDuration, packetAckedTime: now, ackDelay: .zero) parentProtocol.migration.resetTimer(connection: parentProtocol) // Notify the stack about the path becoming validated - if let endpoints = lower.invokeGetPathEndpoints(reference) { - let pathInfo = QUICPathInfo( - local: endpoints.local, - remote: endpoints.remote, - isValidated: state.isValidated - ) + if let datagramPathEndpoints = lower.invokeGetPathEndpoints(reference) { + let pathInfo = QUICPathInfo(datagramPathEndpoints: datagramPathEndpoints, isValidated: state.isValidated) parentProtocol.deliverNetworkProtocolEvent( flow: .allFlows, event: .init(quicEvent: .pathValidated(pathInfo)) diff --git a/Tests/SwiftNetworkTests/SwiftNetworkQUICStackTests.swift b/Tests/SwiftNetworkTests/SwiftNetworkQUICStackTests.swift index e4800c4..78f70ef 100644 --- a/Tests/SwiftNetworkTests/SwiftNetworkQUICStackTests.swift +++ b/Tests/SwiftNetworkTests/SwiftNetworkQUICStackTests.swift @@ -731,16 +731,12 @@ final class SwiftNetworkQUICStackTests: NetTestCase { // This validations are for the server side so the remote should change here if let pathInfo = receivedPathValidatedInfo { XCTAssertTrue(pathInfo.isValidated) - XCTAssertNotNil(pathInfo.local!) - XCTAssertNotNil(pathInfo.remote!) - XCTAssertTrue(pathInfo.local! == serverEndpoint) - XCTAssertTrue(pathInfo.remote! == newClientEndpoint) + XCTAssertTrue(pathInfo.local == serverEndpoint) + XCTAssertTrue(pathInfo.remote == newClientEndpoint) } if let pathInfo = receivedPathChangedInfo { - XCTAssertNotNil(pathInfo.local!) - XCTAssertNotNil(pathInfo.remote!) - XCTAssertTrue(pathInfo.local! == serverEndpoint) - XCTAssertTrue(pathInfo.remote! == newClientEndpoint) + XCTAssertTrue(pathInfo.local == serverEndpoint) + XCTAssertTrue(pathInfo.remote == newClientEndpoint) } let stopExpectation = XCTestExpectation(description: "stop") From 9b7d8e343d1309f571710a6402f284d09f479a69 Mon Sep 17 00:00:00 2001 From: agnosticdev Date: Thu, 23 Jul 2026 08:06:32 -0700 Subject: [PATCH 4/4] Adopt Sendable in AddressEndpoint --- .../Endpoint/AddressEndpoint.swift | 4 ++-- .../Endpoint/EndpointCommon.swift | 2 +- .../Endpoint/EthernetAddress.swift | 2 +- .../Protocols/ManyToManyProtocol.swift | 17 ++-------------- .../Protocols/NetworkEvents.swift | 10 ++-------- .../SwiftNetwork/Protocols/UDPProtocol.swift | 8 ++++---- Sources/SwiftNetwork/QUIC/Migration.swift | 6 +++++- Sources/SwiftNetwork/QUIC/QUICPath.swift | 6 +++++- .../SwiftNetworkQUICStackTests.swift | 20 +++++++++++-------- 9 files changed, 34 insertions(+), 41 deletions(-) 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/ManyToManyProtocol.swift b/Sources/SwiftNetwork/Protocols/ManyToManyProtocol.swift index bd4dee3..22929fb 100644 --- a/Sources/SwiftNetwork/Protocols/ManyToManyProtocol.swift +++ b/Sources/SwiftNetwork/Protocols/ManyToManyProtocol.swift @@ -253,24 +253,11 @@ public protocol MultiplexingPath: UpperProtocolHandler { public protocol MultiplexingDatapathPath: MultiplexingPath where LowerProtocol: OutboundDataLinkage, ParentProtocol: ManyToManyDatapathProtocol {} -@_spi(ProtocolProvider) -@available(Network 0.1.0, *) -public enum PathAddress: Sendable, Equatable { - case v4(IPv4Address, port: UInt16) - case v6(IPv6Address, port: UInt16) - var endpoint: Endpoint { - switch self { - case .v4(let address, let port): return Endpoint(address: address, port: port) - case .v6(let address, let port): return Endpoint(address: address, port: port) - } - } -} - @_spi(ProtocolProvider) @available(Network 0.1.0, *) public struct DatagramPathEndpoints: Sendable, Equatable { - let local: PathAddress - let remote: PathAddress + let local: AddressEndpoint + let remote: AddressEndpoint public static func == (lhs: DatagramPathEndpoints, rhs: DatagramPathEndpoints) -> Bool { lhs.local == rhs.local && lhs.remote == rhs.remote } diff --git a/Sources/SwiftNetwork/Protocols/NetworkEvents.swift b/Sources/SwiftNetwork/Protocols/NetworkEvents.swift index 9a216a0..e831f4f 100644 --- a/Sources/SwiftNetwork/Protocols/NetworkEvents.swift +++ b/Sources/SwiftNetwork/Protocols/NetworkEvents.swift @@ -22,15 +22,9 @@ public struct NetworkEventDomain: Sendable, Hashable, CustomStringConvertible { @_spi(ProtocolProvider) @available(Network 0.1.0, *) public struct QUICPathInfo: Sendable, Equatable { - private var datagramPathEndpoints: DatagramPathEndpoints public let isValidated: Bool - public var remote: Endpoint { datagramPathEndpoints.remote.endpoint } - public var local: Endpoint { datagramPathEndpoints.local.endpoint } - - init(datagramPathEndpoints: DatagramPathEndpoints, isValidated: Bool) { - self.datagramPathEndpoints = datagramPathEndpoints - self.isValidated = isValidated - } + public let remote: AddressEndpoint + public let local: AddressEndpoint } @_spi(ProtocolProvider) diff --git a/Sources/SwiftNetwork/Protocols/UDPProtocol.swift b/Sources/SwiftNetwork/Protocols/UDPProtocol.swift index b86e08b..a4def88 100644 --- a/Sources/SwiftNetwork/Protocols/UDPProtocol.swift +++ b/Sources/SwiftNetwork/Protocols/UDPProtocol.swift @@ -489,13 +489,13 @@ public struct UDPProtocol: NetworkProtocol { func pathEndpoints() -> DatagramPathEndpoints? { if isIPv4 { return DatagramPathEndpoints( - local: .v4(ipv4Local, port: localPort), - remote: .v4(ipv4Remote, port: remotePort) + local: AddressEndpoint(address: ipv4Local, port: localPort), + remote: AddressEndpoint(address: ipv4Remote, port: remotePort) ) } else { return DatagramPathEndpoints( - local: .v6(ipv6Local, port: localPort), - remote: .v6(ipv6Remote, port: remotePort) + local: AddressEndpoint(address: ipv6Local, port: localPort), + remote: AddressEndpoint(address: ipv6Remote, port: remotePort) ) } } diff --git a/Sources/SwiftNetwork/QUIC/Migration.swift b/Sources/SwiftNetwork/QUIC/Migration.swift index 7d04dd9..10e3748 100644 --- a/Sources/SwiftNetwork/QUIC/Migration.swift +++ b/Sources/SwiftNetwork/QUIC/Migration.swift @@ -219,7 +219,11 @@ extension QUICConnection { } // Notify the stack about a path change event if let datagramPathEndpoints = path.lower.invokeGetPathEndpoints(path.reference) { - let pathInfo = QUICPathInfo(datagramPathEndpoints: datagramPathEndpoints, isValidated: path.isValidated) + let pathInfo = QUICPathInfo( + isValidated: path.isValidated, + remote: datagramPathEndpoints.remote, + local: datagramPathEndpoints.local + ) deliverNetworkProtocolEvent(flow: .allFlows, event: .init(quicEvent: .pathChanged(pathInfo))) } diff --git a/Sources/SwiftNetwork/QUIC/QUICPath.swift b/Sources/SwiftNetwork/QUIC/QUICPath.swift index f374be7..f317e61 100644 --- a/Sources/SwiftNetwork/QUIC/QUICPath.swift +++ b/Sources/SwiftNetwork/QUIC/QUICPath.swift @@ -620,7 +620,11 @@ public final class QUICPath: MultiplexingDatagramPath, Equatable parentProtocol.migration.resetTimer(connection: parentProtocol) // Notify the stack about the path becoming validated if let datagramPathEndpoints = lower.invokeGetPathEndpoints(reference) { - let pathInfo = QUICPathInfo(datagramPathEndpoints: datagramPathEndpoints, isValidated: state.isValidated) + let pathInfo = QUICPathInfo( + isValidated: self.isValidated, + remote: datagramPathEndpoints.remote, + local: datagramPathEndpoints.local + ) parentProtocol.deliverNetworkProtocolEvent( flow: .allFlows, event: .init(quicEvent: .pathValidated(pathInfo)) diff --git a/Tests/SwiftNetworkTests/SwiftNetworkQUICStackTests.swift b/Tests/SwiftNetworkTests/SwiftNetworkQUICStackTests.swift index 78f70ef..1bb6692 100644 --- a/Tests/SwiftNetworkTests/SwiftNetworkQUICStackTests.swift +++ b/Tests/SwiftNetworkTests/SwiftNetworkQUICStackTests.swift @@ -548,14 +548,18 @@ final class SwiftNetworkQUICStackTests: NetTestCase { address: IPv4Address(SwiftNetworkQUICStackTests.localIPv4Address)!, port: 1234 ) + let serverAddress = IPv4Address(SwiftNetworkQUICStackTests.localIPv4Address)! + let serverPort: UInt16 = UInt16(8080) let serverEndpoint = Endpoint( - address: IPv4Address(SwiftNetworkQUICStackTests.localIPv4Address)!, - port: 8080 + 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: IPv4Address(newLocalIPv4Address)!, - port: 4321 + address: newClientAddress, + port: newCLientPort ) let clientParameters = Parameters() @@ -731,12 +735,12 @@ final class SwiftNetworkQUICStackTests: NetTestCase { // This validations are for the server side so the remote should change here if let pathInfo = receivedPathValidatedInfo { XCTAssertTrue(pathInfo.isValidated) - XCTAssertTrue(pathInfo.local == serverEndpoint) - XCTAssertTrue(pathInfo.remote == newClientEndpoint) + XCTAssertTrue(pathInfo.local == AddressEndpoint(address: serverAddress, port: serverPort)) + XCTAssertTrue(pathInfo.remote == AddressEndpoint(address: newClientAddress, port: newCLientPort)) } if let pathInfo = receivedPathChangedInfo { - XCTAssertTrue(pathInfo.local == serverEndpoint) - XCTAssertTrue(pathInfo.remote == newClientEndpoint) + XCTAssertTrue(pathInfo.local == AddressEndpoint(address: serverAddress, port: serverPort)) + XCTAssertTrue(pathInfo.remote == AddressEndpoint(address: newClientAddress, port: newCLientPort)) } let stopExpectation = XCTestExpectation(description: "stop")