Skip to content

Latest commit

 

History

History
469 lines (363 loc) · 21 KB

Cancel.md

File metadata and controls

469 lines (363 loc) · 21 KB

Cancelling Promises

PromiseKit 7 adds clear and concise cancellation abilities to promises and to the PromiseKit extensions. Cancelling promises and their associated tasks is now simple and straightforward. Promises and promise chains can safely and efficiently be cancelled from any thread at any time.

UIApplication.shared.isNetworkActivityIndicatorVisible = true

let fetchImage = URLSession.shared.dataTask(.promise, with: url)
    .cancellize()
    .compactMap{ UIImage(data: $0.data) }
let fetchLocation = CLLocationManager.requestLocation().cancellize().lastValue

let finalizer = firstly {
    when(fulfilled: fetchImage, fetchLocation)
}.done { image, location in
    self.imageView.image = image
    self.label.text = "\(location)"
}.ensure {
    UIApplication.shared.isNetworkActivityIndicatorVisible = false
}.catch(policy: .allErrors) { error in
    // `catch` will be invoked with `PMKError.cancelled` when cancel is called
    // on the context. Use the default policy of `.allErrorsExceptCancellation`
    // to ignore cancellation errors.
    self.show(UIAlertController(for: error), sender: self)
}

//…

// Cancel currently active tasks and reject all cancellable promises
// with 'PMKError.cancelled'.  `cancel()` can be called from any thread
// at any time.
finalizer.cancel()

// `finalizer` here refers to the `CancellableFinalizer` for the chain.
// Calling 'cancel' on any promise in the chain or on the finalizer
// cancels the entire chain.  Therefore calling `cancel` on the finalizer
// cancels everything.

Cancel Chains

Promises can be cancelled using a CancellablePromise. The cancellize() method on Promise is used to convert a Promise into a CancellablePromise. If a promise chain is initialized with a CancellablePromise, then the entire chain is cancellable. Calling cancel() on any promise in the chain cancels the entire chain.

Creating a chain where the entire chain can be cancelled is the recommended usage for cancellable promises.

The CancellablePromise contains a CancelContext that keeps track of the tasks and promises for the chain. Promise chains can be cancelled either by calling the cancel() method on any CancellablePromise in the chain, or by calling cancel() on the CancelContext for the chain. It may be desirable to hold on to the CancelContext directly rather than a promise so that the promise can be deallocated by ARC when it is resolved.

For example:

let context = firstly {
    login()
    /* The 'Thenable.cancellize' method initiates a cancellable promise chain by
       returning a 'CancellablePromise'. */
}.cancellize().then { creds in
    fetch(avatar: creds.user)
}.done { image in
    self.imageView = image
}.catch(policy: .allErrors) { error in
    if error.isCancelled {
        // the chain has been cancelled!
    }
}.cancelContext

// …

/* Note: Promises can be cancelled using the 'cancel()' method on the 'CancellablePromise'.
   However, it may be desirable to hold on to the 'CancelContext' directly rather than a
   promise so that the promise can be deallocated by ARC when it is resolved. */
context.cancel()

Creating a partially cancellable chain

A CancellablePromise can be placed at the start of a chain, but it cannot be embedded directly in the middle of a standard (non-cancellable) promise chain. Instead, a partially cancellable promise chain can be used. A partially cancellable chain is not the recommended way to use cancellable promises, although there may be cases where this is useful.

Convert a cancellable chain to a standard chain

CancellablePromise wraps a delegate Promise, which can be accessed with the promise property. The above example can be modified as follows so that once login() completes, the chain can no longer be cancelled:

/* Here, by calling 'promise.then' rather than 'then' the chain is converted from a cancellable
   promise chain to a standard promise chain. In this example, calling 'cancel()' during 'login'
   will cancel the chain but calling 'cancel()' during the 'fetch' operation will have no effect: */
let cancellablePromise = firstly {
    login().cancellize()
}
cancellablePromise.promise.then {
    fetch(avatar: creds.user)      
}.done { image in
    self.imageView = image
}.catch(policy: .allErrors) { error in
    if error.isCancelled {
        // the chain has been cancelled!
    }
}

