Search code examples
javascriptpromiseasync-awaitcancellation

Why does my "thenable" function time out?


I've been messing with the idea of canceling a promise is a transparent way using a higher order function. And I came up with this:

export const fnGetter = state => fn => (...args) => {
  if (!state.canceled) return fn(...args)
  return Promise.resolve()
}

export const cancelable = (promise, state = {}) => {
  const getFn = fnGetter(state)

  return {
    then: fn => cancelable(promise.then(getFn(fn)), state),
    catch: fn => cancelable(promise.catch(getFn(fn)), state),
    cancel: () => {
      state.canceled = true
    }
  }
}

export const withCancel = promiseReturningFn => (...args) =>
  cancelable(promiseReturningFn(...args))

And here are some unit tests where I'm verifying the behavior I want.

const delay = withCancel(ms => new Promise(run => setTimeout(run, ms)))

test('works like normal promise when not canceled', async () => {
  const first = jest.fn()
  const second = jest.fn()

  await delay(1000).then(first).then(second)

  expect(first).toHaveBeenCalledTimes(1)
  expect(second).toHaveBeenCalledTimes(1)
})

test('when ignored, does not call callbacks', async () => {
  const first = jest.fn()
  const second = jest.fn()

  const promise = delay(1000).then(first).then(second)
  promise.cancel()

  await promise

  expect(first).not.toHaveBeenCalled()
  expect(second).not.toHaveBeenCalled()
})

I can't figure out why the first test passes, but calling .cancel() in the second unit test makes it time out.

Edit

I think it has something to do with the way that await handles the then method under the hood. I just need help at this point. I'd like it to be compatible with async await. Here's a passing version that doesn't rely on await.

test('when ignored, does not call callbacks', async () => {
  const first = jest.fn()
  const second = jest.fn()

  const promise = delay(1000).then(first).then(second)
  promise.cancel()

  setTimeout(() => {
    expect(first).not.toHaveBeenCalled()
    expect(second).not.toHaveBeenCalled()
  }, 2000)
})

Solution

  • One problem is that then should take two parameters to deal with rejections.

    As for why it times out: you are using await promise, but await does use then, and your promise is cancelled so it never calls its callbacks. A cancelled promise should call the onreject callback, and your fnGetter should then ignore that cancellation error only for those callbacks that were actually expecting your cancellation.