Skip to content

Instantly share code, notes, and snippets.

@pygy
Last active October 30, 2023 09:22
Show Gist options
  • Star 34 You must be signed in to star a gist
  • Fork 7 You must be signed in to fork a gist
  • Save pygy/6290f78b078e22418821b07d8d63f111 to your computer and use it in GitHub Desktop.
Save pygy/6290f78b078e22418821b07d8d63f111 to your computer and use it in GitHub Desktop.
You can already cancel ES6 Promises

The gist: by having a Promise adopt the state of a forever pending one, you can suspend its then handlers chain.

Promise.pending = Promise.race.bind(Promise, [])

let cancel

new Promise(function(fulfill, reject) {
  cancel = function() {fulfill(Promise.pending())}
  setTimeout(fulfill, 1000, 5)
}).then(console.log)

cancel() // 5 is never logged.

Also, mid-chain:

someAsyncJob().then(result => {
  if (result === 'enough') return Promise.pending()
  return moreAsyncWork()
}).then(otherResult => {
  // won't run if result was 'enough'
})

Possible implementations/API:

Taking a page fron Bluebird (but without canceling parents up the chain)

function cancellablePromise(executor) {
  let cancel
  var res = new Promise(function(fulfill, reject) {
    let handler
    function onCancel(cb) {handler = cb}
    cancel = function cancel() {
      fulfill(Promise.pending()) // adopt a forever pending state
      if (typeof handler === 'function') handler()
    }
    executor(fulfill, reject, onCancel)
  })
  res.cancel = cancel
  return res
}

Alternatively

function cancellablePromise(executor) {
  return new Promise(function(fulfill, reject) {
    function cancel() {fulfill(Promise.pending())}
    executor(fulfill, reject, cancel)
  })
}

Given all the hubub around cancellable promises, I'm sure I'm missing something...

@pygy
Copy link
Author

pygy commented Dec 20, 2016

Rev 5: Fixed a memory leak.

@Narigo
Copy link

Narigo commented Dec 14, 2017

Why is Promise.pending = Promise.race.bind(Promise, []) ? Couldn't it just be Promise.pending = new Promise(() => {/* never resolve or reject */});? Doesn't the current implementation create a new Promise every time you cancel something?

@eranimo
Copy link

eranimo commented Jan 15, 2018

This doesn't seem to work

@spion
Copy link

spion commented May 3, 2019

These cancellation semantics will break cleanup done with finally and using - bluebird will always run finally blocks, even for cancelled promises.

@dondevi
Copy link

dondevi commented Jul 27, 2019

@robianmcd
Copy link

Why doesn't this create a memory leak? Does the garbage collector somehow know that Promise.pending will never resolve so it is able to clean up the subsequent callbacks?

@Mati365
Copy link

Mati365 commented May 4, 2020

Typescript syntax:

export class DiscardablePromiseWrapper<K> {
  public promise: Promise<K>;

  constructor(
    promise: Promise<K>,
    public discarded: boolean = false,
  ) {
    this.promise = new Promise<K>((resolve, reject) => {
      promise
        .then((...args) => {
          if (this.discarded)
            return Promise.reject(new Error('Discarded'));

          return resolve(...args);
        })
        .catch((e) => {
          if (this.discarded)
            return;

          reject(e);
        });
    });
  }

  static fork<K>(promise: Promise<K>): DiscardablePromiseWrapper<K> {
    return new DiscardablePromiseWrapper<K>(promise);
  }
}

@chrisprobst
Copy link

I thought about something similar:

class AbortToken {
    private readonly abortSymbol = Symbol('cancelled');
    private abortPromise: Promise<any>;
    private resolve!: Function; // Works due to promise init

    constructor() {
        this.abortPromise = new Promise(res => this.resolve = res);
    }

    public async wrap<T>(p: PromiseLike<T>): Promise<T> {
        const result = await Promise.race([p, this.abortPromise]);
        if (result === this.abortSymbol) {
            throw new Error('aborted');
        }

        return result;
    }

    public abort() {
        this.resolve(this.abortSymbol);
    }
}

@leihuang23
Copy link

You can't cancel a promise, but you can ignore it. Here is my attempt:

function fromPromise(p) {
  const obj = { handler: () => {} }

  p.then((v) => obj.handler(v))

  return {
    subscribe(_handler) {
      obj.handler = _handler
      return function unsubscribe() {
        obj.handler = () => {}
      }
    },
  }
}

@pygy
Copy link
Author

pygy commented Apr 22, 2022

You can actually cancel the .then chain, by having it absorb a Promise that remains forever suspended.

Live here

Promise.pending = Promise.race.bind(Promise, [])
function cancelablePromise(executor) {
  return new Promise(function(fulfill, reject) {
    function cancel() {fulfill(Promise.pending())}
    executor(fulfill, reject, cancel)
  })
}

cancelablePromise((fulfill, reject, cancel) => {
    setTimeout(cancel, 200) // comment this out to log `5`.
    setTimeout(()=>fulfill(5), 400)
}).then(console.log)

The problem with the approach I described is, as @spion said, that it breaks .finally() handlers, and thus finally {} blocks in async try ( / catch) / finally blocks.

@anhtuank7c
Copy link

I think we have a way to actually aborting a Promise.
Check this out: https://developer.mozilla.org/en-US/docs/Web/API/AbortController

@leihuang23
Copy link

I think we have a way to actually aborting a Promise. Check this out: developer.mozilla.org/en-US/docs/Web/API/AbortController

If you read the introduction in the doc carefully, you'll notice that AbortController is for canceling a network request, not a promise. A request is a platform activity, whereas a promise is a language construct.

@anhtuank7c
Copy link

Ah right

@caiquegaspar
Copy link

caiquegaspar commented Apr 24, 2023