// …

/* This will cancel the 'login' but will not cancel the 'fetch'.  So whether or not the
   chain is cancelled depends on how far the chain has progressed. */
cancellablePromise.cancel()

Convert a standard chain to a cancellable chain

A non-cancellable chain can be converted to a cancellable chain in the middle of the chain as follows:

/* In this example, calling 'cancel()' during 'login' will not cancel the login.  However,
   the chain will be cancelled immediately, and the 'fetch' will not be executed.  If 'cancel()'
   is called during the 'fetch' then both the 'fetch' itself and the promise chain will be
   cancelled immediately. */
let promise = firstly {
    login()
}.then {
    fetch(avatar: creds.user).cancellize()
}.done { image in
    self.imageView = image
}.catch(policy: .allErrors) { error in
    if error.isCancelled {
        // the chain has been cancelled!
    }
}

// …

promise.cancel()

Core Cancellable PromiseKit API

The following classes, methods and functions have been added to PromiseKit to support cancellation. Existing functions or methods with underlying tasks that can be cancelled are indicated by being appended with '.cancellize()'.

Thenable
    cancellize(_:)                 - Converts the Promise or Guarantee (Thenable) into a
                                     CancellablePromise, which is a cancellable variant of the given
                                     Promise or Guarantee (Thenable)

Global functions
    after(seconds:).cancellize()   - 'after' with seconds can be cancelled
    after(_:).cancellize           - 'after' with interval can be cancelled

    firstly(execute:)               - Accepts body returning Promise or CancellablePromise
    hang(_:)                        - Accepts Promise and CancellablePromise
    race(_:)                        - Accepts [Promise] and [CancellablePromise]
    when(fulfilled:)                - Accepts [Promise] and [CancellablePromise]
    when(fulfilled:concurrently:)   - Accepts iterator of type Promise or CancellablePromise
    when(resolved:)                 - Accepts [Promise] and [CancellablePromise]

CancellablePromise properties and methods
    promise                         - Delegate Promise for this CancellablePromise
    result                          - The current Result

    init(_ bridge:cancelContext:)   - Initialize a new cancellable promise bound to the provided Thenable
    init(cancellable:resolver body:).  - Initialize a new cancellable promise that can be resolved with
                                       the provided '(Resolver) throws -> Void' body
    init(cancellable:promise:resolver:)  - Initialize a new cancellable promise using the given Promise
                                       and its Resolver
    init(cancellable:error:)          - Initialize a new rejected cancellable promise
    init(cancellable:)                - Initializes a new cancellable promise fulfilled with Void

    pending() -> (promise:resolver:)  - Returns a tuple of a new cancellable pending promise and its
                                        Resolver

CancellableThenable properties and methods
    thenable                        - Delegate Thenable for this CancellableThenable

    cancel(error:)                  - Cancels all members of the promise chain
    cancelContext                   - The CancelContext associated with this CancellableThenable
    cancelItemList                  - Tracks the cancel items for this CancellableThenable
    isCancelled                     - True if all members of the promise chain have been successfully
                                      cancelled, false otherwise
    cancelAttempted                 - True if 'cancel' has been called on the promise chain associated
                                      with this CancellableThenable, false otherwise
    cancelledError                  - The error generated when the promise is cancelled
    appendCancellable(cancellable:reject:)  - Append the Cancellable task to our cancel context
    appendCancelContext(from:)      - Append the cancel context associated with 'from' to our
                                      CancelContext

    then(on:flags:_ body:)           - Accepts body returning CancellableThenable
    cancellableThen(on:flags:_ body:)  - Accepts body returning Thenable
    map(on:flags:_ transform:)
    compactMap(on:flags:_ transform:)
    done(on:flags:_ body:)
    get(on:flags:_ body:)
    tap(on:flags:_ body:)
    asVoid()

    error
    isPending
    isResolved
    isFulfilled
    isRejected
    value

    mapValues(on:flags:_ transform:)
    flatMapValues(on:flags:_ transform:)
    compactMapValues(on:flags:_ transform:)
    thenMap(on:flags:_ transform:)                 - Accepts transform returning CancellableThenable
    cancellableThenMap(on:flags:_ transform:)      - Accepts transform returning Thenable
    thenFlatMap(on:flags:_ transform:)             - Accepts transform returning CancellableThenable
    cancellableThenFlatMap(on:flags:_ transform:)  - Accepts transform returning Thenable
    filterValues(on:flags:_ isIncluded:)
    firstValue
    lastValue
    sortedValues(on:flags:)

