Promise.race() may hang Promise subsystem, waiting for “losing” Promises to finish

  • Version: v15.4.0
  • Platform: Microsoft Windows NT 10.0.19041.0 x64
  • Subsystem: Promise

What steps will reproduce the bug?

Based on everything I can find online, this is the canonical way to set a timeout for a Promise:

/** The canonical way to time-out a promise. */
function promiseTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
  let timeoutPid;
  const timeoutPromise = new Promise<T>((resolve, reject) => {
    timeoutPid = setTimeout(() => reject(new Error('Timeout')), timeoutMs);
  });

  return Promise.race([
    promise,
    timeoutPromise,
  ]).then((result) => {
    clearTimeout(timeoutPid);
    return result;
  });
}

/** An example that works as expected, but then causes the app to hang for 4 seconds. */
async function main() {
  // A promise that will finish successfully after 5 seconds.
  const p = new Promise(r => setTimeout(() => {
    console.log("Finished");
    r();
  }, 5000));

  // A promise that will timeout the above promise after 1 second, causing it to always "lose" the race.
  const pt = promiseTimeout(p, 1000);

  console.log(Date.now());  // output: some "initial" timestamp
  try {
    await pt;  // This will never finish and the catch block will be called because the timeout (1s) exceeds the promise's duration (5s).
  } catch (e) {
    console.log(e);  // An error message indicating that the promise did timeout as expected. Note: this is being swallowed.
  }
  console.log(Date.now());  // output: some timestamp 1 second after the initial timestamp.
}
main().then(() => console.log("Complete."));  // After this is printed out, the script hangs for an additional 4 seconds.

How often does it reproduce? Is there a required condition?

100%% of the time

What is the expected behavior?

Given that the returned value of the timed-out promise cannot be reached, it seems reasonable that it should be aborted/killed.

What do you see instead?

After “Complete.” is printed to the console, the program hangs for an additional 4 seconds, which is the difference between the 5 seconds that the promise took to complete and the 1 second timeout.

Additional information

I’m having a larger issue where a Promise.all(Promise<void>[]) is hanging. That array of promises are each doing their own version of the code above, e.g. they are variable-duration promises that are wrapped in an equivalent promiseTimeout() function. My loose hypothesis is that the all is waiting for all of the underlying promises to finish, despite the fact that the promise returned by race() has finished. My code is more complicated than I’m describing; I’m trying to simplify it as much as possible to see if it could be related. Regardless of what my underlying issue is, I think this is a valid criticism of how the Promise subsystem is handling promises that have lost the “race”.

1 possible answer(s) on “Promise.race() may hang Promise subsystem, waiting for “losing” Promises to finish

  1. Yep, that’s exactly it. AbortController and AbortSignal are the idiomatic/canonical way of doing cancelation here, and we’ve been going through an adding support in a number of Node.js core APIs.

    Generally the pattern to follow is:

    async someCancelableFunction(arg1, args, options = {}) {
      const { signal } = options;
      if (signal?.aborted)
        throw new Error('Operation is aborted');
    
      function doCancel() {
        // do whatever to cancel the activity.
      }
      if (typeof signal?.addEventListener === 'function')
        signal.addEventListener('abort', doCancel, { once: true });
    
      try {
        await someAsyncActivity({ signal });  // optionally pass signal in if supported
        if (signal?.aborted)
          throw new Error('Operation is aborted');
        await someOtherAsyncActivity({ signal });
      } finally {
        if (typeof signal?.removeEventListener === 'function')
          signal.removeEventListener('abort', doCancel);
      }
    }

    Yes, the boilerplate is a bit much but with a bit of work it can be translated into a utility and becomes fairly natural once it’s in place.