From 95cec595379e02980871368d8ee7908978691aba Mon Sep 17 00:00:00 2001 From: dougzilla32 Date: Sun, 7 Oct 2018 15:18:59 +0900 Subject: [PATCH 1/3] 'Cancel' for PromiseKit -- provides the ability to cancel promises and promise chains --- .travis.yml | 101 +++++---- Cartfile | 3 +- Cartfile.resolved | 2 +- PMKFoundation.xcodeproj/project.pbxproj | 7 +- .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../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 +++++++ 16 files changed, 645 insertions(+), 57 deletions(-) create mode 100644 PMKFoundation.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist 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 2bfea98..27b1f8d 100644 --- a/Cartfile +++ b/Cartfile @@ -1 +1,2 @@ -github "mxcl/PromiseKit" ~> 6.0 +#github "mxcl/PromiseKit" ~> 6.3 +github "dougzilla32/PromiseKit" "CoreCancel" diff --git a/Cartfile.resolved b/Cartfile.resolved index 6d2317d..d3e5cdb 100644 --- a/Cartfile.resolved +++ b/Cartfile.resolved @@ -1,2 +1,2 @@ github "AliSoftware/OHHTTPStubs" "6.1.0" -github "mxcl/PromiseKit" "6.1.0" +github "dougzilla32/PromiseKit" "087b3cf470890ff9ea841212e2f3e285fecf3988" diff --git a/PMKFoundation.xcodeproj/project.pbxproj b/PMKFoundation.xcodeproj/project.pbxproj index c584c8e..de63cc2 100644 --- a/PMKFoundation.xcodeproj/project.pbxproj +++ b/PMKFoundation.xcodeproj/project.pbxproj @@ -337,7 +337,7 @@ GCC_WARN_UNUSED_VARIABLE = YES; INFOPLIST_FILE = Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 8.0; - MACOSX_DEPLOYMENT_TARGET = 10.9; + MACOSX_DEPLOYMENT_TARGET = 10.10; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; PRODUCT_BUNDLE_IDENTIFIER = org.promisekit.Foundation; @@ -397,11 +397,10 @@ GCC_WARN_UNUSED_VARIABLE = YES; INFOPLIST_FILE = Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 8.0; - MACOSX_DEPLOYMENT_TARGET = 10.9; + MACOSX_DEPLOYMENT_TARGET = 10.10; MTL_ENABLE_DEBUG_INFO = NO; PRODUCT_BUNDLE_IDENTIFIER = org.promisekit.Foundation; SUPPORTED_PLATFORMS = "iphonesimulator iphoneos watchsimulator watchos macosx appletvsimulator appletvos"; - SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; SWIFT_VERSION = 3.0; TARGETED_DEVICE_FAMILY = "1,2,3,4"; @@ -454,6 +453,7 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_SUPPRESS_WARNINGS = YES; + SWIFT_VERSION = 4.0; }; name = Debug; }; @@ -466,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/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/PMKFoundation.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/PMKFoundation.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + 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) + } +} + From 8738d6fd4fd593f2589c5c2d7c4d7ac95e5f1031 Mon Sep 17 00:00:00 2001 From: dougzilla32 Date: Tue, 16 Oct 2018 11:53:03 +0900 Subject: [PATCH 2/3] 'Cancel' for PromiseKit -- remove cancellable wrappers (they are unnecessary) --- Sources/NSNotificationCenter+Promise.swift | 11 +- Sources/NSObject+Promise.swift | 15 +-- Sources/NSURLSession+Promise.swift | 118 ++------------------- Sources/Process+Promise.swift | 26 +---- Sources/afterlife.swift | 12 +-- Tests/TestNSNotificationCenter.swift | 2 +- Tests/TestNSObject.swift | 10 +- Tests/TestNSTask.swift | 4 +- Tests/TestNSURLSession.swift | 6 +- 9 files changed, 28 insertions(+), 176 deletions(-) diff --git a/Sources/NSNotificationCenter+Promise.swift b/Sources/NSNotificationCenter+Promise.swift index 0eef93a..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))) @@ -47,12 +49,3 @@ class ObserverTask: CancellableTask { 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 ef4c2bc..9aee7b8 100644 --- a/Sources/NSObject+Promise.swift +++ b/Sources/NSObject+Promise.swift @@ -23,6 +23,8 @@ 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) } @@ -74,16 +76,3 @@ private class KVOProxy: NSObject, CancellableTask { 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 c6202c0..4c194ae 100644 --- a/Sources/NSURLSession+Promise.swift +++ b/Sources/NSURLSession+Promise.swift @@ -76,7 +76,9 @@ 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)> { @@ -93,6 +95,8 @@ extension URLSession { 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)> { var task: URLSessionTask! var reject: ((Error) -> Void)! @@ -107,6 +111,8 @@ extension URLSession { 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)> { var task: URLSessionTask! var reject: ((Error) -> Void)! @@ -123,6 +129,8 @@ extension URLSession { /// - 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)> { var task: URLSessionTask! var reject: ((Error) -> Void)! @@ -283,114 +291,6 @@ extension URLSessionTask: CancellableTask { } } -//////////////////////////////////////////////////////////// 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 { diff --git a/Sources/Process+Promise.swift b/Sources/Process+Promise.swift index cd2df22..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()) @@ -155,28 +157,4 @@ extension Process: CancellableTask { } } -//////////////////////////////////////////////////////////// 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 10f9a10..e01bd6b 100644 --- a/Sources/afterlife.swift +++ b/Sources/afterlife.swift @@ -6,6 +6,8 @@ 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 @@ -44,13 +46,3 @@ private class CancellableReaperTask: CancellableTask { } } } - -//////////////////////////////////////////////////////////// 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 689f8c8..a61cebb 100644 --- a/Tests/TestNSNotificationCenter.swift +++ b/Tests/TestNSNotificationCenter.swift @@ -28,7 +28,7 @@ extension NSNotificationCenterTests { let ex = expectation(description: "") let userInfo = ["a": 1] - NotificationCenter.default.cancellableObserve(once: PMKTestNotification).done { value in + cancellable(NotificationCenter.default.observe(once: PMKTestNotification)).done { value in XCTFail() }.catch(policy: .allErrors) { $0.isCancelled ? ex.fulfill() : XCTFail() diff --git a/Tests/TestNSObject.swift b/Tests/TestNSObject.swift index 585fb52..5e55db7 100644 --- a/Tests/TestNSObject.swift +++ b/Tests/TestNSObject.swift @@ -82,7 +82,7 @@ extension NSObjectTests { let ex = expectation(description: "") let foo = Foo() - foo.cancellableObserve(.promise, keyPath: "bar").done { newValue in + cancellable(foo.observe(.promise, keyPath: "bar")).done { newValue in XCTAssertEqual(newValue as? String, "moo") XCTFail() // ex.fulfill() @@ -98,7 +98,7 @@ extension NSObjectTests { let ex = expectation(description: "") let foo = Foo() - let p = foo.cancellableObserve(.promise, keyPath: "bar").done { newValue in + let p = cancellable(foo.observe(.promise, keyPath: "bar")).done { newValue in XCTAssertEqual(newValue as? String, "moo") XCTFail() // ex.fulfill() @@ -119,7 +119,7 @@ extension NSObjectTests { var p: CancellableFinalizer! func innerScope() { killme = NSObject() - p = cancellableAfter(life: killme).done { _ in + p = cancellable(after(life: killme)).done { _ in XCTFail() }.catch(policy: .allErrors) { $0.isCancelled ? ex.fulfill() : XCTFail() @@ -146,12 +146,12 @@ extension NSObjectTests { var p1, p2: CancellableFinalizer! func innerScope() { killme = NSObject() - p1 = cancellableAfter(life: killme).done { _ in + p1 = cancellable(after(life: killme)).done { _ in XCTFail() }.catch(policy: .allErrors) { $0.isCancelled ? ex1.fulfill() : XCTFail() } - p2 = cancellableAfter(life: killme).done { _ in + p2 = cancellable(after(life: killme)).done { _ in XCTFail() }.catch(policy: .allErrors) { $0.isCancelled ? ex2.fulfill() : XCTFail() diff --git a/Tests/TestNSTask.swift b/Tests/TestNSTask.swift index 417adb5..4af2786 100644 --- a/Tests/TestNSTask.swift +++ b/Tests/TestNSTask.swift @@ -58,7 +58,7 @@ extension NSTaskTests { task.launchPath = "/usr/bin/man" task.arguments = ["ls"] - let context = task.cancellableLaunch(.promise).done { stdout, _ in + 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 @@ -76,7 +76,7 @@ extension NSTaskTests { task.launchPath = "/bin/ls" task.arguments = ["-l", dir] - let context = task.cancellableLaunch(.promise).done { _ in + 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)") diff --git a/Tests/TestNSURLSession.swift b/Tests/TestNSURLSession.swift index 0a38444..d67aee6 100644 --- a/Tests/TestNSURLSession.swift +++ b/Tests/TestNSURLSession.swift @@ -88,7 +88,7 @@ extension NSURLSessionTests { let ex = expectation(description: "") let rq = URLRequest(url: URL(string: "http://example.com")!) let context = firstly { - URLSession.shared.cancellableDataTask(.promise, with: rq) + cancellable(URLSession.shared.dataTask(.promise, with: rq)) }.compactMap { try JSONSerialization.jsonObject(with: $0.data) as? NSDictionary }.done { rsp in @@ -116,7 +116,7 @@ extension NSURLSessionTests { let rq = URLRequest(url: URL(string: "http://example.com")!) let context = cancellable(after(.milliseconds(100))).then { - URLSession.shared.cancellableDataTask(.promise, with: rq) + cancellable(URLSession.shared.dataTask(.promise, with: rq)) }.done { x in XCTAssertEqual(x.data, dummy) ex.fulfill() @@ -141,7 +141,7 @@ extension NSURLSessionTests { let rq = URLRequest(url: URL(string: "http://example.com")!) let context = cancellable(after(.milliseconds(100))).then { - URLSession.shared.cancellableDataTask(.promise, with: rq) + cancellable(URLSession.shared.dataTask(.promise, with: rq)) }.map(String.init).done { XCTAssertEqual($0, dummy) ex.fulfill() From 00e57c3a0ad6cc03795f1120ad1eb95fb2fc7d22 Mon Sep 17 00:00:00 2001 From: dougzilla32 Date: Mon, 26 Nov 2018 17:50:19 -0800 Subject: [PATCH 3/3] 'Cancel' for PromiseKit -- rename to 'cancellableWhen', minor fixes, and doc updates The cancellable 'when' methods must be named 'cancellableWhen' due to a compiler bug. The compiler bug (described on line 330 of 'when.swift') causes a conflict between the non-cancellable 'when' functions and the cancellable 'when' functions. Fix 'isCancelled' on URLSessionTask to properly report the cancelled state. --- Sources/NSURLSession+Promise.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/NSURLSession+Promise.swift b/Sources/NSURLSession+Promise.swift index 4c194ae..cb42a2c 100644 --- a/Sources/NSURLSession+Promise.swift +++ b/Sources/NSURLSession+Promise.swift @@ -287,7 +287,7 @@ public extension Promise where T == (data: Data, response: URLResponse) { extension URLSessionTask: CancellableTask { /// `true` if the URLSessionTask was successfully cancelled, `false` otherwise public var isCancelled: Bool { - return state == .canceling + return state == .canceling || (error as NSError?)?.code == NSURLErrorCancelled } }