Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Proposal for PromiseKit cancellation support #896

Closed
dougzilla32 opened this issue Jul 18, 2018 · 21 comments
Closed

Proposal for PromiseKit cancellation support #896

dougzilla32 opened this issue Jul 18, 2018 · 21 comments

Comments

@dougzilla32
Copy link
Collaborator

dougzilla32 commented Jul 18, 2018

I have a proposal for adding cancellation to PromiseKit. PromiseKit is excellent for making code super clean! But I feel like it is missing a good solution for the real-world problem of cancellation. For example, with the current solution the code becomes rather involved for cancelling a chain of promises.

I’ve been working part-time for a couple months (!!) on add-ons for PromiseKit and the PromiseKit extensions that provide very good and complete (I think!) cancellation support. I hope it can be a useful addition to the PromiseKit implementation, and very useful for developers who want a comprehesive way to cancel promises!

I read up on some relevant threads and realize there are drawbacks to having a built-in ‘cancel’ method, so I'm using a delegation approach to leave the existing PromiseKit code unchanged. The core code is a set of wrappers called CancellablePromise, CancellableGuarantee, CancellableThenable, and CancellableCatchable, which delegate to their PromiseKit counterparts. There are also cancellable varients for all the 'after', 'firstly', 'hang', 'race' and 'when' PromiseKit functions. The new methods and functions mirror the public PromiseKit API, with the addition of cancellation support.

The delegate approach has the advantage that promises can be explicitly created with or without cancellation abilities. So the idea is you can get all the advantages without any of the disadvantages.

There is a class called 'CancelContext' that tracks the cancellable task(s) and 'reject' method for each promise in the chain. This allows for easy cancellation of the entire chain including branches. As each promise is initialized its cancellable task and 'reject' method is added to the CancelContext. And as each promise resolves its cancellable task and 'reject' method are removed from the CancelContext. Because these operations can happen on various threads I made the CancelContext code fully thread safe.

I’ve gone through all the PromiseKit extensions and found parts of 9 of them that are cancellable (see below), and implemented cancellable extensions for all of them.

I have a bunch of test cases for both the core code and for the extensions, so I think it is pretty solid.

Packaging

I've gotten this working very well both integrated with CorePromise and as a separate extension. I see 3 options for packaging this:

  1. Include cancellation with CorePromise — no new extensions or new dependencies. This option works very well but would sully CorePromise with cancellation code. Still, this may be acceptable as the existing code remains unchanged. Only new cancellation classes, methods and functions are added.
  2. Add cancellation as a PromiseKit extension called PMKCancel, and add a dependency on PMKCancel to each PromiseKit extension that can support cancellation — 1 new extension, 9 new dependencies. This is great solution for keeping the cancellation code separate from CorePromise, but it would require 9 existing extensions to have a new dependency on PMKCancel.
  3. Add cancellation as a PromiseKit extension called PMKCancel and make a new cancellable extension for each PromiseKit extension that can support cancellation — 10 new extensions, no new dependencies. This would require the PMKCancel extension plus 9 new 'mirror' extensions, but would not require existing extensions to have any new dependencies.

I will submit pull requests for options 1 and 2. Option 3 looks very similar to the set of projects on my github account: https://github.com/dougzilla32

I personally like option 1 best, then 2, but not so much option 3.

Next steps

I heard back from Max Howell that this might be a good addition to PMK 7. I'll keep the two pull requests for options 1 and 2 up to date with the current PromiseKit code -- the initial pull requests will be for PromiseKit 6.3.4.

I can volunteer to keep the cancellation code up to date as PromiseKit evolves. The other option would be to require contributors to update the cancellation code if any part of the public PromiseKit API is changed.

@dougzilla32
Copy link
Collaborator Author

I've create the pull requests for packaging options 1 and 2. I see that some of the Travis builds are failing for the pull requests, and am working on fixes now.

@mxcl
Copy link
Owner

mxcl commented Jul 20, 2018

