summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAndrej Mihajlov <and@mullvad.net>2021-10-04 12:03:00 +0200
committerAndrej Mihajlov <and@mullvad.net>2021-10-04 12:03:00 +0200
commit8ff5fb4524cb436253f83c7b14ff61cbc2c0a946 (patch)
tree37b65189f956ec96cdc9dea92f70beb42ac59a3b
parent0556f7e2960699bb8022247bcfe33d5c215c1955 (diff)
parentc00981e683531815b73b217b154695af31b1676b (diff)
downloadmullvadvpn-8ff5fb4524cb436253f83c7b14ff61cbc2c0a946.tar.xz
mullvadvpn-8ff5fb4524cb436253f83c7b14ff61cbc2c0a946.zip
Merge branch 'promise-cancellation-chain'
-rw-r--r--ios/MullvadVPN.xcodeproj/project.pbxproj8
-rw-r--r--ios/MullvadVPN/Promise/Cancellable.swift13
-rw-r--r--ios/MullvadVPN/Promise/Promise+BackgroundTask.swift20
-rw-r--r--ios/MullvadVPN/Promise/Promise+Delay.swift15
-rw-r--r--ios/MullvadVPN/Promise/Promise+OperationQueue.swift37
-rw-r--r--ios/MullvadVPN/Promise/Promise+ReceiveOn.swift8
-rw-r--r--ios/MullvadVPN/Promise/Promise.swift210
-rw-r--r--ios/MullvadVPN/TunnelManager/TunnelManager.swift1
-rw-r--r--ios/MullvadVPNTests/PromiseTests.swift196
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)
}
}