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

Background #1

Open
ForbesLindesay opened this issue Dec 16, 2012 · 44 comments
Open

Background #1

ForbesLindesay opened this issue Dec 16, 2012 · 44 comments

Comments

@ForbesLindesay
Copy link
Member

Cancellation has not yet been put into any of the major JavaScript promise libraries as far as I'm aware.

The only promise library to have any cancellation support is when.js. There has however also been some prior art in the form of C# cancellation tokens. There are a few things to decide upon.

Triggering Cancellation

We need to decide how cancellation is triggered. This is probably as simple as a cancel method on the returned promise. The only problem with doing that is you can't then give a promise (as the result of a function) to multiple mutually suspicious receivers. C# does it with a separate CancellationTokenSource object. The relation between CancellationToken and CancellationTokenSource is analogous to the relationship between promise and resolver.

Handling Cancellation

It is easy enough to cancel the resolution of the promise, but there also needs to be a way to handle cancellation of the underlying operation (e.g. downloading a file from a web server). C# provides a CancellationToken to the function, which has methods for testing whether cancellation has been requested and for adding an event handler to be triggered when the operation is cancelled.

Propagating cancellation

Cancellation should propagate to any underlying asyncronous operations. This process has to be controlled carefully though, because there may be points where you don't want to support cancellation. C# supports cancellation by letting you simply pass on the CancellationToken to another asynchronous operation.

Correct Behaviour

When a task is cancelled in C# it is rejected with an OperationCancelledException. Alternatives would be to simply never resolve the promise, or resolve the promise imediately with undefined or null. It could also be thought of as an additional state (pending/fulfilled/rejected/cancelled)

@domenic
Copy link
Member

domenic commented Dec 16, 2012

I think when.js has cancellation. I'll let @briancavalier explain.

The main problem with cancellation that @kriskowal and I have considered is that cancelling provides a communication channel going from the promise to its resolver, representing a capability leak. Before, you could hand out promises to mutually-suspicious parties. With a cancelable promise, each party can affect the other party's operation, which is bad.

@wycats and @slightlyoff think of cancellation as a "fourth state," i.e. pending/fulfilled/rejected/cancelled. That is, it's a third path alongside fulfilled and rejected that a promise can take. This doesn't make much sense to me, because it has no synchronous analog. (For example, how would taskjs handle this third state.) But it's a POV that's worth representing.

@ForbesLindesay
Copy link
Member Author

I've updated my original post to incorporate some of @domenic's points.

@slightlyoff
Copy link

Cancellation absolutely has a synchronous analog (typed exception handling), it just doesn't have syntax in JS. You wind up building a tiny ad-hoc protocols for it which inevitably wind up being incompatible with other's version of the same thing. This implies that not only do you need it, you need it to be part of any standard you build.

@domenic
Copy link
Member

domenic commented Dec 17, 2012

@slightlyoff for me the killer question is "what would taskjs do with a cancellation state." If there's no good answer to that, it doesn't seem very tenable.

On the other hand, I haven't looked much at how Microsoft's Task<T> (promise analog) handles cancellation, as @ForbesLindesay has.

In short Task<T> plus await/async keywords is much like promises + taskjs, so since Task<T> does support cancellation as @ForbesLindesay describes, I'm curious how it combines with async/await. Do you have to just abandon the keywords and drop back to raw Task<T> handling?

@briancavalier
Copy link
Member

@slightlyoff I was just writing something similar about typed exceptions. @domenic I'm not familiar enough yet with task.js to address what cancellation might mean there.

As for when.js, it takes a pretty simple approach and models cancellation as a rejection. So, a cancelable deferred can be created with a cancelation handler that will run (before all other handlers) when the deferred is cancelled (by calling deferred.cancel()), and is allowed to return a cancelation value. That value is then used to reject the deferred's promise. So, from that point on, cancellation behaves exactly like a rejection.

That approach was roughly based Dojo Deferred's cancelation mechanism. I have to be honest and say that while it does get some usage, it's fairly limited compared to other features, so it's hard for me to say whether it's "right" or not, but it has worked out for the folks who are using it.

I've had requests to also add the cancel() method to the promise, which I've resisted because it seems very wrong: it would allow promise consumers to interfere with one another--it's a rejection, after all.

@skaegi
Copy link

skaegi commented Dec 17, 2012