Thanks for this, I appreciate the work and I think you have good direction.

I will take some time, maybe a lot, since this is a lot to review. I'm confident we can work this into the framework, but I am very cautious about things with this project: there are many alternatives to PromiseKit, and I feel the reason PMK is popular is I am careful to keep it high quality.

Anyway, I will review.

@dougzilla32
Copy link
Collaborator Author

Awesome! Yes, I totally understand about keeping PromiseKit concise and high quality. I'm really up for any of the 3 options or anything else you have in mind. And yes, it is a lot so no need to rush anything -- better to take the time and make great decisions.

My preference above is based purely on how easy it is for people to use the new cancellation feature, and putting myself in your shoes I see that stability and maintaining high quality are high priority concerns. A nice upside with this delegate approach is it can be integrated or decoupled as desired!

The Travis builds for all the cancellation pull requests are passing, with the exception of some Swift Package Manager related issues in the Foundation extension build. Will see if I can get those green as well. I would say it is ready for review!

@zdnk
Copy link

zdnk commented Jul 25, 2018

One thing that concerns me is the CC postfix. It is very un-Swifty and not developer friendly.
Othervise it looks good.

The question is, if it is really needed to touch all Promises. Wouldn't it be enough to have Regular Promise and CancellablePromise to go with that? By return type of the promisified function, the developer knows that the task is cancellable and can treat the the chain accordingly. Of course I do not know anything about how PromiseKit is done, but it is important to also provide good developer experience with this.

@dougzilla32
Copy link
Collaborator Author

Responding to Zdeněk's excellent questions --

CC suffix

I agree, I don't much like the 'CC' suffix. But I failed to come up with something better. I discovered that there needs to be something different about the signature for all methods that create a cancellable promise or cancellable guarantee, otherwise you run into many ambiguous situations that would both break existing code and make writing new cancellable code tedious.

For example without the 'CC' suffix or some equivalent, code like the following gives 'ambiguous' compilation errors:

firstly {
  urlSession.dataTask(…)
}.done { data in
  …
}

after(seconds: 0.3).done {
  …
}

To make it work you'd need to explicitly specify the types. For regular promises this is not an option because it would break existing code --

firstly { () -> Promise<(data: Data, response: URLResponse)>
  urlSession.dataTask(…)
}.done { data in
  …
}

let a: Gurantee<Void> = after(seconds: 0.3)
a.done {
  …
}

And for cancellable promises it would be quite tedious --

firstly { () -> CancellablePromise<(data: Data, response: URLResponse)>
  urlSession.dataTask(…)
}.done { data in
  …
}

let a: CancellablePromise<Void> = after(seconds: 0.3)
a.done {
  …
}

Originally I went with 'cancellable' as a prefix rather than the 'CC' suffix, but 'cancellable' just felt too long to put everywhere. I also tried using a new required paramter but that looked ugly as well.

So I settled on 'CC', which I supposed could be considered a very abbreviated version of 'Cancellable'. Therefore 'dataTaskCC' is an abbreviation for 'dataTaskCancellable', meaning the cancellable varient of dataTask.

Would 'WithCancel' abbreviated 'WCL' or 'WCC' be better? For example 'dataTaskWithCancel' abbreviated to 'dataTaskWCL'? Or a longer abbreviation for cancellable like 'CNBL'? Well, that last one sounds more like cannibal. Hmmmm.

I'm open to ideas on this one!! The problem could be completely avoided by baking cancellation directly into Promise and Guarantee (i.e. a new 'cancel' method on those classes), but this would make everything cancellable which has its own drawbacks.

Btw, one of the risks of the new cancellation code is breaking existing code with 'ambiguous' compilation errors. I wrote a bunch of tests to alleviate this risk and hopefully caught all the cases. I got some of these errors where I really thought it should be unambiguous, but the current compiler seems rather tempermental in this particular area.

Why touch all promises?

