diff options
| author | Andrej Mihajlov <and@mullvad.net> | 2021-10-04 12:03:00 +0200 |
|---|---|---|
| committer | Andrej Mihajlov <and@mullvad.net> | 2021-10-04 12:03:00 +0200 |
| commit | 8ff5fb4524cb436253f83c7b14ff61cbc2c0a946 (patch) | |
| tree | 37b65189f956ec96cdc9dea92f70beb42ac59a3b | |
| parent | 0556f7e2960699bb8022247bcfe33d5c215c1955 (diff) | |
| parent | c00981e683531815b73b217b154695af31b1676b (diff) | |
| download | mullvadvpn-8ff5fb4524cb436253f83c7b14ff61cbc2c0a946.tar.xz mullvadvpn-8ff5fb4524cb436253f83c7b14ff61cbc2c0a946.zip | |
Merge branch 'promise-cancellation-chain'
| -rw-r--r-- | ios/MullvadVPN.xcodeproj/project.pbxproj | 8 | ||||
| -rw-r--r-- | ios/MullvadVPN/Promise/Cancellable.swift | 13 | ||||
| -rw-r--r-- | ios/MullvadVPN/Promise/Promise+BackgroundTask.swift | 20 | ||||
| -rw-r--r-- | ios/MullvadVPN/Promise/Promise+Delay.swift | 15 | ||||
| -rw-r--r-- | ios/MullvadVPN/Promise/Promise+OperationQueue.swift | 37 | ||||
| -rw-r--r-- | ios/MullvadVPN/Promise/Promise+ReceiveOn.swift | 8 | ||||
| -rw-r--r-- | ios/MullvadVPN/Promise/Promise.swift | 210 | ||||
| -rw-r--r-- | ios/MullvadVPN/TunnelManager/TunnelManager.swift | 1 | ||||
| -rw-r--r-- | ios/MullvadVPNTests/PromiseTests.swift | 196 |
9 files changed, 373 insertions, 135 deletions
diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj index 40b1cbc403..f85fcd3fcc 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -182,6 +182,9 @@ 5888AD83227B11080051EB06 /* SelectLocationCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5888AD82227B11080051EB06 /* SelectLocationCell.swift */; }; 5888AD87227B17950051EB06 /* SelectLocationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5888AD86227B17950051EB06 /* SelectLocationViewController.swift */; }; 588D2FE3248AC27F00E313F7 /* AsyncOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58E973DD24850EB600096F90 /* AsyncOperation.swift */; }; + 588DD76B26FCB49E006F6233 /* Cancellable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 588DD76A26FCB49E006F6233 /* Cancellable.swift */; }; + 588DD76C26FCB49E006F6233 /* Cancellable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 588DD76A26FCB49E006F6233 /* Cancellable.swift */; }; + 588DD76D26FCB4A2006F6233 /* Cancellable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 588DD76A26FCB49E006F6233 /* Cancellable.swift */; }; 58907D9524D17B4E00CFC3F5 /* DisconnectSplitButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58907D9424D17B4E00CFC3F5 /* DisconnectSplitButton.swift */; }; 5891BF1C25E3E3EB006D6FB0 /* Bundle+ProductVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5891BF1B25E3E3EB006D6FB0 /* Bundle+ProductVersion.swift */; }; 5891BF5125E66B1E006D6FB0 /* UIBarButtonItem+KeyboardNavigation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5891BF5025E66B1E006D6FB0 /* UIBarButtonItem+KeyboardNavigation.swift */; }; @@ -440,6 +443,7 @@ 587CBFE222807F530028DED3 /* UIColor+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIColor+Helpers.swift"; sourceTree = "<group>"; }; 5888AD82227B11080051EB06 /* SelectLocationCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectLocationCell.swift; sourceTree = "<group>"; }; 5888AD86227B17950051EB06 /* SelectLocationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectLocationViewController.swift; sourceTree = "<group>"; }; + 588DD76A26FCB49E006F6233 /* Cancellable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Cancellable.swift; sourceTree = "<group>"; }; 58906DDF2445C7A5002F0673 /* NEProviderStopReason+Debug.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NEProviderStopReason+Debug.swift"; sourceTree = "<group>"; }; 58907D9424D17B4E00CFC3F5 /* DisconnectSplitButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisconnectSplitButton.swift; sourceTree = "<group>"; }; 5891BF1B25E3E3EB006D6FB0 /* Bundle+ProductVersion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Bundle+ProductVersion.swift"; sourceTree = "<group>"; }; @@ -914,6 +918,7 @@ 5860392826DCE7AB00554C79 /* PromiseCompletion.swift */, 58E1336C26D2BE7500CC316B /* AnyResult.swift */, 58E1337026D2BE9C00CC316B /* AnyOptional.swift */, + 588DD76A26FCB49E006F6233 /* Cancellable.swift */, 58E1337426D2BEC400CC316B /* Promise+Optional.swift */, 58E1337826D2BEDD00CC316B /* Promise+ReceiveOn.swift */, 5820675F26E75A4D00655B05 /* Promise+Delay.swift */, @@ -1226,6 +1231,7 @@ 5857F23824C8446700CF6F47 /* AsyncBlockOperation.swift in Sources */, 582AE3122440CA0D00E6733A /* AccountTokenInputTests.swift in Sources */, 585DA8A526B14EE000B8C587 /* TunnelConnectionInfo.swift in Sources */, + 588DD76D26FCB4A2006F6233 /* Cancellable.swift in Sources */, 5896AE7E246ACE65005B36CB /* KeychainAttributes.swift in Sources */, 58B0A2A9238EE6A100BC001D /* RelayConstraints.swift in Sources */, 5807E2C2243203D000F5FF30 /* StringTests.swift in Sources */, @@ -1362,6 +1368,7 @@ 5815039D24D6ECE600C9C50E /* TextFileOutputStream.swift in Sources */, 581CBCEE229826FD00727D7F /* StaticTableViewDataSource.swift in Sources */, 58CE5E64224146200008646E /* AppDelegate.swift in Sources */, + 588DD76B26FCB49E006F6233 /* Cancellable.swift in Sources */, 58ACF64F26567A7100ACE4B7 /* CustomSwitchContainer.swift in Sources */, 5857F24324C8662600CF6F47 /* SelectLocationHeaderView.swift in Sources */, 58AEEF652344A36000C9BBD5 /* KeychainError.swift in Sources */, @@ -1457,6 +1464,7 @@ 58432B9C26F9D7C400F97148 /* ChainedError.swift in Sources */, 58432BC426F9DB0200F97148 /* KeychainReturn.swift in Sources */, 58432BCA26F9DB4500F97148 /* OSLogHandler.swift in Sources */, + 588DD76C26FCB49E006F6233 /* Cancellable.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/ios/MullvadVPN/Promise/Cancellable.swift b/ios/MullvadVPN/Promise/Cancellable.swift new file mode 100644 index 0000000000..7027416515 --- /dev/null +++ b/ios/MullvadVPN/Promise/Cancellable.swift @@ -0,0 +1,13 @@ +// +// Cancellable.swift +// MullvadVPN +// +// Created by pronebird on 23/09/2021. +// Copyright © 2021 Mullvad VPN AB. All rights reserved. +// + +import Foundation + +protocol Cancellable { + func cancel() +} diff --git a/ios/MullvadVPN/Promise/Promise+BackgroundTask.swift b/ios/MullvadVPN/Promise/Promise+BackgroundTask.swift index 1d7bfc3280..95561717a8 100644 --- a/ios/MullvadVPN/Promise/Promise+BackgroundTask.swift +++ b/ios/MullvadVPN/Promise/Promise+BackgroundTask.swift @@ -12,7 +12,7 @@ extension Promise { /// Start the background task for the duration of the upstream execution. func requestBackgroundTime(taskName: String? = nil) -> Promise<Value> { - return Promise<Value> { resolver in + return Promise<Value>(parent: self) { resolver in var backgroundTaskIdentifier: UIBackgroundTaskIdentifier? let beginBackgroundTask = { @@ -29,28 +29,20 @@ extension Promise { backgroundTaskIdentifier = nil } - let endBackgroundTaskOnMainQueue = { - if Thread.isMainThread { - endBackgroundTask() - } else { - DispatchQueue.main.async(execute: endBackgroundTask) - } - } - if Thread.isMainThread { beginBackgroundTask() } else { DispatchQueue.main.async(execute: beginBackgroundTask) } - resolver.setCancelHandler { - endBackgroundTaskOnMainQueue() - } - self.observe { completion in resolver.resolve(completion: completion) - endBackgroundTaskOnMainQueue() + if Thread.isMainThread { + endBackgroundTask() + } else { + DispatchQueue.main.async(execute: endBackgroundTask) + } } } } diff --git a/ios/MullvadVPN/Promise/Promise+Delay.swift b/ios/MullvadVPN/Promise/Promise+Delay.swift index 54f497d04b..4cc6cf354a 100644 --- a/ios/MullvadVPN/Promise/Promise+Delay.swift +++ b/ios/MullvadVPN/Promise/Promise+Delay.swift @@ -11,14 +11,24 @@ import Foundation extension Promise { /// Delay observing the upstream by the given interval. func delay(by timeInterval: DispatchTimeInterval, timerType: TimerType, queue: DispatchQueue? = nil) -> Promise<Value> { - return Promise<Value> { resolver in + return Promise<Value>(parent: self) { resolver in let timer = DispatchSource.makeTimerSource(flags: [], queue: queue) + + let timerCancelHandler = DispatchWorkItem { + resolver.resolve(completion: .cancelled, queue: queue) + } + timer.setEventHandler { + // Prevent potential further invocation of cancel handler + timerCancelHandler.cancel() + self.observe { completion in - resolver.resolve(completion: completion, queue: nil) + resolver.resolve(completion: completion, queue: queue) } } + timer.setCancelHandler(handler: timerCancelHandler) + resolver.setCancelHandler { timer.cancel() } @@ -26,7 +36,6 @@ extension Promise { switch timerType { case .deadline: timer.schedule(deadline: .now() + timeInterval) - case .walltime: timer.schedule(wallDeadline: .now() + timeInterval) } diff --git a/ios/MullvadVPN/Promise/Promise+OperationQueue.swift b/ios/MullvadVPN/Promise/Promise+OperationQueue.swift index 1ee55db770..e79e5f25fe 100644 --- a/ios/MullvadVPN/Promise/Promise+OperationQueue.swift +++ b/ios/MullvadVPN/Promise/Promise+OperationQueue.swift @@ -9,14 +9,20 @@ import Foundation extension Promise { - - /// Returns a promise that adds operation that finishes along with the upstream. - func run(on operationQueue: OperationQueue) -> Promise<Value> { - return Promise { resolver in + /// Returns a promise that adds a mutually exclusive operation that finishes along with the upstream. + func run(on operationQueue: OperationQueue, categories: [String] = []) -> Promise<Value> { + return Promise(parent: self) { resolver in let operation = AsyncBlockOperation { operation in - self.observe { completion in - resolver.resolve(completion: completion) + let completionQueue = operationQueue.underlyingQueue + + if operation.isCancelled { + resolver.resolve(completion: .cancelled, queue: completionQueue) operation.finish() + } else { + self.observe { completion in + resolver.resolve(completion: completion, queue: completionQueue) + operation.finish() + } } } @@ -24,25 +30,10 @@ extension Promise { operation.cancel() } - operationQueue.addOperation(operation) - } - } - - /// Returns a promise that adds a mutually exclusive operation that finishes along with the upstream. - func run(on operationQueue: OperationQueue, categories: [String]) -> Promise<Value> { - return Promise { resolver in - let operation = AsyncBlockOperation { operation in - self.observe { completion in - resolver.resolve(completion: completion) - operation.finish() - } - } - - resolver.setCancelHandler { - operation.cancel() + if !categories.isEmpty { + ExclusivityController.shared.addOperation(operation, categories: categories) } - ExclusivityController.shared.addOperation(operation, categories: categories) operationQueue.addOperation(operation) } } diff --git a/ios/MullvadVPN/Promise/Promise+ReceiveOn.swift b/ios/MullvadVPN/Promise/Promise+ReceiveOn.swift index 6979d5e3fb..ce94f6278e 100644 --- a/ios/MullvadVPN/Promise/Promise+ReceiveOn.swift +++ b/ios/MullvadVPN/Promise/Promise+ReceiveOn.swift @@ -17,7 +17,7 @@ extension Promise { /// Dispatch the upstream value on another queue. func receive(on queue: DispatchQueue) -> Promise<Value> { - return Promise<Value> { resolver in + return Promise<Value>(parent: self) { resolver in self.observe { completion in let work = DispatchWorkItem { resolver.resolve(completion: completion, queue: queue) @@ -25,6 +25,8 @@ extension Promise { resolver.setCancelHandler { work.cancel() + + resolver.resolve(completion: .cancelled, queue: queue) } queue.async(execute: work) @@ -34,7 +36,7 @@ extension Promise { /// Dispatch the upstream value on another queue after delay. func receive(on queue: DispatchQueue, after timeInterval: DispatchTimeInterval, timerType: TimerType) -> Promise<Value> { - return Promise<Value> { resolver in + return Promise<Value>(parent: self) { resolver in self.observe { completion in let work = DispatchWorkItem { resolver.resolve(completion: completion, queue: queue) @@ -42,6 +44,8 @@ extension Promise { resolver.setCancelHandler { work.cancel() + + resolver.resolve(completion: .cancelled, queue: queue) } switch timerType { diff --git a/ios/MullvadVPN/Promise/Promise.swift b/ios/MullvadVPN/Promise/Promise.swift index df78aced85..bda24bf1bd 100644 --- a/ios/MullvadVPN/Promise/Promise.swift +++ b/ios/MullvadVPN/Promise/Promise.swift @@ -8,19 +8,36 @@ import Foundation +/// Enum describing the state of the Promise lifecycle. private enum PromiseState<Value> { - case pending((PromiseResolver<Value>) -> Void, DispatchQueue?) + case pending((PromiseResolver<Value>) -> Void) case executing - case resolved(Value, DispatchQueue?) + case resolved(Value) + case cancelling case cancelled } /// Class describing a block of asynchronous computation that can either resolve or be cancelled. -final class Promise<Value> { +final class Promise<Value>: Cancellable { private var state: PromiseState<Value> private var observers: [AnyPromiseObserver<Value>] = [] private let lock = NSRecursiveLock() + /// Execution queue used for running the Promise body. + private var executionQueue: DispatchQueue? + + /// Completion queue used for delivering results to observers. + private var completionQueue: DispatchQueue? + + /// Parent promise. + private var parent: Cancellable? + + /// Cancellation handler. + private var cancelHandler: (() -> Void)? + + /// Whether to propagate cancellation to the parent promise. + private var shouldPropagateCancellation = true + /// Returns Promise resolved with the given value. class func resolved(_ value: Value) -> Self { return Self.init(value: value) @@ -35,19 +52,25 @@ final class Promise<Value> { /// Initialize Promise with the execution block. init(body: @escaping (PromiseResolver<Value>) -> Void) { - state = .pending(body, nil) + state = .pending(body) + } + + /// Initialize Promise with the execution block and parent. + init(parent aParent: Cancellable?, body: @escaping (PromiseResolver<Value>) -> Void) { + state = .pending(body) + parent = aParent } /// Initialize resolved Promise with the given value. init(value: Value) { - state = .resolved(value, nil) + state = .resolved(value) } deinit { switch state { case .resolved, .cancelled, .pending: break - case .executing: + case .executing, .cancelling: preconditionFailure("\(Self.self) is deallocated in \(state) state without being resolved or cancelled.") } } @@ -57,36 +80,41 @@ final class Promise<Value> { func observe(_ receiveCompletion: @escaping (PromiseCompletion<Value>) -> Void) { return lock.withCriticalBlock { switch state { - case .resolved(let value, let queue): + case .resolved(let value): let completion = PromiseCompletion<Value>.finished(value) - queue?.async { receiveCompletion(completion) } ?? receiveCompletion(completion) + completionQueue?.async { receiveCompletion(completion) } ?? receiveCompletion(completion) case .cancelled: - receiveCompletion(.cancelled) + let completion = PromiseCompletion<Value>.cancelled + completionQueue?.async { receiveCompletion(completion) } ?? receiveCompletion(completion) case .pending: observers.append(AnyPromiseObserver<Value>(receiveCompletion)) execute() - case .executing: + case .executing, .cancelling: observers.append(AnyPromiseObserver<Value>(receiveCompletion)) } } } /// Cancel Promise. - /// When Promise is cancelled, all downstream Promises pending execution are also cancelled. func cancel() { lock.withCriticalBlock { switch state { - case .pending, .executing: + case .pending: state = .cancelled - observers.forEach { observer in - observer.receiveCompletion(.cancelled) + + case .executing: + state = .cancelling + + if shouldPropagateCancellation { + parent?.cancel() } - observers.removeAll() - case .cancelled, .resolved: + triggerCancelHandler() + + case .cancelling, .cancelled, .resolved: break } } @@ -94,13 +122,20 @@ final class Promise<Value> { /// Trasform the value by producing a promise. func then<NewValue>(_ onResolve: @escaping (Value) -> Promise<NewValue>) -> Promise<NewValue> { - return Promise<NewValue> { resolver in + return Promise<NewValue>(parent: self) { resolver in self.observe { completion in switch completion { case .finished(let value): - onResolve(value).observe { completion in + let child = onResolve(value) + + resolver.setCancelHandler { + child.cancel() + } + + child.observe { completion in resolver.resolve(completion: completion) } + case .cancelled: resolver.resolve(completion: .cancelled) } @@ -108,9 +143,9 @@ final class Promise<Value> { } } - /// Transform the value. + /// Transform the value producing new value. func then<NewValue>(_ onResolve: @escaping (Value) -> NewValue) -> Promise<NewValue> { - return Promise<NewValue> { resolver in + return Promise<NewValue>(parent: self) { resolver in self.observe { completion in resolver.resolve(completion: completion.map(onResolve)) } @@ -126,13 +161,21 @@ final class Promise<Value> { return self } + /// Switch the cancellation propagation behaviour + func setShouldPropagateCancellation(_ propagateCancellation: Bool) -> Self { + return lock.withCriticalBlock { + shouldPropagateCancellation = propagateCancellation + return self + } + } + /// Set the queue on which to execute the promise's body block. func schedule(on queue: DispatchQueue) -> Self { return lock.withCriticalBlock { switch state { - case .pending(let block, _): - state = .pending(block, queue) - case .cancelled, .executing, .resolved: + case .pending: + executionQueue = queue + case .cancelling, .cancelled, .executing, .resolved: break } return self @@ -166,16 +209,30 @@ final class Promise<Value> { return returnValue } + // MARK: - Private + /// Execute the promise's body if still pending execution. private func execute() { lock.withCriticalBlock { - guard case .pending(let block, let queue) = state else { return } + guard case .pending(let block) = state else { return } state = .executing let resolver = PromiseResolver(promise: self) - queue?.async { block(resolver) } ?? block(resolver) + executionQueue?.async { block(resolver) } ?? block(resolver) + } + } + + /// Resolve Promise with `PromiseCompletion`. + fileprivate func resolve(completion: PromiseCompletion<Value>, queue: DispatchQueue?) { + lock.withCriticalBlock { + switch completion { + case .finished(let value): + resolve(value: value, queue: queue) + case .cancelled: + resolveCancelled(queue: queue) + } } } @@ -184,24 +241,73 @@ final class Promise<Value> { /// Provide the optional `queue` parameter which will be used to dispatch the resolved value to observers added /// after the promise was already resolved. When providing a `queue`, the call to `resolve()` must happen on /// the same queue. - fileprivate func resolve(value: Value, queue: DispatchQueue?) { + private func resolve(value: Value, queue: DispatchQueue?) { lock.withCriticalBlock { switch state { case .pending, .executing: // Oblige caller to resolve the value on the same queue. queue.map { dispatchPrecondition(condition: .onQueue($0)) } - state = .resolved(value, queue) + completionQueue = queue + state = .resolved(value) observers.forEach { observer in observer.receiveCompletion(.finished(value)) } observers.removeAll() + case .cancelling: + // Oblige caller to resolve the value on the same queue. + queue.map { dispatchPrecondition(condition: .onQueue($0)) } + + completionQueue = queue + state = .cancelled + + observers.forEach { observer in + observer.receiveCompletion(.cancelled) + } + observers.removeAll() + + case .cancelled, .resolved: + break + } + } + } + + private func resolveCancelled(queue: DispatchQueue?) { + lock.withCriticalBlock { + switch state { + case .pending, .executing, .cancelling: + // Oblige caller to resolve the value on the same queue. + queue.map { dispatchPrecondition(condition: .onQueue($0)) } + + completionQueue = queue + state = .cancelled + + observers.forEach { observer in + observer.receiveCompletion(.cancelled) + } + observers.removeAll() + case .cancelled, .resolved: break } + } + } + /// Set cancellation handler. + fileprivate func setCancelHandler(_ handler: @escaping () -> Void) { + lock.withCriticalBlock { + cancelHandler = handler + } + } + + /// Trigger cancellation handler, then reset it. + private func triggerCancelHandler() { + lock.withCriticalBlock { + let cancelHandlerCopy = cancelHandler + cancelHandler = nil + cancelHandlerCopy?() } } @@ -211,14 +317,14 @@ final class PromiseCancellationToken { private var handler: (() -> Void)? private let lock = NSLock() - fileprivate init(_ handler: @escaping () -> Void) { - self.handler = handler + fileprivate init(_ aHandler: @escaping () -> Void) { + handler = aHandler } func cancel() { lock.withCriticalBlock { - self.handler?() - self.handler = nil + handler?() + handler = nil } } @@ -232,46 +338,24 @@ struct PromiseResolver<Value> { private let promise: Promise<Value> /// Private initializer. - fileprivate init(promise: Promise<Value>) { - self.promise = promise + fileprivate init(promise aPromise: Promise<Value>) { + promise = aPromise } - /// Resolve the promise with `PromiseCompletion`. - func resolve(completion: PromiseCompletion<Value>) { - resolve(completion: completion, queue: nil) - } - - /// Resolve the promise with `PromiseCompletion` and ptiona queue on which to dispatch the value too observers added - /// after the promise was already resolved. - func resolve(completion: PromiseCompletion<Value>, queue: DispatchQueue?) { - switch completion { - case .finished(let value): - resolve(value: value, queue: queue) - case .cancelled: - promise.cancel() - } - } - - /// Resolve Promise with the given value. - func resolve(value: Value) { - resolve(value: value, queue: nil) + /// Resolve the promise with `PromiseCompletion` and optional queue on which to dispatch the value to observers + /// added after the promise was already resolved. + func resolve(completion: PromiseCompletion<Value>, queue: DispatchQueue? = nil) { + promise.resolve(completion: completion, queue: queue) } /// Resolve the promise with the given value and optional queue on which to dispatch the value to observers added /// after the promise was already resolved. - fileprivate func resolve(value: Value, queue: DispatchQueue?) { - promise.resolve(value: value, queue: queue) + func resolve(value: Value, queue: DispatchQueue? = nil) { + promise.resolve(completion: .finished(value), queue: queue) } /// Set cancellation handler. - func setCancelHandler(_ cancellation: @escaping () -> Void) { - promise.observe { completion in - switch completion { - case .finished: - break - case .cancelled: - cancellation() - } - } + func setCancelHandler(_ handler: @escaping () -> Void) { + promise.setCancelHandler(handler) } } diff --git a/ios/MullvadVPN/TunnelManager/TunnelManager.swift b/ios/MullvadVPN/TunnelManager/TunnelManager.swift index 8dd38b759a..4fc87b3a2f 100644 --- a/ios/MullvadVPN/TunnelManager/TunnelManager.swift +++ b/ios/MullvadVPN/TunnelManager/TunnelManager.swift @@ -916,6 +916,7 @@ class TunnelManager { DispatchQueue.main.async { releaseObserver() ipcToken = nil + resolver.resolve(completion: .cancelled) } } diff --git a/ios/MullvadVPNTests/PromiseTests.swift b/ios/MullvadVPNTests/PromiseTests.swift index 2c436d1ede..6be0535730 100644 --- a/ios/MullvadVPNTests/PromiseTests.swift +++ b/ios/MullvadVPNTests/PromiseTests.swift @@ -57,6 +57,58 @@ class PromiseTests: XCTestCase { wait(for: [expect], timeout: 1) } + func testReceiveOnCancellation() { + let expect = expectation(description: "Wait for promise to complete") + + let promise = Promise(value: 1) + .receive(on: .main) + + promise.observe { completion in + XCTAssertEqual(completion, .cancelled) + expect.fulfill() + } + + promise.cancel() + + wait(for: [expect], timeout: 1) + } + + func testDelay() throws { + let expect = expectation(description: "Wait for promise") + let queue = DispatchQueue(label: "TestQueue") + + let startDate = Date() + Promise.deferred { () -> Int in + let elapsed = startDate.timeIntervalSinceNow * -1000 + XCTAssertGreaterThanOrEqual(elapsed, 100) + dispatchPrecondition(condition: .onQueue(queue)) + expect.fulfill() + return 1 + } + .delay(by: .milliseconds(100), timerType: .walltime, queue: queue) + .observe { _ in } + + wait(for: [expect], timeout: 1) + } + + func testDelayCancellation() throws { + let expect = expectation(description: "Should never fulfill") + expect.isInverted = true + + let promise = Promise.deferred { () -> Int in + expect.fulfill() + return 1 + }.delay(by: .milliseconds(100), timerType: .walltime) + + promise.observe { completion in + XCTAssertEqual(completion, .cancelled) + } + + promise.cancel() + + wait(for: [expect], timeout: 1) + } + func testScheduleOn() throws { let expect = expectation(description: "Wait for promise") let queue = DispatchQueue(label: "TestQueue") @@ -96,34 +148,6 @@ class PromiseTests: XCTestCase { wait(for: [expect1, expect2], timeout: 1, enforceOrder: true) } - func testCancellation() throws { - let cancelExpectation = expectation(description: "Expect cancellation handler to trigger") - let completionExpectation = expectation(description: "Expect promise to complete") - - let promise = Promise<Int> { resolver in - let work = DispatchWorkItem { - XCTFail() - resolver.resolve(value: 1) - } - - resolver.setCancelHandler { - work.cancel() - cancelExpectation.fulfill() - } - - DispatchQueue.main.async(execute: work) - } - - promise.observe { completion in - XCTAssertEqual(completion, .cancelled) - completionExpectation.fulfill() - } - - promise.cancel() - - wait(for: [cancelExpectation, completionExpectation], timeout: 1) - } - func testOptionalMapNoneWithDefaultValue() { let value: Int? = nil @@ -167,7 +191,7 @@ class PromiseTests: XCTestCase { expect2.fulfill() } - wait(for: [expect1, expect2], timeout: 1) + wait(for: [expect1, expect2], timeout: 1, enforceOrder: true) } func testRunOnOperationQueueWithExcusiveCategory() { @@ -190,7 +214,119 @@ class PromiseTests: XCTestCase { expect2.fulfill() } - wait(for: [expect1, expect2], timeout: 1) + wait(for: [expect1, expect2], timeout: 1, enforceOrder: true) + } + + func testExecutingPromiseCancellation() throws { + let cancelExpectation = expectation(description: "Expect cancellation handler to trigger") + let completionExpectation = expectation(description: "Expect promise to complete") + + let promise = Promise<Int> { resolver in + let work = DispatchWorkItem { + XCTFail() + resolver.resolve(value: 1) + } + + resolver.setCancelHandler { + work.cancel() + cancelExpectation.fulfill() + + // Resolve promise since `work` is cancelled now. + resolver.resolve(completion: .cancelled) + } + + DispatchQueue.main.async(execute: work) + } + + promise.observe { completion in + XCTAssertEqual(completion, .cancelled) + completionExpectation.fulfill() + } + + promise.cancel() + + wait(for: [cancelExpectation, completionExpectation], timeout: 1, enforceOrder: true) + } + + func testPendingPromiseCancellation() { + let completionExpectation = expectation(description: "Expect promise to complete") + + let promise = Promise.deferred { () -> Int in + XCTFail() + return 1 + } + + promise.cancel() + + promise.observe { completion in + XCTAssertEqual(completion, .cancelled) + completionExpectation.fulfill() + } + + wait(for: [completionExpectation], timeout: 1) + } + + func testUnhandledCancellation() { + let expectObserve = expectation(description: "Wait for observer") + let expectCancelHandler = expectation(description: "Wait for cancellation handler") + let expectResolve = expectation(description: "Wait for resolver") + + let promise = Promise<Bool> { resolver in + resolver.setCancelHandler { + expectCancelHandler.fulfill() + // Do nothing and let the promise continue execution. + } + + DispatchQueue.main.async { + expectResolve.fulfill() + + // Resolve the cancelling promise. This should yield the `.cancelled` completion anyway. + resolver.resolve(value: true) + } + } + + promise.observe { completion in + XCTAssertEqual(completion, .cancelled) + expectObserve.fulfill() + } + + promise.cancel() + + wait(for: [expectCancelHandler, expectResolve, expectObserve], timeout: 1, enforceOrder: true) + } + + func testShouldNotPropagateCancellation() { + let expectParentCancel = expectation(description: "Parent cancellation handler should never trigger") + expectParentCancel.isInverted = true + + let expectChildCompletion = expectation(description: "Wait for child to complete") + + let parent = Promise<Int> { resolver in + resolver.setCancelHandler { + expectParentCancel.fulfill() + } + + DispatchQueue.main.async { + resolver.resolve(value: 1) + } + } + + let child = Promise<Int>(parent: parent) { resolver in + parent.observe { completion in + resolver.resolve(completion: completion) + } + } + + _ = child.setShouldPropagateCancellation(false) + + child.observe { completion in + XCTAssertEqual(completion, .cancelled) + expectChildCompletion.fulfill() + } + + child.cancel() + + wait(for: [expectParentCancel, expectChildCompletion], timeout: 1) } } |
