feat(async/unstable): add support for AbortSignal in pooledMap - #7240
feat(async/unstable): add support for AbortSignal in pooledMap#7240tomas-zijdemans wants to merge 12 commits into
pooledMap#7240Conversation
… 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>
bartlomieju
left a comment
There was a problem hiding this comment.
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.
| ): AsyncIterableIterator<R>; | ||
|
|
||
| export function pooledMap<T, R>( | ||
| poolLimitOrOptions: number | PooledMapOptions, |
There was a problem hiding this comment.
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.
| await raceWithSignal(executing); | ||
| } | ||
| } | ||
| await Promise.all(executing); |
There was a problem hiding this comment.
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.
| try { | ||
| signal?.throwIfAborted(); | ||
|
|
||
| for await (const item of array) { |
There was a problem hiding this comment.
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.
| const s = await p; | ||
| controller.enqueue(s); | ||
| } catch (e) { | ||
| if (signal?.aborted) { |
There was a problem hiding this comment.
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.
Note
Recreates #7014, which was closed automatically when I accidentally deleted the head fork. The branch is identical to the original PR head.