I'm understanding this question as, why have cancellable versions for Thenable, Guaratee, after, firstly, hang, race and when? The reason is simply, I want to provide cancellation abilities for the entire PromiseKit API. Done this way, you can add cancellation abilities to any existing code simply by adding the 'CC' suffix (or some alternative) to the methods that are actually creating the promises.

The above example --

firstly {
  urlSession.dataTask(…)
}.done { data in
  …
}

after(seconds: 0.3).done {
  …
}

becomes cancellable with minor changes:

firstly {
  urlSession.dataTaskCC(…)
}.done { data in
  …
}

afterCC(seconds: 0.3).done {
  …
}

Thank you for the feedback!!

@zdnk
Copy link

zdnk commented Jul 29, 2018

Hello, thanks for extensive response!

Well, the thing I do not understand why you would need 2 versions of URLSession.dataTask(_:..) for example. You need just cancellable one, because it simply is cancellable procedure/task/future/promise. It should be up to the developer if he us going to use the cancel-ability of the Promise. And the developer knows the promise is cancellable from the signature of the function/method, its return type (eg func dataTask(request: URLRequest) -> CancellablePromise<(data: Data, response: URLResponse)>).

I believe then firstly, then, after etc. can very simply work based on that.
Maybe there is something more complex that I am missing and it surely has obstacles, but I believe this would be the best developer experience.

On rule: Non-cancellable tasks return Promise, cancellable ones return CancellablePromise, if someone needs to introduce both versions for the same task (whatever might be the reason), they should figure out the naming themselves.

Let me know your thoughts :)

@drekka
Copy link
Contributor

drekka commented Jul 30, 2018

Hi guys, I'm not a developer of PromiseKit, I just use it. I'm struggling a bit to understand the use cases this solves and I'm presuming there are two:

  1. When executing a chain of promises, one of the wrapped closures wants to cancel the entire chain. For example, a series of API calls where a closure in the middle decides that there is no need to make any of the subsequent calls and wants to cancel the rest of the chain. Currently I thought we could do this sort of things by merely throwing a PMKError.cancel.

  2. When executing a chain of promises, an external influence wants to cancel the chain at some obituary point in time. Again using the chain of API calls, I'm presuming this would occur if the UI that initiated the chain, was unloaded by the user and therefore wanted to cancel the chain. Normally in the code bases I've worked on we've used guard blocks which check the UI at various points through a chain like this and if any of them detect the UI being unloaded they stop the chain. Usually without triggering any errors or UI displays.

Am I correct in that scenario #2 is the one being addressed by this PR?

The idea of not having to add guard blocks to effectively monitor external state in order to cancel a chain sounds great. But I'm not keep on expanding the range of functions like the above. If feels like it's creating a large amount of complexity which will make life harder for both experienced and newbie PromiseKit user's alike. I'm especially keen on this because one of the things I love about PromiseKit is it's simplicity.

I have another idea which just jumped into my head whilst writing this :-) so I don't know if it's practical at all.

Basically it's to make Thenable 'cancellable'. :-) by default. A property could be added to Thenable -> var cancelIf: () -> Bool = { false } which could be tested before the promise's closure is called and after. If it returns a true then the promise chain cancels out using PMKError.cancel as if the closure had thrown it. Then any promise or guarantee can become a cancellable one simply by setting this property. Something like this:

    firstly {
        doFirstThing()
    }.cancelIf { [weak self] in self == nil }
    .then { result1 in
       doSecondthing(result1)
    }
    }.cancelIf { [weak self] in self?.userHasCancelled || result1.stop }
    .then { result2 in
       doSecondthing(result2)
    }
    .done { result3 in
       doSecondthing(result3)
    }
    

Not sure if that's workable, just throwing around some ideas in my head.

@zdnk
Copy link

zdnk commented Jul 30, 2018

