From 93c27eb92371bd72bab625e9ee5f77179ac4e8d9 Mon Sep 17 00:00:00 2001 From: Simon Lecoq <22963968+lowlighter@users.noreply.github.com> Date: Sun, 25 Jan 2026 20:04:37 +0000 Subject: [PATCH] feat(async/unstable): allow `AbortableOptions` with optional signal in `abortable` --- async/deno.json | 1 + async/unstable_abortable.ts | 182 +++++++++++++++++++++++ async/unstable_abortable_test.ts | 243 +++++++++++++++++++++++++++++++ 3 files changed, 426 insertions(+) create mode 100644 async/unstable_abortable.ts create mode 100644 async/unstable_abortable_test.ts diff --git a/async/deno.json b/async/deno.json index aa79a3c77de8..098f0126d7f2 100644 --- a/async/deno.json +++ b/async/deno.json @@ -13,6 +13,7 @@ "./retry": "./retry.ts", "./unstable-retry": "./unstable_retry.ts", "./tee": "./tee.ts", + "./unstable-abortable": "./unstable_abortable.ts", "./unstable-throttle": "./unstable_throttle.ts", "./unstable-wait-for": "./unstable_wait_for.ts", "./unstable-semaphore": "./unstable_semaphore.ts", diff --git a/async/unstable_abortable.ts b/async/unstable_abortable.ts new file mode 100644 index 000000000000..832dd21c481a --- /dev/null +++ b/async/unstable_abortable.ts @@ -0,0 +1,182 @@ +// Copyright 2018-2026 the Deno authors. MIT license. +// This module is browser compatible. + +/** Options for {@linkcode abortable}. */ +export interface AbortableOptions { + /** The signal to abort the promise with. */ + signal?: AbortSignal | undefined; +} + +// TODO(iuioiua): Remove `ignore` directives from following snippets +/** + * 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 Error-handling a timeout + * ```ts ignore + * 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 ignore + * import { abortable, delay } from "@std/async"; + * import { assertRejects, assertEquals } from "@std/assert"; + * + * const promise = delay(1_000); + * const controller = new AbortController(); + * controller.abort(new Error("This is my reason")); + * + * // Rejects with `DOMException` immediately + * await assertRejects( + * () => abortable(promise, controller.signal), + * Error, + * "This is my reason" + * ); + * ``` + */ +export function abortable( + p: Promise, + signal: AbortSignal | AbortableOptions, +): 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 Error-handling a timeout + * ```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 items: string[] = []; + * // 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( + p: AsyncIterable, + signal: AbortSignal | AbortableOptions, +): AsyncGenerator; +export function abortable( + p: Promise | AsyncIterable, + signal: AbortSignal | AbortableOptions, +): Promise | AsyncIterable { + if (!(signal instanceof AbortSignal)) { + if (!signal.signal) { + return p; + } + signal = signal.signal; + } + if (p instanceof Promise) { + return abortablePromise(p, signal); + } else { + return abortableAsyncIterable(p, signal); + } +} + +function abortablePromise( + p: Promise, + signal: AbortSignal, +): Promise { + const { promise, reject } = Promise.withResolvers(); + const abort = () => reject(signal.reason); + if (signal.aborted) abort(); + signal.addEventListener("abort", abort, { once: true }); + return Promise.race([promise, p]).finally(() => { + signal.removeEventListener("abort", abort); + }); +} + +async function* abortableAsyncIterable( + p: AsyncIterable, + signal: AbortSignal, +): AsyncGenerator { + signal.throwIfAborted(); + const { promise, reject } = Promise.withResolvers(); + const abort = () => reject(signal.reason); + signal.addEventListener("abort", abort, { once: true }); + + const it = p[Symbol.asyncIterator](); + try { + while (true) { + const race = Promise.race([promise, it.next()]); + race.catch(() => { + signal.removeEventListener("abort", abort); + }); + const { done, value } = await race; + if (done) { + signal.removeEventListener("abort", abort); + const result = await it.return?.(value); + return result?.value; + } + yield value; + } + } catch (e) { + await it.return?.(); + throw e; + } +} diff --git a/async/unstable_abortable_test.ts b/async/unstable_abortable_test.ts new file mode 100644 index 000000000000..8323f1d47457 --- /dev/null +++ b/async/unstable_abortable_test.ts @@ -0,0 +1,243 @@ +// Copyright 2018-2026 the Deno authors. MIT license. +import { assertEquals, assertRejects } from "@std/assert"; +import { abortable } from "./unstable_abortable.ts"; + +Deno.test("abortable() handles promise", async () => { + const c = new AbortController(); + const { promise, resolve } = Promise.withResolvers(); + const t = setTimeout(() => resolve("Hello"), 100); + const result = await abortable(promise, c.signal); + assertEquals(result, "Hello"); + clearTimeout(t); +}); + +Deno.test("abortable() handles promise with aborted signal after delay", async () => { + const c = new AbortController(); + const { promise, resolve } = Promise.withResolvers(); + const t = setTimeout(() => resolve("Hello"), 100); + setTimeout(() => c.abort(), 50); + const error = await assertRejects( + () => abortable(promise, c.signal), + DOMException, + "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); +}); + +Deno.test("abortable() handles promise with already aborted signal", async () => { + const c = new AbortController(); + const { promise, resolve } = Promise.withResolvers(); + const t = setTimeout(() => resolve("Hello"), 100); + c.abort(); + const error = await assertRejects( + async () => { + await abortable(promise, c.signal); + }, + DOMException, + "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); +}); + +Deno.test("abortable.AsyncIterable()", async () => { + const c = new AbortController(); + const { promise, resolve } = Promise.withResolvers(); + const t = setTimeout(() => resolve("Hello"), 100); + const a = async function* () { + yield "Hello"; + await promise; + yield "World"; + }; + const items = await Array.fromAsync(abortable(a(), c.signal)); + assertEquals(items, ["Hello", "World"]); + clearTimeout(t); +}); + +Deno.test("abortable.AsyncIterable() handles aborted signal after delay", async () => { + const c = new AbortController(); + const { promise, resolve } = Promise.withResolvers(); + const t = setTimeout(() => resolve("Hello"), 100); + const a = async function* () { + yield "Hello"; + await promise; + yield "World"; + }; + setTimeout(() => c.abort(), 50); + const items: string[] = []; + const error = await assertRejects( + async () => { + for await (const item of abortable(a(), c.signal)) { + items.push(item); + } + }, + DOMException, + "The signal has been aborted", + ); + assertEquals(error.name, "AbortError"); + assertEquals(items, ["Hello"]); + clearTimeout(t); +}); + +Deno.test("abortable.AsyncIterable() handles already aborted signal", async () => { + const c = new AbortController(); + const { promise, resolve } = Promise.withResolvers(); + const t = setTimeout(() => resolve("Hello"), 100); + const a = async function* () { + yield "Hello"; + await promise; + yield "World"; + }; + c.abort(); + const items: string[] = []; + const error = await assertRejects( + async () => { + for await (const item of abortable(a(), c.signal)) { + items.push(item); + } + }, + DOMException, + "The signal has been aborted", + ); + assertEquals(error.name, "AbortError"); + assertEquals(items, []); + clearTimeout(t); +}); + +Deno.test("abortable.AsyncIterable() calls return before throwing", async () => { + const c = new AbortController(); + let returnCalled = false; + let timeoutId: number; + const iterable: AsyncIterable = { + [Symbol.asyncIterator]: () => ({ + next: () => + new Promise((resolve) => { + timeoutId = setTimeout( + () => resolve({ value: "Hello", done: false }), + 1, + ); + }), + return: () => { + returnCalled = true; + return Promise.resolve({ value: undefined, done: true }); + }, + }), + }; + setTimeout(() => c.abort(), 1); + const items: string[] = []; + const error = await assertRejects( + async () => { + for await (const item of abortable(iterable, c.signal)) { + items.push(item); + } + }, + DOMException, + "The signal has been aborted", + ); + assertEquals(returnCalled, true); + assertEquals(error.name, "AbortError"); + assertEquals(items, []); + clearTimeout(timeoutId!); +}); + +Deno.test("abortable.AsyncIterable() behaves just like original when not aborted", async () => { + async function* gen() { + yield 1; + yield await Promise.resolve(2); + yield 3; + return 4; + } + const normalIterator = gen(); + const abortController = new AbortController(); + const abortableIterator = abortable(gen(), abortController.signal); + + assertEquals(await abortableIterator.next(), await normalIterator.next()); + assertEquals(await abortableIterator.next(), await normalIterator.next()); + assertEquals(await abortableIterator.next(), await normalIterator.next()); + assertEquals(await abortableIterator.next(), await normalIterator.next()); + assertEquals(await abortableIterator.next(), await normalIterator.next()); +}); + +Deno.test("abortable.AsyncIterable() behaves just like original when return is called", async () => { + async function* gen() { + yield 1; + yield await Promise.resolve(2); + yield 3; + return 4; + } + const normalIterator = gen(); + const abortController = new AbortController(); + const abortableIterator = abortable(gen(), abortController.signal); + + assertEquals( + await abortableIterator.next(123), + await normalIterator.next(123), + ); + assertEquals( + await abortableIterator.return(321), + await normalIterator.return(321), + ); + assertEquals(await abortableIterator.next(), await normalIterator.next()); +}); + +Deno.test("abortable() does not throw when the signal is already aborted and the promise is already rejected", async () => { + const promise = Promise.reject(new Error("Rejected")); + const signal = AbortSignal.abort(); + await assertRejects( + () => abortable(promise, signal), + DOMException, + "The signal has been aborted", + ); +}); + +Deno.test("abortable() is a no-op when no signal is provided", async () => { + const signal: AbortSignal | undefined = undefined; + const { promise, resolve } = Promise.withResolvers(); + const t = setTimeout(() => resolve("Hello"), 100); + const result = await abortable(promise, { signal }); + assertEquals(result, "Hello"); + clearTimeout(t); +}); + +Deno.test("abortable() supports passing signal through AbortableOptions", async () => { + const c = new AbortController(); + const { promise, resolve } = Promise.withResolvers(); + const t = setTimeout(() => resolve("Hello"), 100); + c.abort(); + const error = await assertRejects( + async () => { + await abortable(promise, { signal: c.signal }); + }, + DOMException, + "The signal has been aborted", + ); + assertEquals(error.name, "AbortError"); + clearTimeout(t); +});