diff --git a/Cartfile b/Cartfile index 381bc31..27b1f8d 100644 --- a/Cartfile +++ b/Cartfile @@ -1 +1,2 @@ -github "mxcl/PromiseKit" ~> 6.3 +#github "mxcl/PromiseKit" ~> 6.3 +github "dougzilla32/PromiseKit" "CoreCancel" diff --git a/Cartfile.resolved b/Cartfile.resolved index 3123a48..d3e5cdb 100644 --- a/Cartfile.resolved +++ b/Cartfile.resolved @@ -1,2 +1,2 @@ github "AliSoftware/OHHTTPStubs" "6.1.0" -github "mxcl/PromiseKit" "6.3.4" +github "dougzilla32/PromiseKit" "087b3cf470890ff9ea841212e2f3e285fecf3988" diff --git a/Package.swift b/Package.swift index 6072eb0..6b0c618 100644 --- a/Package.swift +++ b/Package.swift @@ -3,7 +3,9 @@ import PackageDescription let package = Package( name: "PMKFoundation", dependencies: [ - .Package(url: "https://github.com/mxcl/PromiseKit.git", majorVersion: 6) +// Switch this back before integrating: +// .Package(url: "https://github.com/mxcl/PromiseKit.git", majorVersion: 6) + .Package(url: "https://github.com/dougzilla32/PromiseKitCoreCancel.git", majorVersion: 6) ], swiftLanguageVersions: [3, 4], exclude: [ diff --git a/Sources/NSNotificationCenter+Promise.swift b/Sources/NSNotificationCenter+Promise.swift index 3b7f843..8b5426d 100644 --- a/Sources/NSNotificationCenter+Promise.swift +++ b/Sources/NSNotificationCenter+Promise.swift @@ -20,6 +20,8 @@ import PromiseKit */ extension NotificationCenter { /// Observe the named notification once + /// - Note: cancelling this guarantee will cancel the underlying task + /// - SeeAlso: [Cancellation](http://promisekit.org/docs/) public func observe(once name: Notification.Name, object: Any? = nil) -> Guarantee { let (promise, fulfill) = Guarantee.pending() #if os(Linux) && ((swift(>=4.0) && !swift(>=4.0.1)) || (swift(>=3.0) && !swift(>=3.2.1))) @@ -27,7 +29,23 @@ extension NotificationCenter { #else let id = addObserver(forName: name, object: object, queue: nil, using: fulfill) #endif + promise.setCancellableTask(ObserverTask { self.removeObserver(id) }) promise.done { _ in 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..9aee7b8 100644 --- a/Sources/NSObject+Promise.swift +++ b/Sources/NSObject+Promise.swift @@ -23,19 +23,27 @@ extension NSObject { - Returns: A promise that resolves when the provided keyPath changes. - Warning: *Important* The promise must not outlive the object under observation. - SeeAlso: Apple’s KVO documentation. + - Note: cancelling this promise will cancel the underlying task + - SeeAlso: [Cancellation](http://promisekit.org/docs/) */ public func observe(_: PMKNamespacer, keyPath: String) -> Guarantee { return Guarantee { KVOProxy(observee: self, keyPath: keyPath, resolve: $0) } } } -private class KVOProxy: NSObject { +private class KVOProxy: NSObject, CancellableTask { var retainCycle: KVOProxy? 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 @@ -47,10 +55,23 @@ private class KVOProxy: NSObject { fulfill(change[NSKeyValueChangeKey.newKey]) if let object = object as? NSObject, let keyPath = keyPath { 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 926eadf..cb42a2c 100644 --- a/Sources/NSURLSession+Promise.swift +++ b/Sources/NSURLSession+Promise.swift @@ -76,26 +76,68 @@ extension URLSession { - Returns: A 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. - + - Note: cancelling this promise will cancel the underlying task + - SeeAlso: [Cancellation](http://promisekit.org/docs/) + [OMGHTTPURLRQ]: https://github.com/mxcl/OMGHTTPURLRQ */ public func dataTask(_: PMKNamespacer, with convertible: URLRequestConvertible) -> Promise<(data: Data, response: URLResponse)> { - return Promise { dataTask(with: convertible.pmkRequest, completionHandler: adapter($0)).resume() } + var task: URLSessionTask! + var reject: ((Error) -> Void)! + + let promise = Promise<(data: Data, response: URLResponse)> { + reject = $0.reject + task = self.dataTask(with: convertible.pmkRequest, completionHandler: adapter($0)) + task.resume() + } + + promise.setCancellableTask(task, reject: reject) + return promise } + /// - Note: cancelling this promise will cancel the underlying task + /// - SeeAlso: [Cancellation](http://promisekit.org/docs/) public func uploadTask(_: PMKNamespacer, with convertible: URLRequestConvertible, from data: Data) -> Promise<(data: Data, response: URLResponse)> { - return Promise { uploadTask(with: convertible.pmkRequest, from: data, completionHandler: adapter($0)).resume() } + var task: URLSessionTask! + var reject: ((Error) -> Void)! + + let promise = Promise<(data: Data, response: URLResponse)> { + reject = $0.reject + task = self.uploadTask(with: convertible.pmkRequest, from: data, completionHandler: adapter($0)) + task.resume() + } + + promise.setCancellableTask(task, reject: reject) + return promise } + /// - Note: cancelling this promise will cancel the underlying task + /// - SeeAlso: [Cancellation](http://promisekit.org/docs/) public func uploadTask(_: PMKNamespacer, with convertible: URLRequestConvertible, fromFile file: URL) -> Promise<(data: Data, response: URLResponse)> { - return Promise { uploadTask(with: convertible.pmkRequest, fromFile: file, completionHandler: adapter($0)).resume() } + var task: URLSessionTask! + var reject: ((Error) -> Void)! + + let promise = Promise<(data: Data, response: URLResponse)> { + reject = $0.reject + task = self.uploadTask(with: convertible.pmkRequest, fromFile: file, completionHandler: adapter($0)) + task.resume() + } + + promise.setCancellableTask(task, reject: reject) + return promise } /// - Remark: we force a `to` parameter because Apple deletes the downloaded file immediately after the underyling completion handler returns. /// - Note: we do not create the destination directory for you, because we move the file with FileManager.moveItem which changes it behavior depending on the directory status of the URL you provide. So create your own directory first! + /// - Note: cancelling this promise will cancel the underlying task + /// - SeeAlso: [Cancellation](http://promisekit.org/docs/) public func downloadTask(_: PMKNamespacer, with convertible: URLRequestConvertible, to saveLocation: URL) -> Promise<(saveLocation: URL, response: URLResponse)> { - return Promise { seal in - downloadTask(with: convertible.pmkRequest, completionHandler: { tmp, rsp, err in + var task: URLSessionTask! + var reject: ((Error) -> Void)! + + let promise = Promise<(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 { @@ -108,8 +150,12 @@ extension URLSession { } else { seal.reject(PMKError.invalidCallingConvention) } - }).resume() + }) + task.resume() } + + promise.setCancellableTask(task, reject: reject) + return promise } } @@ -237,3 +283,18 @@ public extension Promise where T == (data: Data, response: URLResponse) { } } #endif + +extension URLSessionTask: CancellableTask { + /// `true` if the URLSessionTask was successfully cancelled, `false` otherwise + public var isCancelled: Bool { + return state == .canceling || (error as NSError?)?.code == NSURLErrorCancelled + } +} + +#if swift(>=3.1) +public extension CancellablePromise where T == (data: Data, response: URLResponse) { + func validate() -> CancellablePromise { + return cancellable(promise.validate()) + } +} +#endif diff --git a/Sources/Process+Promise.swift b/Sources/Process+Promise.swift index 0448475..eec66ba 100644 --- a/Sources/Process+Promise.swift +++ b/Sources/Process+Promise.swift @@ -32,6 +32,8 @@ extension Process { }.then { stdout in print(str) } + - Note: cancelling this promise will cancel the underlying task + - SeeAlso: [Cancellation](http://promisekit.org/docs/) */ public func launch(_: PMKNamespacer) -> Promise<(out: Pipe, err: Pipe)> { let (stdout, stderr) = (Pipe(), Pipe()) @@ -67,7 +69,7 @@ extension Process { } } - return Promise { seal in + return Promise<(out: Pipe, err: Pipe)>(cancellableTask: self) { seal in q.async { self.waitUntilExit() @@ -143,4 +145,16 @@ extension Process { } } +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 + } +} + #endif diff --git a/Sources/afterlife.swift b/Sources/afterlife.swift index 232c8da..e01bd6b 100644 --- a/Sources/afterlife.swift +++ b/Sources/afterlife.swift @@ -6,12 +6,15 @@ import PromiseKit /** - Returns: A promise that resolves when the provided object deallocates - 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. + - Note: cancelling this guarantee will cancel the underlying task + - SeeAlso: [Cancellation](http://promisekit.org/docs/) */ public func after(life object: NSObject) -> Guarantee { var reaper = objc_getAssociatedObject(object, &handle) as? GrimReaper if reaper == nil { reaper = GrimReaper() objc_setAssociatedObject(object, &handle, reaper, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + reaper!.promise.setCancellableTask(CancellableReaperTask(object: object)) } return reaper!.promise } @@ -24,3 +27,22 @@ private class GrimReaper: NSObject { } let (promise, fulfill) = Guarantee.pending() } + +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, &handle, nil, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + isCancelled = true + } + } +} diff --git a/Tests/TestNSNotificationCenter.swift b/Tests/TestNSNotificationCenter.swift index 3851029..a61cebb 100644 --- a/Tests/TestNSNotificationCenter.swift +++ b/Tests/TestNSNotificationCenter.swift @@ -20,3 +20,22 @@ class NSNotificationCenterTests: XCTestCase { } private let PMKTestNotification = Notification.Name("PMKTestNotification") + +//////////////////////////////////////////////////////////// Cancellation + +extension NSNotificationCenterTests { + func testCancel() { + let ex = expectation(description: "") + let userInfo = ["a": 1] + + cancellable(NotificationCenter.default.observe(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..5e55db7 100644 --- a/Tests/TestNSObject.swift +++ b/Tests/TestNSObject.swift @@ -74,3 +74,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() + cancellable(foo.observe(.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 = cancellable(foo.observe(.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 = cancellable(after(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 = cancellable(after(life: killme)).done { _ in + XCTFail() + }.catch(policy: .allErrors) { + $0.isCancelled ? ex1.fulfill() : XCTFail() + } + p2 = cancellable(after(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..4af2786 100644 --- a/Tests/TestNSTask.swift +++ b/Tests/TestNSTask.swift @@ -49,4 +49,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 = cancellable(task.launch(.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 = cancellable(task.launch(.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..d67aee6 100644 --- a/Tests/TestNSURLSession.swift +++ b/Tests/TestNSURLSession.swift @@ -74,3 +74,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 { + cancellable(URLSession.shared.dataTask(.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 = cancellable(after(.milliseconds(100))).then { + cancellable(URLSession.shared.dataTask(.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 = cancellable(after(.milliseconds(100))).then { + cancellable(URLSession.shared.dataTask(.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) + } +} +