Well I like the idea of having a chain member like your cancelIf, but for me, the most important is cancelling promises externally. Imagine you have a UITableViewCell subclass that got and URL of image to download and display. I image that cancellation of the image download and display chain would happen in the prepareForReuse() as it is the simplest solution.
Also, the cancelIf on your example is called only after the Promise in the chain is completed right? In case of network requests this is simple waste of data and power.
I believe the goal of cancellable promises should be external cancellation as the cancelIf may have limited usage and might lead to people creating yet another cancellation tokens to be able to cancel the chain externally.

@dougzilla32
Copy link
Collaborator Author

Yes, agreed -- this proposal is for cancelling promises externally. And, the idea is that this can be done with very minimal code changes.

I agree with Zdeněk's image loading example where you just want to cancel the whole operation. Another example is a user typing with search completions -- with each keystroke, you want to cancel the previous search (of course you'd introduce a small delay to reduce the number of search operations which you can conveniently do with 'after', but I digress).

In fact, the only code change necessary is to specify that you want the 'cancellable' option. This is what all the 'CC' methods are for, they are saying that you want the chain to be cancellable. A different approach would be to use a flag or parameter instead of the 'CC' suffix. You just need something there to say you want cancellation ability.

The 'cancelIf' could work well, but I'm trying to avoid requiring the developer to add new code for handling cancellation. I think it is quite nice to just call 'cancel', and it just stops executing without executing any more code blocks. If you need to add some cancellation handling code you can do it in the 'catch' with the '.allErrors' policy.

OR, it is possible to simply default to having everything be 'cancellable'. Then there would be no need to specify a 'cancellable' option and the 'CC' methods would go away. The 'cancel()' method would be defined right inside 'Promise' rather than 'CancellablePromise'.

Perhaps for completeness it would be good exercise to create a pull request for 'Option Zero', where everything defaults to be cancellable. There would be no 'CC' methods and 'cancel()' would be defined directly inside 'Promise'. I've been proceeding on the assumption that cancellation should be optional, but perhaps this comes at the cost of a less intuitive API. I realize there are some big downsides to having cancellation as a default. But 'Option Zero' could be useful for comparison at least. I'll give it a go.

The to summarize the options (as defined in the Packaging section above):

  1. the opposite of option 3, no new extensions and no 'CancellablePromise, everything is done in the existing classes
  2. 'CancellablePromise' is part of the core PromiseKit and cancellation is built into the extensions that can support it
  3. 'CancellablePromise' is a PromiseKit extension and cancellation is built into the extensions that can support it
  4. the opposite of option zero, the PromiseKit code base is unchanged and everything is done with 'CancellablePromise' and all new extensions

@drekka
Copy link
Contributor

drekka commented Aug 5, 2018

Yeah, the cancelIf I suggested was purely about allowing promises to reach out to check some condition. Cancelling the promise from an external execution would more difficult.

Please excuse me if I'm suggesting what you've already done. I really haven't had the time to research this like you guys have.

I presume the builtin cancel methods you've been looking at work by executing the exit or cancel methods on the thread started by the promise (providing it's not the same thread) and then returning an error from the promise. Is that correct?

@dougzilla32
Copy link
Collaborator Author

Regarding cancelIf -- got it! In fact, part of what CancellablePromise does is just like cancelIf: it checks if the promise has been cancelled before executing the next code block. If it has been cancelled then it calls reject on the resolver for the promise.

The builtin cancel method actually keeps track of all pending promises in a chain, and for each of these calls cancel on the underlying task and calls reject on the promise's resolver. It does this by using the CancelContext class to track pending promises along with their underlying tasks and reject methods. The behavior of calling cancel on the underlying task depends on the type of task.

@mxcl
Copy link
Owner

mxcl commented Aug 8, 2018

I would like to remove cancelation from all extensions that are not inherently cancelable, eg:

Alamofire is inherently cancelable, the networking can be canceled.

after() is not.

However I understand that sometimes you want to just ignore something like an after and the effect of ignoring it is to cancel the chain. To facilitate this we (could) provide a wrapper:

let context = cancelable(after(.seconds(2)))