CancellableCatchable properties and methods
    catchable                                      - Delegate Catchable for this CancellableCatchable
    catch(on:flags:policy::_ body:)                - Accepts body returning Void
    recover(on:flags:policy::_ body:)              - Accepts body returning CancellableThenable
    cancellableRecover(on:flags:policy::_ body:)   - Accepts body returning Thenable
    ensure(on:flags:_ body:)                       - Accepts body returning Void
    ensureThen(on:flags:_ body:)                   - Accepts body returning CancellablePromise
    finally(_ body:)
    cauterize()

Extensions

Cancellation support has been added to the PromiseKit extensions, but only where the underlying asynchronous tasks can be cancelled. This example Podfile lists the PromiseKit extensions that support cancellation along with a usage example:

pod "PromiseKit/Alamofire"
# Alamofire.request("http://example.com", method: .get).responseDecodable(DecodableObject.self).cancellize()

pod "PromiseKit/Bolts"
# CancellablePromise(…).then() { _ -> BFTask in /*…*/ }  // Returns CancellablePromise

pod "PromiseKit/CoreLocation"
# CLLocationManager.requestLocation().cancellize().then { /*…*/ }

pod "PromiseKit/Foundation"
# URLSession.shared.dataTask(.promise, with: request).cancellize().then { /*…*/ }

pod "PromiseKit/MapKit"
# MKDirections(…).calculate().cancellize().then { /*…*/ }

pod "PromiseKit/OMGHTTPURLRQ"
# URLSession.shared.GET("http://example.com").cancellize().then { /*…*/ }

pod "PromiseKit/StoreKit"
# SKProductsRequest(…).start(.promise).cancellize().then { /*…*/ }

pod "PromiseKit/SystemConfiguration"
# SCNetworkReachability.promise().cancellize().then { /*…*/ }

pod "PromiseKit/UIKit"
# UIViewPropertyAnimator(…).startAnimation(.promise).cancellize().then { /*…*/ }

Here is a complete list of PromiseKit extension methods that support cancellation:

Alamofire

Alamofire.DataRequest
    response(_:queue:).cancellize()
    responseData(queue:).cancellize()
    responseString(queue:).cancellize()
    responseJSON(queue:options:).cancellize()
    responsePropertyList(queue:options:).cancellize()
    responseDecodable(queue::decoder:).cancellize()
    responseDecodable(_ type:queue:decoder:).cancellize()

Alamofire.DownloadRequest
    response(_:queue:).cancellize()
    responseData(queue:).cancellize()

Bolts

CancellablePromise<T>
    then<U>(on: DispatchQueue?, body: (T) -> BFTask<U>) -> CancellablePromise

CoreLocation

CLLocationManager
    requestLocation(authorizationType:satisfying:).cancellize()
    requestAuthorization(type requestedAuthorizationType:).cancellize()

Foundation

NotificationCenter:
    observe(once:object:).cancellize()

NSObject
    observe(_:keyPath:).cancellize()

Process
    launch(_:).cancellize()

URLSession
    dataTask(_:with:).cancellize()
    uploadTask(_:with:from:).cancellize()
    uploadTask(_:with:fromFile:).cancellize()
    downloadTask(_:with:to:).cancellize()

CancellablePromise
    validate()

HomeKit

HMPromiseAccessoryBrowser
    start(scanInterval:).cancellize()

HMHomeManager
    homes().cancellize()

MapKit

MKDirections
    calculate().cancellize()
    calculateETA().cancellize()

