From bfcb3f7b4641cd5d07df2114adc1acd09b18acb5 Mon Sep 17 00:00:00 2001 From: dougzilla32 Date: Tue, 11 Sep 2018 18:51:48 -0700 Subject: [PATCH] 'Cancel' for PromiseKit -- provides the ability to cancel promises and promise chains --- .travis.yml | 101 +++++---- Cartfile | 3 +- Cartfile.resolved | 2 +- PMKFoundation.xcodeproj/project.pbxproj | 2 + .../xcschemes/PMKFoundation.xcscheme | 4 +- Package.swift | 5 +- Sources/NSNotificationCenter+Promise.swift | 31 ++- Sources/NSObject+Promise.swift | 34 ++- Sources/NSURLSession+Promise.swift | 207 +++++++++++++++++- Sources/Process+Promise.swift | 38 +++- Sources/afterlife.swift | 30 +++ Tests/TestNSNotificationCenter.swift | 19 ++ Tests/TestNSObject.swift | 96 ++++++++ Tests/TestNSTask.swift | 37 ++++ Tests/TestNSURLSession.swift | 80 +++++++ 15 files changed, 635 insertions(+), 54 deletions(-) diff --git a/.travis.yml b/.travis.yml index f7adb26..16c4e88 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,39 +1,64 @@ matrix: include: - - {osx_image: xcode8.3, env: 'SWFT=3.1 PLAT=iOS DST="OS=10.3.1,name=iPhone SE"', os: osx, language: objective-c} - - {osx_image: xcode8.3, env: 'SWFT=3.1 PLAT=tvOS DST="OS=10.2,name=Apple TV 1080p"', os: osx, language: objective-c} - - {osx_image: xcode8.3, env: 'SWFT=3.1 PLAT=macOS DST="arch=x86_64"', os: osx, language: objective-c} - - {osx_image: xcode8.3, env: 'SWFT=3.1 PLAT=watchOS DST="OS=3.2,name=Apple Watch - 38mm"', os: osx, language: objective-c} + - {osx_image: xcode8.3, env: 'SWFT=3.1 PLAT=iOS DST="OS=10.3.1,name=iPhone SE"', os: osx, language: objective-c} + - {osx_image: xcode8.3, env: 'SWFT=3.1 PLAT=tvOS DST="OS=10.2,name=Apple TV 1080p"', os: osx, language: objective-c} + - {osx_image: xcode8.3, env: 'SWFT=3.1 PLAT=macOS DST="arch=x86_64"', os: osx, language: objective-c} + - {osx_image: xcode8.3, env: 'SWFT=3.1 PLAT=watchOS DST="OS=3.2,name=Apple Watch - 38mm"', os: osx, language: objective-c} - - {osx_image: xcode9.2, env: 'SWFT=3.2 PLAT=iOS DST="OS=11.2,name=iPhone SE"', os: osx, language: objective-c} - - {osx_image: xcode9.2, env: 'SWFT=3.2 PLAT=tvOS DST="OS=11.2,name=Apple TV"', os: osx, language: objective-c} - - {osx_image: xcode9.2, env: 'SWFT=3.2 PLAT=macOS DST="arch=x86_64"', os: osx, language: objective-c} - - {osx_image: xcode9.2, env: 'SWFT=3.2 PLAT=watchOS DST="OS=4.2,name=Apple Watch - 38mm"', os: osx, language: objective-c} + - {osx_image: xcode9.2, env: 'SWFT=3.2 PLAT=iOS DST="OS=11.2,name=iPhone SE"', os: osx, language: objective-c} + - {osx_image: xcode9.2, env: 'SWFT=3.2 PLAT=tvOS DST="OS=11.2,name=Apple TV"', os: osx, language: objective-c} + - {osx_image: xcode9.2, env: 'SWFT=3.2 PLAT=macOS DST="arch=x86_64"', os: osx, language: objective-c} + - {osx_image: xcode9.2, env: 'SWFT=3.2 PLAT=watchOS DST="OS=4.2,name=Apple Watch - 38mm"', os: osx, language: objective-c} - - {osx_image: xcode9.3, env: 'SWFT=3.3 PLAT=iOS DST="OS=11.3,name=iPhone SE"', os: osx, language: objective-c} - - {osx_image: xcode9.3, env: 'SWFT=3.3 PLAT=tvOS DST="OS=11.3,name=Apple TV"', os: osx, language: objective-c} - - {osx_image: xcode9.3, env: 'SWFT=3.3 PLAT=macOS DST="arch=x86_64"', os: osx, language: objective-c} - - {osx_image: xcode9.3, env: 'SWFT=3.3 PLAT=watchOS DST="OS=4.3,name=Apple Watch - 38mm"', os: osx, language: objective-c} + - {osx_image: xcode9.4, env: 'SWFT=3.3 PLAT=iOS DST="OS=11.4,name=iPhone 5s"', os: osx, language: objective-c} + - {osx_image: xcode9.4, env: 'SWFT=3.3 PLAT=tvOS DST="OS=11.4,name=Apple TV"', os: osx, language: objective-c} + - {osx_image: xcode9.3, env: 'SWFT=3.3 PLAT=macOS DST="arch=x86_64"', os: osx, language: objective-c} + - {osx_image: xcode9.3, env: 'SWFT=3.3 PLAT=watchOS DST="OS=4.3,name=Apple Watch - 38mm"', os: osx, language: objective-c} - - {osx_image: xcode9.2, env: 'SWFT=4.0 PLAT=macOS DST="arch=x86_64"', os: osx, language: objective-c} - - {osx_image: xcode9.2, env: 'SWFT=4.0 PLAT=iOS DST="OS=8.4,name=iPhone 4s"', os: osx, language: objective-c} - - {osx_image: xcode9.2, env: 'SWFT=4.0 PLAT=iOS DST="OS=9.3,name=iPhone SE"', os: osx, language: objective-c} - - {osx_image: xcode9.2, env: 'SWFT=4.0 PLAT=iOS DST="OS=10.3.1,name=iPhone SE"', os: osx, language: objective-c} - - {osx_image: xcode9.2, env: 'SWFT=4.0 PLAT=iOS DST="OS=11.2,name=iPhone SE"', os: osx, language: objective-c} - - {osx_image: xcode9.2, env: 'SWFT=4.0 PLAT=tvOS DST="OS=9.2,name=Apple TV 1080p"', os: osx, language: objective-c} - - {osx_image: xcode9.2, env: 'SWFT=4.0 PLAT=tvOS DST="OS=10.2,name=Apple TV 1080p"', os: osx, language: objective-c} - - {osx_image: xcode9.2, env: 'SWFT=4.0 PLAT=tvOS DST="OS=11.2,name=Apple TV"', os: osx, language: objective-c} - - {osx_image: xcode9.2, env: 'SWFT=4.0 PLAT=watchOS DST="OS=2.2,name=Apple Watch - 38mm"', os: osx, language: objective-c} - - {osx_image: xcode9.2, env: 'SWFT=4.0 PLAT=watchOS DST="OS=3.2,name=Apple Watch - 38mm"', os: osx, language: objective-c} - - {osx_image: xcode9.2, env: 'SWFT=4.0 PLAT=watchOS DST="OS=4.2,name=Apple Watch - 38mm"', os: osx, language: objective-c} + - {osx_image: xcode10, env: 'SWFT=3.4 PLAT=iOS DST="OS=12.0,name=iPhone SE"', os: osx, language: objective-c} + - {osx_image: xcode10, env: 'SWFT=3.4 PLAT=tvOS DST="OS=12.0,name=Apple TV"', os: osx, language: objective-c} + - {osx_image: xcode10, env: 'SWFT=3.4 PLAT=macOS DST="arch=x86_64"', os: osx, language: objective-c} + - {osx_image: xcode10, env: 'SWFT=3.4 PLAT=watchOS DST="OS=5.0,name=Apple Watch Series 3 - 42mm"', os: osx, language: objective-c} - - {osx_image: xcode9.3, env: 'SWFT=4.1 PLAT=iOS DST="OS=11.3,name=iPhone SE"', os: osx, language: objective-c} - - {osx_image: xcode9.3, env: 'SWFT=4.1 PLAT=tvOS DST="OS=11.3,name=Apple TV"', os: osx, language: objective-c} - - {osx_image: xcode9.3, env: 'SWFT=4.1 PLAT=macOS DST="arch=x86_64"', os: osx, language: objective-c} - - {osx_image: xcode9.3, env: 'SWFT=4.1 PLAT=watchOS DST="OS=4.3,name=Apple Watch - 38mm"', os: osx, language: objective-c} + - {osx_image: xcode9.2, env: 'SWFT=4.0 PLAT=iOS DST="OS=11.2,name=iPhone SE"', os: osx, language: objective-c} + - {osx_image: xcode9.2, env: 'SWFT=4.0 PLAT=tvOS DST="OS=11.2,name=Apple TV"', os: osx, language: objective-c} + - {osx_image: xcode9.2, env: 'SWFT=4.0 PLAT=macOS DST="arch=x86_64"', os: osx, language: objective-c} + - {osx_image: xcode9.2, env: 'SWFT=4.0 PLAT=watchOS DST="OS=4.2,name=Apple Watch - 38mm"', os: osx, language: objective-c} + + - {osx_image: xcode9.4, env: 'SWFT=4.1 PLAT=macOS DST="arch=x86_64" TEST=1', os: osx, language: objective-c} + - {osx_image: xcode9.4, env: 'SWFT=4.1 PLAT=iOS DST="OS=8.4,name=iPhone 4s"', os: osx, language: objective-c} + - {osx_image: xcode9.4, env: 'SWFT=4.1 PLAT=iOS DST="OS=9.3,name=iPhone 5s"', os: osx, language: objective-c} + - {osx_image: xcode9.4, env: 'SWFT=4.1 PLAT=iOS DST="OS=10.3.1,name=iPhone SE"', os: osx, language: objective-c} + - {osx_image: xcode9.4, env: 'SWFT=4.1 PLAT=iOS DST="OS=11.4,name=iPhone 5s" TEST=1', os: osx, language: objective-c} + - {osx_image: xcode9.3, env: 'SWFT=4.1 PLAT=tvOS DST="OS=9.2,name=Apple TV 1080p"', os: osx, language: objective-c} + - {osx_image: xcode9.3, env: 'SWFT=4.1 PLAT=tvOS DST="OS=10.2,name=Apple TV 1080p"', os: osx, language: objective-c} + - {osx_image: xcode9.4, env: 'SWFT=4.1 PLAT=tvOS DST="OS=11.4,name=Apple TV" TEST=1', os: osx, language: objective-c} + - {osx_image: xcode9.3, env: 'SWFT=4.1 PLAT=watchOS DST="OS=2.2,name=Apple Watch - 38mm"', os: osx, language: objective-c} + - {osx_image: xcode9.3, env: 'SWFT=4.1 PLAT=watchOS DST="OS=3.2,name=Apple Watch - 38mm"', os: osx, language: objective-c} + - {osx_image: xcode9.4, env: 'SWFT=4.1 PLAT=watchOS DST="OS=4.3,name=Apple Watch - 38mm"', os: osx, language: objective-c} + + - {osx_image: xcode10, env: 'SWFT=4.3 PLAT=iOS DST="OS=12.0,name=iPhone SE"', os: osx, language: objective-c} + - {osx_image: xcode10, env: 'SWFT=4.3 PLAT=tvOS DST="OS=12.0,name=Apple TV"', os: osx, language: objective-c} + - {osx_image: xcode10, env: 'SWFT=4.3 PLAT=macOS DST="arch=x86_64"', os: osx, language: objective-c} + - {osx_image: xcode10, env: 'SWFT=4.3 PLAT=watchOS DST="OS=5.0,name=Apple Watch Series 3 - 42mm"', os: osx, language: objective-c} + + # Swift 3.2.0 (we have some source-conditionals for this version) + - {os: linux, dist: trusty, sudo: required, language: generic, env: 'SWIFT_BUILD_VERSION=3 SWIFT_VERSION=4.0'} + # Swift 3.2.3 + - {os: linux, dist: trusty, sudo: required, language: generic, env: 'SWIFT_BUILD_VERSION=3 SWIFT_VERSION=4.0.3'} + # Swift 3.3 + - {os: linux, dist: trusty, sudo: required, language: generic, env: 'SWIFT_BUILD_VERSION=3 SWIFT_VERSION=4.1.2 TEST=1'} + # Swift 3.4 + - {os: linux, dist: trusty, sudo: required, language: generic, env: 'SWIFT_BUILD_VERSION=3 SWIFT_VERSION=DEVELOPMENT-SNAPSHOT-2018-06-20-a TEST=1'} + # Swift 4.0.0 (we have some source-conditionals for this version) + - {os: linux, dist: trusty, sudo: required, language: generic, env: 'SWIFT_BUILD_VERSION=4 SWIFT_VERSION=4.0'} + # Swift 4.0.3 + - {os: linux, dist: trusty, sudo: required, language: generic, env: 'SWIFT_BUILD_VERSION=4 SWIFT_VERSION=4.0.3'} + # Swift 4.1 + - {os: linux, dist: trusty, sudo: required, language: generic, env: 'SWIFT_BUILD_VERSION=4 SWIFT_VERSION=4.1.2 TEST=1'} + # Swift 4.2 + - {os: linux, dist: trusty, sudo: required, language: generic, env: 'SWIFT_BUILD_VERSION=4 SWIFT_VERSION=DEVELOPMENT-SNAPSHOT-2018-06-20-a TEST=1'} - - {os: linux, dist: trusty, sudo: required, language: generic, env: 'SWIFT_VERSION=3.1'} - - {os: linux, dist: trusty, sudo: required, language: generic, env: 'SWIFT_VERSION=4.0'} cache: directories: - Carthage @@ -42,22 +67,24 @@ before_install: carthage bootstrap --cache-builds --no-use-binaries --platform $PLAT --verbose; else eval "$(curl -sL https://swiftenv.fuller.li/install.sh)"; + swift --version; fi install: - case $PLAT in - macOS|tvOS|iOS) - xcodebuild -scheme PMKFoundation -quiet -destination "$DST" SWIFT_VERSION=$SWFT build-for-testing -enableCodeCoverage YES;; - watchOS) - xcodebuild -scheme PMKFoundation -quiet -destination "$DST" SWIFT_VERSION=$SWFT build;; + macOS|tvOS|iOS|watchOS) + xcodebuild -scheme PMKFoundation -target PMKFoundation -quiet -destination "$DST" SWIFT_VERSION=$SWFT SWIFT_TREAT_WARNINGS_AS_ERRORS=YES build; + if [[ $TEST == "1" ]]; then + xcodebuild -scheme PMKFoundation -target PMKNSTests -quiet -destination "$DST" SWIFT_TREAT_WARNINGS_AS_ERRORS=YES build; + fi;; *) - swift build;; + swift build -Xswiftc -swift-version -Xswiftc $SWIFT_BUILD_VERSION;; esac script: - case $PLAT in macOS|tvOS|iOS) - xcodebuild -scheme PMKFoundation -quiet -destination "$DST" test -enableCodeCoverage YES;; - watchOS) - ;; + if [[ $TEST == "1" ]]; then + xcodebuild -scheme PMKFoundation -destination "$DST" test -enableCodeCoverage YES; + fi;; *) ;; esac 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 da4d5c5..3e686f2 100644 --- a/Cartfile.resolved +++ b/Cartfile.resolved @@ -1,2 +1,2 @@ github "AliSoftware/OHHTTPStubs" "6.1.0" -github "mxcl/PromiseKit" "6.3.0" +github "dougzilla32/PromiseKit" "ff694600d4d03458121515bdc027ba76df14f7ef" diff --git a/PMKFoundation.xcodeproj/project.pbxproj b/PMKFoundation.xcodeproj/project.pbxproj index 3fc5c6b..de63cc2 100644 --- a/PMKFoundation.xcodeproj/project.pbxproj +++ b/PMKFoundation.xcodeproj/project.pbxproj @@ -453,6 +453,7 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_SUPPRESS_WARNINGS = YES; + SWIFT_VERSION = 4.0; }; name = Debug; }; @@ -465,6 +466,7 @@ "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "$(inherited) @executable_path/../Frameworks @loader_path/../Frameworks"; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_SUPPRESS_WARNINGS = YES; + SWIFT_VERSION = 4.0; }; name = Release; }; diff --git a/PMKFoundation.xcodeproj/xcshareddata/xcschemes/PMKFoundation.xcscheme b/PMKFoundation.xcodeproj/xcshareddata/xcschemes/PMKFoundation.xcscheme index 58583cd..660f49f 100644 --- a/PMKFoundation.xcodeproj/xcshareddata/xcschemes/PMKFoundation.xcscheme +++ b/PMKFoundation.xcodeproj/xcshareddata/xcschemes/PMKFoundation.xcscheme @@ -4,10 +4,10 @@ version = "1.3"> + buildImplicitDependencies = "NO"> Guarantee { let (promise, fulfill) = Guarantee.pending() - #if !os(Linux) - let id = addObserver(forName: name, object: object, queue: nil, using: fulfill) - #else + #if os(Linux) && ((swift(>=4.0) && !swift(>=4.0.1)) || (swift(>=3.0) && !swift(>=3.2.1))) let id = addObserver(forName: name, object: object, queue: nil, usingBlock: fulfill) + #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 +} + +//////////////////////////////////////////////////////////// Cancellable wrapper + +extension NotificationCenter { + /// Observe the named notification once + public func cancellableObserve(once name: Notification.Name, object: Any? = nil) -> CancellablePromise { + return cancellable(observe(once: name, object: object)) + } +} diff --git a/Sources/NSObject+Promise.swift b/Sources/NSObject+Promise.swift index 135719b..ef4c2bc 100644 --- a/Sources/NSObject+Promise.swift +++ b/Sources/NSObject+Promise.swift @@ -29,13 +29,19 @@ extension NSObject { } } -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,11 +53,37 @@ 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() }() } + +//////////////////////////////////////////////////////////// Cancellable wrapper + +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 cancellableObserve(_: PMKNamespacer, keyPath: String) -> CancellablePromise { + return cancellable(observe(.promise, keyPath: keyPath)) + } +} diff --git a/Sources/NSURLSession+Promise.swift b/Sources/NSURLSession+Promise.swift index 7e06c5a..c6202c0 100644 --- a/Sources/NSURLSession+Promise.swift +++ b/Sources/NSURLSession+Promise.swift @@ -80,22 +80,56 @@ extension URLSession { [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 } 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 } 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! 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 +142,12 @@ extension URLSession { } else { seal.reject(PMKError.invalidCallingConvention) } - }).resume() + }) + task.resume() } + + promise.setCancellableTask(task, reject: reject) + return promise } } @@ -168,7 +206,7 @@ private func adapter(_ seal: Resolver<(data: T, response: U)>) -> (T?, U?, #if swift(>=3.1) -public enum PMKHTTPError: Error, LocalizedError { +public enum PMKHTTPError: Error, LocalizedError, CustomStringConvertible { case badStatusCode(Int, Data, HTTPURLResponse) public var errorDescription: String? { @@ -183,12 +221,44 @@ public enum PMKHTTPError: Error, LocalizedError { } } + public func decodeResponse(_ t: T.Type, decoder: JSONDecoder = JSONDecoder()) -> T? { + switch self { + case .badStatusCode(_, let data, _): + return try? decoder.decode(t, from: data) + } + } + + //TODO rename responseJSON public var jsonDictionary: Any? { switch self { case .badStatusCode(_, let data, _): return try? JSONSerialization.jsonObject(with: data) } } + + var responseBodyString: String? { + switch self { + case .badStatusCode(_, let data, _): + return String(data: data, encoding: .utf8) + } + } + + public var failureReason: String? { + return responseBodyString + } + + public var description: String { + switch self { + case .badStatusCode(let code, let data, let response): + var dict: [String: Any] = [ + "Status Code": code, + "Body": String(data: data, encoding: .utf8) ?? "\(data.count) bytes" + ] + dict["URL"] = response.url + dict["Headers"] = response.allHeaderFields + return " \(NSDictionary(dictionary: dict))" // as NSDictionary makes the output look like NSHTTPURLResponse looks + } + } } public extension Promise where T == (data: Data, response: URLResponse) { @@ -205,3 +275,126 @@ 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 + } +} + +//////////////////////////////////////////////////////////// Cancellable wrappers + +extension URLSession { + /** + Example usage with explicit cancel context: + + let context = firstly { + URLSession.shared.cancellableDataTask(.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.cancellableDataTask(.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.cancellableDataTask(.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.cancellableDataTask(.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.cancellableDataTask(.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.cancellableDataTask(.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 cancellableDataTask(_: PMKNamespacer, with convertible: URLRequestConvertible) -> CancellablePromise<(data: Data, response: URLResponse)> { + return cancellable(dataTask(.promise, with: convertible)) + } + + /// Wraps the (Data?, URLResponse?, Error?) response from URLSession.uploadTask(with:from:) as CancellablePromise<(Data,URLResponse)> + public func cancellableUploadTask(_: PMKNamespacer, with convertible: URLRequestConvertible, from data: Data) -> CancellablePromise<(data: Data, response: URLResponse)> { + return cancellable(uploadTask(.promise, with: convertible, from: data)) + } + + /// Wraps the (Data?, URLResponse?, Error?) response from URLSession.uploadTask(with:fromFile:) as CancellablePromise<(Data,URLResponse)> + public func cancellableUploadTask(_: PMKNamespacer, with convertible: URLRequestConvertible, fromFile file: URL) -> CancellablePromise<(data: Data, response: URLResponse)> { + return cancellable(uploadTask(.promise, with: convertible, fromFile: file)) + } + + /** + 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 cancellableDownloadTask(_: PMKNamespacer, with convertible: URLRequestConvertible, to saveLocation: URL) -> CancellablePromise<(saveLocation: URL, response: URLResponse)> { + return cancellable(downloadTask(.promise, with: convertible, to: saveLocation)) + } +} + +#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..cd2df22 100644 --- a/Sources/Process+Promise.swift +++ b/Sources/Process+Promise.swift @@ -67,7 +67,7 @@ extension Process { } } - return Promise { seal in + return Promise<(out: Pipe, err: Pipe)>(cancellableTask: self) { seal in q.async { self.waitUntilExit() @@ -143,4 +143,40 @@ 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 + } +} + +//////////////////////////////////////////////////////////// Cancellable wrapper + +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.cancellableLaunch(.promise).compactMap { std in + String(data: std.out.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) + }.then { stdout in + print(str) + }.cancelContext + + //… + + context.cancel() + */ + public func cancellableLaunch(_: PMKNamespacer) -> CancellablePromise<(out: Pipe, err: Pipe)> { + return cancellable(launch(.promise)) + } +} + #endif diff --git a/Sources/afterlife.swift b/Sources/afterlife.swift index 232c8da..10f9a10 100644 --- a/Sources/afterlife.swift +++ b/Sources/afterlife.swift @@ -12,6 +12,7 @@ public func after(life object: NSObject) -> Guarantee { 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 +25,32 @@ 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 + } + } +} + +//////////////////////////////////////////////////////////// Cancellable wrapper + +/** + - 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 cancellableAfter(life object: NSObject) -> CancellablePromise { + return cancellable(after(life: object)) +} diff --git a/Tests/TestNSNotificationCenter.swift b/Tests/TestNSNotificationCenter.swift index 3851029..689f8c8 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] + + NotificationCenter.default.cancellableObserve(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..585fb52 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() + foo.cancellableObserve(.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.cancellableObserve(.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 = cancellableAfter(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 = cancellableAfter(life: killme).done { _ in + XCTFail() + }.catch(policy: .allErrors) { + $0.isCancelled ? ex1.fulfill() : XCTFail() + } + p2 = cancellableAfter(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..417adb5 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 = task.cancellableLaunch(.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.cancellableLaunch(.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..0a38444 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 { + URLSession.shared.cancellableDataTask(.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 { + URLSession.shared.cancellableDataTask(.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 { + URLSession.shared.cancellableDataTask(.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) + } +} +