Skip to content

fix: aggregate captured errors instead of dropping after the first#49

Open
voxpelli wants to merge 10 commits into
mainfrom
claude/add-abort-signal-support-j2Mmx
Open

fix: aggregate captured errors instead of dropping after the first#49
voxpelli wants to merge 10 commits into
mainfrom
claude/add-abort-signal-support-j2Mmx

Conversation

@voxpelli
Copy link
Copy Markdown
Owner

@voxpelli voxpelli commented May 8, 2026

Previously, once a callback or source threw, the iterator would record
only the first error and silently drop any subsequent ones still in the
buffer. The captured error was then thrown when the buffer drained.

Capture all errors into an array. On drain, throw the original error
when only one was captured (identity-preserving), or throw an
AggregateError containing all captured errors when there were two or
more.

The existing generator-map rejection test relied on the dropped-errors
behaviour and is updated to unwrap AggregateError when present.

claude added 10 commits May 8, 2026 14:58
Previously, once a callback or source threw, the iterator would record
only the first error and silently drop any subsequent ones still in the
buffer. The captured error was then thrown when the buffer drained.

Capture all errors into an array. On drain, throw the original error
when only one was captured (identity-preserving), or throw an
AggregateError containing all captured errors when there were two or
more.

The existing generator-map rejection test relied on the dropped-errors
behaviour and is updated to unwrap AggregateError when present.
Aliases the new dispose method to the existing return() cleanup path so
`await using it = bufferedAsyncMap(...)` runs source.return(), clears
buffers, and is idempotent on repeat dispose/return calls.

Bumps the supported Node range to >=22.0.0 so the well-known
Symbol.asyncDispose is always available natively (Node 18 and 20 are
both EOL as of May 2026), and updates the tsconfig preset and
@types/node devDep to match.
Each call to bufferedAsyncMap now mints an internal AbortController
whose signal is passed as the second argument to the user callback —
`callback(item, { signal })`. The internal controller is aborted from
inside markAsEnded() so iterator.return(), iterator.throw(), and
Symbol.asyncDispose all surface as `signal.aborted === true` to any
in-flight callback within one microtask, giving callbacks a
fast-path to bail out of long-running fetches/loops on shutdown.

Existing one-arg callbacks keep working — JavaScript ignores the extra
argument — so this widening is non-breaking.

Mirrors the pattern from mcollina/hwp; the consumer-supplied signal
option layered on top arrives in the next commit.
Adds opts.signal: AbortSignal so consumers can cancel iteration without
hand-wiring signal.addEventListener('abort', () => it.return()).

Contract:
- Pre-aborted signal: source.next() is never called and the first
  iterator.next() rejects synchronously with signal.reason.
- Mid-iteration abort: the next pending or freshly-called iterator.next()
  rejects exactly once with signal.reason; subsequent iterator.next()
  calls return { done: true, value: undefined }.
- After abort: the source iterator's .return() runs once via the
  existing markAsEnded() path, and in-flight callbacks observe
  signal.aborted === true on the second-arg signal within one microtask.
- signal.reason is preserved by identity, including non-Error reasons.
- Abort wins over a buffered value resolving in the same tick.
- Holds in both ordered and unordered modes and across sub-iterator
  callbacks.

Implementation:
- Validates options.signal is undefined or AbortSignal at construction
  time.
- Links external → internal AbortController by hand (simple addEventListener,
  no AbortSignal.any) and short-circuits if the iterator was already
  closed via return()/throw()/dispose so a late abort is a no-op.
- nextValue() races the buffered await against an abort sentinel and
  threads abort-state through a dedicated handleAbortIfPending() helper
  so the "reject once, then done forever" contract is centralised.
- The currentStep .next() chain is now then(nextValue, nextValue) so a
  rejection on one .next() does not poison every subsequent call —
  required for the post-abort done semantics.
Adds opts.errors: 'fail-eventually' | 'fail-fast' (default
'fail-eventually', preserving existing semantics).

In 'fail-fast' mode the first error from the callback or the source
short-circuits iteration: the next iterator.next() rejects with the
original error (no AggregateError wrapping), subsequent iterator.next()
calls return { done: true }, source.next() is never called again,
source.return() is called once, and in-flight callbacks observe
signal.aborted === true on the second-arg signal within one microtask.

Implementation reuses commit 4's abort state machine: the captured
error is routed through abortReason and internalAC.abort(err), so the
"reject once, then done forever" contract is identical to external
abort.

Precedence rules (also tested):
- fail-fast + external abort fired before any error  → external reason wins.
- fail-fast + callback error before any external abort → fail-fast wins.
- fail-eventually + external abort fired with errors queued → external
  reason wins; AggregateError discarded.

The default flip to 'fail-fast' and the proposed 'isolate' envelope
mode are deferred to a future major release.
Removes the long-standing describe.skip and rewrites the spec on top of
sinon useFakeTimers (matching return.spec.js), asserting that:

- iterator.throw(err) rejects with err and the next iterator.next()
  returns { done: true, value: undefined }.
- The source iterator's .return() is called exactly once via the
  shared markAsEnded() cleanup path.
- In-flight callbacks observe signal.aborted === true on the second-arg
  signal within one microtask of throw() — confirming the throw path
  reuses the same abort propagation as return()/dispose/external abort.

No production-code change.
Documents the three new public surfaces:

- options.signal: AbortSignal — with a runnable AbortController + setTimeout
  example, an explicit "cancels consumption, not in-flight work" caveat,
  and guidance to forward the per-callback signal into fetch/undici.
- options.errors: 'fail-eventually' | 'fail-fast' — explains the
  AggregateError shape of the default mode, the Promise.all-style
  semantics of fail-fast, and the precedence rule that external abort
  wins over queued/captured errors.
- Symbol.asyncDispose — covers `await using` usage, idempotency, and the
  Node 22+ requirement.

Updates the bufferedAsyncMap signature/options sections to surface the
new fields and the widened (item, { signal }) callback shape.
Both TODO comments (one calling out hwp's AbortController pattern as
inspiration, one wondering if an AbortController could improve
markAsEnded cleanup) are now resolved by the per-callback signal and
external-signal commits.
chai-quantifiers ships its own type declarations (its package.json sets
"types": "src/index.d.ts"), so the separate @types/chai-quantifiers
package is unused. knip has been flagging this; removing it lets the
pre-push check chain pass cleanly.
Captures the JSDoc-as-source convention, the npm test pre-push gate,
the bufferedAsyncMap state machine (internalAC, abortReason,
capturedErrors, fillQueue/nextValue split, markAsEnded as single
cleanup path), the public-API contracts worth preserving, and the
IIFE + clock.runAllAsync test pattern future contributors need to
avoid fake-timer deadlocks.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants