diff --git a/Cartfile b/Cartfile index 381bc31..7ab4e88 100644 --- a/Cartfile +++ b/Cartfile @@ -1 +1,4 @@ -github "mxcl/PromiseKit" ~> 6.3 +#github "mxcl/PromiseKit" ~> 6.3 +github "dougzilla32/PromiseKit" "PMKCancel" +#github "PromiseKit/Cancel" ~> 1.0 +github "dougzilla32/Cancel" ~> 1.0 diff --git a/Cartfile.resolved b/Cartfile.resolved index dd48856..e965758 100644 --- a/Cartfile.resolved +++ b/Cartfile.resolved @@ -1,2 +1,3 @@ github "AliSoftware/OHHTTPStubs" "6.1.0" -github "mxcl/PromiseKit" "6.3.3" +github "dougzilla32/Cancel" "1.0.0" +github "dougzilla32/PromiseKit" "a0217bd7b69af68237dcdeee0197e63259b9d445" diff --git a/PMKFoundation.xcodeproj/project.pbxproj b/PMKFoundation.xcodeproj/project.pbxproj index de63cc2..dc7e8b0 100644 --- a/PMKFoundation.xcodeproj/project.pbxproj +++ b/PMKFoundation.xcodeproj/project.pbxproj @@ -236,6 +236,7 @@ inputPaths = ( PromiseKit, OHHTTPStubs, + PMKCancel, ); name = "Embed Carthage Frameworks"; outputPaths = ( diff --git a/Package.swift b/Package.swift index c5319d0..5466649 100644 --- a/Package.swift +++ b/Package.swift @@ -3,7 +3,10 @@ import PackageDescription let package = Package( name: "PMKFoundation", dependencies: [ - .Package(url: "https://github.com/mxcl/PromiseKit.git", majorVersion: 6) + .Package(url: "https://github.com/mxcl/PromiseKit.git", majorVersion: 6, minor: 3), +// Change this to point to 'PromiseKit/Cancel' before integrating +// .Package(url: "https://github.com/PromiseKit/Cancel.git", majorVersion: 1), + .Package(url: "https://github.com/dougzilla32/Cancel.git", majorVersion: 1) ], exclude: [ "Sources/NSNotificationCenter+AnyPromise.m", diff --git a/Sources/NSNotificationCenter+Promise.swift b/Sources/NSNotificationCenter+Promise.swift index ad1113e..a0150d1 100644 --- a/Sources/NSNotificationCenter+Promise.swift +++ b/Sources/NSNotificationCenter+Promise.swift @@ -1,5 +1,6 @@ import Foundation #if !PMKCocoaPods +import PMKCancel import PromiseKit #endif @@ -31,3 +32,37 @@ extension NotificationCenter { return promise } } + +//////////////////////////////////////////////////////////// Cancellation + +extension NotificationCenter { + /// Observe the named notification once + public func observeCC(once name: Notification.Name, object: Any? = nil) -> CancellablePromise { + let (promise, resolver) = CancellablePromise.pending() +#if !os(Linux) + let id = addObserver(forName: name, object: object, queue: nil, using: resolver.fulfill) +#else + let id = addObserver(forName: name, object: object, queue: nil, usingBlock: resolver.fulfill) +#endif + + promise.appendCancellableTask(task: ObserverTask { self.removeObserver(id) }, reject: resolver.reject) + + _ = promise.ensure { self.removeObserver(id) } + return promise + } +} + +class ObserverTask: CancellableTask { + let cancelBlock: () -> Void + + init(cancelBlock: @escaping () -> Void) { + self.cancelBlock = cancelBlock + } + + func cancel() { + cancelBlock() + isCancelled = true + } + + var isCancelled = false +} diff --git a/Sources/NSObject+Promise.swift b/Sources/NSObject+Promise.swift index 135719b..8663308 100644 --- a/Sources/NSObject+Promise.swift +++ b/Sources/NSObject+Promise.swift @@ -1,5 +1,6 @@ import Foundation #if !PMKCocoaPods +import PMKCancel import PromiseKit #endif @@ -55,3 +56,71 @@ private class KVOProxy: NSObject { return Unmanaged.passUnretained(self).toOpaque() }() } + +//////////////////////////////////////////////////////////// Cancellation + +extension NSObject { + /** + - Returns: A promise that resolves when the provided keyPath changes, or when the promise is cancelled. + - Warning: *Important* The promise must not outlive the object under observation. + - SeeAlso: Apple’s KVO documentation. + */ + public func observeCC(_: PMKNamespacer, keyPath: String) -> CancellablePromise { + var task: CancellableTask! + var reject: ((Error) -> Void)! + + let promise = CancellablePromise { seal in + reject = seal.reject + task = CancellableKVOProxy(observee: self, keyPath: keyPath, resolve: seal.fulfill) + } + + promise.appendCancellableTask(task: task, reject: reject) + return promise + } +} + +private class CancellableKVOProxy: NSObject, CancellableTask { + var retainCycle: CancellableKVOProxy? + let fulfill: (Any?) -> Void + let observeeObject: NSObject + let observeeKeyPath: String + var observing: Bool + + @discardableResult + init(observee: NSObject, keyPath: String, resolve: @escaping (Any?) -> Void) { + fulfill = resolve + observeeObject = observee + observeeKeyPath = keyPath + observing = true + super.init() + observee.addObserver(self, forKeyPath: keyPath, options: NSKeyValueObservingOptions.new, context: pointer) + retainCycle = self + } + + fileprivate override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey: Any]?, context: UnsafeMutableRawPointer?) { + if let change = change, context == pointer { + defer { retainCycle = nil } + fulfill(change[NSKeyValueChangeKey.newKey]) + if let object = object as? NSObject, let keyPath = keyPath, observing { + object.removeObserver(self, forKeyPath: keyPath) + observing = false + } + } + } + + func cancel() { + if !isCancelled { + if observing { + observeeObject.removeObserver(self, forKeyPath: observeeKeyPath) + observing = false + } + isCancelled = true + } + } + + var isCancelled = false + + private lazy var pointer: UnsafeMutableRawPointer = { + return Unmanaged.passUnretained(self).toOpaque() + }() +} diff --git a/Sources/NSURLSession+Promise.swift b/Sources/NSURLSession+Promise.swift index 7e06c5a..eedc837 100644 --- a/Sources/NSURLSession+Promise.swift +++ b/Sources/NSURLSession+Promise.swift @@ -1,5 +1,6 @@ import Foundation #if !PMKCocoaPods +import PMKCancel import PromiseKit #endif @@ -205,3 +206,187 @@ public extension Promise where T == (data: Data, response: URLResponse) { } } #endif + +//////////////////////////////////////////////////////////// Cancellation + +extension URLSessionTask: CancellableTask { + /// `true` if the URLSessionTask was successfully cancelled, `false` otherwise + public var isCancelled: Bool { + return state == .canceling + } +} + +extension URLSession { + /** + Example usage with explicit cancel context: + + let context = firstly { + URLSession.shared.dataTaskCC(.promise, with: rq) + }.compactMap { data, _ in + try JSONSerialization.jsonObject(with: data) as? [String: Any] + }.then { json in + //… + }.cancelContext + //… + context.cancel() + + Example usage with implicit cancel context: + + let promise = firstly { + URLSession.shared.dataTaskCC(.promise, with: rq) + }.compactMap { data, _ in + try JSONSerialization.jsonObject(with: data) as? [String: Any] + }.then { json in + //… + } + //… + promise.cancel() + + We recommend the use of [OMGHTTPURLRQ] which allows you to construct correct REST requests: + + let context = firstly { + let rq = OMGHTTPURLRQ.POST(url, json: parameters) + URLSession.shared.dataTaskCC(.promise, with: rq) + }.then { data, urlResponse in + //… + }.cancelContext + //… + context.cancel() + + We provide a convenience initializer for `String` specifically for this promise: + + let context = firstly { + URLSession.shared.dataTaskCC(.promise, with: rq) + }.compactMap(String.init).then { string in + // decoded per the string encoding specified by the server + }.then { string in + print("response: string") + } + //… + context.cancel() + + Other common types can be easily decoded using compactMap also: + + let context = firstly { + URLSession.shared.dataTaskCC(.promise, with: rq) + }.compactMap { + UIImage(data: $0) + }.then { + self.imageView.image = $0 + } + //… + context.cancel() + + Though if you do decode the image this way, we recommend inflating it on a background thread + first as this will improve main thread performance when rendering the image: + + let context = firstly { + URLSession.shared.dataTaskCC(.promise, with: rq) + }.compactMap(on: QoS.userInitiated) { data, _ in + guard let img = UIImage(data: data) else { return nil } + _ = cgImage?.dataProvider?.data + return img + }.then { + self.imageView.image = $0 + } + //… + context.cancel() + + - Parameter convertible: A URL or URLRequest. + - Returns: A cancellable promise that represents the URL request. + - SeeAlso: [OMGHTTPURLRQ] + - Remark: We deliberately don’t provide a `URLRequestConvertible` for `String` because in our experience, you should be explicit with this error path to make good apps. + + [OMGHTTPURLRQ]: https://github.com/mxcl/OMGHTTPURLRQ + */ + public func dataTaskCC(_: PMKNamespacer, with convertible: URLRequestConvertible) -> CancellablePromise<(data: Data, response: URLResponse)> { + var task: URLSessionTask! + var reject: ((Error) -> Void)! + + let promise = CancellablePromise<(data: Data, response: URLResponse)> { + reject = $0.reject + task = self.dataTask(with: convertible.pmkRequest, completionHandler: adapter($0)) + task.resume() + } + + promise.appendCancellableTask(task: task, reject: reject) + return promise + } + + /// Wraps the (Data?, URLResponse?, Error?) response from URLSession.uploadTask(with:from:) as CancellablePromise<(Data,URLResponse)> + public func uploadTaskCC(_: PMKNamespacer, with convertible: URLRequestConvertible, from data: Data) -> CancellablePromise<(data: Data, response: URLResponse)> { + var task: URLSessionTask! + var reject: ((Error) -> Void)! + + let promise = CancellablePromise<(data: Data, response: URLResponse)> { + reject = $0.reject + task = self.uploadTask(with: convertible.pmkRequest, from: data, completionHandler: adapter($0)) + task.resume() + } + + promise.appendCancellableTask(task: task, reject: reject) + return promise + } + + /// Wraps the (Data?, URLResponse?, Error?) response from URLSession.uploadTask(with:fromFile:) as CancellablePromise<(Data,URLResponse)> + public func uploadTaskCC(_: PMKNamespacer, with convertible: URLRequestConvertible, fromFile file: URL) -> CancellablePromise<(data: Data, response: URLResponse)> { + var task: URLSessionTask! + var reject: ((Error) -> Void)! + + let promise = CancellablePromise<(data: Data, response: URLResponse)> { + reject = $0.reject + task = self.uploadTask(with: convertible.pmkRequest, fromFile: file, completionHandler: adapter($0)) + task.resume() + } + + promise.appendCancellableTask(task: task, reject: reject) + return promise + } + + /** + Wraps the URLSesstionDownloadTask response from URLSession.downloadTask(with:) as CancellablePromise<(URL,URLResponse)> + - Remark: we force a `to` parameter because Apple deletes the downloaded file immediately after the underyling completion handler returns. + */ + public func downloadTaskCC(_: PMKNamespacer, with convertible: URLRequestConvertible, to saveLocation: URL) -> CancellablePromise<(saveLocation: URL, response: URLResponse)> { + var task: URLSessionTask! + var reject: ((Error) -> Void)! + + let promise = CancellablePromise<(saveLocation: URL, response: URLResponse)> { seal in + reject = seal.reject + task = self.downloadTask(with: convertible.pmkRequest, completionHandler: { tmp, rsp, err in + if let error = err { + seal.reject(error) + } else if let rsp = rsp, let tmp = tmp { + do { + try FileManager.default.moveItem(at: tmp, to: saveLocation) + seal.fulfill((saveLocation, rsp)) + } catch { + seal.reject(error) + } + } else { + seal.reject(PMKError.invalidCallingConvention) + } + }) + task.resume() + } + + promise.appendCancellableTask(task: task, reject: reject) + return promise + } +} + +#if swift(>=3.1) +public extension CancellablePromise where T == (data: Data, response: URLResponse) { + func validate() -> CancellablePromise { + return map { + guard let response = $0.response as? HTTPURLResponse else { return $0 } + switch response.statusCode { + case 200..<300: + return $0 + case let code: + throw PMKHTTPError.badStatusCode(code, $0.data, response) + } + } + } +} +#endif diff --git a/Sources/Process+Promise.swift b/Sources/Process+Promise.swift index 0448475..832b12e 100644 --- a/Sources/Process+Promise.swift +++ b/Sources/Process+Promise.swift @@ -1,5 +1,6 @@ import Foundation #if !PMKCocoaPods +import PMKCancel import PromiseKit #endif @@ -143,4 +144,40 @@ extension Process { } } +//////////////////////////////////////////////////////////// Cancellation + +extension Process: CancellableTask { + /// Sends an interrupt signal to the process + public func cancel() { + interrupt() + } + + /// `true` if the Process was successfully interrupted, `false` otherwise + public var isCancelled: Bool { + return !isRunning + } +} + +extension Process { + /** + Launches the receiver and resolves when it exits, or when the promise is cancelled. + + let proc = Process() + proc.launchPath = "/bin/ls" + proc.arguments = ["/bin"] + let context = proc.launchCC(.promise).compactMap { std in + String(data: std.out.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) + }.then { stdout in + print(str) + }.cancelContext + + //… + + context.cancel() + */ + public func launchCC(_: PMKNamespacer) -> CancellablePromise<(out: Pipe, err: Pipe)> { + return CancellablePromise(task: self, self.launch(.promise)) + } +} + #endif diff --git a/Sources/afterlife.swift b/Sources/afterlife.swift index 232c8da..5c13d5d 100644 --- a/Sources/afterlife.swift +++ b/Sources/afterlife.swift @@ -1,5 +1,6 @@ import Foundation #if !PMKCocoaPods +import PMKCancel import PromiseKit #endif @@ -24,3 +25,48 @@ private class GrimReaper: NSObject { } let (promise, fulfill) = Guarantee.pending() } + +//////////////////////////////////////////////////////////// Cancellation + +/** + - Returns: A cancellable promise that resolves when the provided object deallocates, and can be unregistered and rejected by calling 'cancel' + - Important: The promise is not guarenteed to resolve immediately when the provided object is deallocated. So you cannot write code that depends on exact timing. + */ +public func afterCC(life object: NSObject) -> CancellablePromise { + var reaper = objc_getAssociatedObject(object, &cancellableHandle) as? CancellableGrimReaper + if reaper == nil { + reaper = CancellableGrimReaper() + objc_setAssociatedObject(object, &cancellableHandle, reaper, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + reaper!.promise.appendCancellableTask(task: CancellableReaperTask(object: object), reject: reaper!.resolver.reject) + } + return reaper!.promise +} + +private var cancellableHandle: UInt8 = 0 + +private class CancellableGrimReaper: NSObject { + let (promise, resolver) = CancellablePromise.pending() + + deinit { + resolver.fulfill(()) + } +} + +private class CancellableReaperTask: CancellableTask { + weak var object: NSObject? + + var isCancelled = false + + init(object: NSObject) { + self.object = object + } + + func cancel() { + if !isCancelled { + if let obj = object { + objc_setAssociatedObject(obj, &cancellableHandle, nil, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + isCancelled = true + } + } +} diff --git a/Tests/TestNSNotificationCenter.swift b/Tests/TestNSNotificationCenter.swift index 3851029..a289102 100644 --- a/Tests/TestNSNotificationCenter.swift +++ b/Tests/TestNSNotificationCenter.swift @@ -1,3 +1,4 @@ +import PMKCancel import PMKFoundation import Foundation import PromiseKit @@ -20,3 +21,22 @@ class NSNotificationCenterTests: XCTestCase { } private let PMKTestNotification = Notification.Name("PMKTestNotification") + +//////////////////////////////////////////////////////////// Cancellation + +extension NSNotificationCenterTests { + func testCancel() { + let ex = expectation(description: "") + let userInfo = ["a": 1] + + NotificationCenter.default.observeCC(once: PMKTestNotification).done { value in + XCTFail() + }.catch(policy: .allErrors) { + $0.isCancelled ? ex.fulfill() : XCTFail() + }.cancel() + + NotificationCenter.default.post(name: PMKTestNotification, object: nil, userInfo: userInfo) + + waitForExpectations(timeout: 1) + } +} diff --git a/Tests/TestNSObject.swift b/Tests/TestNSObject.swift index fc8806e..da588e4 100644 --- a/Tests/TestNSObject.swift +++ b/Tests/TestNSObject.swift @@ -1,3 +1,4 @@ +import PMKCancel import PMKFoundation import Foundation import PromiseKit @@ -74,3 +75,99 @@ class NSObjectTests: XCTestCase { private class Foo: NSObject { @objc dynamic var bar: String = "bar" } + +//////////////////////////////////////////////////////////// Cancellation + +extension NSObjectTests { + func testCancelKVO() { + let ex = expectation(description: "") + + let foo = Foo() + foo.observeCC(.promise, keyPath: "bar").done { newValue in + XCTAssertEqual(newValue as? String, "moo") + XCTFail() + // ex.fulfill() + }.catch(policy: .allErrors) { + $0.isCancelled ? ex.fulfill() : XCTFail() + }.cancel() + foo.bar = "moo" + + waitForExpectations(timeout: 1) + } + + func testCancelKVO2() { + let ex = expectation(description: "") + + let foo = Foo() + let p = foo.observeCC(.promise, keyPath: "bar").done { newValue in + XCTAssertEqual(newValue as? String, "moo") + XCTFail() + // ex.fulfill() + }.catch(policy: .allErrors) { + $0.isCancelled ? ex.fulfill() : XCTFail() + } + foo.bar = "moo" + p.cancel() + + waitForExpectations(timeout: 1) + } + + func testCancelAfterlife() { + let ex = expectation(description: "") + var killme: NSObject! + + autoreleasepool { + var p: CancellableFinalizer! + func innerScope() { + killme = NSObject() + p = afterCC(life: killme).done { _ in + XCTFail() + }.catch(policy: .allErrors) { + $0.isCancelled ? ex.fulfill() : XCTFail() + } + } + + innerScope() + + after(.milliseconds(200)).done { + killme = nil + p.cancel() + } + } + + waitForExpectations(timeout: 1) + } + + func testCancelMultiObserveAfterlife() { + let ex1 = expectation(description: "") + let ex2 = expectation(description: "") + var killme: NSObject! + + autoreleasepool { + var p1, p2: CancellableFinalizer! + func innerScope() { + killme = NSObject() + p1 = afterCC(life: killme).done { _ in + XCTFail() + }.catch(policy: .allErrors) { + $0.isCancelled ? ex1.fulfill() : XCTFail() + } + p2 = afterCC(life: killme).done { _ in + XCTFail() + }.catch(policy: .allErrors) { + $0.isCancelled ? ex2.fulfill() : XCTFail() + } + } + + innerScope() + + after(.milliseconds(200)).done { + p1.cancel() + p2.cancel() + killme = nil + } + } + + waitForExpectations(timeout: 1) + } +} diff --git a/Tests/TestNSTask.swift b/Tests/TestNSTask.swift index 0ed49b7..ba14df6 100644 --- a/Tests/TestNSTask.swift +++ b/Tests/TestNSTask.swift @@ -1,3 +1,4 @@ +import PMKCancel import PMKFoundation import Foundation import PromiseKit @@ -49,4 +50,41 @@ class NSTaskTests: XCTestCase { } } +//////////////////////////////////////////////////////////// Cancellation + +extension NSTaskTests { + func testCancel1() { + let ex = expectation(description: "") + let task = Process() + task.launchPath = "/usr/bin/man" + task.arguments = ["ls"] + + let context = task.launchCC(.promise).done { stdout, _ in + let stdout = String(data: stdout.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) + XCTAssertEqual(stdout, "bar\n") + }.catch(policy: .allErrors) { error in + error.isCancelled ? ex.fulfill() : XCTFail("Error: \(error)") + }.cancelContext + context.cancel() + waitForExpectations(timeout: 3) + } + + func testCancel2() { + let ex = expectation(description: "") + let dir = "/usr/bin" + + let task = Process() + task.launchPath = "/bin/ls" + task.arguments = ["-l", dir] + + let context = task.launchCC(.promise).done { _ in + XCTFail("failed to cancel process") + }.catch(policy: .allErrors) { error in + error.isCancelled ? ex.fulfill() : XCTFail("unexpected error \(error)") + }.cancelContext + context.cancel() + waitForExpectations(timeout: 3) + } +} + #endif diff --git a/Tests/TestNSURLSession.swift b/Tests/TestNSURLSession.swift index f6906b5..1c982b0 100644 --- a/Tests/TestNSURLSession.swift +++ b/Tests/TestNSURLSession.swift @@ -1,3 +1,4 @@ +import PMKCancel import PMKFoundation import OHHTTPStubs import PromiseKit @@ -74,3 +75,83 @@ class NSURLSessionTests: XCTestCase { OHHTTPStubs.removeAllStubs() } } + +//////////////////////////////////////////////////////////// Cancellation + +extension NSURLSessionTests { + func testCancel1() { + let json: NSDictionary = ["key1": "value1", "key2": ["value2A", "value2B"]] + + OHHTTPStubs.stubRequests(passingTest: { $0.url!.host == "example.com" }) { _ in + return OHHTTPStubsResponse(jsonObject: json, statusCode: 200, headers: nil) + } + + let ex = expectation(description: "") + let rq = URLRequest(url: URL(string: "http://example.com")!) + let context = firstly { + URLSession.shared.dataTaskCC(.promise, with: rq) + }.compactMap { + try JSONSerialization.jsonObject(with: $0.data) as? NSDictionary + }.done { rsp in + XCTAssertEqual(json, rsp) + XCTFail("failed to cancel session") + }.catch(policy: .allErrors) { error in + error.isCancelled ? ex.fulfill() : XCTFail("Error: \(error)") + }.cancelContext + context.cancel() + waitForExpectations(timeout: 1) + } + + func testCancel2() { + + // test that URLDataPromise chains thens + // this test because I don’t trust the Swift compiler + + let dummy = ("fred" as NSString).data(using: String.Encoding.utf8.rawValue)! + + OHHTTPStubs.stubRequests(passingTest: { $0.url!.host == "example.com" }) { _ in + return OHHTTPStubsResponse(data: dummy, statusCode: 200, headers: [:]) + } + + let ex = expectation(description: "") + let rq = URLRequest(url: URL(string: "http://example.com")!) + + let context = afterCC(.milliseconds(100)).then { + URLSession.shared.dataTaskCC(.promise, with: rq) + }.done { x in + XCTAssertEqual(x.data, dummy) + ex.fulfill() + }.catch(policy: .allErrors) { error in + error.isCancelled ? ex.fulfill() : XCTFail("Error: \(error)") + }.cancelContext + context.cancel() + + waitForExpectations(timeout: 1) + } + + /// test that our convenience String constructor applies + func testCancel3() { + let dummy = "fred" + + OHHTTPStubs.stubRequests(passingTest: { $0.url!.host == "example.com" }) { _ in + let data = dummy.data(using: .utf8)! + return OHHTTPStubsResponse(data: data, statusCode: 200, headers: [:]) + } + + let ex = expectation(description: "") + let rq = URLRequest(url: URL(string: "http://example.com")!) + + let context = afterCC(.milliseconds(100)).then { + URLSession.shared.dataTaskCC(.promise, with: rq) + }.map(String.init).done { + XCTAssertEqual($0, dummy) + ex.fulfill() + }.catch(policy: .allErrors) { error in + error.isCancelled ? ex.fulfill() : XCTFail("Error: \(error)") + }.cancelContext + context.cancel() + + waitForExpectations(timeout: 1) + } +} +