diff options
| author | Emīls <emils@mullvad.net> | 2024-10-02 13:06:41 +0200 |
|---|---|---|
| committer | Emīls <emils@mullvad.net> | 2024-10-02 13:06:41 +0200 |
| commit | 09d2b8ee10c7b93035503c417a479b3bdcfb13e1 (patch) | |
| tree | 5bd3c23313b643d2fff1fcea2714b0affb20da2e | |
| parent | 9e5b9c36a4a5b1577a42afcadf556ab2fbac2111 (diff) | |
| parent | 3fee7666bc4c93823179d79594cab05c56e8ad1f (diff) | |
| download | mullvadvpn-09d2b8ee10c7b93035503c417a479b3bdcfb13e1.tar.xz mullvadvpn-09d2b8ee10c7b93035503c417a479b3bdcfb13e1.zip | |
Merge branch 'separate-icmp-reads-from-writes-ios-852'
| -rw-r--r-- | ios/MullvadVPN.xcodeproj/project.pbxproj | 10 | ||||
| -rw-r--r-- | ios/MullvadVPN.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved | 2 | ||||
| -rw-r--r-- | ios/PacketTunnelCore/Pinger/Pinger.swift | 317 | ||||
| -rw-r--r-- | ios/PacketTunnelCore/Pinger/TunnelPinger.swift | 41 | ||||
| -rw-r--r-- | ios/PacketTunnelCoreTests/PingerTests.swift | 33 |
5 files changed, 23 insertions, 380 deletions
diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj index a2e87c4f98..82506832af 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -279,7 +279,6 @@ 58C7A4492A863F490060C66F /* PacketTunnelCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 58C7A4362A863F440060C66F /* PacketTunnelCore.framework */; }; 58C7A44A2A863F490060C66F /* PacketTunnelCore.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 58C7A4362A863F440060C66F /* PacketTunnelCore.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 58C7A4512A863FB50060C66F /* PingerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58799A352A84FC9F007BE51F /* PingerProtocol.swift */; }; - 58C7A4522A863FB50060C66F /* Pinger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5838318A27C40A3900000571 /* Pinger.swift */; }; 58C7A4552A863FB90060C66F /* TunnelMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FC040927B3EE03001C21F0 /* TunnelMonitor.swift */; }; 58C7A4562A863FB90060C66F /* DefaultPathObserverProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58225D252A84E8A10083D7F1 /* DefaultPathObserverProtocol.swift */; }; 58C7A4572A863FB90060C66F /* TunnelDeviceInfoProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 582403162A821FD700163DE8 /* TunnelDeviceInfoProtocol.swift */; }; @@ -289,7 +288,6 @@ 58C7A45C2A8640490060C66F /* MullvadLogging.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 58D223F3294C8FF00029F5F8 /* MullvadLogging.framework */; }; 58C7A4692A8643A90060C66F /* IPv4Header.h in Headers */ = {isa = PBXBuildFile; fileRef = 58218E1428B65058000C624F /* IPv4Header.h */; settings = {ATTRIBUTES = (Public, ); }; }; 58C7A46A2A8643A90060C66F /* ICMPHeader.h in Headers */ = {isa = PBXBuildFile; fileRef = 58218E1628B65396000C624F /* ICMPHeader.h */; settings = {ATTRIBUTES = (Public, ); }; }; - 58C7A4702A8649ED0060C66F /* PingerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58C7A46F2A8649ED0060C66F /* PingerTests.swift */; }; 58C7AF112ABD8480007EDD7A /* TunnelProviderMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 585DA89226B0323E00B8C587 /* TunnelProviderMessage.swift */; }; 58C7AF122ABD8480007EDD7A /* TunnelProviderReply.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5898D2A7290182B000EB5EBA /* TunnelProviderReply.swift */; }; 58C7AF162ABD84A8007EDD7A /* URLRequestProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58D229B6298D1D5200BB5A2D /* URLRequestProxy.swift */; }; @@ -1476,7 +1474,6 @@ 582FFA82290A84E700895745 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; }; 58342C032AAB61FB003BA12D /* State+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "State+Extensions.swift"; sourceTree = "<group>"; }; 5835B7CB233B76CB0096D79F /* TunnelManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelManager.swift; sourceTree = "<group>"; }; - 5838318A27C40A3900000571 /* Pinger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Pinger.swift; sourceTree = "<group>"; }; 5838321A2AC1B18400EA2071 /* PacketTunnelActor+Mocks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PacketTunnelActor+Mocks.swift"; sourceTree = "<group>"; }; 5838321C2AC1C54600EA2071 /* TaskSleepTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskSleepTests.swift; sourceTree = "<group>"; }; 5838321E2AC3160A00EA2071 /* PacketTunnelActor+KeyPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PacketTunnelActor+KeyPolicy.swift"; sourceTree = "<group>"; }; @@ -1673,7 +1670,6 @@ 58C7A4362A863F440060C66F /* PacketTunnelCore.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = PacketTunnelCore.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 58C7A4382A863F450060C66F /* PacketTunnelCore.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = PacketTunnelCore.h; sourceTree = "<group>"; }; 58C7A43D2A863F460060C66F /* PacketTunnelCoreTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = PacketTunnelCoreTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - 58C7A46F2A8649ED0060C66F /* PingerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PingerTests.swift; sourceTree = "<group>"; }; 58C8191729FAA2C400DEB1B4 /* NotificationConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationConfiguration.swift; sourceTree = "<group>"; }; 58CAF9F72983D36800BE19F7 /* Coordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Coordinator.swift; sourceTree = "<group>"; }; 58CAF9FF2983FF0200BE19F7 /* LoginInteractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginInteractor.swift; sourceTree = "<group>"; }; @@ -3361,7 +3357,6 @@ children = ( 58218E1628B65396000C624F /* ICMPHeader.h */, 58218E1428B65058000C624F /* IPv4Header.h */, - 5838318A27C40A3900000571 /* Pinger.swift */, 58799A352A84FC9F007BE51F /* PingerProtocol.swift */, 449275412C3570CA000526DE /* ICMP.swift */, 449275432C3C3029000526DE /* TunnelPinger.swift */, @@ -3392,7 +3387,6 @@ 58EC067D2A8D2B0700BEB973 /* Mocks */, F0C4C9BD2C49477B00A79006 /* MultiHopEphemeralPeerExchangerTests.swift */, 58FE25D32AA729B5003D1918 /* PacketTunnelActorTests.swift */, - 58C7A46F2A8649ED0060C66F /* PingerTests.swift */, A97D25B12B0CB02D00946B2D /* ProtocolObfuscatorTests.swift */, F0A163882C47B46300592300 /* SingleHopEphemeralPeerExchangerTests.swift */, 5838321C2AC1C54600EA2071 /* TaskSleepTests.swift */, @@ -5489,7 +5483,6 @@ files = ( 58FE25F42AA9D730003D1918 /* PacketTunnelActor+Extensions.swift in Sources */, 58DDA18F2ABC32380039C360 /* Timings.swift in Sources */, - 58C7A4522A863FB50060C66F /* Pinger.swift in Sources */, 580D6B8C2AB3369300B2D6E0 /* BlockedStateErrorMapperProtocol.swift in Sources */, 58C7AF172ABD84AA007EDD7A /* ProxyURLRequest.swift in Sources */, 5838321F2AC3160A00EA2071 /* PacketTunnelActor+KeyPolicy.swift in Sources */, @@ -5576,7 +5569,6 @@ 58FE25D42AA729B5003D1918 /* PacketTunnelActorTests.swift in Sources */, F07751572C50F149006E6A12 /* EphemeralPeerExchangingPipelineTests.swift in Sources */, 7A3FD1B52AD4465A0042BEA6 /* AppMessageHandlerTests.swift in Sources */, - 58C7A4702A8649ED0060C66F /* PingerTests.swift in Sources */, A97D25B22B0CB02D00946B2D /* ProtocolObfuscatorTests.swift in Sources */, F062B94D2C16E09700B6D47A /* TunnelSettingsManagerTests.swift in Sources */, ); @@ -9232,7 +9224,7 @@ repositoryURL = "https://github.com/mullvad/wireguard-apple.git"; requirement = { kind = revision; - revision = 5d5fbf1af490c2ec893cae908f5a204ac6f0da46; + revision = f1401d43f9d03438a81ca806b9f0c20269b116cb; }; }; /* End XCRemoteSwiftPackageReference section */ diff --git a/ios/MullvadVPN.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/MullvadVPN.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 0ded07e5b3..09aa5c9a58 100644 --- a/ios/MullvadVPN.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ios/MullvadVPN.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -14,7 +14,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/mullvad/wireguard-apple.git", "state" : { - "revision" : "5d5fbf1af490c2ec893cae908f5a204ac6f0da46" + "revision" : "f1401d43f9d03438a81ca806b9f0c20269b116cb" } } ], diff --git a/ios/PacketTunnelCore/Pinger/Pinger.swift b/ios/PacketTunnelCore/Pinger/Pinger.swift deleted file mode 100644 index 69ae1ab5af..0000000000 --- a/ios/PacketTunnelCore/Pinger/Pinger.swift +++ /dev/null @@ -1,317 +0,0 @@ -// -// Pinger.swift -// PacketTunnelCore -// -// Created by pronebird on 21/02/2022. -// Copyright © 2022 Mullvad VPN AB. All rights reserved. -// - -import Foundation -import Network - -// This is the legacy Pinger using native TCP/IP networking. - -/// ICMP client. -public final class Pinger: PingerProtocol { - // Socket read buffer size. - private static let bufferSize = 65535 - - // Sender identifier passed along with ICMP packet. - private let identifier: UInt16 - - private var sequenceNumber: UInt16 = 0 - private var socket: CFSocket? - private var readBuffer = [UInt8](repeating: 0, count: bufferSize) - private let stateLock = NSRecursiveLock() - private let replyQueue: DispatchQueue - private var destAddress: IPv4Address? - - public var onReply: ((PingerReply) -> Void)? { - get { - stateLock.withLock { - return _onReply - } - } - set { - stateLock.withLock { - _onReply = newValue - } - } - } - - private var _onReply: ((PingerReply) -> Void)? - - deinit { - closeSocket() - } - - public init(identifier: UInt16 = 757, replyQueue: DispatchQueue) { - self.identifier = identifier - self.replyQueue = replyQueue - } - - /// Open socket and optionally bind it to the given interface. - /// Automatically closes the previously opened socket when called multiple times in a row. - public func openSocket(bindTo interfaceName: String?, destAddress: IPv4Address) throws { - stateLock.lock() - defer { stateLock.unlock() } - - closeSocket() - - self.destAddress = destAddress - - var context = CFSocketContext() - context.info = Unmanaged.passUnretained(self).toOpaque() - - guard let newSocket = CFSocketCreate( - kCFAllocatorDefault, - AF_INET, - SOCK_DGRAM, - IPPROTO_ICMP, - CFSocketCallBackType.readCallBack.rawValue, - { socket, callbackType, _, _, info in - guard let socket, let info, callbackType == .readCallBack else { - return - } - - let pinger = Unmanaged<Pinger>.fromOpaque(info).takeUnretainedValue() - - pinger.readSocket(socket) - }, - &context - ) else { - throw Error.createSocket - } - - let flags = CFSocketGetSocketFlags(newSocket) - if (flags & kCFSocketCloseOnInvalidate) == 0 { - CFSocketSetSocketFlags(newSocket, flags | kCFSocketCloseOnInvalidate) - } - - if let interfaceName { - try bindSocket(newSocket, to: interfaceName) - } - - guard let runLoop = CFSocketCreateRunLoopSource(kCFAllocatorDefault, newSocket, 0) else { - throw Error.createRunLoop - } - - CFRunLoopAddSource(CFRunLoopGetMain(), runLoop, .commonModes) - - socket = newSocket - } - - public func closeSocket() { - stateLock.lock() - defer { stateLock.unlock() } - - if let socket { - CFSocketInvalidate(socket) - - self.socket = nil - } - } - - /// Send ping packet to the given address. - /// Returns `PingerSendResult` on success, otherwise throws a `Pinger.Error`. - public func send() throws -> PingerSendResult { - stateLock.lock() - defer { stateLock.unlock() } - - guard let socket else { - throw Error.closedSocket - } - - guard let destAddress else { - throw Error.parseIPAddress - } - - var sa = sockaddr_in() - sa.sin_len = UInt8(MemoryLayout.size(ofValue: sa)) - sa.sin_family = sa_family_t(AF_INET) - sa.sin_addr = destAddress.rawValue.withUnsafeBytes { buffer in - buffer.bindMemory(to: in_addr.self).baseAddress!.pointee - } - - let sequenceNumber = nextSequenceNumber() - let packetData = ICMP.createICMPPacket( - identifier: identifier, - sequenceNumber: sequenceNumber - ) - - let bytesSent = packetData.withUnsafeBytes { dataBuffer -> Int in - withUnsafeBytes(of: &sa) { bufferPointer in - let sockaddrPointer = bufferPointer.bindMemory(to: sockaddr.self).baseAddress! - - return sendto( - CFSocketGetNative(socket), - dataBuffer.baseAddress!, - dataBuffer.count, - 0, - sockaddrPointer, - socklen_t(MemoryLayout<sockaddr_in>.size) - ) - } - } - - guard bytesSent >= 0 else { - throw Error.sendPacket(errno) - } - - return PingerSendResult(sequenceNumber: sequenceNumber) - } - - private func nextSequenceNumber() -> UInt16 { - stateLock.lock() - let (nextValue, _) = sequenceNumber.addingReportingOverflow(1) - sequenceNumber = nextValue - stateLock.unlock() - - return nextValue - } - - private func readSocket(_ socket: CFSocket) { - var address = sockaddr() - var addressLength = socklen_t(MemoryLayout.size(ofValue: address)) - - let bytesRead = recvfrom( - CFSocketGetNative(socket), - &readBuffer, - Self.bufferSize, - 0, - &address, - &addressLength - ) - - do { - guard bytesRead > 0 else { throw Error.receivePacket(errno) } - - let icmpHeader = try ICMP.parseICMPResponse(buffer: &readBuffer, length: bytesRead) - guard icmpHeader.identifier == identifier else { - throw Error.clientIdentifierMismatch - } - guard icmpHeader.type == ICMP_ECHOREPLY else { - throw Error.invalidICMPType(icmpHeader.type) - } - guard let sender = Self.makeIPAddress(from: address) else { throw Error.parseIPAddress } - - replyQueue.async { - self.onReply?(.success(sender, icmpHeader.sequenceNumber)) - } - } catch Pinger.Error.clientIdentifierMismatch { - // Ignore responses from other senders. - } catch { - replyQueue.async { - self.onReply?(.parseError(error)) - } - } - } - - private func bindSocket(_ socket: CFSocket, to interfaceName: String) throws { - var index = if_nametoindex(interfaceName) - guard index > 0 else { - throw Error.mapInterfaceNameToIndex(errno) - } - - let result = setsockopt( - CFSocketGetNative(socket), - IPPROTO_IP, - IP_BOUND_IF, - &index, - socklen_t(MemoryLayout.size(ofValue: index)) - ) - - if result == -1 { - throw Error.bindSocket(errno) - } - } - - private static func makeIPAddress(from sa: sockaddr) -> IPAddress? { - if sa.sa_family == AF_INET { - return withUnsafeBytes(of: sa) { buffer -> IPAddress? in - buffer.bindMemory(to: sockaddr_in.self).baseAddress.flatMap { boundPointer in - var saddr = boundPointer.pointee - let data = Data(bytes: &saddr.sin_addr, count: MemoryLayout<in_addr>.size) - - return IPv4Address(data, nil) - } - } - } - - if sa.sa_family == AF_INET6 { - return withUnsafeBytes(of: sa) { buffer in - buffer.bindMemory(to: sockaddr_in6.self).baseAddress - .flatMap { boundPointer in - var saddr6 = boundPointer.pointee - let data = Data( - bytes: &saddr6.sin6_addr, - count: MemoryLayout<in6_addr>.size - ) - - return IPv6Address(data) - } - } - } - - return nil - } -} - -extension Pinger { - public enum Error: LocalizedError { - /// Failure to create a socket. - case createSocket - - /// Failure to map interface name to index. - case mapInterfaceNameToIndex(Int32) - - /// Failure to bind socket to interface. - case bindSocket(Int32) - - /// Failure to create a runloop for socket. - case createRunLoop - - /// Failure to send a packet due to socket being closed. - case closedSocket - - /// Failure to send packet. Contains the `errno`. - case sendPacket(Int32) - - /// Failure to receive packet. Contains the `errno`. - case receivePacket(Int32) - - /// Unexpected ICMP reply type - case invalidICMPType(UInt8) - - /// Response identifier does not match the sender identifier. - case clientIdentifierMismatch - - /// Failure to parse IP address. - case parseIPAddress - - public var errorDescription: String? { - switch self { - case .createSocket: - return "Failure to create socket." - case .mapInterfaceNameToIndex: - return "Failure to map interface name to index." - case .bindSocket: - return "Failure to bind socket to interface." - case .createRunLoop: - return "Failure to create run loop for socket." - case .closedSocket: - return "Socket is closed." - case let .sendPacket(code): - return "Failure to send packet (errno: \(code))." - case let .receivePacket(code): - return "Failure to receive packet (errno: \(code))." - case let .invalidICMPType(type): - return "Unexpected ICMP reply type: \(type)" - case .clientIdentifierMismatch: - return "Response identifier does not match the sender identifier." - case .parseIPAddress: - return "Failed to parse IP address." - } - } - } -} diff --git a/ios/PacketTunnelCore/Pinger/TunnelPinger.swift b/ios/PacketTunnelCore/Pinger/TunnelPinger.swift index f011fc9a01..9b9162f2e5 100644 --- a/ios/PacketTunnelCore/Pinger/TunnelPinger.swift +++ b/ios/PacketTunnelCore/Pinger/TunnelPinger.swift @@ -15,7 +15,7 @@ import WireGuardKit public final class TunnelPinger: PingerProtocol { private var sequenceNumber: UInt16 = 0 private let stateLock = NSRecursiveLock() - private let pingQueue: DispatchQueue + private let pingReceiveQueue: DispatchQueue private let replyQueue: DispatchQueue private var destAddress: IPv4Address? private var _onReply: ((PingerReply) -> Void)? @@ -39,7 +39,7 @@ public final class TunnelPinger: PingerProtocol { init(pingProvider: ICMPPingProvider, replyQueue: DispatchQueue) { self.pingProvider = pingProvider self.replyQueue = replyQueue - self.pingQueue = DispatchQueue(label: "PacketTunnel.icmp") + self.pingReceiveQueue = DispatchQueue(label: "PacketTunnel.Receive.icmp") self.logger = Logger(label: "TunnelPinger") } @@ -50,6 +50,22 @@ public final class TunnelPinger: PingerProtocol { public func openSocket(bindTo interfaceName: String?, destAddress: IPv4Address) throws { try pingProvider.openICMP(address: destAddress) self.destAddress = destAddress + pingReceiveQueue.async { [weak self] in + while let self { + do { + let seq = try pingProvider.receiveICMP() + + replyQueue.async { [weak self] in + self?.onReply?(PingerReply.success(destAddress, UInt16(seq))) + } + } catch { + replyQueue.async { [weak self] in + self?.onReply?(PingerReply.parseError(error)) + } + return + } + } + } } public func closeSocket() { @@ -59,25 +75,10 @@ public final class TunnelPinger: PingerProtocol { public func send() throws -> PingerSendResult { let sequenceNumber = nextSequenceNumber() - logger.debug("*** sending ping \(sequenceNumber)") - pingQueue.async { [weak self] in - guard let self, let destAddress else { return } - let reply: PingerReply - do { - try pingProvider.sendICMPPing(seqNumber: sequenceNumber) - // NOTE: we cheat here by returning the destination address we were passed, rather than parsing it from the packet on the other side of the FFI boundary. - reply = .success(destAddress, sequenceNumber) - } catch { - reply = .parseError(error) - } - self.logger.debug("--- Pinger reply: \(reply)") - - replyQueue.async { [weak self] in - guard let self else { return } - self.onReply?(reply) - } - } + guard let destAddress else { throw WireGuardAdapterError.invalidState } + // NOTE: we cheat here by returning the destination address we were passed, rather than parsing it from the packet on the other side of the FFI boundary. + try pingProvider.sendICMPPing(seqNumber: sequenceNumber) return PingerSendResult(sequenceNumber: UInt16(sequenceNumber)) } diff --git a/ios/PacketTunnelCoreTests/PingerTests.swift b/ios/PacketTunnelCoreTests/PingerTests.swift deleted file mode 100644 index e849b0fda2..0000000000 --- a/ios/PacketTunnelCoreTests/PingerTests.swift +++ /dev/null @@ -1,33 +0,0 @@ -// -// PingerTests.swift -// PacketTunnelCoreTests -// -// Created by pronebird on 11/08/2023. -// Copyright © 2023 Mullvad VPN AB. All rights reserved. -// - -@testable import MullvadMockData -import Network -import PacketTunnelCore -import XCTest - -final class PingerTests: XCTestCase { - func testPingingLocalhost() throws { - let expectation = self.expectation(description: "Wait for ping reply.") - let pinger = Pinger(identifier: 1234, replyQueue: .main) - - var sendResult: PingerSendResult? - - pinger.onReply = { reply in - if case let .success(sender, sequenceNumber) = reply, sendResult?.sequenceNumber == sequenceNumber { - XCTAssertTrue(sender.isLoopback) - expectation.fulfill() - } - } - - try pinger.openSocket(bindTo: "lo0", destAddress: .loopback) - sendResult = try pinger.send() - - waitForExpectations(timeout: .UnitTest.timeout) - } -} |