(Updated on 04/26/2023)
Here is my attempt:

const timeoutArr = [];

async function usePromiseTimeout({
  delay = 0,
  callBackFn = null,
  abortController = null,
  str = 'Default sucess message',
  reason = null,
}) {
  const idx = timeoutArr.length;
  let abort = false;

  if (abortController) abortController.idxs.push(idx);

  const abortFn = () => {
    abort = true;
    clearTimeout(timeoutArr[idx].timeout);
    queueMicrotask(() => timeoutArr[idx].resolve());
  };

  const abortFnProxy = new Proxy(abortFn, {
    apply(target, thisArg, args) {
      target.apply(thisArg, args);
    },
  });

  const resolveTimeout = ({ resolve, reject }) => {
    if (reason !== null || abort) {
      reject(reason);
      // throw new Error('test'); // if necessary
      return;
    }

    if (callBackFn) callBackFn();

    resolve(str);
  };

  return new Promise(
    (resolve, reject) =>
      (timeoutArr[idx] = {
        resolve: () => resolveTimeout({ resolve, reject }),
        timeout: setTimeout(() => resolveTimeout({ resolve, reject }), delay),
        abort: abortFnProxy,
      })
  );
}

function useAbortController() {
  return {
    idxs: [],
    abort() {
      this.idxs.forEach((idx) => {
        timeoutArr[idx].abort();
      });
    },
  };
}

So it can be implemented like this:

console.log('start');

const ctrl1 = useAbortController();
const ctrl2 = useAbortController();
const ctrl3 = useAbortController();

// timeout 1
usePromiseTimeout({
  delay: 3000,
  abortController: ctrl1,
})
  .then((result) => console.log('finish promise 1', result))
  .finally(() => console.log('finally test promise 1'));

// timeout 2
(async () =>
  await promiseTimeout({
    delay: 3000,
    callBackFn: () => console.log('callback function2'),
    abortController: ctrl2,
  }))();

// timeout 3
(async () =>
  await promiseTimeout({
    delay: 3000,
    callBackFn: () => console.log('callback function3'),
    abortController: ctrl3,
  }))();

// timeout 4
(async () =>
  await promiseTimeout({
    delay: 3000,
    callBackFn: () => console.log('callback function4'),
    abortController: ctrl1, // same controller of promise 1
  }))();

setTimeout(() => ctrl1.abort(), 2000);
setTimeout(() => ctrl2.abort(), 2000);

And output is:

start

finally test promise 1

callback function3

Timeout 1 was aborted.
Timeout 1 .finally() runs even if the timeout is aborted.
Timeout 2 was aborted.
Timeout 3 completed after 3 seconds.
Timeout 4, which uses the same AbortController than timeout 1, was aborted.

Here the same AbortController can abort multiple promises


Typescript syntax:

interface PromiseTimeoutInterface {
  delay: number;
  callBackFn?: () => any;
  abortController?: AbortControllerInterface;
  str: string;
  reason?: string;
}

interface AbortControllerInterface {
  idxs: number[];
  abort(): void;
}

type PromiseParams = {
  resolve: (value: string | PromiseLike<string>) => void;
  reject: (reason?: any) => void;
};

const timeoutArr: { resolve(): void; timeout: number; abort(): void }[] = [];

export async function usePromiseTimeout({
  delay = 0,
  callBackFn,
  abortController,
  str = "Timeout ended!",
  reason,
}: PromiseTimeoutInterface): Promise<string> {
  const idx = timeoutArr.length;
  let abort = false;

  if (abortController) abortController.idxs.push(idx);

  const abortFn = () => {
    abort = true;
    clearTimeout(timeoutArr[idx].timeout);
    queueMicrotask(() => timeoutArr[idx].resolve());
  };

  const abortFnProxy = new Proxy(abortFn, {
    apply(target, thisArg, args: []) {
      target.apply(thisArg, args);
    },
  });

  const resolveTimeout = <A extends PromiseParams>({ resolve, reject }: A) => {
    if (reason || abort) {
      reject(reason);
      // or
      // throw new Error(reason)

      return;
    }

    if (callBackFn) callBackFn();

    resolve(str);
  };

  return new Promise(
    (resolve, reject) =>
      (timeoutArr[idx] = {
        resolve: () => resolveTimeout({ resolve, reject }),
        timeout: setTimeout(() => resolveTimeout({ resolve, reject }), delay),
        abort: abortFnProxy,
      })
  );
}

export function useAbortController(): AbortControllerInterface {
  return {
    idxs: [],
    abort() {
      this.idxs.forEach((idx) => {
        timeoutArr[idx].abort();
      });
    },
  };
}

Link to see it in action:
https://stackblitz.com/edit/promise-timeout-n-abort?file=index.js

@king-of-poppk
Copy link

let cancel

new Promise(function(resolve, reject) {
  cancel = () => reject(new PromiseCancelledError())}
  setTimeout(resolve, 1000, 5)
}).then(console.log, (error) => {
  if (!(error instanceof PromiseCancelledError)) { ... }
})

cancel() // 5 is never logged.

@alecat88
Copy link

alecat88 commented Oct 5, 2023

Seems to me that all of the above creates a memory leak, or am I wrong?

@king-of-poppk
Copy link

Seems to me that all of the above creates a memory leak, or am I wrong?

Mine just above should not leak if you discard the reference to cancel and clear the timeout once you do not need it anymore. Of course you cannot "clear the timeout" in the general case. Each async "leg" of a promised computation should have a way to test whether it should stop instead of continuing the promise callback chain and each truly async operation (like fetch or setTimeout) should have a real "abort" method that interrupts the request (through AbortController/clearTimeout). It seems everything can be centralized as is done in the solutions above but it still requires each "leg" to be connected to that central piece of information.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment