Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Ability to pass an AbortSignal to Bun.sleep #17866

Open
iitzkube opened this issue Mar 3, 2025 · 3 comments
Open

Ability to pass an AbortSignal to Bun.sleep #17866

iitzkube opened this issue Mar 3, 2025 · 3 comments
Labels
enhancement New feature or request

Comments

@iitzkube
Copy link

iitzkube commented Mar 3, 2025

What is the problem this feature would solve?

The Bun.sleep() method is really handy. I think it would be even better if it supported AbortSignals.

There's a lot of cases when it's crucial to stop the timer immediately and this isn't possible with Bun.sleep. I have a custom cancelable sleep utility in my codebase but it'd be nice if I didn't have to – the global Bun.sleep() is a lot nicer 😄

What is the feature you are proposing to solve the problem?

Proposed API change:

Allow passing an AbortSignal to the Bun's sleep/sleepAsync methods to make them "cancelable".

This will not be a breaking change.

Now, whether it should throw or not when the signal is aborted, I think ideally it should, but an argument can still be made that a quiet return could be preferable in scenarios where aborts are an expected part of the normal flow, reducing the need for excessive error-handling boilerplate and treating cancellation as a valid outcome rather than an exception.

API proposal:

  • Passing an options argument: options: { signal: AbortSignal; throwOnAbort?: boolean }
  • Allow user to control the throwing behavior.
- function sleep(ms: number | Date): Promise<void>;
+ function sleep(ms: number | Date, options?: { signal?: AbortSignal, throwOnAbort?: boolean }): Promise<void>;


- function sleepSync(ms: number | Date): Promise<void>;
+ function sleepSync(ms: number | Date, options?: { signal?: AbortSignal, throwOnAbort?: boolean }): void;

Full implementation draft specified below

Alternative API proposal:

  • Passing an options argument: options?: { signal?: AbortSignal }.
  • Similar API to fetch, addEventListener, ReadableStream.pipeTo, etc.
  • sleep will always throws on signal's abort.
- function sleep(ms: number | Date): Promise<void>;
+ function sleep(ms: number | Date, options?: { signal?: AbortSignal }): Promise<void>;


- function sleepSync(ms: number | Date): Promise<void>;
+ function sleepSync(ms: number | Date, options?: { signal?: AbortSignal }): void;

Always throwing on abort is also consistent with other built-in APIs. The { throwOnAbort: false } behavior from above could be achieved with a simple .catch():

await sleep(1000, { signal }).catch(() => {})

What alternatives have you considered?

Custom sleep utility

@iitzkube iitzkube added the enhancement New feature or request label Mar 3, 2025
@cirospaciari
Copy link
Member

cirospaciari commented Mar 3, 2025

Something like the code bellow right?

function sleep(
  ms: number,
  abortSignal: AbortSignal | null = null,
  throwOnAbort: boolean = true
) {
  const { promise, resolve, reject } = Promise.withResolvers();
  const timeout = setTimeout(resolve, ms);
  if (abortSignal) {
    abortSignal.addEventListener("abort", () => {
      clearTimeout(timeout);
      if (throwOnAbort) {
        reject(abortSignal.reason);
      } else {
        resolve(abortSignal.reason);
      }
    });
  }
  return promise;
}

@iitzkube
Copy link
Author

iitzkube commented Mar 3, 2025

@cirospaciari yes, pretty much...

If throwOnAbort were to be considered, I'd just suggest passing an options: { signal: AbortSignal; throwOnAbort?: boolean } as a second argument so it's less ambiguous compared to just passing two separate arguments. This API is also similar to fetch, addEventListener, ReadableStream.pipeTo, etc.

Here's my take on the implementation with function overloads so that throwOnAbort requires a signal to be passed, and also handling already aborted signals:

function sleep(ms: number): Promise<void>;
function sleep(ms: number, options: { signal: AbortSignal; throwOnAbort?: boolean }): Promise<void>;
function sleep(ms: number, { signal, throwOnAbort = true }: { signal?: AbortSignal; throwOnAbort?: boolean } = {}) {
  const { promise, resolve, reject } = Promise.withResolvers();

  // handling signals that are already aborted
  if (signal?.aborted) {
    if (throwOnAbort) {
      reject(signal.reason);
    } else {
      resolve(signal.reason);
    }
    return promise;
  }

  const timeout = setTimeout(resolve, ms);

  signal?.addEventListener('abort', () => {
    clearTimeout(timeout);
    if (throwOnAbort) {
      reject(signal.reason);
    } else {
      resolve(signal.reason);
    }
  });

  return promise;
}

Usage:

const abortController = new AbortController();
const signal = abortController.signal;

await sleep(1000);
await sleep(1000, { signal });
await sleep(1000, { signal, throwOnAbort: true });
await sleep(1000, { signal, throwOnAbort: false });

// @ts-expect-error (throws TSC error: "Property 'signal' is missing")
await sleep(1000, { throwOnAbort: true });

Tests:

expect(() => sleep(1000, { signal: AbortSignal.timeout(1) })).toThrow();
expect(() => sleep(1000, { signal: AbortSignal.timeout(1), throwOnAbort: false })).not.toThrow();
expect(() => sleep(1000, { signal: AbortSignal.abort('already aborted') })).toThrow();

@190n
Copy link
Contributor

190n commented Mar 3, 2025

We more or less have this already in the form of require('node:timers/promises').setTimeout, so it shouldn't be too difficult to expose that in Bun.sleep too.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request
Projects
None yet
Development

No branches or pull requests

3 participants