MKMapSnapshotter
    start().cancellize()

StoreKit

SKProductsRequest
    start(_:).cancellize()

SKReceiptRefreshRequest
    promise().cancellize()

SystemConfiguration

SCNetworkReachability
    promise().cancellize()

UIKit

UIViewPropertyAnimator
    startAnimation(_:).cancellize()

Choose Your Networking Library

All the networking library extensions supported by PromiseKit are now simple to cancel!

Alamofire

// pod 'PromiseKit/Alamofire'
// # https://github.com/PromiseKit/Alamofire

let context = firstly {
    Alamofire
        .request("http://example.com", method: .post, parameters: params)
        .responseDecodable(Foo.self)
}.cancellize().done { foo in
    //…
}.catch { error in
    //…
}.cancelContext

//…

context.cancel()

And (of course) plain URLSession from Foundation:

// pod 'PromiseKit/Foundation'
// # https://github.com/PromiseKit/Foundation

let context = firstly {
    URLSession.shared.dataTask(.promise, with: try makeUrlRequest())
}.cancellize().map {
    try JSONDecoder().decode(Foo.self, with: $0.data)
}.done { foo in
    //…
}.catch { error in
    //…
}.cancelContext

//…

context.cancel()

func makeUrlRequest() throws -> URLRequest {
    var rq = URLRequest(url: url)
    rq.httpMethod = "POST"
    rq.addValue("application/json", forHTTPHeaderField: "Content-Type")
    rq.addValue("application/json", forHTTPHeaderField: "Accept")
    rq.httpBody = try JSONSerialization.jsonData(with: obj)
    return rq
}

Cancellability Goals

  • Provide a streamlined way to cancel a promise chain, which rejects all associated promises and cancels all associated tasks. For example:
let promise = firstly {
    login()
}.cancellize().then { creds in // Use the 'cancellize' function to initiate a cancellable promise chain
    fetch(avatar: creds.user)
}.done { image in
    self.imageView = image
}.catch(policy: .allErrors) { error in
    if error.isCancelled {
        // the chain has been cancelled!
    }
}
//…
promise.cancel()
  • Ensure that subsequent code blocks in a promise chain are never called after the chain has been cancelled

  • Fully support concurrency, where all code is thread-safe. Cancellable promises and promise chains can safely and efficiently be cancelled from any thread at any time.

  • Provide cancellable support for all PromiseKit extensions whose native tasks can be cancelled (e.g. Alamofire, Bolts, CoreLocation, Foundation, HealthKit, HomeKit, MapKit, StoreKit, SystemConfiguration, UIKit)

  • Support cancellation for all PromiseKit primitives such as 'after', 'firstly', 'when', 'race'

  • Provide a simple way to make new types of cancellable promises

  • Ensure promise branches are properly cancelled. For example:

import Alamofire
import PromiseKit

func updateWeather(forCity searchName: String) {
    refreshButton.startAnimating()
    let context = firstly {
        getForecast(forCity: searchName)
    }.cancellize().done { response in
        updateUI(forecast: response)
    }.ensure {
        refreshButton.stopAnimating()
    }.catch { error in
        // Cancellation errors are ignored by default
        showAlert(error: error)
    }.cancelContext

    //…

    /* **** Cancels EVERYTHING (except... the 'ensure' block always executes regardless)    
       Note: non-cancellable tasks cannot be interrupted.  For example: if 'cancel()' is
       called in the middle of 'updateUI()' then the chain will immediately be rejected,
       however the 'updateUI' call will complete normally because it is not cancellable.
       Its return value (if any) will be discarded. */
    context.cancel()
}

func getForecast(forCity name: String) -> CancellablePromise<WeatherInfo> {
    return firstly {
        Alamofire.request("https://autocomplete.weather.com/\(name)")
            .responseDecodable(AutoCompleteCity.self)
    }.cancellize().then { city in
        Alamofire.request("https://forecast.weather.com/\(city.name)")
            .responseDecodable(WeatherResponse.self).cancellize()
    }.map { response in
        format(response)
    }
}