diff options
| author | Andrej Mihajlov <and@mullvad.net> | 2022-01-28 10:44:38 +0100 |
|---|---|---|
| committer | Andrej Mihajlov <and@mullvad.net> | 2022-02-01 11:04:15 +0100 |
| commit | 9837e20d4dcad10251965e41eca9146683887791 (patch) | |
| tree | 942cf777d3e568d71e296ba095c85323d67cc317 | |
| parent | ee5d1d93b0ef5b53d2c3d0b8587a3cad6b931820 (diff) | |
| download | mullvadvpn-9837e20d4dcad10251965e41eca9146683887791.tar.xz mullvadvpn-9837e20d4dcad10251965e41eca9146683887791.zip | |
Add IPC timeout
| -rw-r--r-- | ios/MullvadVPN.xcodeproj/project.pbxproj | 4 | ||||
| -rw-r--r-- | ios/MullvadVPN/Operations/OperationCompletion.swift | 11 | ||||
| -rw-r--r-- | ios/MullvadVPN/TunnelIPC/TunnelIPCError.swift | 39 | ||||
| -rw-r--r-- | ios/MullvadVPN/TunnelIPC/TunnelIPCRequestOperation.swift | 232 | ||||
| -rw-r--r-- | ios/MullvadVPN/TunnelIPC/TunnelIPCSession.swift | 88 | ||||
| -rw-r--r-- | ios/MullvadVPN/TunnelManager/MapConnectionStatusOperation.swift | 51 | ||||
| -rw-r--r-- | ios/MullvadVPN/TunnelManager/ReloadTunnelOperation.swift | 96 |
7 files changed, 348 insertions, 173 deletions
diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj index fe92e0b56c..0cf24d4fb1 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -162,6 +162,7 @@ 5868585524054096000B8131 /* AppButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5868585424054096000B8131 /* AppButton.swift */; }; 5868BD33261DCD2600E6027F /* CustomSplitViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5868BD32261DCD2600E6027F /* CustomSplitViewController.swift */; }; 586ADD4723FC13F400CE9E87 /* countries.geo.json in Resources */ = {isa = PBXBuildFile; fileRef = 586ADD4523FC13F400CE9E87 /* countries.geo.json */; }; + 586E54FB27A2DF6D0029B88B /* TunnelIPCRequestOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 586E54FA27A2DF6D0029B88B /* TunnelIPCRequestOperation.swift */; }; 5871FB8325498CA20051A0A4 /* Swizzle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5871FB8225498CA20051A0A4 /* Swizzle.swift */; }; 5871FB96254ADE4E0051A0A4 /* ConsolidatedApplicationLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5871FB95254ADE4E0051A0A4 /* ConsolidatedApplicationLog.swift */; }; 5871FBA0254C26C00051A0A4 /* NSRegularExpression+IPAddress.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5871FB9F254C26BF0051A0A4 /* NSRegularExpression+IPAddress.swift */; }; @@ -466,6 +467,7 @@ 5868585424054096000B8131 /* AppButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppButton.swift; sourceTree = "<group>"; }; 5868BD32261DCD2600E6027F /* CustomSplitViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomSplitViewController.swift; sourceTree = "<group>"; }; 586ADD4523FC13F400CE9E87 /* countries.geo.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = countries.geo.json; sourceTree = "<group>"; }; + 586E54FA27A2DF6D0029B88B /* TunnelIPCRequestOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelIPCRequestOperation.swift; sourceTree = "<group>"; }; 5871FB8225498CA20051A0A4 /* Swizzle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Swizzle.swift; sourceTree = "<group>"; }; 5871FB95254ADE4E0051A0A4 /* ConsolidatedApplicationLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConsolidatedApplicationLog.swift; sourceTree = "<group>"; }; 5871FB9F254C26BF0051A0A4 /* NSRegularExpression+IPAddress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSRegularExpression+IPAddress.swift"; sourceTree = "<group>"; }; @@ -805,6 +807,7 @@ 585DA89226B0323E00B8C587 /* TunnelIPCRequest.swift */, 585DA89526B0328000B8C587 /* TunnelIPCResponse.swift */, 5875960926F371FC00BF6711 /* TunnelIPCSession.swift */, + 586E54FA27A2DF6D0029B88B /* TunnelIPCRequestOperation.swift */, ); path = TunnelIPC; sourceTree = "<group>"; @@ -1507,6 +1510,7 @@ 5868BD33261DCD2600E6027F /* CustomSplitViewController.swift in Sources */, 5806766E27048E5600C858CB /* KeychainMatchLimit.swift in Sources */, 58CCA01E2242787B004F3011 /* AccountTextField.swift in Sources */, + 586E54FB27A2DF6D0029B88B /* TunnelIPCRequestOperation.swift in Sources */, 584592612639B4A200EF967F /* ConsentContentView.swift in Sources */, 584EBDBD2747C98F00A0C9FD /* NSAttributedString+Markdown.swift in Sources */, 5875960A26F371FC00BF6711 /* TunnelIPCSession.swift in Sources */, diff --git a/ios/MullvadVPN/Operations/OperationCompletion.swift b/ios/MullvadVPN/Operations/OperationCompletion.swift index 8c115540e3..23d206d32d 100644 --- a/ios/MullvadVPN/Operations/OperationCompletion.swift +++ b/ios/MullvadVPN/Operations/OperationCompletion.swift @@ -30,4 +30,15 @@ enum OperationCompletion<Success, Failure: Error> { self = .failure(error) } } + + func mapError<NewFailure: Error>(_ block: (Failure) -> NewFailure) -> OperationCompletion<Success, NewFailure> { + switch self { + case .success(let value): + return .success(value) + case .failure(let error): + return .failure(block(error)) + case .cancelled: + return .cancelled + } + } } diff --git a/ios/MullvadVPN/TunnelIPC/TunnelIPCError.swift b/ios/MullvadVPN/TunnelIPC/TunnelIPCError.swift index 8775ce1e2e..b2af509c80 100644 --- a/ios/MullvadVPN/TunnelIPC/TunnelIPCError.swift +++ b/ios/MullvadVPN/TunnelIPC/TunnelIPCError.swift @@ -7,21 +7,22 @@ // import Foundation +import NetworkExtension extension TunnelIPC { /// An error type emitted by `TunnelIPC.Session`. enum Error: ChainedError { - /// A failure to encode the request + /// A failure to encode the request. case encoding(Swift.Error) - /// A failure to decode the response + /// A failure to decode the response. case decoding(Swift.Error) - /// A failure to send the IPC request - case send(Swift.Error) + /// A failure to send the IPC request. + case send(TunnelIPC.SendError) - /// A failure that's raised when the IPC response does not contain any data however the decoder - /// expected to receive data for decoding + /// A failure that's raised when the IPC response does not contain any data however the + /// decoder expected to receive data for decoding. case nilResponse var errorDescription: String? { @@ -31,10 +32,34 @@ extension TunnelIPC { case .decoding: return "Decoding failure" case .send: - return "Submission failure" + return "Send failure" case .nilResponse: return "Unexpected nil response from the tunnel" } } } + + enum SendError: ChainedError { + /// Tunnel process is either down or about to go down. + case tunnelDown(NEVPNStatus) + + /// Timeout + case timeout + + /// System error. + case system(Swift.Error) + + var errorDescription: String? { + switch self { + case .tunnelDown(let status): + return "Tunnel is either down or about to go down (status: \(status))" + + case .timeout: + return "Request timeout" + + case .system: + return "System error" + } + } + } } diff --git a/ios/MullvadVPN/TunnelIPC/TunnelIPCRequestOperation.swift b/ios/MullvadVPN/TunnelIPC/TunnelIPCRequestOperation.swift new file mode 100644 index 0000000000..0b74f6c737 --- /dev/null +++ b/ios/MullvadVPN/TunnelIPC/TunnelIPCRequestOperation.swift @@ -0,0 +1,232 @@ +// +// TunnelIPCRequestOperation.swift +// MullvadVPN +// +// Created by pronebird on 27/01/2022. +// Copyright © 2022 Mullvad VPN AB. All rights reserved. +// + +import Foundation +import NetworkExtension + +extension TunnelIPC { + + struct RequestOptions { + /// Wait until the tunnel transitioned from reasserting to connected state before sending + /// the request. + var waitIfReasserting: Bool + + /// Timeout interval in seconds. + var timeout: TimeInterval = 5 + } + + final class RequestOperation<Output>: AsyncOperation { + typealias DecoderHandler = (Data?) -> Result<Output, TunnelIPC.Error> + typealias CompletionHandler = (OperationCompletion<Output, TunnelIPC.Error>) -> Void + + private let queue: DispatchQueue + private let notificationQueue: OperationQueue + + private let connection: VPNConnectionProtocol + private let request: TunnelIPC.Request + private let options: RequestOptions + + private let decoderHandler: DecoderHandler + private var completionHandler: CompletionHandler? + + private var statusObserver: NSObjectProtocol? + private var timeoutTimer: DispatchSourceTimer? + + init(queue: DispatchQueue, + connection: VPNConnectionProtocol, + request: TunnelIPC.Request, + options: TunnelIPC.RequestOptions, + decoderHandler: @escaping DecoderHandler, + completionHandler: @escaping CompletionHandler) + { + self.queue = queue + self.notificationQueue = OperationQueue() + self.notificationQueue.underlyingQueue = queue + + self.connection = connection + self.request = request + self.options = options + + self.decoderHandler = decoderHandler + self.completionHandler = completionHandler + } + + override func main() { + queue.async { + self.execute() + } + } + + override func cancel() { + super.cancel() + + queue.async { + if self.isExecuting { + self.completeOperation(completion: .cancelled) + } + } + } + + private func execute() { + guard !isCancelled else { + completeOperation(completion: .cancelled) + return + } + + startTimeoutTimer() + + statusObserver = NotificationCenter.default.addObserver( + forName: .NEVPNStatusDidChange, + object: connection, + queue: notificationQueue) { [weak self] notification in + guard let self = self else { return } + guard let connection = notification.object as? VPNConnectionProtocol else { return } + + self.handleVPNStatus(connection.status) + } + + handleVPNStatus(connection.status) + } + + private func removeVPNStatusObserver() { + if let statusObserver = statusObserver { + NotificationCenter.default.removeObserver(statusObserver) + self.statusObserver = nil + } + } + + private func startTimeoutTimer() { + let timer = DispatchSource.makeTimerSource(queue: queue) + timer.setEventHandler { [weak self] in + self?.completeOperation(completion: .failure(.send(.timeout))) + } + + timer.schedule(wallDeadline: .now() + options.timeout) + timer.activate() + + timeoutTimer = timer + } + + private func stopTimeoutTimer() { + timeoutTimer?.cancel() + timeoutTimer = nil + } + + private func handleVPNStatus(_ status: NEVPNStatus) { + guard !isCancelled else { + return + } + + switch status { + case .connected: + sendRequest() + + case .connecting: + // Sending IPC message while in connecting state may cause the tunnel process to + // freeze for no apparent reason. + break + + case .reasserting: + if !options.waitIfReasserting { + sendRequest() + } + + case .invalid, .disconnecting, .disconnected: + completeOperation(completion: .failure(.send(.tunnelDown(status)))) + + @unknown default: + break + } + } + + private func sendRequest() { + let session = connection as! VPNTunnelProviderSessionProtocol + + removeVPNStatusObserver() + + let messageData: Data + do { + messageData = try TunnelIPC.Coding.encodeRequest(request) + } catch { + completeOperation(completion: .failure(.encoding(error))) + return + } + + do { + try session.sendProviderMessage(messageData) { [weak self] responseData in + guard let self = self else { return } + + self.queue.async { + let decodingResult = self.decoderHandler(responseData) + + self.completeOperation(completion: OperationCompletion(result: decodingResult)) + } + } + } catch { + completeOperation(completion: .failure(.send(.system(error)))) + } + } + + private func completeOperation(completion: OperationCompletion<Output, TunnelIPC.Error>) { + removeVPNStatusObserver() + stopTimeoutTimer() + + completionHandler?(completion) + completionHandler = nil + + finish() + } + } +} + +extension TunnelIPC.RequestOperation where Output: Codable { + convenience init( + queue: DispatchQueue, + connection: VPNConnectionProtocol, + request: TunnelIPC.Request, + options: TunnelIPC.RequestOptions, + completionHandler: @escaping CompletionHandler + ) + { + self.init( + queue: queue, + connection: connection, + request: request, + options: options, + decoderHandler: { data in + guard let data = data else { + return .failure(.nilResponse) + } + + let result = Result { try TunnelIPC.Coding.decodeResponse(Output.self, from: data) } + + return result.mapError { .decoding($0) } + }, + completionHandler: completionHandler + ) + } +} + +extension TunnelIPC.RequestOperation where Output == Void { + convenience init( + queue: DispatchQueue, + connection: VPNConnectionProtocol, + request: TunnelIPC.Request, + options: TunnelIPC.RequestOptions, + completionHandler: @escaping CompletionHandler + ) { + self.init( + queue: queue, + connection: connection, + request: request, + options: options, + decoderHandler: { _ in .success(()) }, + completionHandler: completionHandler + ) + } +} diff --git a/ios/MullvadVPN/TunnelIPC/TunnelIPCSession.swift b/ios/MullvadVPN/TunnelIPC/TunnelIPCSession.swift index 4589eeb45f..4b12b15db4 100644 --- a/ios/MullvadVPN/TunnelIPC/TunnelIPCSession.swift +++ b/ios/MullvadVPN/TunnelIPC/TunnelIPCSession.swift @@ -7,78 +7,50 @@ // import Foundation +import NetworkExtension extension TunnelIPC { - /// Wrapper class around `NETunnelProviderSession` that provides convenient interface for interacting with the - /// Packet Tunnel process. + /// Wrapper class around `NETunnelProviderSession` that provides convenient interface for + /// interacting with the Packet Tunnel process. final class Session { - private let tunnelProviderSession: VPNTunnelProviderSessionProtocol + private let connection: VPNConnectionProtocol + private let queue = DispatchQueue(label: "TunnelIPC.SessionQueue") + private let operationQueue = OperationQueue() - init<T: VPNTunnelProviderManagerProtocol>(from tunnelProvider: T) { - tunnelProviderSession = tunnelProvider.connection as! VPNTunnelProviderSessionProtocol + init(connection: VPNConnectionProtocol) { + self.connection = connection } - func reloadTunnelSettings(completionHandler: @escaping (TunnelIPC.Error?) -> Void) { - send(message: .reloadTunnelSettings) { result in - completionHandler(result.error) - } - } - - func getTunnelConnectionInfo(completionHandler: @escaping (Result<TunnelConnectionInfo?, TunnelIPC.Error>) -> Void) { - send(message: .tunnelConnectionInfo) { result in - completionHandler(result) - } - } - - // MARK: - Private - - private func send(message: TunnelIPC.Request, completionHandler: @escaping (Result<(), TunnelIPC.Error>) -> Void) { - sendWithoutDecoding(message: message) { (result) in - let result = result.map { _ in () } + func reloadTunnelSettings(completionHandler: @escaping (OperationCompletion<(), TunnelIPC.Error>) -> Void) -> Cancellable { + let operation = RequestOperation( + queue: queue, + connection: connection, + request: .reloadTunnelSettings, + options: TunnelIPC.RequestOptions(waitIfReasserting: true), + completionHandler: completionHandler + ) - completionHandler(result) - } - } - - private func send<T>(message: TunnelIPC.Request, completionHandler: @escaping (Result<T, TunnelIPC.Error>) -> Void) where T: Codable - { - sendWithoutDecoding(message: message) { (result) in - let result = result.flatMap { (data) -> Result<T, TunnelIPC.Error> in - guard let data = data else { - return .failure(.nilResponse) - } - - return Result { try TunnelIPC.Coding.decodeResponse(T.self, from: data) } - .mapError { error in - return .decoding(error) - } - } + operationQueue.addOperation(operation) - completionHandler(result) + return AnyCancellable { + operation.cancel() } } - private func sendWithoutDecoding(message: TunnelIPC.Request, completionHandler: @escaping (Result<Data?, TunnelIPC.Error>) -> Void) { - do { - let data = try TunnelIPC.Coding.encodeRequest(message) + func getTunnelConnectionInfo(completionHandler: @escaping (OperationCompletion<TunnelConnectionInfo?, TunnelIPC.Error>) -> Void) -> Cancellable { + let operation = RequestOperation<TunnelConnectionInfo?>( + queue: queue, + connection: connection, + request: .tunnelConnectionInfo, + options: TunnelIPC.RequestOptions(waitIfReasserting: false), + completionHandler: completionHandler + ) - sendProviderMessage(data) { (result) in - completionHandler(result) - } - } catch { - completionHandler(.failure(.encoding(error))) - } - } + operationQueue.addOperation(operation) - private func sendProviderMessage(_ messageData: Data, completionHandler: @escaping (Result<Data?, TunnelIPC.Error>) -> Void) { - do { - try tunnelProviderSession.sendProviderMessage(messageData) { response in - completionHandler(.success(response)) - } - } catch { - completionHandler(.failure(.send(error))) + return AnyCancellable { + operation.cancel() } } - } } diff --git a/ios/MullvadVPN/TunnelManager/MapConnectionStatusOperation.swift b/ios/MullvadVPN/TunnelManager/MapConnectionStatusOperation.swift index 88002f7212..6292b831b7 100644 --- a/ios/MullvadVPN/TunnelManager/MapConnectionStatusOperation.swift +++ b/ios/MullvadVPN/TunnelManager/MapConnectionStatusOperation.swift @@ -17,6 +17,7 @@ class MapConnectionStatusOperation: AsyncOperation { private let state: TunnelManager.State private let connectionStatus: NEVPNStatus private var startTunnelHandler: StartTunnelHandler? + private var request: Cancellable? private let logger = Logger(label: "TunnelManager.MapConnectionStatusOperation") @@ -37,10 +38,7 @@ class MapConnectionStatusOperation: AsyncOperation { super.cancel() queue.async { - // Finish immediately if cancelled during execution. - if self.isExecuting { - self.finish() - } + self.request?.cancel() } } @@ -62,27 +60,35 @@ class MapConnectionStatusOperation: AsyncOperation { } case .reasserting: - requestTunnelRelay(from: tunnelProvider) { [weak self] result in + let session = TunnelIPC.Session(connection: tunnelProvider.connection) + + request = session.getTunnelConnectionInfo { [weak self] completion in guard let self = self else { return } - if case .success(.some(let connectionInfo)) = result, !self.isCancelled { - self.state.tunnelState = .reconnecting(connectionInfo) - } + self.queue.async { + if case .success(.some(let connectionInfo)) = completion, !self.isCancelled { + self.state.tunnelState = .reconnecting(connectionInfo) + } - self.finish() + self.finish() + } } return case .connected: - requestTunnelRelay(from: tunnelProvider) { [weak self] result in + let session = TunnelIPC.Session(connection: tunnelProvider.connection) + + request = session.getTunnelConnectionInfo { [weak self] completion in guard let self = self else { return } - if case .success(.some(let connectionInfo)) = result, !self.isCancelled { - self.state.tunnelState = .connected(connectionInfo) - } + self.queue.async { + if case .success(.some(let connectionInfo)) = completion, !self.isCancelled { + self.state.tunnelState = .connected(connectionInfo) + } - self.finish() + self.finish() + } } return @@ -121,21 +127,4 @@ class MapConnectionStatusOperation: AsyncOperation { finish() } - - private func requestTunnelRelay(from tunnelProvider: TunnelProviderManagerType, completionHandler: @escaping (Result<TunnelConnectionInfo?, TunnelIPC.Error>?) -> Void) { - guard tunnelProvider.connection.status == .reasserting || tunnelProvider.connection.status == .connected else { - completionHandler(nil) - return - } - - let ipcSession = TunnelIPC.Session(from: tunnelProvider) - - ipcSession.getTunnelConnectionInfo { [weak self] result in - guard let self = self else { return } - - self.queue.async { - completionHandler(result) - } - } - } } diff --git a/ios/MullvadVPN/TunnelManager/ReloadTunnelOperation.swift b/ios/MullvadVPN/TunnelManager/ReloadTunnelOperation.swift index c1740c50d3..a520244e0f 100644 --- a/ios/MullvadVPN/TunnelManager/ReloadTunnelOperation.swift +++ b/ios/MullvadVPN/TunnelManager/ReloadTunnelOperation.swift @@ -7,15 +7,14 @@ // import Foundation -import NetworkExtension class ReloadTunnelOperation: AsyncOperation { typealias CompletionHandler = (OperationCompletion<(), TunnelManager.Error>) -> Void private let queue: DispatchQueue private let state: TunnelManager.State + private var request: Cancellable? private var completionHandler: CompletionHandler? - private var statusObserver: NSObjectProtocol? init(queue: DispatchQueue, state: TunnelManager.State, completionHandler: @escaping CompletionHandler) { self.queue = queue @@ -25,98 +24,41 @@ class ReloadTunnelOperation: AsyncOperation { override func main() { queue.async { - self.execute { [weak self] completion in - self?.completeOperation(completion: completion) - } - } - } - - override func cancel() { - super.cancel() - - queue.async { - self.removeStatusObserver() - - if self.isExecuting { + guard !self.isCancelled else { self.completeOperation(completion: .cancelled) + return } - } - } - - private func completeOperation(completion: OperationCompletion<(), TunnelManager.Error>) { - completionHandler?(completion) - completionHandler = nil - - finish() - } - - private func execute(completionHandler: @escaping CompletionHandler) { - guard !isCancelled else { - completionHandler(.cancelled) - return - } - - guard let tunnelProvider = self.state.tunnelProvider else { - completionHandler(.failure(.missingAccount)) - return - } - - let ipcSession = TunnelIPC.Session(from: tunnelProvider) - - // Add observer - statusObserver = NotificationCenter.default.addObserver( - forName: .NEVPNStatusDidChange, - object: tunnelProvider.connection, - queue: nil) { [weak self] notification in - guard let self = self else { return } - guard let connection = notification.object as? VPNConnectionProtocol else { return } - self.queue.async { - self.handleStatus(connection.status, ipcSession: ipcSession, completionHandler: completionHandler) - } + guard let tunnelProvider = self.state.tunnelProvider else { + self.completeOperation(completion: .failure(.missingAccount)) + return } - // Run initial check - handleStatus(tunnelProvider.connection.status, ipcSession: ipcSession, completionHandler: completionHandler) - } - - private func handleStatus(_ status: NEVPNStatus, ipcSession: TunnelIPC.Session, completionHandler: @escaping CompletionHandler) { - guard !isCancelled else { - completionHandler(.cancelled) - return - } + let session = TunnelIPC.Session(connection: tunnelProvider.connection) - switch status { - case .connected: - removeStatusObserver() - - ipcSession.reloadTunnelSettings { [weak self] error in + self.request = session.reloadTunnelSettings { [weak self] completion in guard let self = self else { return } self.queue.async { - completionHandler(error.map { .failure(.reloadTunnel($0)) } ?? .success(())) + self.completeOperation(completion: completion.mapError { .reloadTunnel($0) }) } } + } + } - case .connecting, .reasserting: - // wait for transition to complete - break - - case .invalid, .disconnecting, .disconnected: - removeStatusObserver() - completionHandler(.success(())) + override func cancel() { + super.cancel() - @unknown default: - break + queue.async { + self.request?.cancel() } } - private func removeStatusObserver() { - if let statusObserver = statusObserver { - NotificationCenter.default.removeObserver(statusObserver) + private func completeOperation(completion: OperationCompletion<(), TunnelManager.Error>) { + completionHandler?(completion) + completionHandler = nil - self.statusObserver = nil - } + finish() } } |
