node.jstypescriptasync-awaitdenodebouncing

Promise somhow escapes to event loop?


I need a self-contained abstraction for debouncing a function call. While deno/async provides such a function, AFAICT there is no way to cancel an already running function (for example, to kill a child process). So, a few days ago, I attempted to implement this requirement myself:

function debounced<T>(
  fn: (signal: AbortSignal) => Promise<T>,
  ms: number,
): () => Promise<T> {
  let controller: AbortController | null = null;
  return async () => {
    controller?.abort();
    controller = new AbortController();
    const signal = controller.signal;
    await delay(ms, { signal });
    return await fn(signal);
  };
}

This test should demonstrate the expected behavior:

Deno.test(async function debouncedAbortsRunningFunctions() {
  // Arrange
  using time = new FakeTime();
  const enter = spy();
  const exit = spy();

  const fn = debounced(async (signal) => {
    enter();

    // simulate a long running function
    await delay(100, { signal });

    exit();
  }, 100); // 100ms delay before calling the function

  // Act
  const p1 = fn();
  time.tick(50);
  const p2 = fn();
  time.tick(150);
  const p3 = fn();

  time.tick(1000);
  const actual = await Promise.allSettled([p1, p2, p3]);

  // Assert
  assertEquals(actual[0].status, "rejected");
  assertEquals(actual[1].status, "rejected");
  assertEquals(actual[2].status, "fulfilled");

  assertSpyCalls(enter, 2); // 2 because the first call was aborted during the delay
  assertSpyCalls(exit, 1); // 1 because the second call was aborted mid-execution
});

However, I'm getting error: Promise resolution is still pending but the event loop has already resolved.

Running equivalent code in NodeJs gives (uncaught error) error: (in promise) AbortError: The signal has been aborted.

I assume that suggests that somhow an unbound/unawaited promise escapes to be a "background task", so the AbortError exception raises to the event loop. I just don't see how and where that could happen.

At this point I have tried numerous implementations of debounced(), using new Promise(...) and whatnot.

I am lost. Any help is appreciated!


Solution

  • Your test does indeed reject p1 long before it is awaited (through Promise.allSettled), that is where the unhandled rejection is coming from.

    I'm not that familiar with deno/testing, but I would suggest you try

    // act
    const p1 = fn();
    const p2 = delay(50).then(fn);
    const p3 = delay(200).then(fn);
    
    const promise = Promise.allSettled([p1, p2, p3]);
    time.tick(1200);
    const actual = await promise;
    

    where no rejection should be able to escape the handling of Promise.allSettled (unless there is a synchronous exception between the promise construction and the allSettled call).