Skip to content

feat(async/unstable): add support for AbortSignal in pooledMap - #7240

Open
tomas-zijdemans wants to merge 12 commits into
denoland:mainfrom
tomas-zijdemans:pool-signal
Open

feat(async/unstable): add support for AbortSignal in pooledMap#7240
tomas-zijdemans wants to merge 12 commits into
denoland:mainfrom
tomas-zijdemans:pool-signal

Conversation

@tomas-zijdemans

Copy link
Copy Markdown
Contributor

Note

Recreates #7014, which was closed automatically when I accidentally deleted the head fork. The branch is identical to the original PR head.

tomas-zijdemans and others added 12 commits February 19, 2026 08:43
… cast

Address review nits on `pooledMap` options overload: expand the JSDoc to
mention that the returned iterator rejects with `options.signal.reason` on
abort or with an `AggregateError` of `iteratorFn` rejections, and drop the
unnecessary `as unknown` cast since `e` is already typed `unknown` under
strict catch typing.

Co-authored-by: Cursor <cursoragent@cursor.com>
@github-actions github-actions Bot added the async label Jul 15, 2026

@bartlomieju bartlomieju left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks — the abort mechanics here are largely sound: throwIfAborted() before any work starts, in-flight promises awaited via Promise.allSettled, signal.reason reaching the consumer, and the listener removed in .finally() so a long-lived signal doesn't accumulate listeners.

The main thing I'd like changed is consistency with pooledMapSettled, which landed in #7015/#7093 and already solves most of this. It takes (array, iteratorFn, options) — options last — which matches every other signal-taking API in the module (delay(), retry(), poll()). This PR instead overloads the first positional argument. Shipping two pool APIs from @std/async with opposite argument orders is a bad outcome for users, and since this one is still unstable it's the one that should move.

Beyond the signature, pooledMapSettled also handles three cases this PR doesn't — abort during the final drain, abort against a slow source iterable, and closing the source iterator on abort. I've left inline notes on each. In general I think the answer for all four points is "do what pooledMapSettled does", so it may be worth reading async/unstable_pool_settled.ts side by side and lifting the structure directly.

A few test cases are missing too: a bare controller.abort() should surface a DOMException named "AbortError" (worth pinning, since it's the web-platform contract), abort after completion should be a no-op, and it'd be good to assert no listener is retained on a long-lived signal.

Comment thread async/unstable_pool.ts
): AsyncIterableIterator<R>;

export function pooledMap<T, R>(
poolLimitOrOptions: number | PooledMapOptions,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This is the consistency problem. pooledMapSettled (async/unstable_pool_settled.ts:87) signs as (array, iteratorFn, options) with options last, matching delay.ts:7, retry.ts:82 and poll.ts:9. Overloading the first positional argument here means @std/async would export two pool functions with opposite argument orders.

Since this module is unstable, I'd rather change it now: keep pooledMap(array, iteratorFn, poolLimit) working and add an options bag in the same trailing position that pooledMapSettled uses.

Comment thread async/unstable_pool.ts
await raceWithSignal(executing);
}
}
await Promise.all(executing);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Abort isn't observed during the final drain. If the source iterable ends and the signal fires while this Promise.all is pending, the iterator resolves successfully rather than rejecting with signal.reason — so a consumer who aborted sees a clean completion.

pooledMapSettled races the drain against the abort signal instead; worth mirroring that here.

Comment thread async/unstable_pool.ts
try {
signal?.throwIfAborted();

for await (const item of array) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Abort can't interrupt a slow source. throwIfAborted() on the next line only runs once it.next() resolves, so aborting while the source is awaiting produces no effect until the source yields — potentially never.

pooledMapSettled (async/unstable_pool_settled.ts:169-173) races it.next() against an abort deferred and calls it.return?.() in a finally. This PR does neither, so on abort the source iterator is also never closed and its cleanup never runs.

Comment thread async/unstable_pool.ts
const s = await p;
controller.enqueue(s);
} catch (e) {
if (signal?.aborted) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Once signal.aborted is true, this branch discards the real rejection and errors the stream with signal.reason instead. If an item genuinely failed around the same time as the abort, that error is lost and the AggregateError path below is skipped — so the caller can't tell "aborted cleanly" from "aborted, and also something threw".

Checking whether the caught error is the abort reason before taking this branch would preserve genuine failures.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants