Cancellation architectural observations

# Dean Tribble (7 years ago)

Another thread here brought up the challenge of supporting cancellation in an async environment. I spent some time on that particular challenge a few years ago, and it turned out to be bigger and more interesting than it appeared on the surface. In the another thread, Ron Buckton pointed at the .Net approach and it's use in JavaScript:

AsyncJS (rbuckton/asyncjs) uses a separate abstraction for cancellation based on the .NET CancellationTokenSource/CancellationToken types. You can find more information about this abstraction in the MSDN documentation here: msdn.microsoft.com/en-us/library/dd997364(v=vs.110).aspx

It's great that asyncjs already has started using it. I was surprised at how well the cancellationToken approach worked in both small applications and when extended to a very large async system. I'll summarize some of the architectural observations, especially from extending it to async:

Cancel requests, not results Promises are like object references for async; any particular promise might be returned or passed to more than one client. Usually, programmers would be surprised if a returned or passed in reference just got ripped out from under them by another client. this is especially obvious when considering a library that gets a promise passed into it. Using "cancel" on the promise is like having delete on object references; it's dangerous to use, and unreliable to have used by others.

Cancellation is heterogeneous It can be misleading to think about canceling a single activity. In most systems, when cancellation happens, many unrelated tasks may need to be cancelled for the same reason. For example, if a user hits a stop button on a large incremental query after they see the first few results, what should happen?

  • the async fetch of more query results should be terminated and the connection closed
  • background computation to process the remote results into renderable form should be stopped
  • rendering of not-yet rendered content should be stopped. this might include retrieval of secondary content for the items no longer of interest (e.g., album covers for the songs found by a complicated content search)
  • the animation of "loading more" should be stopped, and should be replaced with "user cancelled"
  • etc.

Some of these are different levels of abstraction, and for any non-trivial application, there isn't a single piece of code that can know to terminate all these activities. This kind of system also requires that cancellation support is consistent across many very different types of components. But if each activity takes a cancellationToken, in the above example, they just get passed the one that would be cancelled if the user hits stop and the right thing happens.

Cancellation should be smart Libraries can and should be smart about how they cancel. In the case of an async query, once the result of a query from the server has come back, it may make sense to finish parsing and caching it rather than just reflexively discarding it. In the case of a brokerage system, for example, the round trip to the servers to get recent data is the expensive part. Once that's been kicked off and a result is coming back, having it available in a local cache in case the user asks again is efficient. If the application spawned another worker, it may be more efficient to let the worker complete (so that you can reuse it) rather than abruptly terminate it (requiring discarding of the running worker and cached state).

