diff --git a/async/unstable_deadline.ts b/async/unstable_deadline.ts new file mode 100644 index 000000000000..2512ae25959c --- /dev/null +++ b/async/unstable_deadline.ts @@ -0,0 +1,58 @@ +// Copyright 2018-2025 the Deno authors. MIT license. +// This module is browser compatible. + +import { abortable } from "./abortable.ts"; + +/** Options for {@linkcode deadline}. */ +export interface DeadlineOptions { + /** Signal used to abort the deadline. */ + signal?: AbortSignal; + /** + * Allow the deadline to be infinite. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + */ + allowInfinity?: boolean; +} + +/** + * Create a promise which will be rejected with {@linkcode DOMException} when + * a given delay is exceeded. + * + * Note: Prefer to use {@linkcode AbortSignal.timeout} instead for the APIs + * that accept {@linkcode AbortSignal}. + * + * @throws {DOMException & { name: "TimeoutError" }} If the provided duration + * runs out before resolving. + * @throws {DOMException & { name: "AbortError" }} If the optional signal is + * aborted with the default `reason` before resolving or timing out. + * @throws {AbortSignal["reason"]} If the optional signal is aborted with a + * custom `reason` before resolving or timing out. + * @typeParam T The type of the provided and returned promise. + * @param p The promise to make rejectable. + * @param ms Duration in milliseconds for when the promise should time out. + * @param options Additional options. + * @returns A promise that will reject if the provided duration runs out before resolving. + * + * @example Usage + * ```ts ignore + * import { deadline } from "@std/async/deadline"; + * import { delay } from "@std/async/delay"; + * + * const delayedPromise = delay(1_000); + * // Below throws `DOMException` after 10 ms + * const result = await deadline(delayedPromise, 10); + * ``` + */ +export async function deadline( + p: Promise, + ms: number, + options: DeadlineOptions = {}, +): Promise { + const signals: AbortSignal[] = []; + if ((!options?.allowInfinity) || (ms !== Infinity)) { + signals.push(AbortSignal.timeout(ms)); + } + if (options.signal) signals.push(options.signal); + return await abortable(p, AbortSignal.any(signals)); +} diff --git a/async/unstable_deadline_test.ts b/async/unstable_deadline_test.ts new file mode 100644 index 000000000000..bdd8268e4683 --- /dev/null +++ b/async/unstable_deadline_test.ts @@ -0,0 +1,102 @@ +// Copyright 2018-2025 the Deno authors. MIT license. +import { assertEquals, assertRejects } from "@std/assert"; +import { delay } from "./delay.ts"; +import { deadline } from "./unstable_deadline.ts"; + +Deno.test("deadline() returns fulfilled promise", async () => { + const controller = new AbortController(); + const { signal } = controller; + const p = delay(100, { signal }) + .catch(() => {}) + .then(() => "Hello"); + const result = await deadline(p, 1000); + assertEquals(result, "Hello"); + controller.abort(); +}); + +Deno.test("deadline() throws DOMException", async () => { + const controller = new AbortController(); + const { signal } = controller; + const p = delay(1000, { signal }) + .catch(() => {}) + .then(() => "Hello"); + const error = await assertRejects( + () => deadline(p, 100), + DOMException, + "Signal timed out.", + ); + assertEquals(error.name, "TimeoutError"); + controller.abort(); +}); + +Deno.test("deadline() throws when promise is rejected", async () => { + const controller = new AbortController(); + const { signal } = controller; + const p = delay(100, { signal }) + .catch(() => {}) + .then(() => Promise.reject(new Error("booom"))); + await assertRejects( + async () => { + await deadline(p, 1000); + }, + Error, + "booom", + ); + controller.abort(); +}); + +Deno.test("deadline() handles non-aborted signal", async () => { + const controller = new AbortController(); + const { signal } = controller; + const p = delay(100, { signal }) + .catch(() => {}) + .then(() => "Hello"); + const abort = new AbortController(); + const result = await deadline(p, 1000, { signal: abort.signal }); + assertEquals(result, "Hello"); + controller.abort(); +}); + +Deno.test("deadline() handles aborted signal after delay", async () => { + const controller = new AbortController(); + const { signal } = controller; + const p = delay(100, { signal }) + .catch(() => {}) + .then(() => "Hello"); + const abort = new AbortController(); + const promise = deadline(p, 100, { signal: abort.signal }); + abort.abort(); + const error = await assertRejects( + () => promise, + DOMException, + "The signal has been aborted", + ); + assertEquals(error.name, "AbortError"); + controller.abort(); +}); + +Deno.test("deadline() handles already aborted signal", async () => { + const controller = new AbortController(); + const { signal } = controller; + const p = delay(100, { signal }) + .catch(() => {}) + .then(() => "Hello"); + const abort = new AbortController(); + abort.abort(); + const error = await assertRejects( + () => deadline(p, 100, { signal: abort.signal }), + DOMException, + "The signal has been aborted", + ); + assertEquals(error.name, "AbortError"); + controller.abort(); +}); + +Deno.test("deadline() supports allowInfinity option", async () => { + await assertRejects( + () => deadline(Promise.resolve("Hello"), Infinity), + TypeError, + "Argument 1 is not a finite number", + ); + await deadline(Promise.resolve("Hello"), Infinity, { allowInfinity: true }); +});