This removes a lot of the additional API in the extensions and reduces the overwhelmingness of someone reading The API significantly.

I still have concerns, I fear that in practice the burden on people constructing APIs and chains with promises will increase significantly due to this change. Because I think it is important that in general users are encouraged to not offer cancellation in situations where it would cause them to leak methods that allow the caller to break the state machine of the callee. So in general it should be opt-in to return a cancellation context.

But we can see how it is in a 7-alpha release.

The cc suffix can stay for now, but certainly we will lose it for a final release.

I feel CancellableGuarantee is incorrect from a idiomatic stand point, with my wrapper suggestion above, we can drop it entirely.

@dougzilla32
Copy link
Collaborator Author

This is great! I really like your suggestion for a cancelable wrapper, and I agree that afterCC and CancellableGuarantee can be dropped by using the wrapper (along with valueCC, firstlyCC, hangCC, raceCC, whenCC, etc.). I am trying it out in the code.

Having cancelation as an opt-in feature sounds great to me!

A couple questions:

What is the best way to eliminate the CC methods for the inherently cancelable extensions?

  • All 'CC' methods could be replaced with the wrapper. To make this work I am thinking Thenable would need a new package private optional property where you could stash the cancelable task, if there is one. This property would be used if the Promise/Guarantee is subsequently wrapped with cancelable. What are the potential drawbacks with having a strong reference to the underlying task for all Promises/Guarantees that could be canceled?

  • OR it could be done with a flag parameter that defaults to non-cancelable. No need for a new property in Thenable for this case.

Btw I've been spelling 'cancellable' with two Ls to match the existing CancellableError. I googled and on grammarly.com it says that in the US we like to use 'cancelable' whereas with British english it is 'cancellable'. The exception is that 'cancellation' always has two Ls. cancelable would be a bit shorter — any preference?

@dougzilla32
Copy link
Collaborator Author

dougzilla32 commented Oct 9, 2018

This is pretty much ready to go, pending additional review. Docs and code are all in shape to be reviewed now. The 'Documentation/Cancel.md' doc is much clearer now.

'CC' methods are gone and 'CancellableGuarantee' is gone. Everything is passing in the CI reports.

More details are available in the pull request: #899

@dougzilla32
Copy link
Collaborator Author

dougzilla32 commented Oct 16, 2018

I've updated the 'Cancel' doc to match the latest code, please feel free to provide feedback!! Here is the latest (nearly finalized?) version:

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 = cancellable(URLSession.shared.dataTask(.promise, with: url)).compactMap{ UIImage(data: $0.data) }
let fetchLocation = cancellable(CLLocationManager.requestLocation()).lastValue

let promise = 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.
promise.cancel()

/* 'promise' here refers to the last promise in the chain.  Calling 'cancel' on
   any promise in the chain cancels the entire chain.  Therefore cancelling the
   last promise in the chain cancels everything. */

Cancel Chains

