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

BREAKING(async): simplify deadline() logic, remove DeadlineError and improve errors #5058

Merged
merged 22 commits into from
Jun 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 0 additions & 9 deletions async/_util.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
32 changes: 2 additions & 30 deletions async/_util_test.ts
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down Expand Up @@ -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",
);
});
116 changes: 80 additions & 36 deletions async/abortable.ts
Original file line number Diff line number Diff line change
@@ -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"));
iuioiua marked this conversation as resolved.
Show resolved Hide resolved
*
* // 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<T>(p: Promise<T>, signal: AbortSignal): Promise<T>;
/**
* 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<T>(
Expand All @@ -77,11 +125,9 @@ function abortablePromise<T>(
p: Promise<T>,
signal: AbortSignal,
): Promise<T> {
if (signal.aborted) {
return Promise.reject(createAbortError(signal.reason));
}
if (signal.aborted) return Promise.reject(signal.reason);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On 2nd thought. Maybe this should throw. Thoughts?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks fine to me

const { promise, reject } = Promise.withResolvers<never>();
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);
Expand All @@ -92,11 +138,9 @@ async function* abortableAsyncIterable<T>(
p: AsyncIterable<T>,
signal: AbortSignal,
): AsyncGenerator<T> {
if (signal.aborted) {
throw createAbortError(signal.reason);
}
signal.throwIfAborted();
const { promise, reject } = Promise.withResolvers<never>();
const abort = () => reject(createAbortError(signal.reason));
const abort = () => reject(signal.reason);
signal.addEventListener("abort", abort, { once: true });

const it = p[Symbol.asyncIterator]();
Expand Down
50 changes: 39 additions & 11 deletions async/abortable_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,24 @@ Deno.test("abortable() handles promise with aborted signal after delay", async (
const { promise, resolve } = Promise.withResolvers<string>();
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<string>();
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);
});
Expand All @@ -31,12 +43,26 @@ Deno.test("abortable() handles promise with already aborted signal", async () =>
const { promise, resolve } = Promise.withResolvers<string>();
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<string>();
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);
});
Expand Down Expand Up @@ -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);
});
Expand All @@ -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);
});
49 changes: 15 additions & 34 deletions async/deadline.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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.
Expand All @@ -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<T>(
export async function deadline<T>(
p: Promise<T>,
ms: number,
options: DeadlineOptions = {},
): Promise<T> {
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));
}
Loading