I sort of agree with the idea that canceling is like exception handling with similar unwinding the stack considerations for promise chains, but I think one difference is that the user initiating cancel is outside the stack. I'm not sure what the synchronous analog for that is? Thread.interrupt?

a late comment now but...
Dojo has cancel and here's the reference -- http://dojotoolkit.org/reference-guide/1.8/dojo/Deferred.html.

Orion Deferred (https://github.com/eclipse/orion.client/blob/master/bundles/org.eclipse.orion.client.core/web/orion/Deferred.js) also supports "cancel" and we have some practical experience with it. We use use it primarily for cancelling long running operations like search and compile tasks and it does work for us. We were supporting "cancelation" primarily to retain functionality our users previously had in Dojo but we were always unsure if the cancel behavior is spec'ed correctly. In particular we found getting the promise chain canceling to work correctly a challenge and although it works for us could do with some guidelines.

Also, the considerations around capability leak are also completely valid and a real concern to us. We currently working around this problem by returning promises with empty cancel methods. It might be better if the underlying Deferred could optionally choose to react to the cancel request but that might just be an implementation detail.

@kriskowal
Copy link
Member

Rather than creating another state, cancelation should be modeled as a subtype of rejection. As @slightlyoff hints, we should specify the type so head the ad hoc protocols off at the pass. My recommendation is that cancelation be modeled as a rejection with an Error with a true canceled property. Thus, on the consumer side:

promise.catch(function (error) {
    if (error.canceled) {
        // ...
    } else {
        // ...
    }
})

On the producer side, a a convenience:

resolver.cancel(message_opt)

@kriskowal
Copy link
Member

My present notion for cancelation is to create a Cancelable from a promise/resolver pair. The Cancelable would look like a Promise and a Promise would look like a Cancelable to a consumer, so the consumer can write code agnostic to whether they have the right to cancel.

The Cancelable would have the elevated right to reject. However, it would also have an internal refcount and could be "forked". "fork" would produce a new Cancelable with the right to drop the refcount of the parent cancelable by one and only one when its "cancel" is called. When the refcount drops to zero, it would reject the promise with an Error with a canceled property. Otherwise, the Cancelable would serve as a proxy for the Promise.

A general Promise would have noop methods "fork" and "cancel".

For convenience, Promises and promise-alikes would have a "cancelable(cancel)" method which would produce a Cancelable proxying the promise and adding a cancelation routine. The cancel function would receive the wrapped promise as this or an argument so it could forward the cancelation to the source.

This is at least the experiment I’m entertaining for the next rev of Q. It has not proven itself in the wild.

@briancavalier
Copy link
Member

@kriskowal That seems like a fairly complex approach (3 new methods, plus ref-counting). Do you have use cases in mind that are driving you in that direction?

@ForbesLindesay
Copy link
Member Author

C# treats it as a rejection so it's as if you threw an OperationCancelledException where the await is. It can then be handled in a try-catch block.

@domenic
Copy link
Member

domenic commented Dec 17, 2012

As in promises-aplus/progress-spec#1 (comment) I point out that we can use the name property of the error to get cross-library distinguished error types. Seems a bit nicer than reason.canceled.

@slightlyoff
Copy link

How is taskjs now the gold standard for what we should do? Honestly, it's just doubling-down on the Python/ES6 mistake of exceptions for Generator control flow in the first place...this can't be our mental model for how we proceed. It's nuts.

Either you want Promises/Futures as a way for programmers to communicate about operation completion, or you want a concurrency mechanism. It can't be both. Which is it?

@domenic
Copy link
Member

domenic commented Dec 18, 2012

How is taskjs now the gold standard for what we should do?

The idea is that consuming promises with then is a stopgap until we have language-level support for shallow coroutines, either through generators in the ES6 timeframe, or hopefully through an async/await-type mechanism (as Luke Hoban and other TC39 members suggest) in the ES7 timeframe.

Promises are still valuable as a first-class object, so you can e.g. join on them or pass them between layers of abstraction. But forcing code into continuation passing style is a stopgap, and "taskjs" is our shorthand for "the shallow coroutine future." Thus if there are aspects of promises that can't be consumed using shallow coroutines, they become more awkward, forcing you to switch your ES6- and ES7-era code into ES5-style once you start needing those features.

"taskjs" is also used by us as a useful reminder that promises are designed as an asynchronous parallel to synchronous control flow, and not as simply event aggregators or "ways to communicate about operation completion." (This is seen in e.g., the return value/fulfillment value and rejection reason/thrown exception parallel, the single-resolution invariant and its corresponding functions-must-return-or-throw-but-not-both invariant, etc. I'm sure we've all read my essay on the subject, so I won't belabor the point.) So again, if there are aspects of promises that are not captured by a synchronous parallel, then we are uneasy with them. And saying "taskjs" is a way of invoking this analogy with the force of a real implementation behind it.

This is why e.g. progress invokes such uneasiness, and we are trying here to model cancellation in terms of rejections, since they have a clear synchronous parallel and thus could be consumed in "taskjs" (i.e. in "the shallow coroutine future").

@dherman
Copy link

dherman commented Dec 18, 2012

Honestly, it's just doubling-down on the Python/ES6 mistake of exceptions for Generator control flow in the first place...this can't be our mental model for how we proceed. It's nuts.

This is just not true. At all. With ES6 generators, task.js never needs to use exceptions for control flow. If you're responding to the TaskResult thingy, that's a temporary stopgap because SpiderMonkey (the only engine to support generators so far) doesn't yet support returning a value from a generator function. But other than that, when you use task.js you never use exceptions for control flow.

Either you want Promises/Futures as a way for programmers to communicate about operation completion, or you want a concurrency mechanism. It can't be both.

That doesn't even make sense. Communicating about operation completion is a concurrency mechanism.

Dave

@dherman
Copy link

dherman commented Dec 18, 2012

I think I may have jumped in prematurely and missed some important context (sorry!). Alex, I think you're arguing that cancellation should not be performed via rejection, and the "doubling-down" point is more of an analogy—which I happen to disagree with, but I think it's not the main point.

In fact, task.js doesn't currently unify cancel and reject. For example, the choose operation takes multiple promises and picks the first fulfilled result and then cancels all the others:

https://github.com/mozilla/task.js/blob/master/lib/task.js#L301

Pretty sure I agree with Alex that cancellation doesn't need to and shouldn't be unified with rejection. But I don't think it's relevant whether you think the StopIteration protocol is "nuts." I don't—I understand the objection, but all the alternatives I've seen suck, each in its own way—but it's really not what we're talking about.

Dave

@skaegi
Copy link

skaegi commented Dec 18, 2012

What are people's thoughts on cancellation propagation?

In our Deferred implementation we are currently doing parent propagation for "cancel" and generally have regretted it. What we do is that when 'cancel' is called we will climb the parent chain to find the highest "pending" promise and 'reject' it with a cancellation reason. The reason we did this was to support task code of the style...

var task = op1.then(op2).then(op3).then(op4);
task.cancel();

This might seem sensible but what we've found is that if additional commands have been "then"-ed to op1 we can inadvertently cause them to be rejected as well. An example is if op1 is a common authentication step shared by both a file contents request and search request. If we cancel the promise associated with the search we can inadvertently stop the file content retrieval. This also has the property that things sometimes work based on the timing of the cancel and if op1 has already resolved.

I'd propose not doing parent propagation at all. Instead if what is desired is atomic task semantics with parent propagation of cancel, to instead support this in a specialized library. Same goes if you want to do reference counting of children to figure out when to cancel a parent.

What are people's thoughts on cancellation propagation to children in the event that the promise has already been resolved or rejected?

I personally would hope that the 'cancel' would not propagate to the children since the promise has made it's change but am wondering what others think. I guess I was thinking we could somehow unify 'cancel' and 'reject' so that a call to 'cancel' on the promise would amount to a call to 'reject(CancelError)' but seeing Dave's last post I'm feeling a bit sheepish and wondering what I'm missing.

@kriskowal
Copy link
Member

@briancavalier With regard to use-cases, @skaegi hints at propagating cancelation. In any substantial program, cancelation must propagate, but how it propagates depends on the situation. I agree with @skaegi; cancelation should not implicitly propagate.

var a = getPromiseForA();
var b = a.then(function (a) {
    return a + 1;
})
var c = a.then(function (a) {
    return a + 2;
});
c.cancel();

In this situation, promises B and C both depend on A. Canceling C does not necessarily mean that promise A should be canceled, but if promises B and C are both canceled, and if no other entities are using promise A, it would be appropriate to cancel A.

A more substantial and common problem would be a function that memoizes.

var jobs = Map();
function getJobResult(x) {
    if (!jobs.has(x)) {
        jobs.set(x, startJob(x));
    }
    return jobs.get(x);
}

In this situation, if cancelation were to propagate implicitly, one job user can interfere with another job user by canceling it.

// user A
getJobResult(1).then(function () {
});
// user B
getJobResult(1).cancel("Pwned");

This might be solved by having a convenient mechanism for counting how many users are depending on the job.

var jobs = Map();
function getJobResult(x) {
    if (!jobs.has(x)) {
        jobs.set(x, startJob(x));
    }
    return jobs.get(x).fork();
}

My proposal is that cancelation would be a noop by default and that cancelation might be explicitly hooked up. The most commonly useful way to determine when to propagate cancelation would be counting references to outstanding dependencies.


While we’re entertaining use cases, here’s an unrelated matter:

var A = getPromiseA();
var C = A.then(function (A) {
    return getPromiseB(A);
});
// later
C.cancel();

At various times, assuming nothing is rejected:

  • A is pending, B has not been made, canceling C should cancel A and the rejection should propagate to B
  • A is fulfilled, B is pending, canceling C should cancel B
  • A is fulfilled, B is fulfilled, canceling C should have no effect

@skaegi
Copy link

skaegi commented Dec 19, 2012

A really simple alternative to a noop for cancel that I'll try is:

promise.cancel = function(reason) {
    if (_deferred.isCompleted()) return;
    var cancelError = new Error(reason);
    cancelError.canceled = true;
    _deferred.reject(cancelError);
}

With this implementation we get no further cancel propagation other than the regular reject propagation.
(At least for now) Cancelation is determined by looking for a true "canceled" property as @kriskowal proposed.

In the project I work on we are already using rejection for cancelation in our implementation so I don't see anything above that is worse. Providing a cancel handler is currently done when constructing our Deferred but we can instead do this by "then"-ing a reject cancel handler which might actually be slightly better.

@kriskowal I have a testcase that looks nearly identical to your last usecase. I need to get that passing before I can commit my change so will report back my implementation of C.cancel .

@juandopazo
Copy link

Could someone please summarize the reasons for/against "cancel" being a form of rejection?

@ForbesLindesay
Copy link
Member Author

Cancellation as a rejection

Advantages

  • it's easy to handle along with any other errors, i.e. rejection is the normal way to indicate something didn't complete successfully.
  • We already have methods for handling rejection, we don't need another thing added to then to handle cancellation
  • It is familiar to people who have used parallels from synchronous but multi-threaded environments (you can cancell another thread's operation and it typically throws an exception).
  • The rejection naturally travells up in exactly the same way as cancel would.

Disadvantages

  • If you need to handle cancellation differently to other errors, you have more work to do this way.
  • propagated cancellation triggering doesn't look anything like a rejection (I'll elaborate)

Why cancellation can't just be a rejection

The result of cancellation (i.e. rejection) propagates just like an exception, but the invoking of cancellation needs to propagate the other way.

   ^   foo()
   |   .then(() => return foo())
CANCEL .then(() => return foo())
   |   .then(() => return foo())
   v   .then(() => return foo())

//vs.

       foo()
       .then(() => return foo())
REJECT .then(() => return foo())
   |   .then(() => return foo())
   v   .then(() => return foo())

If I cancel something, then the result of that cancel propagates up as an exception, but if the promise I cancel was the result of calling then on some other promise, I also want to cancel that other promise, and so on up the chain. The only problem is I don't want to reject all those previous promises, because there might be rejection handlers in that part of the promise chain, and they shouldn't get called, because we've cancelled the operation, and those rejection handlers are intended to handle a different type of error.

@ForbesLindesay
Copy link
Member Author

The way I see it there are only two options that are in any way sane for 'what to do when you cancel something'.

  1. reject it
  2. NEVER reject or resolve it in the future, so it's always pending.

I believe what you actually want to do is reject the promise that had cancel called on it initially, then use option 2 to silently cancel all the other promises up the chain (somehow taking account of the issues around forking etc. that @kriskowal talked about)

@juandopazo
Copy link

Ah I understand now. Thanks. I'll post my opinions once I have studied the problem a bit more.

So far the only thing I can say is that cancellation needs rejection or we'll end up having to add yet another callback to every promise to notify the user of the result of an operation.

@slightlyoff
Copy link

So what's wrong with another callback?

I can see a strong case that there's some set of APIs that cancel and some that don't. I'm alright with the idea that if you don't provide a cancel callback, we throw a type of CancelError to an error callback.

Thoughts?

@kriskowal
Copy link
Member

Having a catchCancelation method might provide a better user-experience and be easier to maintain.

Promise.prototype.catchCancelation = function (callback) {
    return this.then(null, function (error) {
        if (error.canceled) {
           return callback(error);
        } else {
            throw error;
        }
    });
};

@ForbesLindesay
Copy link
Member Author

I'd tend towards .handleCancellation in preference to .catchCancellation, but just a personal preference.

I agree that it would be a very useful extension, but I think that it belongs in the same spec as .fail and .done, probably not in the initial cancel spec.

@domenic
Copy link
Member

domenic commented Dec 29, 2012

handleCancellation is a pretty elegant solution. It makes cancellation into the sugar that it is, and besides, then is getting so overloaded anyway---between progress and cancellation, a separate callback makes the signature

promise.then(onFulfilled, onRejected, onProgress, onCancelled);

@skaegi
Copy link

skaegi commented Jan 9, 2013

We've changed our Promise implementation so that it has no cancel propagation other than reject and the regular promise assuming for a "then" and all seems to work well and makes a whole lot more sense when debugging.

We also have removed the capability of passing a cancel handler into our Deferred constructor and have library code that is similar to catchCancelation / handleCancellation. This works really well and makes it easy to add cancel handling and logging later in the process.

We have a few places in our code that require cancel parent propagation and other specialized handling of more than one promise where cancel ordering matters. For those cases we are overriding and providing our own implementation of cancel.

For the use case @kriskowal provided my test case is overriding cancel as follows:

var A = getPromiseA();
var C = A.then(function (A) {
    return getPromiseB(A);
});

//override C.cancel to handle parent propagation
var originalCancel = C.cancel;
C.cancel = function(reason) {
    A.cancel(reason);
    originalCancel(reason);
};

// later
C.cancel();

e.g. We call the parent cancel and then call the original cancel.

This might look overly simple but works correctly as the cancel call is ignored if the underlying promise is already complete. We did try something fancier but it accomplished essentially the same thing and the code was a pain to both read and write.

@slightlyoff
Copy link

So after a discussion with AnneVK today, I think I'm coming around to the MSFT way of doing cancelation: make it a type of rejection, but provide cancel() (and timeout()) methods on the Resolver so that they always result in Error objects with the same well-known properties/values so that they can be easily distinguished in an onreject handler.

I've updated the DOMFuture design with this.

@ForbesLindesay
Copy link
Member Author

@slightlyoff Your missing the point, propagating that exception and handling that side of things is trivial/a solved problem. Propagating the cancellation is not.

var get = require('get-method-that-returns-a-promise-and-supports-cancellation');
function getJSON(url) {
  return get(url)
    .then(function (res) {
      return JSON.parse(res);
    });
}

It needs to be possible to call getJSON and then cancel it, and doing so needs to cancel the underlying web-request. That's the bit that's tricky to do without completely breaking the promises security model and guarantees.

@slightlyoff
Copy link

@ForbesLindesay the security model is what it is. If particular APIs want to vend cancellation capabilities, they need to subclass Resolver and Future to do so (or make the Future type configurable in the Resolver ctor/prototype). And that's an API-by-API contract, not something for the core Future/Resolver API to take on.

@ForbesLindesay
Copy link
Member Author

As I've said before specifying cancellation without propagation is worthless. If people want to have their own helper method that rejects a promise with an OperationCancelled exception, then that's up to them, but propagation requires standardization, otherwise you can't propagate into anyone else's promise library.

@slightlyoff
Copy link

I'm arguing that the cancel() should always behave the same way, creating the same type/value pair so that it's possible to always distingiush it (and propagate if necessary). In DOMFutures that's a DOMError with a .name equal to "Cancel".

I suggest the general case should be instanceof Error with .name of "Cancel", so yes, I'm all for standardizing it. Perhaps we're just in violent agreement here and I was too terse before. If so, apologies.

@ForbesLindesay
Copy link
Member Author

The problem I see at the moment is that standardizing the error and cancel() method isn't really that much help towards working out how cancellation should propagate. Propagation of cancellation is a really important thing to get right, and if it's not the same (or at least nearly the same) everywhere, it won't seem reliable.

The other problem is that cancellation is rarely needed. Mostly it's sufficient to just ignore the results. This means that most code will always be written without taking any notice of how cancellation should propagate, so if it doesn't propagate automatically, we'll almost never be able to cancel the underlying operation when we want to.

@domenic
Copy link
Member

domenic commented Jan 17, 2013

I think there is some violent agreement here, in that you've both settled on the minimal piece necessary: cancellation as a specific, standardized type of rejection.

@ForbesLindesay is making the point, however, that this is minimal and specifying propagation is (in his mind) necessary for cancellation to be worthwhile. I'm not sure I entirely agree that it's necessary, although it probably would be desirable.

@domenic
Copy link
Member

domenic commented Jan 17, 2013

Considering @ForbesLindesay's example: I think @slightlyoff's point is that getJSON would not return a cancellable promise, so there's not much to worry about. And that if you wanted it to do so, you'd need to go through the extra hoops necessary, e.g.

// promises returned by `get` have a `cancel` method:
var get = require('get-method-that-returns-a-promise-and-supports-cancellation');

// no cancellation method, no problem with propagation
function getJSON(url) {
  return get(url)
    .then(function (res) {
      return JSON.parse(res);
    });
}

// this is what you'd need to do
function getJSONWithCancellation(url) {
  var promise = get(url);
  var derivedPromise = promise.then(function (res) {
    return JSON.parse(res);
  });
  derivedPromise.cancel = promise.cancel.bind(promise);
  return derivedPromise;
}

does this make sense to everyone?

@ForbesLindesay
Copy link
Member Author

Thanks @domenic, that makes sense. My concern with doing that is that people attempting to write a generic getJSON function will never go to the trouble of adding that much code just to support cancellation, and it would be really helpful to be able to find a happy middle ground between no propagation unless done manually, and propagating fully automatically. One option would be to make .then responsible for propagating cancel by accepting a fourth argument.

@kriskowal
Copy link
Member

I’ve posted my most recent thoughts on cancellation https://github.com/kriskowal/gtor/blob/master/cancelation.md#canceling-asynchronous-tasks

@bergus
Copy link

bergus commented Sep 12, 2014

Interesting ideas. There is one thing about .fork() that I don't like though:

When you know that a promise is used exactly N times (in control flow, not with arbitrary consumers) you would need to call .fork() (the first?) N-1 times, and not call .fork() in one place. Or use .fork() everywhere and afterwards call .cancel() on the original Task. That seems to be rather unmaintainable to me.

@yahuio
Copy link

yahuio commented Mar 6, 2015

Interesting to see different thoughts on cancellation, I'd like to throw my notion on cancellation as well for feedbacks. Specials thanks to @bergus who elaborated #11 for me.

My understanding of Promise concept

The Promise pattern is about Consumer and Producer. ConsumerA request ItemA from the ProducerA, ProducerA creates a Promise pA with TaskA (suppose to produce ItemA) to return to Consumer A immediately, regardless whether ItemA is available now or later. So Promise is the kind of the proxy to access A, providing to Producer utility of then. However, when P(ItemA)'s then is being triggered with handler onFulfilled, P(ItemA) itself becomes a Producer to create a Promise for the new Consumer.

What's "cancel"?

Thanks to @bergus, led me to this understanding of "cancel": Consumer cancels it's the request (demand of ItemA) from the Producer. Which is only a message to the Producer, so that Producer can response to that, such as:

  • rejecting the Promise with the message,
  • do nothing and just let the task carry on, etc

The main point here is that, it's solely up to the Producer to decide how to react upon cancellation of a request.

As stated in the concept section, then is a Producer that creates another Promise. So it's up to then to decide what to do when it receives a cancel request.

Cancel handling for the then Producer

In my understanding, the task of then is, run the handler upon fulfillment/rejection.

So, when it's being cancelled, (while it's unresolved yet of coz'), it should cancel the task on them. However, since a Promise may have multiple Consumers request through multiple triggers of then, it should ONLY cancel the task if there're no more Consumers.

Moreover, since a Promise may be hold up by Consumer indefinitely, the task should only be suspended upon cancellation and resumed if a new Consumer trigger then again.

In Summary

  1. Consumer cancel a promise is just a message to the Producer
  2. then Producer suspends it's task, run the handler upon fulfillment/rejection, upon cancellation, if
    a. Promise of then is still PENDING
    b. there's no more Consumer
  3. then Producer shall resumes it's task, upon then being triggered

API Prosposal

Constructor

var promise = new Promise( function resolver(resolve, reject,notify) {
   //resolver
}, function onCancel(resolve, reject, notify, message) {
  //cancel message handler, 
  // provided same set of methods to alter the state of the Promise up to the Producer
});

Promise Instance Method

promise.cancel(message);

Sample Usage

Cancel involving Suspend and Resume

var p1 = new promise.Promise(function(resolve, reject, notify) {
    setTimeout(function() {
        resolve(1);  //resolve p1
    },1);
}, function(resolve, reject, notify, cancelMsg) {
    // called upon p3.cancel below
    // here it does nothing, so promise is still resolvable after timeout
});

var p2 = p1.then(function() {
   // called upon p1 resolved as p4 resumed p2 after suspended through propagation of p3.cancel
});

var p3 = p2.then(function() {
    // never called as suspended by p3.cancel below
});

p3.cancel(); //p3, p2, is being suspended, triggered p1's onCancel handler

p2.isPending(); // true, p2 stay pending but suspended
p3.isPending(); // true, p3 stay pending but suspended

var p4 = p2.then(function() {
       //called upon p2 resolved
 }); //p2 resumed as there's someone demanding it again

Cancelling Promise that has more than one Consumer

var p1 = new promise.Promise( function resolver(resolve, reject, notify) {
}, function onCancel(resolve, reject, notify, message) {
     //not triggered
});

var p2 = p1.then(function() {});
var p3 = p1.then(function() {});

p2.cancel(); // cancel p2 suspends p2 only but message not propagated to p1 since p1 still has a request (i.e. p3) pending

P.S. A final note on this approach. From the usage you can see that there's no conflict with the current promise/A+ proposal. Adding the cancel method shall have no side effect as long as onCancel handler is never provided

Any thoughts are welcome.

@bergus
Copy link

bergus commented Mar 6, 2015

Hi,
I don't think cancellation should involve a suspend+resume notion. If a promise is cancelled, it will need to wrap itself up. Some tasks cannot be suspended and resumed, cancellation will trigger an unrecoverable abort - and the promise needs to be rejected with a cancellation error.
If we want suspendable tasks, a different channel of producer-consumer communication will be required.

@yahuio
Copy link

yahuio commented Mar 6, 2015

@bergus, initially I also think the same, but after I tried to implement with unit test. I found that having an adding cancelled state to a promise conflicts with the existing usage of Promises. As one of the main benefit for Promise pattern is the handling unexpected result/expection, i.e. rejection, gracefully. However, if cancellation has to wrap itself up, original handling of exception has to extend explicitly to handle cancellation, which introduces rigorous handling needed. IMO that diminish the elegancy of simplistic Promise pattern.

And to clarify, what I want to bring up is that, at conceptual level, Promise itself cannot be cancelled, cancel is just a specific message Consumer wants to tell the Producer. It's up to the Producer to decide what to do with it. Producer may decide to ignore, reject, fulfil, or even notify. It full depends on the intention of the what originally Producer is trying to do. For example, let say there's a Producer delay(ms), that uses setTimeout, returns a Promise. Upon cancel, Producer delay will reject the promise, which behind the scene uses clearTimeout stop the timeout created.

I understand the notion of Producer then to suspend/resume upon cancel may sound weird. However, it's just my two cents to try keep then usage simplistic as it's the cornerstone of all other Promise-based features. I'm just concerned about the consequences of adding another dimension to then will result in multi-dimension complexity for the rest of the Promise-based features.

@yahuio
Copy link

yahuio commented Mar 6, 2015

I've opened #15 for further discussion. :)

@yahuio
Copy link

yahuio commented Apr 13, 2015

Regardless of which approach will be taken, I believe enhancing Promise/A+ with cancellation would be a great addition. Is there a plan to push this thread into reality?

@kriskowal
Copy link
Member

For the lack of a better venue to bring the topic of cancellation idioms back from the dead, I propose that one good way to deal with cancellation is to take a page from Go’s context object, which deals with deadlines, cancellation, and "context-local-storage". The idiom in Go is for blocking functions to accept a context as their first argument and thread it through their transitive call graph.

I've published working code for contexts. The module exports an uncancellable background "root" context and you can create child contexts using withTimeout and withCancel. It provides a convenience function for creating a timer that is scoped to the context. Every context has a cancelled promise that you can use to bind other cancellation API’s.

The new AbortSignal API for DOM fetch is spiritually similar.

https://github.com/kriskowal/context

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

10 participants