5
\$\begingroup\$

Updated, below is a more current and simple version of this. Also, if you track the progress of TC39's "ECMAScript Cancellation" proposal, this thread might be worth checking out.


I'm playing with Promise Extensions for JavaScript (prex) library and I like it a lot. Amongst other things, this library appears to be a prototype behind the current ECMA TC39 proposal for cancellation and it uses the familiar cancellation token approach, popularized by .NET Task API.

I want to extend the standard Promise class with cancellation support, similar to how it is implemented in Bluebird (i.e., with an optional oncancel callback) but using prex.CancellationToken.

Here is a draft that can be run with NodeJS:

const prex = require('prex');

class CancellablePromise extends Promise {
  constructor(executor, token) {
    if (!token) {
      // if no token supplied, just delegate to the parent class
      super(executor);
      return;
    }
    
    const observeCancellation = async () => {
      // prex.Deferred is similar to TaskCompletionSource in .NET
      const deferred = new prex.Deferred();

      executor(
        deferred.resolve, 
        deferred.reject, 
        cancelListener => 
          deferred.cancelListener = cancelListener);  

      const registration = token.register(() => {
        try {
          // capture the CancelError
          token.throwIfCancellationRequested();
        }
        catch (cancelError) {
          try {
            // the token cancellation callback is synchronous,
            // and so is the executor-provided cancelListener callback
            deferred.cancelListener && deferred.cancelListener(cancelError);
            // reject here if cancelListener has not resolved/rejected it
            deferred.reject(cancelError);
          }
          catch (error) {
            // in case cancelListener throws
            deferred.reject(error);
          }
        }
      });
    
      try {
        return await deferred.promise;
      }
      finally {
        registration.unregister();
      }
    };

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

// delayWithCancellation
function delayWithCancellation(timeoutMs, token) {
  console.log(`delayWithCancellation: ${timeoutMs}`);

  return new CancellablePromise((resolve, reject, setCancelListener) => {
    token.throwIfCancellationRequested();
    const id = setTimeout(resolve, timeoutMs);
    setCancelListener(e => clearTimeout(id));
  }, token);
}

// main
async function main() {
  const tokenSource = new prex.CancellationTokenSource();
  setTimeout(() => tokenSource.cancel(), 2000); // cancel after 1500ms

  const token = tokenSource.token;

  await delayWithCancellation(1000, token);
  console.log("successfully delayed."); // we should reach here

  await delayWithCancellation(1500, token);
  console.log("successfully delayed."); // we should not reach here
}

main().catch(error => console.log(error));
\$\endgroup\$
8
  • 1
    \$\begingroup\$ Assuming you're still using the npm package prex for CancellationTokenSource, you have a bug. The constructor takes an iterable of tokens, not a single token. \$\endgroup\$ Commented Aug 12, 2020 at 3:26
  • \$\begingroup\$ I don't use it anymore. I've made my own lightweight version without any external dependencies, and more closely modelled after .NET CancellationTokenSource and CancellationToken than what Prex does. \$\endgroup\$
    – noseratio
    Commented Aug 12, 2020 at 3:31
  • 1
    \$\begingroup\$ The .NET CancellationTokenSource class constructor doesn't accept a token at all... \$\endgroup\$ Commented Aug 12, 2020 at 3:32
  • \$\begingroup\$ @PatrickRoberts, that's where I deviated a bit. I didn't want to replicate CancellationTokenSource.CreateLinkedTokenSource, so I've made it an option to pass a token to the token source constructor. Do you think it is not a good idea? \$\endgroup\$
    – noseratio
    Commented Aug 12, 2020 at 3:35
  • 1
    \$\begingroup\$ @PatrickRoberts will do shortly, I'm currently adding tests and JSDocs. \$\endgroup\$
    – noseratio
    Commented Aug 12, 2020 at 3:41

2 Answers 2

4
\$\begingroup\$

You can override the executor and can pass an onCancel function to it like following:

class ResponsePromise extends Promise {
  constructor(executor) {
    const onCancel =  (cb) => {
      //using nextTick because we cant use "this" before super()
      setTimeout(()=> {
        this.cancelCb = cb;
      })
    }

    const oExecutor = (resolve, reject) => {
      executor(resolve, reject, onCancel);
    }

    super(oExecutor);
  }
  cancel() {
    if (this.cancelCb) {
      this.cancelCb();
    } else {
      console.warn('onCancel not provided');
    }
  }

I have explained this in http://w3.cafe/promise-with-progress-javascript/

\$\endgroup\$
1
  • 2
    \$\begingroup\$ Thanks, I'm glad there's interest in this. I still prefer the CancelationToken approach tough, and these days I'm using a much simplified version of that code. I'll publish that library soon. \$\endgroup\$
    – noseratio
    Commented Aug 1, 2020 at 22:26
0
\$\begingroup\$

For completeness, below is my my current version of CancellablePromise which still uses the "Promise Extensions for JavaScript (prex)" package. One notable improvement is using [Symbol.species], more details on why it's needed. A runkit is here.

// https://codereview.stackexchange.com/q/207116/35474

const prex = 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 callbacks 
   *  and a token: { resolve, reject, cancel, token }
   * @param {CancellationToken} token - a cancellation token.
   */
  constructor(executor, token) {
    const withCancellation = async () => {
      const linkedSource = new prex.CancellationTokenSource([token]);

      try {
        const linkedToken = linkedSource.token;
        linkedToken.throwIfCancellationRequested();

        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,
        });
 
        return await deferred.promise;
      }
      finally {
        // this will free the linkedToken registration
        linkedSource.close();
      }
    };

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

//
// An example of using CancellablePromise 
//

// async delay with cancellation
function delayWithCancellation(timeoutMs, token) {
  console.log(`delayWithCancellation: ${timeoutMs}`);

  return new CancellablePromise(d => {
    token.throwIfCancellationRequested();
    const id = setTimeout(d.resolve, timeoutMs);
    d.token.register(() => clearTimeout(id));
  }, token);
}

// main
async function main() {
  const tokenSource = new prex.CancellationTokenSource();
  setTimeout(() => tokenSource.cancel(), 2000); // cancel after 2000ms

  const token = tokenSource.token;

  await delayWithCancellation(1000, token);
  console.log("successfully delayed."); // we should reach here

  await delayWithCancellation(3000, token);
  console.log("successfully delayed."); // we should not reach here
}

main().catch(error => console.log(`Error caught, ${error}`));
\$\endgroup\$
0

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.