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

Cancellable promise? #3

Closed
noseratio opened this issue Sep 19, 2019 · 3 comments
Closed

Cancellable promise? #3

noseratio opened this issue Sep 19, 2019 · 3 comments

Comments

@noseratio
Copy link

@rbuckton, could you consider adding something like this CancellablePromise to Prex? If so, I'd be happy to collaborate on code/tests.

@yovanoc
Copy link

yovanoc commented Sep 25, 2019

I've done a ControllablePromise here that we can cancel, pause, resume:

https://github.com/yovanoc/eway/tree/master/src/promises/ControllablePromise

@rbuckton
Copy link
Owner

rbuckton commented Aug 14, 2020

Generally, you don't want to expose cancellation on a Promise itself, as it could allow any code between the caller and the Promise creator to abruptly cancel a Promise. Ideally you want cancellation to be separated from the Promise so that you do not expose the entire surface area to intermediate callers. Controlling promise cancellation directly is often best enabled by passing in a token either to a Promise constructor, or to one of its continuation methods (i.e. then, catch, or finally) so that intermediate callers cannot inadvertently cancel a promise. I do plan to propose something like this to TC39 in the future.

Note that prex has essentially moved to https://esfx.js.org/esfx/. The @esfx/async package is mostly equivalent to prex, but with a smaller set of more focused packages (for example, cancellation has been broken into: @esfx/cancelable for the core Symbol-based API I've discussed at TC39, while @esfx/async-canceltoken is an implementation of the cancellation token primitive on top of Cancelable (similar to the CancellationToken in prex). There is also @esfx/cancelable-dom-shim which makes the DOM AbortController into a Cancelable, allowing APIs to depend on the Cancelable interface regardless of what runtime they are executing in.

@noseratio
Copy link
Author

noseratio commented Aug 14, 2020

These are great insights, thanks a lot @rbuckton. I'll be sure to check out @esfx/async.

Coming from C# background, I really like that in many ways prex was modeled after .NET tasks. And IMO, the closest thing to Promises we have in .NET is TaskCompletionSource. Or rather, it'd be more semantically close to Deferred, but Deferreds aren't a standard feature of JavaScript. And so there we have TaskCompletionSource.SetCancelled, that allows to cancel the consumer's part of the API, TaskCompletionSource.Task. This would hypothetically map to Deferred.cancel() and Deferred.promise, correspondingly.

In this light, I totally agree that cancel() should not be exposed as a method of Promise class, similar to how we can't and should not be able to do Task.Cancel() in .NET.

OTOH, I would be very interested to know your opinion about possibly passing cancel to the executor callback, along with resolve, reject and the token itself, which then would be internal to the Promise object. That, in a way, currently provides me with the same tool as TaskCompletionSource.SetCancelled in .NET.

Here's the code I use for that:

const prex = require('prex');
const { CancellationToken } = require('prex');

/**
 * Class representing a cancellable promise.
 * @extends Promise
 */
class CancellablePromise extends Promise {
  static get [Symbol.species]() {
    return Promise;
  }

  /**
   * Create an instance of CancellablePromise promise.
   * @param {Function} executor - accepts an object with 
   *  the promise resolving callbacks and the cancellation token: 
   *  { resolve, reject, cancel, token }
   * @param {CancellationToken} token - a cancellation token.
   */
  constructor(executor, token) {
    token?.throwIfCancellationRequested();

    const withCancellation = async () => {
      const linkedSource = new prex.CancellationTokenSource(token?.canBeCanceled? [token]: []);
      try {
        const linkedToken = linkedSource.token;
        const deferred = new prex.Deferred();
  
        linkedToken.register(() => deferred.reject(new prex.CancelError()));
  
        executor({ 
          resolve: value => deferred.resolve(value),
          reject: error => deferred.reject(error),
          cancel: () => linkedSource.cancel(),
          token: linkedToken,
        });

        await deferred.promise;
      } 
      finally {
        // this will also free all linkedToken registrations
        linkedSource.close();
      }
    };

    super((resolve, reject) => withCancellation().then(resolve, reject));
  }
}

This way, not only the executor has an option to either resolve, reject or cancel the promise, but it also can observe the external cancellation via token and clean-up its internal resources, e.g., to call clearTimeout like below:

// async delay with cancellation
function delayWithCancellation(timeoutMs, token) {
  return new CancellablePromise(d => {
    const id = setTimeout(d.resolve, timeoutMs);
    d.token.register(() => clearTimeout(id));
    // we also have an option to call d.cancel() here
  }, token);
}

const tokenSource = new prex.CancellationTokenSource();
setTimeout(() => tokenSource.cancel(), 2000); // cancel after 2000ms
await delayWithCancellation(3000, tokenSource.token);
// we should not reach here

Thanks for any further thoughts on this.

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

3 participants