Promises can be cancelled using a CancellablePromise. The global cancellable(_:) function is used to convert a standard Promise into a CancellablePromise. If a promise chain is initiazed 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 cancelleed 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 chainm, 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 {
    /* The 'cancellable' function initiates a cancellable promise chain by
       returning a 'CancellablePromise'. */
    cancellable(login())
}.then { creds in
    cancellable(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 {
    promise = cancellable(login())
}
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 = cancellable(firstly {
    login()
}).then {
    cancellable(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()

Troubleshooting

At the time of this writing, the swift compiler error messages are usually misleading if there is a compile-time error in a cancellable promise chain. Here are a few examples where the compiler error is not helpful.

Cancellable promise embedded in the middle of a standard promise chain

Error: Ambiguous reference to member firstly(execute:). Fixed by adding cancellable to login().

let promise = firstly {  /// <-- ERROR: Ambiguous reference to member 'firstly(execute:)'
    /* The 'cancellable' function initiates a cancellable promise chain by
       returning a 'CancellablePromise'. */
    login() /// SHOULD BE: "cancellable(login())"
}.then { creds in
    cancellable(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()

The return type for a multi-line closure returning CancellablePromise is not explicitly stated

The Swift compiler cannot (yet) determine the return type of a multi-line closure.

The following example gives the unhelpful error: Enum element allErrors cannot be referenced as an instance member. This is fixed by explicitly declaring the return type as a CancellablePromise.

let promise = firstly {
    cancellable(login())
}.then { creds in /// SHOULD BE: "}.then { creds -> CancellablePromise<UIImage> in"
    let f = fetch(avatar: creds.user)
    return cancellable(f)
}.done { image in
    self.imageView = image
}.catch(policy: .allErrors) { error in  /// <-- ERROR: Enum element 'allErrors' cannot be referenced as an instance member
    if error.isCancelled {
        // the chain has been cancelled!
    }
}

// ...

promise.cancel()

Declaring a Promise return type instead of CancellablePromise

You'll get a very misleading error message if you declare a return type of Promise where it should be CancellablePromise. This example yields the obtuse error: Ambiguous reference to member firstly(execute:). This is fixed by declaring the return type as a CancellablePromise rather than a Promise.

let promise = firstly {  /// <-- ERROR: Ambiguous reference to member 'firstly(execute:)'
    /* The 'cancellable' function initiates a cancellable promise chain by
       returning a 'CancellablePromise'. */
    cancellable(login())
}.then { creds -> Promise<UIImage> in /// SHOULD BE: "}.then { creds -> CancellablePromise<UIImage> in"
    let f = fetch(avatar: creds.user)
    return cancellable(f)
}.done { image in
    self.imageView = image
}.catch(policy: .allErrors) { error in
    if error.isCancelled {
        // the chain has been cancelled!
    }
}

// ...

promise.cancel()

Trying to cancel a standard promise chain

Error: Value of type PMKFinalizer has no member cancel. Fixed by adding cancellable to both login() and fetch().

let promise = firstly {
    login() /// SHOULD BE: "cancellable(login())"
}.then { creds in
    fetch(avatar: creds.user) /// SHOULD BE: cancellable(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()  /// <-- ERROR: Value of type 'PMKFinalizer' has no member '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 wrapped with 'cancellable()'.

Global functions
    cancellable(_:)                 - Accepts a Promise or Guarantee and returns a CancellablePromise,
                                      which is a cancellable variant of the given Promise or Guarantee
    
    cancellable(after(seconds:))    - 'after' with seconds can be cancelled
    cancellable(after(_:))          - 'after' with interval can be cancelled

    firstly(execute:)               - Accepts body returning CancellablePromise
    hang(_:)                        - Accepts CancellablePromise
    race(_:)                        - Accepts [CancellablePromise]
    when(fulfilled:)                - Accepts [CancellablePromise]
    when(fulfilled:concurrently:)   - Accepts iterator of type CancellablePromise
    when(resolved:)                 - Accepts [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(task:resolver body:).      - Initialize a new cancellable promise that can be resolved with
                                       the provided '(Resolver) throws -> Void' body
    init(task:promise:resolver:)    - Initialize a new cancellable promise using the given Promise
                                       and its Resolver
    init(task:error:)               - Initialize a new rejected cancellable promise
    init(task:)                     - 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
    appendCancellableTask(_ task:reject:)  - Append the CancellableTask 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"
# cancellable(Alamofire.request("http://example.com", method: .get).responseDecodable(DecodableObject.self))

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

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

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

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

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

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

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

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

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

Alamofire

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

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

Bolts

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

CoreLocation

CLLocationManager
    cancellable(requestLocation(authorizationType:satisfying:))
    cancellable(requestAuthorization(type requestedAuthorizationType:))

Foundation

NotificationCenter:
    cancellable(observe(once:object:))

NSObject
    cancellable(observe(_:keyPath:))

Process
    cancellable(launch(_:))

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

CancellablePromise
    validate()

HomeKit

HMPromiseAccessoryBrowser
    cancellable(start(scanInterval:))

HMHomeManager
    cancellable(homes())

MapKit

MKDirections
    cancellable(calculate())
    cancellable(calculateETA())
    
MKMapSnapshotter
    cancellable(start())

StoreKit

SKProductsRequest
    cancellable(start(_:))
    
SKReceiptRefreshRequest
    cancellable(promise())

SystemConfiguration

SCNetworkReachability
    cancellable(promise())

UIKit

UIViewPropertyAnimator
    cancellable(startAnimation(_:))

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 {
    cancellable(Alamofire
        .request("http://example.com", method: .post, parameters: params)
        .responseDecodable(Foo.self))
}.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 {
    cancellable(URLSession.shared.dataTask(.promise, with: try makeUrlRequest()))
}.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 {
    cancellable(login()) // Use the 'cancellable' function to initiate a cancellable promise chain
}.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!
    }
}
//…
promise.cancel()
  • Ensure that subsequent code blocks in a promise chain are never called after the chain has been cancelled

  • Fully support concurrecy, where all code is thead-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 {
        cancellable(getForecast(forCity: searchName))
    }.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 {
        cancellable(Alamofire.request("https://autocomplete.weather.com/\(name)")
            .responseDecodable(AutoCompleteCity.self))
    }.then { city in
        cancellable(Alamofire.request("https://forecast.weather.com/\(city.name)")
            .responseDecodable(WeatherResponse.self)) 
    }.map { response in
        format(response)
    }
}

@mxcl
Copy link
Owner

mxcl commented Mar 19, 2019

Merged and tagged 7.0.0-alpha1, available for testing!

@mxcl mxcl closed this as completed Mar 19, 2019
@MoridinBG
Copy link

How do I cancel the task wrapped within the promise with the new APIs? Like an Operation or network request?
Especially when returning a promise from a function, so I can't just use a catch and look for the cancelled error.

@MoridinBG
Copy link

Well, after lots of code reading it seems to me that the key is to subclass Cancellable and wrap the underlying task. Then cancel it on cancel(). Pass the Cancellable to the CancellablePromise and when the promise is cancelled, the Cancellable will get cancelled as well.
Is my understanding correct?

@dougzilla32
Copy link
Collaborator Author

dougzilla32 commented Oct 17, 2019

Sorry for the confusion -- there's a method on Promise called 'setCancellableTask' for this purpose. You can set the cancellable task on the regular Promise and then use the 'cancellize' function to convert it to a CancellablePromise.

The cancellable extensions have not yet been included in PromiseKit V7, so I can see how this is confusing.

For example, the URLSession.dataTask in PMKFoundation is made cancellable as follows:

extension URLSession {
    public func dataTask(_: PMKNamespacer, with convertible: URLRequestConvertible) -> Promise<(data: Data, response: URLResponse)> {
        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
    }
}

...

class MyRequest {
    func performCancellableURLRequest() {
        let request = URLRequest(url: URL(string: "http://example.com")!)
        let promise = firstly {
            cancellize(URLSession.shared.dataTask(.promise, with: request))
        }.compactMap {
            try JSONSerialization.jsonObject(with: $0.data) as? NSDictionary
        }.done { rsp in
...
        }.catch(policy: .allErrors) { error in
            error.isCancelled ? print("cancelled") : print("Error: \(error)")
        }
...
        // To cancel:
        promise.cancel()
    }
}

@MoridinBG
Copy link

MoridinBG commented Oct 23, 2019

Hey, I saw this code in the PMKFoundation but it didn't make sense initially, but now I get it. URLSessionTask is extended with Cancellable and setCancellableTask has become appendCancellable!

@dougzilla32
Copy link
Collaborator Author

Ah! I had forgotten that we changed setCancellableTask to appendCancellable. And yes exactly, URLSessionTask is extended with Cancellable. You've got it exactly right.

Will try and get the extensions added as part of PromiseKit V7 soon!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

5 participants