diff --git a/async/_util.ts b/async/_util.ts index 235e5ed3c71b..31de9f0abc3c 100644 --- a/async/_util.ts +++ b/async/_util.ts @@ -1,15 +1,6 @@ // Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. // This module is browser compatible. -// This `reason` comes from `AbortSignal` thus must be `any`. -// deno-lint-ignore no-explicit-any -export function createAbortError(reason?: any): DOMException { - return new DOMException( - reason ? `Aborted: ${reason}` : "Aborted", - "AbortError", - ); -} - export function exponentialBackoffWithJitter( cap: number, base: number, diff --git a/async/_util_test.ts b/async/_util_test.ts index 626a33f1bb88..99b3ab6c9b67 100644 --- a/async/_util_test.ts +++ b/async/_util_test.ts @@ -1,6 +1,6 @@ // Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. -import { createAbortError, exponentialBackoffWithJitter } from "./_util.ts"; -import { assertEquals, assertInstanceOf } from "@std/assert"; +import { exponentialBackoffWithJitter } from "./_util.ts"; +import { assertEquals } from "@std/assert"; // test util to ensure deterministic results during testing of backoff function by polyfilling Math.random function prngMulberry32(seed: number) { @@ -50,31 +50,3 @@ Deno.test("exponentialBackoffWithJitter()", () => { assertEquals(results as typeof row, row); } }); - -Deno.test("createAbortError()", () => { - const error = createAbortError(); - assertInstanceOf(error, DOMException); - assertEquals(error.name, "AbortError"); - assertEquals(error.message, "Aborted"); -}); - -Deno.test("createAbortError() handles aborted signal with reason", () => { - const c = new AbortController(); - c.abort("Expected Reason"); - const error = createAbortError(c.signal.reason); - assertInstanceOf(error, DOMException); - assertEquals(error.name, "AbortError"); - assertEquals(error.message, "Aborted: Expected Reason"); -}); - -Deno.test("createAbortError() handles aborted signal without reason", () => { - const c = new AbortController(); - c.abort(); - const error = createAbortError(c.signal.reason); - assertInstanceOf(error, DOMException); - assertEquals(error.name, "AbortError"); - assertEquals( - error.message, - "Aborted: AbortError: The signal has been aborted", - ); -}); diff --git a/async/abortable.ts b/async/abortable.ts index 365e71ac3a44..ec249b18015d 100644 --- a/async/abortable.ts +++ b/async/abortable.ts @@ -1,61 +1,109 @@ // Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. // This module is browser compatible. -import { createAbortError } from "./_util.ts"; - /** * Make a {@linkcode Promise} abortable with the given signal. * + * @throws {DOMException} If the signal is already aborted and `signal.reason` + * is undefined. Otherwise, throws `signal.reason`. * @typeParam T The type of the provided and returned promise. * @param p The promise to make abortable. * @param signal The signal to abort the promise with. * @returns A promise that can be aborted. * - * @example Usage - * ```ts no-eval - * import { - * abortable, - * delay, - * } from "@std/async"; + * @example Error-handling a timeout + * ```ts + * import { abortable, delay } from "@std/async"; + * import { assertRejects, assertEquals } from "@std/assert"; + * + * const promise = delay(1_000); + * + * // Rejects with `DOMException` after 100 ms + * await assertRejects( + * () => abortable(promise, AbortSignal.timeout(100)), + * DOMException, + * "Signal timed out." + * ); + * ``` + * + * @example Error-handling an abort + * ```ts + * import { abortable, delay } from "@std/async"; + * import { assertRejects, assertEquals } from "@std/assert"; * - * const p = delay(1000); - * const c = new AbortController(); - * setTimeout(() => c.abort(), 100); + * const promise = delay(1_000); + * const controller = new AbortController(); + * controller.abort(new Error("This is my reason")); * - * // Below throws `DOMException` after 100 ms - * await abortable(p, c.signal); + * // Rejects with `DOMException` immediately + * await assertRejects( + * () => abortable(promise, controller.signal), + * Error, + * "This is my reason" + * ); * ``` */ export function abortable(p: Promise, signal: AbortSignal): Promise; /** * Make an {@linkcode AsyncIterable} abortable with the given signal. * + * @throws {DOMException} If the signal is already aborted and `signal.reason` + * is undefined. Otherwise, throws `signal.reason`. * @typeParam T The type of the provided and returned async iterable. * @param p The async iterable to make abortable. * @param signal The signal to abort the promise with. * @returns An async iterable that can be aborted. * - * @example Usage - * ```ts no-eval - * import { - * abortable, - * delay, - * } from "@std/async"; + * @example Error-handling a timeout + * ```ts + * import { abortable, delay } from "@std/async"; + * import { assertRejects, assertEquals } from "@std/assert"; * - * const p = async function* () { + * const asyncIter = async function* () { * yield "Hello"; - * await delay(1000); + * await delay(1_000); * yield "World"; * }; - * const c = new AbortController(); - * setTimeout(() => c.abort(), 100); * - * // Below throws `DOMException` after 100 ms - * // and items become `["Hello"]` * const items: string[] = []; - * for await (const item of abortable(p(), c.signal)) { - * items.push(item); - * } + * // Below throws `DOMException` after 100 ms and items become `["Hello"]` + * await assertRejects( + * async () => { + * for await (const item of abortable(asyncIter(), AbortSignal.timeout(100))) { + * items.push(item); + * } + * }, + * DOMException, + * "Signal timed out." + * ); + * assertEquals(items, ["Hello"]); + * ``` + * + * @example Error-handling an abort + * ```ts + * import { abortable, delay } from "@std/async"; + * import { assertRejects, assertEquals } from "@std/assert"; + * + * const asyncIter = async function* () { + * yield "Hello"; + * await delay(1_000); + * yield "World"; + * }; + * const controller = new AbortController(); + * controller.abort(new Error("This is my reason")); + * + * const items: string[] = []; + * // Below throws `DOMException` immediately + * await assertRejects( + * async () => { + * for await (const item of abortable(asyncIter(), controller.signal)) { + * items.push(item); + * } + * }, + * Error, + * "This is my reason" + * ); + * assertEquals(items, []); * ``` */ export function abortable( @@ -77,11 +125,9 @@ function abortablePromise( p: Promise, signal: AbortSignal, ): Promise { - if (signal.aborted) { - return Promise.reject(createAbortError(signal.reason)); - } + if (signal.aborted) return Promise.reject(signal.reason); const { promise, reject } = Promise.withResolvers(); - const abort = () => reject(createAbortError(signal.reason)); + const abort = () => reject(signal.reason); signal.addEventListener("abort", abort, { once: true }); return Promise.race([promise, p]).finally(() => { signal.removeEventListener("abort", abort); @@ -92,11 +138,9 @@ async function* abortableAsyncIterable( p: AsyncIterable, signal: AbortSignal, ): AsyncGenerator { - if (signal.aborted) { - throw createAbortError(signal.reason); - } + signal.throwIfAborted(); const { promise, reject } = Promise.withResolvers(); - const abort = () => reject(createAbortError(signal.reason)); + const abort = () => reject(signal.reason); signal.addEventListener("abort", abort, { once: true }); const it = p[Symbol.asyncIterator](); diff --git a/async/abortable_test.ts b/async/abortable_test.ts index f0f0464bfce2..09bc9f34f75f 100644 --- a/async/abortable_test.ts +++ b/async/abortable_test.ts @@ -16,12 +16,24 @@ Deno.test("abortable() handles promise with aborted signal after delay", async ( const { promise, resolve } = Promise.withResolvers(); const t = setTimeout(() => resolve("Hello"), 100); setTimeout(() => c.abort(), 50); - await assertRejects( - async () => { - await abortable(promise, c.signal); - }, + const error = await assertRejects( + () => abortable(promise, c.signal), DOMException, - "AbortError", + "The signal has been aborted", + ); + assertEquals(error.name, "AbortError"); + clearTimeout(t); +}); + +Deno.test("abortable() handles promise with aborted signal after delay with reason", async () => { + const c = new AbortController(); + const { promise, resolve } = Promise.withResolvers(); + const t = setTimeout(() => resolve("Hello"), 100); + setTimeout(() => c.abort(new Error("This is my reason")), 50); + await assertRejects( + () => abortable(promise, c.signal), + Error, + "This is my reason", ); clearTimeout(t); }); @@ -31,12 +43,26 @@ Deno.test("abortable() handles promise with already aborted signal", async () => const { promise, resolve } = Promise.withResolvers(); const t = setTimeout(() => resolve("Hello"), 100); c.abort(); - await assertRejects( + const error = await assertRejects( async () => { await abortable(promise, c.signal); }, DOMException, - "AbortError", + "The signal has been aborted", + ); + assertEquals(error.name, "AbortError"); + clearTimeout(t); +}); + +Deno.test("abortable() handles promise with already aborted signal with reason", async () => { + const c = new AbortController(); + const { promise, resolve } = Promise.withResolvers(); + const t = setTimeout(() => resolve("Hello"), 100); + c.abort(new Error("This is my reason")); + await assertRejects( + () => abortable(promise, c.signal), + Error, + "This is my reason", ); clearTimeout(t); }); @@ -66,15 +92,16 @@ Deno.test("abortable.AsyncIterable() handles aborted signal after delay", async }; setTimeout(() => c.abort(), 50); const items: string[] = []; - await assertRejects( + const error = await assertRejects( async () => { for await (const item of abortable(a(), c.signal)) { items.push(item); } }, DOMException, - "AbortError", + "The signal has been aborted", ); + assertEquals(error.name, "AbortError"); assertEquals(items, ["Hello"]); clearTimeout(t); }); @@ -90,15 +117,16 @@ Deno.test("abortable.AsyncIterable() handles already aborted signal", async () = }; c.abort(); const items: string[] = []; - await assertRejects( + const error = await assertRejects( async () => { for await (const item of abortable(a(), c.signal)) { items.push(item); } }, DOMException, - "AbortError", + "The signal has been aborted", ); + assertEquals(error.name, "AbortError"); assertEquals(items, []); clearTimeout(t); }); diff --git a/async/deadline.ts b/async/deadline.ts index c755ee183f97..08e4ca904f57 100644 --- a/async/deadline.ts +++ b/async/deadline.ts @@ -1,7 +1,10 @@ // Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. -// This module is browser compatible. - -import { delay } from "./delay.ts"; +// TODO(iuioiua): Add web-compatible declaration once TypeScript 5.5 is released +// and in the Deno runtime. See https://github.com/microsoft/TypeScript/pull/58211 +// +// Note: this code is still compatible with recent +// web browsers. See https://caniuse.com/?search=AbortSignal.any +import { abortable } from "./abortable.ts"; /** Options for {@linkcode deadline}. */ export interface DeadlineOptions { @@ -10,29 +13,14 @@ export interface DeadlineOptions { } /** - * Error thrown when {@linkcode deadline} times out. - * - * @example Usage - * ```ts no-assert - * import { DeadlineError } from "@std/async/deadline"; - * - * const error = new DeadlineError(); - * ``` - */ -export class DeadlineError extends Error { - constructor() { - super("Deadline"); - this.name = this.constructor.name; - } -} - -/** - * Create a promise which will be rejected with {@linkcode DeadlineError} when + * 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} When the provided duration runs out before resolving + * or if the optional signal is aborted, and `signal.reason` is undefined. * @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. @@ -44,24 +32,17 @@ export class DeadlineError extends Error { * import { deadline } from "@std/async/deadline"; * import { delay } from "@std/async/delay"; * - * const delayedPromise = delay(1000); - * // Below throws `DeadlineError` after 10 ms + * const delayedPromise = delay(1_000); + * // Below throws `DOMException` after 10 ms * const result = await deadline(delayedPromise, 10); * ``` */ -export function deadline( +export async function deadline( p: Promise, ms: number, options: DeadlineOptions = {}, ): Promise { - const controller = new AbortController(); - const { signal } = options; - if (signal?.aborted) { - return Promise.reject(new DeadlineError()); - } - signal?.addEventListener("abort", () => controller.abort(signal.reason)); - const d = delay(ms, { signal: controller.signal }) - .catch(() => {}) // Do NOTHING on abort. - .then(() => Promise.reject(new DeadlineError())); - return Promise.race([p.finally(() => controller.abort()), d]); + const signals = [AbortSignal.timeout(ms)]; + if (options.signal) signals.push(options.signal); + return await abortable(p, AbortSignal.any(signals)); } diff --git a/async/deadline_test.ts b/async/deadline_test.ts index 539423d5e31a..e723bc746173 100644 --- a/async/deadline_test.ts +++ b/async/deadline_test.ts @@ -1,7 +1,7 @@ // Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. import { assertEquals, assertRejects } from "@std/assert"; import { delay } from "./delay.ts"; -import { deadline, DeadlineError } from "./deadline.ts"; +import { deadline } from "./deadline.ts"; Deno.test("deadline() returns fulfilled promise", async () => { const controller = new AbortController(); @@ -14,15 +14,18 @@ Deno.test("deadline() returns fulfilled promise", async () => { controller.abort(); }); -Deno.test("deadline() throws DeadlineError", async () => { +Deno.test("deadline() throws DOMException", async () => { const controller = new AbortController(); const { signal } = controller; const p = delay(1000, { signal }) .catch(() => {}) .then(() => "Hello"); - await assertRejects(async () => { - await deadline(p, 100); - }, DeadlineError); + const error = await assertRejects( + () => deadline(p, 100), + DOMException, + "Signal timed out.", + ); + assertEquals(error.name, "TimeoutError"); controller.abort(); }); @@ -63,9 +66,12 @@ Deno.test("deadline() handles aborted signal after delay", async () => { const abort = new AbortController(); const promise = deadline(p, 100, { signal: abort.signal }); abort.abort(); - await assertRejects(async () => { - await promise; - }, DeadlineError); + const error = await assertRejects( + () => promise, + DOMException, + "The signal has been aborted", + ); + assertEquals(error.name, "AbortError"); controller.abort(); }); @@ -77,8 +83,11 @@ Deno.test("deadline() handles already aborted signal", async () => { .then(() => "Hello"); const abort = new AbortController(); abort.abort(); - await assertRejects(async () => { - await deadline(p, 100, { signal: abort.signal }); - }, DeadlineError); + const error = await assertRejects( + () => deadline(p, 100, { signal: abort.signal }), + DOMException, + "The signal has been aborted", + ); + assertEquals(error.name, "AbortError"); controller.abort(); }); diff --git a/async/delay.ts b/async/delay.ts index 1feed535d192..c5b7dc07ad5e 100644 --- a/async/delay.ts +++ b/async/delay.ts @@ -15,6 +15,8 @@ export interface DelayOptions { /** * Resolve a {@linkcode Promise} after a given amount of milliseconds. * + * @throws {DOMException} If the optional signal is aborted before the delay + * duration, and `signal.reason` is undefined. * @param ms Duration in milliseconds for how long the delay should last. * @param options Additional options. * diff --git a/async/mod.ts b/async/mod.ts index d5f1a89dc27a..3dfccf023be9 100644 --- a/async/mod.ts +++ b/async/mod.ts @@ -1,5 +1,9 @@ // Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. -// This module is browser compatible. +// TODO(iuioiua): Add web-compatible declaration once TypeScript 5.5 is released +// and in the Deno runtime. See https://github.com/microsoft/TypeScript/pull/58211 +// +// Note: this code is still compatible with recent +// web browsers. See https://caniuse.com/?search=AbortSignal.any /** * Provide helpers with asynchronous tasks like {@linkcode delay | delays},