13 May 2017

The halting problem; Promises don’t make it easy.

If you like the idea of async/await, then you have to be all in on Promises. Lots of frameworks embrace the new paradigm and it’s truly a delight to program in–most of the time.

You can’t cancel a Promise. That’s the hardest thing to deal with if you create a procedure with a Promise that will never resolve or if you want to stop a long-running process inside that procedure.

Luckily, the garbage collection seems to take care of the objects and listeners created. So, we only need to concern ourselves with how to cancel the rogue Promise’s process. Consider the following example:

Promise.race([process1(), process2()])
  .then((result) => /* do something */)
  .catch(/* handle errors */)

If the Promise returned by process1 resolves and we want to cancel the Promise returned by process2 we’re out of luck. We don’t even get a reference to the Promise process2 creates, we only get the result of process1’s resolve.

We need an abstraction. This idea is borrowed from pump while dealing with a similar issue in streams. When one stream closes, you want to close all of them and pipe() doesn’t do that for you.

Instead of calling Promise.race() we need to create our own race() function so we can abort() any cancelable Promises in the then() handler. It should look like this:

race(cancelablePromise(), regularPromise(), cancelablePromise())
      .then((resolvedValue) => handleResolvedValue)
      .catch((rejectOrError) => handleRejectOrError)

Here’s my implementation:

const send = name => obj => {
  if (typeof obj === 'object' && typeof obj[name] === 'function') {
    obj[name]()
  }
}
const race = (...promises) => {
  return Promise.race(promises)
    .then((result) => {
      promises.forEach(send('abort'))
      // make sure we forward the resolve value
      return result
    })
    .catch((err) => {
      promises.forEach(send('abort'))
      // make sure we throw again
      throw err
    })
}

Now we need a Promise we can actually cancel by calling abort but keep all the semantics of native Promises.

const cancelablePromise = () => {
  let abort
  const internal = new Promise((resolve, reject) => {
    /* some internals */
    abort = () => reject()
  })
  // as an expando property
  internal.abort = abort
  return internal
}

Ideally, we wouldn’t call abort() until a parallel process finishes. At that time, the value of our cancelablePromise doesn’t matter. But, just in case, we don’t want to use resolve. If we’re worried about tampering, we could also use Object.defineProperty().

This isn’t clean because we need the actual reject method injected by the internal Promise. So, we can’t pass in our own resolver. But with a little es6 class-fu, we can make a class that seems more natural.

class CancelablePromise extends Promise {
  constructor(resolver, onAbort = () => {}) {
    let handle
    super((resolve, reject) => handle = {resolve, reject})
    resolver.call(null, handle.resolve, handle.reject)
    Object.defineProperty(this, 'abort', {
      enumerable: false,
      writable: false,
      configurable: false,
      value: () => {
        handle.reject('aborted')
        onAbort()
        return this
      }
    })
  }
}

Let’s put it together:

let interval
const promise1 = () => new CancelablePromise(() => {
  // will not resolve on its own
  interval = setInterval(() => {console.log('Promise 1 still going')})
}, () => clearInterval(interval))

let timeout
const promise2 = () => new CancelablePromise((resolve) => {
  timeout = setTimeout(() => {
    console.log('resolved Promise 2')
    resolve(2)
  }, 1000)
}, () => clearTimeout(timeout))

const promise3 = () => new Promise((resolve, reject) => setTimeout(() => {
  // will resolve but can't be aborted early
  console.log('resolved Promise 3')
  resolve(3)
}, 1500))

race(promise1(), promise2(), promise3())
  .then((resolvedValue) => console.log({resolvedValue}))
  .catch((caughtError) => console.error({caughtError}))

My output looks something like this:

(252) Promise 1 still going
resolved Promise 2
Object {resolvedValue: 2}
resolved Promise 3

This race() calls abort() on the cancelable promise once our process resolves. Sadly, it can’t do anything to stop promise3 so it keeps going.

Let’s try it with an error scenario:

const promise4 = () => new Promise((resolve, reject) => {
  setTimeout(() => reject(4), 500)
})
race(promise1(), promise2(), promise3(), promise4())
  .then((resolvedValue) => console.log({resolvedValue}))
  .catch((caughtError) => console.error({caughtError}))

Now my output looks like this:

(128) Promise 1 still going
Object {caughtError: 4}
resolved Promise 3

Promise1 and Promise2 abort early and we still catch the rejection.



Discussion:

blog comments powered by Disqus