Cancellation is a race In an async system, new activities may be getting continuously scheduled by asks that are themselves scheduled but not currently running. The act of cancelling needs to run in this environment. When cancel starts, you can think of it as a signal racing out to catch up with all the computations launched to achieve the now-cancelled objective. Some of those may choose to complete (see the caching example above). Some may potentially keep launching more work before that work itself gets signaled (yeah it's a bug but people write buggy code). In an async system, cancellation is not prompt. Thus, it's infeasible to ask "has cancellation finished?" because that's not a well defined state. Indeed, there can be code scheduled that should and does not get cancelled (e.g., the result processor for a pub/sub system), but that schedules work that will be cancelled (parse the publication of an update to the now-cancelled query).

Cancellation is "don't care" Because smart cancellation sometimes doesn't stop anything and in an async environment, cancellation is racing with progress, it is at most "best efforts". When a set of computations are cancelled, the party canceling the activities is saying "I no longer care whether this completes". That is importantly different from saying "I want to prevent this from completing". The former is broadly usable resource reduction. The latter is only usefully achieved in systems with expensive engineering around atomicity and transactions. It was amazing how much simpler cancellation logic becomes when it's "don't care".

Cancellation requires separation of concerns In the pattern where more than one thing gets cancelled, the source of the cancellation is rarely one of the things to be cancelled. It would be a surprise if a library called for a cancellable activity (load this image) cancelled an unrelated server query just because they cared about the same cancellation event. I find it interesting that the separation between cancellation token and cancellation source mirrors that separation between a promise and it's resolver.

Cancellation recovery is transient As a task progresses, the cleanup action may change. In the example above, if the data table requests more results upon scrolling, it's cancellation behavior when there's an outstanding query for more data is likely to be quite different than when it's got everything it needs displayed for the current page. That's the reason why the "register" method returns a capability to unregister the action.

I don't want to derail the other threads on the topic, but thought it useful to start articulating some of the architectural background for a consistent async cancellation architecture.

# Salvador de la Puente González (7 years ago)

I think this did not reach the mailing list to the W3: esdiscuss.org/topic/cancelable-promises#content-9

Actually the result cancellation is different from promise cancellation. First is full of implementation details, thus you are talking about smart cancellation. It is not smart, is the only way to provide control, thus, the cancellationToken.

In the other side you're talking about "don't care" what is actually related with the flow control. It turns out, after a timeout, you are no longer interested in the result so you don't use it because you don't care.

I insist, two concepts: one for cancelling a control flow, another for an specific operation of the implementation.

# Marius Gundersen (7 years ago)

I think a better term than cancel would be ignore. If the promise has side effects then it is impossible for a cancel method to guarantee that the side effects do not happen. For example a POST request to the server that updates the server state can be cancelled, but it is not possible to know if it happens before or after the server has updated its state. It is therefore dangerous to think of it as cancelling. A better concept is to ignore the results of the promise. Even if the promise has been completed, the results can be ignored. Depending on what job the promise is doing it can decide if it wants to stop the task or not. A simple async task might not need to have a way to be cancelled; the result of the task can just be ignored. But a long running task that is ignored might decide to stop what it is doing to save processing power/battery.

//this promise has a way to stop what it is doing
var p1 = new Promise(function(resolve, reject, ignored){
  var task = setTimeout(resolve.bind(null, "the result"), 1000);
  ignored(() => clearTimeout(task));
});

//ignore the result from the above promise, triggers the ignored listener
p1.ignore();
p1.then(result => "this is never called");

//this promise cannot be cancelled, but the result can be ignored
var p2 = Promise.resolve("the result");

//the callback is not called until next event loop cycle
p2.then(result => "this is never called");

//the result is ignored, the above callback is never run
p2.ignore();

Maybe calling ignore on a rejected or resolved promise should throw.

Marius Gundersen

# Gray Zhang (7 years ago)

+1 to the ignore term, I’ve opened an issue about it in promises-aplus/cancellation-spec#14

IMO the term cancel(or abort) and ignore are totally different things, the former one means “do not continue, stop it right now” and the “stop” state should be broadcast to everyone who is interested in the work, while the latter means “I don’t care about the result anymore, just play it as you like”, it means the async progress can be continued

In practice both scenario are commonly seen, we may abort a resource fetch in order to save bandwidth and opened connections, or we may in other side just ignore it since continue to complete the fetch can result in a local cache, which speeds up our fetch next time

Back to the ignore scenario, there is still some confusion left for me:

Should ignore be a individual behavior, or a global one. This means should we just be able to “ignore” one callback, or just take a promise to a “ignore” state in which all callbacks are revoked Should later then calls be aware that the promise is already ignored, or ignorance should be completely transparent to clients (which means all later then are effortless)

# Kevin Smith (7 years ago)

//this promise has a way to stop what it is doing var p1 = new Promise(function(resolve, reject, ignored){ var task = setTimeout(resolve.bind(null, "the result"), 1000); ignored(() => clearTimeout(task)); });

Wouldn't the cancellation token strategy be more appropriate here, for all the reasons that Dean mentioned? Something like:

function delay(ms, { cancellation } = {}) {
    return new Promise(resolve => {
        let id = setTimeout(_=> resolve(), ms);
        if (cancellation) cancellation.then(_=> clearTimeout(id));
    });
}

(Thanks, Dean, for taking the time to write that up BTW.)

//this promise cannot be cancelled, but the result can be ignored

var p2 = Promise.resolve("the result");

//the callback is not called until next event loop cycle p2.then(result => "this is never called");

//the result is ignored, the above callback is never run p2.ignore();

It seems like what you want here is to "unregister" the callback, similar to how you would unregister an event listener. It seems like that only makes sense on a callback-by-callback basis, since other callbacks for the same promise might still want to fire. Can you not handle this use case with existing API, plus higher order functions?

let cbs = forgettable();
p2.then(cbs(result => "this is never called"));
cbs.forget();
# Dean Tribble (7 years ago)

On Mon, Mar 2, 2015 at 6:32 AM, Gray Zhang <otakustay at icloud.com> wrote:

+1 to the ignore term, I’ve opened an issue about it in promises-aplus/cancellation-spec#14

I have little attachment to any term, but there's value in keeping terminology that has years of investment and use in other contexts. However "ignore" also has the wrong sense, because it implies that the computation completes anyway. That can be accomplished more easily by simply dropping the promise.

IMO the term cancel(or abort) and ignore are totally different things, the former one means “do not continue, stop it right now” and the “stop” state should be broadcast to everyone who is interested in the work, while the latter means “I don’t care about the result anymore, just play it as you like”, it means the async progress can be continued

This goes back to some of the observations above: you cannot stop it "right now" because async notification is not synchronous; indeed the operation may already be complete before you stop it. Thus consumers of the result of a cancellable request need to be able to handle either successful completion or the cancelled state (which just looks like any other error that prevented completion). Attempting broadcast to "everyone" adds complexity and resources that are needed only in the rare cancellation case. It's typically not only not worth the software complexity, but not a good idea. When you cancel a print job, the document editor should make best efforts in the background to stop requesting fonts, stop laying out print pages, stop spitting out pages on the printer, etc. but most importantly, it should start paying attention to my new edits and hang waiting for everything that might be involved in printing to wrap itself up.

In practice both scenario are commonly seen, we may abort a resource fetch in order to save bandwidth and opened connections, or we may in other side just ignore it since continue to complete the fetch can result in a local cache, which speeds up our fetch next time

The resource point is important. That's the "don't care" scenario, not the "abort" scenario. It's the request processor that knows what cleanup is worth the effort. The initiator of the request only knows they don't care about the result anymore.

# Andrea Giammarchi (7 years ago)

the rare cancellation case

abort is not a rare case, and an ignore that does not abort makes no sense whatsoever.

If I am paying data and I've requested 10 tiles on a mobile maps based sites and I search/go/locate somewhere else on the other edge on the world it makes no sense to wait for the initial 10 tiles to downloads: either because my cache won't hold them anyway due settings limitations or browser behavior, or because I won't see the new location until new tiles requests are queued behind old tiles.

We can drop downloads on XHR, there is a whatwg Fetch API proposal that is based on network requests and AFAIK it does not provide a way to be canceled (being Promise based)

A mechanism that tries to abort, instead of faking it, allows a faking (read: ignore) logic to be implemented regardless, but not the other way round.

Moreover, being sure about the end of the Network operation is a real-world problem but not solvable here for the simple reason that my mobile network could die anytime in the process, with or without ignored Promises.

So that maye my rejected error was triggered after the server already updated ... how are we going to solve this anyway?

If the POST didn't explicitly complete, we could let the server know about it with a lighter request, instead of keep sendong 25MB of image that you realized is the wrong one ... So, having real reactions on cancel/abort should be the only option, IMO, so that we can solve explicit developers intent.

As summary: as a developer, I don't know yet which way is the best one, but if there's a way to interrupt the execution, however it's going to be called, I am expecting such explicit action to be done, and not to be silently ignored, specially from the underlying logic ( network requests, POST requests, download, preload, etc )

Please let's get this right, thank you.

# Mark Miller (7 years ago)

On Mon, Mar 2, 2015 at 10:25 AM, Dean Tribble <tribble at e-dean.com> wrote:

On Mon, Mar 2, 2015 at 6:32 AM, Gray Zhang <otakustay at icloud.com> wrote:

+1 to the ignore term, I’ve opened an issue about it in promises-aplus/cancellation-spec#14

I have little attachment to any term, but there's value in keeping terminology that has years of investment and use in other contexts. However "ignore" also has the wrong sense, because it implies that the computation completes anyway. That can be accomplished more easily by simply dropping the promise.

Agree. But I feel even more strongly that "cancel" is the right term and "ignore" the wrong one. In the real world, when I order something to be delivered, if I then decide to "ignore" it, that describes how I react to receiving the package. It is a private decision about how I will react and does not communicate any information to the sender about that decision. If I attempt to cancel the order, I may or may not receive the package because of the asynchronous race that Dean describes. But even if I receive the package, it does not violate my encapsulation expectations for the sender to find out that I attempted to cancel.

IMO the term cancel(or abort) and ignore are totally different things, the

# Ron Buckton (7 years ago)

In light of Async Functions in ES7, it may make sense to separate the abstractions between promises and cancellation. Promises and cancellation signals have different use cases:

Promises

  • Promises are consumed by the caller and produced by the callee.
  • Observation a promise resolution can only happen in a later turn.
  • The consumer of a promise cannot directly resolve the promise.

Cancellation

  • Cancellation signals are produced by the caller and consumed by the callee.
  • Observation a cancellation signal must happen immediately.
  • The consumer of a cancellation token cannot directly cancel the token.

API Proposal:

class CancellationTokenSource { /** Create a new CTS, optionally with an iterable of linked cancellation tokens. */ constructor(linkedTokens?: Iterable<CancellationToken>);

/** Gets the cancellation token for this source. */ get token(): CancellationToken;

/** Cancels the source and sends a cancellation signal with an optional reason (default Error("Operation canceled")). */ cancel(reason?: any): void;

/** Cancels the source after a delay (in milliseconds), with an optional reason. */ cancelAfter(delay: number, reason?: any): void;

/** Prevents any possible future cancellation of the source and removes all linked registrations. */ close(): void; }

class CancellationToken { /** Gets a cancellation token that can never be canceled. / static get default(): CancellationToken; /* Gets a value indicating whether the cancellation signal was sent. / get canceled(): boolean; /* If canceled, gets the reason for cancellation if provided; otherwise, returns undefined. / get reason(): any; /* If canceled, throws either the reason or a general “Operation Canceled” error. / throwIfCanceled(): void; /* * Registers a callback to execute immediately when a cancellation signal is received. * The callback can be removed using the unregister method of the return value. */ register(callback: (reason: any) => void): { unregister(): void }; }

Usage (Abort):

// aborts and throws error when canceled
function fetchAsync(url, cancellationToken = CancellationToken.default) {
  return new Promise((resolve, reject) => {
    cancellationToken.throwIfCanceled();
    var xhr = new XMLHttpRequest();
    var registration = cancellationToken.register(() => xhr.abort());
    xhr.open("GET", url, /*async*/ true);
    xhr.onload = event => {
        registration.unregister();
        resolve(xhr.responseText);
    };
    xhr.onerror = event => {
        registration.unregister();
        reject(xhr.statusText);
    }
    xhr.send(null);
  });
}

fetchAsync(...).then(...); // as expected

var cts1 = new CancellationTokenSource();
fetchAsync(..., cts1.token).catch(...);
cts1.cancel(new Error("Operation Canceled"); // .catch gets the error and the xhr is aborted.


Usage (Ignore):


// ignore operation/stop processing
async function startTicker(receiveSymbol, cancellationToken = CancellationToken.default) {
    while (!cancellationToken.canceled) {
        var symbols = await fetchSymbols();
        for (var symbol of symbols) {
            receiveSymbol(symbol);
        }
    }
}

var stopTicker = new CancellationTokenSource();
stopTicker.cancelAfter(5 * 60 * 1000); // stop after 5 minutes.
startTicker(..., stopTicker.token).catch(...); // .catch only gets error from `fetchSymbols`.


Ron
# Andrea Giammarchi (7 years ago)

So this is my simplified view of the matter ... it already works, and it aborts eventually with the ability to ignore the onabort callback.

The config object can have onabort that activates the "abort-ability", the onprogress so that eventually this promise inside a generator can still update UIs, and potentially any other sort of property but for demo sake just method for GET, HEAD, PUT, POST and other requests.

function fetch(url, config) {
  config || (config = {});
  var
    xhr = new XMLHttpRequest,
    promise = new Promise(function (res, rej) {
      xhr.addEventListener('error', function (pe) { rej(xhr); });
      xhr.addEventListener('load', function (pe) { res(xhr); });
      if (config.onabort)
        xhr.addEventListener('abort', config.onabort);
      if (config.onprogress)
        xhr.addEventListener('progress', config.onprogress);
      xhr.open(config.method || 'GET', url, true);
      xhr.send(null);
    })
  ;
  if (config.onabort)
    promise.abort = xhr.abort.bind(xhr);
  return promise;
}

abort example: fetch('?page', {onabort: console.warn.bind(console)}).abort();

with progress too fetch('?page', {onabort: console.warn.bind(console), onprogress: console.log.bind(console)}).abort();

full request fetch('?page', {onabort: console.warn.bind(console), onprogress: console.log.bind(console)}).then(console.log.bind(console));

Why this code? Simply to somehow show that I am all for getting this right, but to me it's also probably a simpler matter than it looks like, specially for cases where cancel or abort is meant and needed.

Best

# Tab Atkins Jr. (7 years ago)

Thanks for this summary of some concerns! All valid, I think.

In the GitHub issue slightlyoff/ServiceWorker#625, there are

some additional usability concerns, which I think make the cancellation token approach much less attractive, and lean the desired solution more towards a promise subclass. In particular:

Cancellations should "chain"

If you have a cancellable promise p1, and use .then() to produce a new promise p2, p2 should also be cancelable, and in the default case, should "chain up" to p1 and cause it to cancel as well.

If you chain multiple promises off of p1, like p2a and p2b, then canceling either one of the p2X promises should do nothing, but cancelling both of them should cancel p1. In other words, p1 can ref-count its "child" promises that retain cancellation abilities, and cancel itself when everything consuming its result has been cancelled.

This is important so you don't have to explicitly keep track of every single cancelable thing you're doing, if you're only using it to immediately chain onward again. You can just care about the final result, and if you end up not needing it, you can cancel it and it walks back and tries to cancel everything your result depends on.

Combinators should combine cancellations

If you do let fp3 = FetchPromise.all(fp1, fp2), then an fp3.cancel() should try to cancel fp1 and fp2, as noted above. You want all the (cancellation-aware) combinators to be just as friendly as chaining directly, for usability.

You need to be able to "clean" a cancellable promise

If the promise is what carries the cancellation ability, you need to be able to observe its value without carrying the cancellability around, to prevent spreading power around in an unwanted way (and prevent upping the "chained promises" refcount). This is doable by just wrapping it in a standard promise - Promise.resolve(fetch(...)) will return a normal non-cancellable promise.

A cancellation token is basically an ocap, and that means you have to keep track of the ocaps explicitly and separately from the promise for the result. This means more value-passing, and when you return another cancellable promise in the callback (like fetch(...).then(x=>fetch(...))), you have to explicitly smuggle that

cancellation token out of the callback and hold onto both of them. Combinators become annoying, as you have to grab all of the cancel tokens used and hold them together, etc.

Attaching cancellation to the promise just provides more usable behavior overall, without preventing safe behavior when you desire it.

# Ron Buckton (7 years ago)

While I agree that many specific use cases of "abort" could be this simple, more complex cases might be better satisfied by a separate abstraction. There are two issues I see with this approach:

  • A layer that sits between the promise consumer and the promise producer can directly abort the promise.
  • It is much more complicated to cancel large chunks of asynchronously executing code in bulk (such as when navigating to a new page, or shutting down an application cleanly).

I have examples of [1] and [2] here: gist.github.com/rbuckton/256c4e929f4a097e2c16

The gist basically contains four scenarios:

  • Promise Producer/Cancellation Consumer
  • Promise Consumer/Cancellation Producer
  • Promise Consumer/Cancellation Consumer
  • Cancellation Aggregation

I will adapt these somewhat to your sample here:

Promise Producer/Cancellation Consumer


function fetch(url, config) {

  config || (config = {});

  return new Promise(function (res, rej) {

    var xhr = new XMLHttpRequest();

    xhr.addEventListener('error', function (pe) { rej(xhr); });

    xhr.addEventListener('load', function (pe) { res(xhr); });

    if (config.onabort)

      xhr.addEventListener('abort', config.onabort);

    if (config.onprogress)

      xhr.addEventListener('progress', config.onprogress);

    if (config.token) {

      config.token.register(function () { xhr.abort(); });

    }

    xhr.open(config.method || 'GET', url, true);

    xhr.send(null);

  });

}

Promise Consumer/Cancellation Producer


// abort

var cts = new CancellationTokenSource();

fetch('?page', { onabort: console.warn.bind(console), token: cts.token });

cts.cancel();


// with progress too

var cts = new CancellationTokenSource();

fetch('?page', { onabort: console.warn.bind(console), onprogress: console.log.bind(console), token: cts.token });

cts.cancel();

Promise Consumer/Cancellation Consumer


function markLoading(value) {

  document.querySelector("#loading").style.display = value ? "block" : "none";

}


function fetchPage(token) {

  markLoading(true);

  if (token)

    token.register(function() { markLoading(false); });

  return fetch('?page', { token }).then(

    value => {

      markLoading(false);

      return value;

    },

    reason => {

      markLoading(false);

      throw reason;

    });
}

Cancellation Aggregation


var root = new CancellationTokenSource();


function fetchPage(token) {

  token = token ? new CancellationTokenSource([root.token, token]).token : token;

  token.register(function() { markLoading(false); });

  return fetch('?page', { token }).then(

    value => {

      markLoading(false);

      return value;

    },

    reason => {

      markLoading(false);

      throw reason;

    });
}


// abort this request

var cts = new CancellationTokenSource();

fetchPage(cts.token);

cts.cancel();


// abort all requests

fetchPage();

fetchPage();

root.cancel();

Ron

# Kevin Smith (7 years ago)

I'm afraid I don't quite understand. How is one supposed to create a cancelable task with async functions, under your proposed architecture?

# Tab Atkins Jr. (7 years ago)

On Mon, Mar 2, 2015 at 3:18 PM, Kevin Smith <zenparsing at gmail.com> wrote:

I'm afraid I don't quite understand. How is one supposed to create a cancelable task with async functions, under your proposed architecture?

I'm not sure! The mapping between promises and async functions isn't intuitive to me yet, and I'm not sure how async functions will be able to produce promise subclasses rather than plain promises.

# Kevin Smith (7 years ago)

I'm not sure! The mapping between promises and async functions isn't intuitive to me yet, and I'm not sure how async functions will be able to produce promise subclasses rather than plain promises.

Fair enough. I think, though, that one of the design goals for a cancellation architecture needs to be that we are able to created cancelable tasks with async functions.

My intuition here is that async functions rely on the fact that promises convey no information or capability other than what is represented by their completion value. That's what enables us to make the leap from promise API to declarative syntax.

# Domenic Denicola (7 years ago)

From: es-discuss [mailto:es-discuss-bounces at mozilla.org] On Behalf Of Tab Atkins Jr.

Cancellations should "chain"

This is the most important issue, in my mind. I often illustrate it with

function fetchUser(id) {
  return fetch(`/users/user/${id}`);
}

function fetchUserAsJSON(id) {
  return fetchUser(id).then(JSON.parse);
}

If fetchUser(1) is cancellable but fetchUserAsJSON(1) is not, then I think we've failed.

However, I think this still works with cancellation tokens:

function fetchUser(id, canceller) {
  return fetch(`/users/user/${id}`, { canceller });
}

function fetchUserAsJSON(id, canceller) {
  return fetchUser(id, canceller).then(JSON.parse);
}

The API stays the same for both of them.

Combinators should combine cancellations

Still works, I think, although again in a different form:

function fetchUsers(ids, canceller) {
  return Promise.all(ids.map(id => fetchUser(id, canceller)));
}

You need to be able to "clean" a cancellable promise

Simple:

function fetchUserUncancellable(id) {
  return fetchUser(id); // no second argument
}

Overall I am more optimistic about cancellation tokens these days...

# Ron Buckton (7 years ago)

Cancellations should "chain"

If you have a cancellable promise p1, and use .then() to produce a new promise p2, p2 should also be cancelable, and in the default case, should "chain up" to p1 and cause it to cancel as well.

The CTS/Token approach can do this directly without needing a chain, which is observable on the p2 via its onrejected handler.

If you chain multiple promises off of p1, like p2a and p2b, then canceling either one of the p2X promises should do nothing, but cancelling both of them should cancel p1. In other words, p1 can ref-count its "child" promises that retain cancellation abilities, and cancel itself when everything consuming its result has been cancelled.

This is important so you don't have to explicitly keep track of every single cancelable thing you're doing, if you're only using it to immediately chain onward again. You can just care about the final result, and if you end up not needing it, you can cancel it and it walks back and tries to cancel everything your result depends on.

CTS partially accomplishes this through linked registrations, though assuming that if all chained promises are canceled that the root should be canceled could be a mistake:

var rootCts = new CancellationTokenSource();
var configPromise = fetchConfig(rootCts.token);

var alphaCts = new CancellationTokenSource();
var alphaPromise = fetchConfigProperty(configPromise, "alpha", alphaCts.token);

var betaCts = new CancellationTokenSource();
var betaPromise = fetchConfigProperty(configPromise, "beta", betaCts.token);

alphaCts.cancel();
betaCts.cancel();

// do we want to cancel configPromise here? What if we want to enlist via .then later?
// if we are sure we want that behavior we can do this explicitly

var requests = new Set();
function fetchConfigProperty(configPromise, name, token) {
  requests.add(token);
  var registration = token.register(() => {
    requests.delete(token);
    if (requests.size <= 0) { rootCts.cancel(); }
  });
  return configPromise.then(
    config => {
      requests.delete(token);
      registration.unregister();
      return config[name];
    },
    reason => {
      requests.delete(token);
      registration.unregister();
      throw reason;
    });
}

Combinators should combine cancellations

If you do let fp3 = FetchPromise.all(fp1, fp2), then an fp3.cancel() should try to cancel fp1 and fp2, as noted above. You want all the (cancellation-aware) combinators to be just as friendly as chaining directly, for usability.

If the fetch api supports tokens, you could instead do:

var cts = new CancellationTokenSource();
var fp1 = fetch(..., cts.token);
var fp2 = fetch(..., cts.token);
let fp3 = Promise.all([fp1, fp2]);
cts.cancel(); // will cancel fp1 and fp2

You need to be able to "clean" a cancellable promise

If the promise is what carries the cancellation ability, you need to be able to observe its value without carrying the cancellability around, to prevent spreading power around in an unwanted way (and prevent upping the "chained promises" refcount). This is doable by just wrapping it in a standard promise - Promise.resolve(fetch(...)) will return a normal non-cancellable promise.

This behavior is an explicit benefit of CTS, as it does not conflate Promise with the cancellation signal, and is very explicit about the source of cancellation. If you want to expose a promise to a consumer without cancellation, and instead support cancellation internally, you would do the following:

// [api.js]
var rootCts = new CancellationTokenSource();
export function getRecord(id) {
  // cancellation not exposed to consumer
  return fetch(id, rootCts.token);
}
export function shutdown() {
  rootCts.cancel(); // cancellation only exposed to consumer via explicit entry point
}

// [consumer.js]
import { getRecord, shutdown } from 'consumer';
var fp1 = getRecord(...); // cannot cancel this individual request
var fp2 = getRecord(...);
shutdown(); // cancels all requests through explicit entry point

A cancellation token is basically an ocap, and that means you have to keep track of the ocaps explicitly and separately from the promise for the result. This means more value-passing, and when you return another cancellable promise in the callback (like fetch(...).then(x=>fetch(...))), you have to explicitly smuggle that cancellation token out of the callback and hold onto both of them. Combinators become annoying, as you have to grab all of the cancel tokens used and hold them together, etc.

That's what linking registrations are for, and you should only need to add cancellation to methods where you can explicitly cancel. You wouldn't need tokens for every single promise. Also, the token doesn't come "out of the callback", but rather is passed in from the caller:

var cts = new CancellationTokenSource();
var f = token => fetch(..., token).then(x => fetch(..., token));

f(cts.token);

Attaching cancellation to the promise just provides more usable behavior overall, without preventing safe behavior when you desire it.

Adding cancellation directly to a Promise means that supporting safe behavior is harder than the default.

Ron

# Kevin Smith (7 years ago)

Cancellation

  • Cancellation signals are produced by the caller and * consumed* by the callee.
  • Observation a cancellation signal must happen immediately.

This is a crucially important question. Can you elaborate on why you

think this is the case?

# Ron Buckton (7 years ago)

The simplest example is:


function doLater(callback, token) {

  var handle = setImmediate(callback);

  token.register(() => clearImmediate(handle));
}


var cts = new CancellationTokenSource();

doLater(..., cts.token);

cts.cancel();

If token registrations occur in a later turn, I have no way to clear the handle. I can write the following instead, however:


function doLater(callback, token) {

  setImmediate(() => {

    if (token.canceled) return;

    callback();

  });

}

But in this case, I still have a callback scheduled which may be unnecessary overhead. If this were a native API, we could chose to prioritize cancellation registrations over promise tasks. Even if the registrations are async, the cancellation signal would still need to be observable in a synchronous manner, even if only through reading the CancellationToken#canceled property.

Ron

# Jonas Sicking (7 years ago)

On Sun, Mar 1, 2015 at 11:06 PM, Dean Tribble <tribble at e-dean.com> wrote:

Cancel requests, not results Promises are like object references for async; any particular promise might be returned or passed to more than one client. Usually, programmers would be surprised if a returned or passed in reference just got ripped out from under them by another client. this is especially obvious when considering a library that gets a promise passed into it. Using "cancel" on the promise is like having delete on object references; it's dangerous to use, and unreliable to have used by others.

I do wonder if there are two types of "cancelling". Which I think several of the posts in this thread has touched on.

On one hand there are operations like read requests from the filesystem, side-effect free GET requests from the network, and computation requests such as calculating the SHA-256 value of a large document.

In all of these instances the operation has no side effects other than producing the value that the promise resolves to.

The second category is operations that do have side effects. Such as a writing operation to a filesystem or a database, or a POST network request.

(For now ignoring the fact that some GET requests do have side effects, and some POST requests don't).

The second category seems simpler. Here it's clear that a lack of interest in the result does not mean that any requests can or should be cancelled. An approach like a cancellation token definitely seems sensible here.

For the first category, a lack of interest in the result means that we should abort all requests involved in producing that result.

For stream-like APIs we've sort of taken this for granted. Once back-pressure, or a call to .close() (or similar), indicates that data is currently not being consumed, the API automatically signals to all producers that they can and should stop producing results.

Why should we treat APIs that return a single value, rather than a stream of values, any differently?

But it requires knowing that no one is interested in the result. Simply having access to a Promise object does not guarantee that due to Promises nature that you can not only call .then() multiple times, the time when calling .then() is permitted is unbounded.

So I think I'm persuaded by the argument that as long as an API returns a Promise, there is no way that we can put a cancellation/result-ignoring/close() API on that Promise. It seems to simply break the Promise contract.

Instead we'd need to return some object which allows us to track consumers. I.e. where if you call a .then-like function, you prevent anyone else from doing so. And if you want to enable others to consume the result, you have to first fork the result and then call the .then-like function on your fork.

Unfortunately that .then-like function probably can't be called "then" given the elevated status that Promises has given that function name.

/ Jonas

# Ron Buckton (7 years ago)

The upside of having a separate abstraction for cancellation, is that it composes well with async functions:

async function runStockTicker(receiveSymbol, cancellationToken) {
  while (!cancellationToken.canceled) {
    var symbols = await fetchSymbols(cancellationToken);
    if (!cancellationToken.canceled) {
      for (var symbol of symbols) {
        receiveSymbol(symbol);
      }
      await sleep(1000);
    }
  }
}

var stopTicker = new CancellationTokenSource();
runStockTicker(..., stopTicker.token);
...
stopTicker.cancel(); // stop the current fetch and the loop

Ron

# Tab Atkins Jr. (7 years ago)

On Mon, Mar 2, 2015 at 3:39 PM, Ron Buckton <rbuckton at chronicles.org> wrote:

CTS partially accomplishes this through linked registrations, though assuming that if all chained promises are canceled that the root should be canceled could be a mistake:

var rootCts = new CancellationTokenSource();
var configPromise = fetchConfig(rootCts.token);

var alphaCts = new CancellationTokenSource();
var alphaPromise = fetchConfigProperty(configPromise, "alpha", alphaCts.token);

var betaCts = new CancellationTokenSource();
var betaPromise = fetchConfigProperty(configPromise, "beta", betaCts.token);

alphaCts.cancel();
betaCts.cancel();

// do we want to cancel configPromise here? What if we want to enlist via .then later?

Yes, you do want configPromise to cancel there. It's the common case. If you dont' want that to happen, produce all your chained promises first; if you can't do that, chain a dummy promise off of it (to increase the ref count) and then cancel it when you're done producing chained promises. That'll ensure configPromise stays alive until you're done chaining things off of it.

Combinators should combine cancellations

If you do let fp3 = FetchPromise.all(fp1, fp2), then an fp3.cancel() should try to cancel fp1 and fp2, as noted above. You want all the (cancellation-aware) combinators to be just as friendly as chaining directly, for usability.

If the fetch api supports tokens, you could instead do:

var cts = new CancellationTokenSource();
var fp1 = fetch(..., cts.token);
var fp2 = fetch(..., cts.token);
let fp3 = Promise.all([fp1, fp2]);
cts.cancel(); // will cancel fp1 and fp2

That works if you produced the source cancelables, and had the ability to initialize them with the same cancel token. If you didn't, and had their individual cancel tokens passed in a side channel, it's much more frustrating, as you have to explicitly track all of their tokens together and then cancel all of them.

This doesn't seem complicated when you're just looking at simple examples like this, but it's extra information that quickly gets out of hand when you're working on real-world code.

This behavior is an explicit benefit of CTS, as it does not conflate Promise with the cancellation signal, and is very explicit about the source of cancellation. If you want to expose a promise to a consumer without cancellation, and instead support cancellation internally, you would do the following:

Yeah, you just don't hand the consumer the cancellation token, that's easy.

That's what linking registrations are for, and you should only need to add cancellation to methods where you can explicitly cancel. You wouldn't need tokens for every single promise. Also, the token doesn't come "out of the callback", but rather is passed in from the caller:

Sorry, I didn't provide an example, so you misunderstand what I'm referring to. I mean in the case of something like:

let p = somePromiseFunc().then(x=>fetch(...));

If you want that fetch to be cancellable, you need to explicitly create a token outside to pass in:

let ct = new CancelToken();
let p = somePromiseFunc().then(x=>fetch(..., ct));

Which, again, doesn't seem like any big burden when you're looking at trivial isolated examples, but is that much more state you have to carry around for every single fetch() you do. Imagine you were kicking off a variable number of fetches, for example - then you need to create an array on the outside, and create your cancel tokens on the inside and push them into the array to exfiltrate them. This kind of exfiltration is annoying when you're trying to extract the resolve/reject functions from the Promise constructor, but that's fairly rare; requiring it for common cases of fetch() usage makes it obnoxious.

Whereas with a promise subclass, you do have to know that you want it to be cancellable, but then you write:

let p = FetchPromise.resolve(somePromiseFunc()).then(x=>fetch(...));

You need to upgrade the vanilla promise into a cancellable promise, but then chaining will work - if you cancel p, it'll cancel the fetch() too, without having to keep track of anything further.

Adding cancellation directly to a Promise means that supporting safe behavior is harder than the default.

Agreed, and it's not ideal, but it's not an automatic tradeoff. If the safe behavior is significantly less usable, it can still be worth it to default to unsafe, as long as switching to safe is fairly easy (and it is).

# Tab Atkins Jr. (7 years ago)

On Mon, Mar 2, 2015 at 3:52 PM, Jonas Sicking <jonas at sicking.cc> wrote:

So I think I'm persuaded by the argument that as long as an API returns a Promise, there is no way that we can put a cancellation/result-ignoring/close() API on that Promise. It seems to simply break the Promise contract.

Instead we'd need to return some object which allows us to track consumers. I.e. where if you call a .then-like function, you prevent anyone else from doing so. And if you want to enable others to consume the result, you have to first fork the result and then call the .then-like function on your fork.

Unfortunately that .then-like function probably can't be called "then" given the elevated status that Promises has given that function name.

This was the approach I was angling for early in the FetchPromise thread at slightlyoff/ServiceWorker#625;

I originally proposed that .then() always return a vanilla Promise, and a separate method (I proposed .pipe()) do a chain that maintained the cancellability (triggering refcounting and such).

# John Lenz (7 years ago)

This summaries my concerns and attitude toward this issue very well, thank you.

# Kevin Smith (7 years ago)

function doLater(callback, token) { var handle = setImmediate(callback); token.register(() => clearImmediate(handle)); }

var cts = new CancellationTokenSource(); doLater(..., cts.token); cts.cancel();

I'm not really clear about the ordering properties of setImmediate relative to the promise microtask queue, but:

function doLater(callback, cancelPromise) {
    let handle;
    cancelPromise.then(_=> clearImmediate(handle));
    handle = setImmediate(callback);
}

This should work if setImmediate uses either the same MT queue as promises or a lower-priority queue.

# Marius Gundersen (7 years ago)

On Tue, Mar 3, 2015 at 12:49 AM, Ron Buckton <rbuckton at chronicles.org>

wrote:


function doLater(callback, token) {

 setImmediate(() => {

   if (token.canceled) return;

   callback();

 });

}

But in this case, I still have a callback scheduled which may be unnecessary overhead. If this were a native API, we could chose to prioritize cancellation registrations over promise tasks. Even if the registrations are async, the cancellation signal would still need to be observable in a synchronous manner, even if only through reading the CancellationToken#canceled property.

Since cancellations are likely to be triggered asynchronously by the user, it doesn't really matter if the cancellation signal is async or not, it might still be too late to cancel the job. For example:


new Promise(resolve => doLater(resolve, cts.token)).then(handleResult);

setImmediate(() => cts.cancel());

In this scenario cancel would be called right after the resolve method is called, but before handlerResult is called. For this to work with a cancellation token you would need to pass the token to every step in the chain to both stop work being done and to ignore the result/prevent a handler from being called. Wouldn't it be better if the promise chain took care of this for the programmer?

Marius Gundersen

# Salvador de la Puente González (7 years ago)

El 03/03/2015 22:42, "Marius Gundersen" <gundersen at gmail.com> escribió:

On Tue, Mar 3, 2015 at 12:49 AM, Ron Buckton <rbuckton at chronicles.org>

wrote:


function doLater(callback, token) {

  setImmediate(() => {

    if (token.canceled) return;

    callback();

  });

}

But in this case, I still have a callback scheduled which may be

unnecessary overhead. If this were a native API, we could chose to prioritize cancellation registrations over promise tasks. Even if the registrations are async, the cancellation signal would still need to be observable in a synchronous manner, even if only through reading the CancellationToken#canceled property.

Since cancellations are likely to be triggered asynchronously by the

user, it doesn't really matter if the cancellation signal is async or not, it might still be too late to cancel the job. For example:


new Promise(resolve => doLater(resolve, cts.token)).then(handleResult);
setImmediate(() => cts.cancel());

In this scenario cancel would be called right after the resolve method is

called, but before handlerResult is called. For this to work with a cancellation token you would need to pass the token to every step in the chain to both stop work being done and to ignore the result/prevent a handler from being called.

It is not necessary, if you don't care about the result as Dean pointed at the beginning of the thread, and the promise is already resolved, let's be it while if it is not resolved, the implementation could never call acceptance or rejection callbacks and this way, the promise is effectively prevented from calling any further callback.

Wouldn't it be better if the promise chain took care of this for the

programmer?

Cancellation is a feature of the value, not of the promise itself. The value should follow a cancelable architecture. I.e. fetch() will resolve almost immediately with a value not representing a response but a cancellableProcess then it could offer a .cancel() method, a .isCancelled flag and a .response() method which resolve in the actual response, rejects if network error or never resolves if the flag is true.

# Alexander Fritze (7 years ago)

I know ES is taking a different route here - keeping async sitting on top of the language - but I can't resist wading in on the cancellation discussion and give a perspective from our experience with Stratified JavaScript (SJS).

One of the most surprising things we found is how crucial cancellation is to make concurrent logic composable, and how easy it is to fold it into the existing JavaScript base language.

TL;DR: If asynchronous code is recast as blocking code, then handling cancellation/aborting cleanup is straightforward by leveraging and extending the try/catch/finally construct.

**** Background

SJS (stratifiedjs.org) is a project that has been going on for the past 5 years or so, with the goal of folding async into the base JS language, and building a homogeneous JS client/server framework on top of it (conductance.io). We've gained a great deal of experience with it, and we've been using it to build large web apps.

The basic idea behind SJS is to recast asynchronous code as blocking code. E.g. we can make a blocking 'pause' function with the following code:

function pause(delay) { waitfor() { setTimeout(resume, delay); } }

(for more details on the waitfor-resume construct see conductance.io/reference#sjs:%23language/syntax::waitfor-resume)

We can now use 'pause' to code blocking logic in a straightforward way, e.g.:

for (var i=0; i<100; ++i) { document.getElementById('foo').left = i; pause(100); // wait 100ms }

Similarly to 'pause', we can cast other asynchronous functions into blocking form. E.g. a function that waits for a DOM event could look like this:

function waitforEvent(elem, event) { waitfor(var rv) { elem.addEventListener(event, resume, true); } return rv; }

And a function that asynchronously fetches a document via XMLHttpRequest like this:

function httpGet(url) { var request = new XMLHttpRequest() request.open("GET", url, true); waitfor() { request.onreadystatechange = function() { if (request.readyState == 4) resume(); }; request.send(null); } return request.responseText; }

These can then be used as part of any normal JS control flow, e.g.:

if (waitforEvent(document, 'click').target == some_dom_element) { do_something_with_response(httpGet('foo.bar')) }

**** Adding in concurrency

For performing multiple asynchronous codepaths simultaneously and orchestrating their interactions, SJS adds a couple of 'structured concurrency combinators' to JS: waitfor-and & waitfor-or.

E.g. simultanously kicking off a request to cnn or bbc and then proceeding with the first result, looks something like this:

var news;

waitfor { news = httpGet('cnn.com'); } or { news = httpGet('bbc.com'); }

... now do something with the news ...

These constructs work very well with functional abstraction & composition. E.g. defining the code above as 'function getNews()', we can layer more concurrency around it:

var result;

waitfor { result = getNews(); } or { waitforEvent(cancel_button, 'click'); result = 'The user cancelled!'; } or { pause(10000); result = 'Timeout!'; }

...

Being able to abstract and meaningfully compose concurrent strands of logic that themselves might be composed of complicated strands of concurrent logic is very powerful: It allows us to build large concurrent programs using a tractable functional decomposition approach.

**** Now the interesting bit: Retraction/Cancellation

Now, the problem is that with the 'pause', 'waitforEvent' and 'httpGet' functions defined as above, this piece of code will not properly clean up after itself. If e.g. the user aborts, the requests to cnn and bbc will still be pending even though they are not needed any more, potentially wasting server time and network resources. Also, no matter which of the code paths 'wins', we'll always leak the click event listener on the cancel_button.

But as it turns out, it is quite easy to fold cancellation/abortion/cleanup into the language when asynchronous code is sequentialized as it is in SJS. . The key is to realize two things:

  1. 'being cancelled' is just another mode of exiting a block of code - the others being via normal control flow or by an exception being thrown, and

  2. cancellation flow is similar to exception propagation. We want to handle cancellation starting from the blocked site propagating upwards along the call stack. A cancellation is really very similar to an exception, the main difference being that an exception is generated at the bottom of the call stack, whereas a cancellation is generated at the top of the call stack.

Now, JS already has the try/catch/finally construct that handles cleanup after an exception (catch clause), or unconditional cleanup (finally clause), and this construct can be extended to handle the cancellation case too:

SJS just ensures that 'finally' clauses are also honored when code is being cancelled, thereby preserving the semantics that try/finally cleans up unconditionally, no matter how a block of code is exited.

Furthermore, SJS adds a new 'retract' clause to try/catch/finally. Much like a 'catch' clause is executed when an exception is thrown, the 'retract' clause is executed when a block of code is cancelled (or 'retracted' as we say in SJS) from 'the outside'.

With try/finally/retract, it becomes straight-forward to add cleanup code to our blocking functions:

function pause(delay) { try { waitfor() { var id = setTimeout(resume, delay); } } retract { clearTimeout(id); } }

function waitforEvent(elem, event) { waitfor(var rv) { elem.addEventListener(event, resume, true); } finally { elem.removeEventListener(event, resume, true); } return rv; }

function httpGet(url) { var request = new XMLHttpRequest() request.open("GET", url, true); waitfor() { request.onreadystatechange = function() { if (request.readyState == 4) resume(); }; request.send(null); } retract { request.abort(); } return request.responseText; }

(In the last two function we used an SJS shorthand, whereby a 'finally' or retract block can be tacked right onto a waitfor(){...} block, without wrapping it in a try {...})

With our blocking functions amended in this way, we can now compose them without caring about cancellation. Appropriate cleanup will be handled automatically for all code paths.

# Ron Buckton (7 years ago)

new Promise(resolve => doLater(resolve, cts.token)).then(handleResult); setImmediate(() => cts.cancel());

In this scenario cancel would be called right after the resolve method is called, but before handlerResult is called. For this to work with a cancellation token you would need to pass the token to every step in the chain to both stop work being done and to ignore the result/prevent a handler from being called. Wouldn't it be better if the promise chain took care of this for the programmer?

I can be convinced that CancellationToken registrations should be invoked asynchronously, though I wonder if then CancellationTokenSource#cancel should return a Promise to observe any errors that occur.

Ron

# Andrea Giammarchi (7 years ago)

following up from this whatwg/fetch#27 , and most likely late to the party.

How about cancel-ability done this way ?


var p = new Promise(function (res, rej, cancel) {
  // resolved in 1 second via random value
  var t = setTimeout(res, 1000, Math.random());
  // if meant to be canceled
  // we define internally what to do
  cancel(function () {
    clearTimeout(t);
  });
});

// whenever/if needed
p.cancel().then(...);

The exposed cancel-ability is arbitrary provided internally so that if missing, an error is thrown while if provided it sets the internal state of the promise as canceled in case it's different from resolved or rejected

It gives the ability to react, if a then is attached, or the ability to ignore, if nothing happens to the returned, non cancel-able, Promise.

This avoids exposing to the outer world what happens inside the Promise and provides arbitrary ability to cancel one.

A cancel-able Promise is one that defined such behavior, which by default is throwing if that's not defined internally. This would solve already many cases I have in mind, via users, or UserAgent, and legitimately behind the scene without any need to expose any internal logic.

How to resolve or throw other attached promises? Well, p.cancel().then() resolves, while p.cancel().throw() does not. p.cancel(), without then or throw would simply throw away pending promises as explicit ignore intent.

How bad does it look and what am I screwing up in here in terms of Promises philosophy?

Best

# Andrea Giammarchi (7 years ago)

OK, just for record sake, I'v ecreated a working example of what I previously meant. gist.github.com/WebReflection/a015c9c02ff2482d327e

Here a basic example (something that will reject on timeout)


// will be resolvednew Promise(function ($res, $rej, ifCanceled) {
  var internal = setTimeout($rej, 1000);
  ifCanceled(function () {
    clearTimeout(internal);
  });
})// will be resolved without executing
.then(
  function () {
    console.log('on time');
  },
  function () {
    console.log('error');
  }
)
.cancel()// will simply execute and resolve
.then(function () {
  console.log('no time');
});

The idea is that a Promise is cancelable only if a function that defines how to cancel is provided, otherwise there's no cancel-ability, it's a regular Promise, and nothing is exported.

This still grants internal private/hidden cancelability through a resolution or rejection but it also gives the ability to expose a .cancel() to the outher world.

There's no "forever pending" problem because all Promises involved in the chain will be silently resolved. The code might be a bit convolute but it was mainly to provide a playground and I don't see any real violation of the Promises principles.

The presence of cancel and its ability is a contract between the Promise creator/provider and the external world.

Best

# Frankie Bagnardi (7 years ago)

I don't think this has been brought up, but isn't what we're doing here bubbling an event? Would it make sense to generalize this to a simple event bubbling system?

function cancelableGet(url){ const xhr = new XMLHttpRequest(); return new Promise(function(resolve, reject){ xhr.open('get', url); xhr....

this.on('cancel', reason => {
  xhr.abort();
  reject(new CanceldRequest(reason));

  // if we wanted to pass it upwards
  this.emit('cancel', reason);
});

});

}

cancelableGet('/foo') .then(...) .emit('cancel');

Our listener would be unbound as soon as the promise in cancelableGet settled.

This also allows for non-overlapping names for events (Symbol should be permitted), where there may be multiple cancelable things in the chain.

It also allows additional metadata, or different event names when 'cancel' can mean multiple things. For example, a process. Do we send sigint or sigkill to cancel it?

Thoughts?

# Andrea Giammarchi (7 years ago)

quickly at least one thought: this inside a Promise callback, is not the Promise itself so nothing could possibly work.

Moreover, you are setting an event that does not cancel, it resolves. Meaning all attached Promised will resolve too which is IMO not what "cancel" mans in the dictionary.

My code brancches out resolving without executing, it's a very different approach. It solves a forever pending problem, without triggering. Also, in your example, if I trigger a cancel in a promise created though then via the cancelable method, nothing would happen which is undesired in a chained expected behavior.