feat!: v2.0.0 — AbortSignal, Symbol.asyncDispose, error modes, cleanupTimeout#50
Open
voxpelli wants to merge 82 commits into
Open
feat!: v2.0.0 — AbortSignal, Symbol.asyncDispose, error modes, cleanupTimeout#50voxpelli wants to merge 82 commits into
voxpelli wants to merge 82 commits into
Conversation
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.
ba54b28 to
1b45e12
Compare
Layers minimal refactors on top of the abort-signal feature commits:
- Add normalizeError helper (lib/misc.js) and use it in all 5 fillQueue /
fail-fast catch sites
- Extract isValueObject helper in lib/type-checks.js (DRY for isIterable /
isAsyncIterable)
- Add bounds check to ordered-insertion while loop in fillQueue so the
BufferPromise type cast is honest past array end
- Inline null-safety on the IteratorResult shape check
(`!result || typeof result !== 'object'`); typeof null === 'object' would
have let null through to the next-line property accesses
Drops the larger-scope tweaks from the harden / efficiency branches that
weren't earning their keep:
- @voxpelli/typed-utils runtime dep (kept the lib zero-dep; the only
material correctness win — null check on IteratorResult — is inlined)
- yieldArrayWithItem generator (replaces a 1-element array allocation in
the common case with a generator allocation; not a measurable win and
less readable than the spread)
- guardedArrayIncludes (the type cast in isPartOfArray is honest enough)
BREAKING CHANGE: engine requirement bumped from >=18.6.0 to >=22.0.0 (native
Symbol.asyncDispose is required). Callback signature widened from
`(item)` to `(item, { signal })`; existing one-arg callbacks keep working
since JS ignores extra args, but TypeScript consumers that pass a callback
type with a strict single-parameter signature may need to update the type.
1b45e12 to
1d87f8e
Compare
- Add normalizeError(err, defaultMessage) to lib/misc.js entry, with a reuse hint to avoid open-coding the err-instanceof-Error pattern. - Add isObject to lib/type-checks.js entry; note it closes the typeof null === 'object' hole and is what isAsyncIterable / isIterable / the IteratorResult shape check are now built on.
…variants
- Hoist raceAbort() into a single abortPromise created at construction so
the {once:true} listener count does not grow per consumer pull. Flatten
the per-call Promise.race so buffered promises and abortPromise live in
one input array.
- Use IteratorYieldResult<R> as the cast type for the yielded result
(still {value} at runtime — the lib type's done is optional false, and
two existing specs assert on the loose shape).
- Rename local normalisedErr -> normalizedErr to match the helper name.
- Document the load-bearing invariants inline (why internalAC is
unconditional, why abortPromise is shared, why return() bypasses the
currentStep chain) and expand JSDoc on bufferedAsyncMap, markAsEnded,
fillQueue, nextValue, and handleAbortIfPending so future readers don't
have to re-derive them from tests.
Refresh CLAUDE.md to reflect the shared abortPromise and add an
"Implementation invariants worth preserving" block plus a "Style notes"
note (zero-runtime-dep / one-listener-per-call / unconditional-internalAC,
helper/local spelling convention).
Autofix from npx eslint --fix; pre-existing warning unrelated to the preceding logic change.
- Rename internalAC -> internalAbortController in index.js and CLAUDE.md (the variable was short for AbortController; the abbreviation overloaded with "AC X.Y" acceptance-criteria refs and was hard to read). - Strip "AC X.Y(.Y)?(+...)?: " prefix from 41 it() descriptions across test/abort.spec.js, test/dispose.spec.js, test/errors.spec.js, test/errors-fail-fast.spec.js, test/per-task-signal.spec.js, and test/throw.spec.js. Acceptance-criteria numbering isn't persisted outside the test labels themselves so the prefixes don't add value. - Replace AC-numbered prose in index.js comments and the CLAUDE.md invariants block with descriptive references to the spec files.
mergeIterables previously only accepted bufferSize. Widen its options bag to forward signal, errors, and ordered through to bufferedAsyncMap. Pure pass-through — no new code paths, no test breakage. Existing "should process iterables in parallel" still passes unchanged because it doesn't specify ordered. Also lands the sensible follow-ups from the /review pass: - More specific "Expected ... iterator next() result to be an object" TypeError messages so the stack distinguishes subiterator vs source iterator protocol violations. test/values.spec.js updated. - README: document the new mergeIterables options; clarify that in-flight callbacks see signal.aborted within one microtask of iterator close (but Promises can't be cancelled); add an explicit "Requirements: Node.js >=22.0.0" line. Fix "simultanoeus" / "ordinare" typos. - test/values.spec.js: three new specs covering signal abort, fail-fast error mode, and ordered:true merging.
…ofix sort - .github/workflows/nodejs.yml: node-versions 18,20,21 -> 22,24. The previous matrix was below the engines floor (>=22.0.0) and only passed because `[Symbol.asyncDispose]` becomes the string key "undefined" when the global symbol is absent — so the dispose specs never exercised the real path. Node 22 (current LTS) and Node 24 are now in matrix. - index.js: sort the mergeIterables type and inner option object so `errors` precedes `ordered`/`signal` alphabetically; lands the perfectionist/sort-objects auto-fix. - test/values.spec.js: rewrite the "ordered: true" mergeIterables spec with asymmetric timing (first source 10x slower than second). Under the default ordered:false this would interleave second-* values between first-1 and first-2; under ordered:true the first iterable drains completely first. The previous symmetric-timing version would have passed even if `ordered` had been silently dropped.
There was a problem hiding this comment.
Pull request overview
This PR extends bufferedAsyncMap (and the mergeIterables wrapper) with cancellation and improved error surfacing by adding AbortSignal support, a configurable fail-fast error mode, and Symbol.asyncDispose for deterministic cleanup.
Changes:
- Add
options.signalcancellation, a per-callback{ signal }argument, and iterator support forSymbol.asyncDispose. - Add
errors: 'fail-fast' | 'fail-eventually'mode and normalize/aggregate error reporting semantics. - Update docs, Node engine/CI targets, and expand test coverage for abort, disposal, per-task signals, and fail-fast behavior.
Reviewed changes
Copilot reviewed 14 out of 15 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| tsconfig.json | Updates TS config baseline (extends Node 20 config). |
| package.json | Bumps Node engine to ≥22 and updates type deps. |
| index.js | Implements abort handling, per-task signal propagation, fail-fast mode, and Symbol.asyncDispose. |
| lib/type-checks.js | Adds isObject() and refactors iterable guards to use it. |
| lib/misc.js | Adds normalizeError() helper for non-Error rejections. |
| README.md | Documents new options (signal, errors), per-task cancellation, and resource management semantics. |
| .github/workflows/nodejs.yml | Updates CI Node matrix to 22/24. |
| CLAUDE.md | Adds repo guidance and documents the iterator state machine/contracts. |
| test/values.spec.js | Adjusts expectations for single-error vs AggregateError behavior and updated TypeError messages. |
| test/throw.spec.js | Enables/updates throw() tests and adds signal/cleanup assertions. |
| test/per-task-signal.spec.js | Adds coverage ensuring per-task signals exist and abort on close paths. |
| test/errors.spec.js | Adds coverage for fail-eventually error identity vs AggregateError. |
| test/errors-fail-fast.spec.js | Adds comprehensive fail-fast behavior, source cleanup, and abort interaction tests. |
| test/dispose.spec.js | Adds tests for Symbol.asyncDispose cleanup/idempotency. |
| test/abort.spec.js | Adds abort semantics tests including “reject once then done”, cleanup, and race coverage. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Previously handleAbortIfPending threw synchronously when an abort was
pending fresh delivery, propagating out of nextValue (async) as a
rejected promise before the await markAsEnded() lines ran. For direct
.next() consumers this meant source.return() wasn't called until either
a subsequent .next() or an explicit iterator.return() — the for await
loop's implicit cleanup-on-exception papered over the issue, but the
abort-delivery contract was not actually carrying its own cleanup.
Restructure handleAbortIfPending to return a discriminated descriptor
({kind:'throw', reason} | {kind:'done'} | undefined) instead of
throwing. The two call sites in nextValue (early-abort check and
post-race branch) now run markAsEnded() before propagating the throw,
matching the fail-fast branch's ordering.
Pin the new contract in test/abort.spec.js by asserting
returnSpy.calledOnce after the first rejecting .next() resolves, before
the drain call that previously masked the issue.
Also reword README §"Resource management": Symbol.asyncDispose is
equivalent to iterator.return() for cleanup, not literally aliased (the
dispose method has its own body returning Promise<void> per the
AsyncDisposable contract).
Resolves copilot-pull-request-reviewer comments on PR #50.
- .knip.jsonc: drop the stale `entry` array (no benchmark/ dir; index.js is auto-detected from package.json exports) — knip no longer prints configuration hints on every check run. - index.js: remove the stale `// TODO: Add "throw"` comment that sat directly above the implemented throw() method; document the invariant behind the currently-unreachable ordered-insertion loop body so the coverage gap reads as intentional. - test: cover normalizeError's non-Error branch via a callback that rejects with a non-Error value; lib/misc.js is now at 100%. PR description rewritten and the two copilot-pull-request-reviewer threads resolved separately on GitHub.
The input-shape dispatch checked isIterable first, so an object
implementing BOTH Symbol.iterator and Symbol.asyncIterator was consumed
via its sync iterator — the opposite of for-await's GetIterator(async)
preference. For hybrid inputs with distinct sync/async semantics (e.g. a
collection whose sync iterator snapshots while its async iterator
streams), the library silently processed different data than a plain
for-await loop would. Check isAsyncIterable(input) first; the
sync-iterable wrap only applies to inputs with no async protocol at all.
Also tighten the construction guard to require a CALLABLE
Symbol.asyncIterator: 'in'-presence alone let { [Symbol.asyncIterator]:
null } through validation only to crash at invocation with an unbranded
'is not a function' instead of the descriptive TypeError the validation
line promises.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FSyTJjbP823pjYfueuSsdu
The docs claim eager validation, but only the outer array was checked: mergeIterables([goodGen, null]) constructed fine and the `yield * null` TypeError surfaced only when that element was first pulled — in default fail-eventually mode that means after every healthy source fully drained, possibly minutes later and wrapped in an AggregateError. A programming mistake was deferred and disguised as a runtime stream error. Validate at call time: the input must be an array (the documented shape since the 1.x typings) and every element an (async) iterable or array, with the TypeError naming the offending index. String elements are rejected on purpose — strings satisfy the iterable protocol, so 'abc' silently merged as 'a','b','c' (char explosion) when a caller almost certainly meant something else; the error says to spread the string if chars are genuinely wanted. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FSyTJjbP823pjYfueuSsdu
Three cleanups with identical behavior, gated by the existing
abort/error suites:
* requestConsumerAbort(reason) is now the single writer for the
'record abortReason + abort the internal controller with the same
reason' pairing that was open-coded at three sites (pre-aborted
branch, external-abort listener, fail-fast). A future fourth abort
source can no longer set one half and silently diverge the
per-callback signal's reason from the consumer-facing rejection.
* deliverAbort() is the one delivery sequence, shared by nextValue's
pre-race and post-race sites — the two copies of the consume-
descriptor / run-cleanup / throw-or-done ritual had to be kept in
lockstep by hand. The pre-race guard also drops a per-pull function
call from the hot path (plain abortReason check).
* handleStreamError's fail-fast branch loses its provably-dead
delivered re-check and unreachable { done: true } return: delivery is
single-flight on the currentStep chain, so nothing can flip the flag
during the markAsEnded await. Its callers drop the correspondingly
dead 'if (handled) return handled' guards; the function now throws
(delivery) or captures (fail-eventually), nothing else.
Bench: hot-path and delivery benches scatter within the established
noise band, no directional movement.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FSyTJjbP823pjYfueuSsdu
drainOrContinue was defined inside nextValue, allocating a closure on every delivered item even though only the terminal error/done paths ever call it — one throwaway function object per item on exactly the loop CLAUDE.md marks as hot. Hoisted next to deliverAbort with the single per-pull input (fromSubIterator) as a parameter; everything else it reads is stable closure state. Bench: throughput groups within noise. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FSyTJjbP823pjYfueuSsdu
* stubAsyncIterator (byte-identical in values.spec.js and yield.spec.js), fromArray (identical in errors.spec.js and errors-fail-fast.spec.js) and the four open-coded AggregateError-unwrap expressions move to test/utils.js — the documented home for shared test helpers — as stubAsyncIterator, fromArray and unwrapCapturedError. * Three specs that pinned behavior already pinned elsewhere are dropped: throw.spec.js's signal.aborted-after-throw() (owned by per-task-signal.spec.js), errors-fail-fast.spec.js's default-mode AggregateError check (errors.spec.js pins the same fixture with stronger assertions, also on the default mode), and abort.spec.js's fresh-next-after-abort (literally the first iteration of the exactly-once spec below it). * The cleanupTimeout timer-leak assertion becomes a before/after countTimers delta, pinning 'close leaves no timer behind' instead of 'nothing else in the process owns a timer right now'. * test/utils.js joins the type-coverage ignore list (test/**/*.js) — same rationale as the spec files; it is still tsc-strict checked. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FSyTJjbP823pjYfueuSsdu
BufferedAsyncIterableIterator deliberately doesn't extend AsyncIterableIterator (an intersection leaks the lib's any-typed method signatures), and its structural assignability was a comment-only claim — a future TypeScript lib change tightening the iterator types could have silently broken consumers passing the iterator to for-await-typed APIs. The new spec's @type-annotated assignments make tsc (and therefore npm run check) fail the moment that assignability breaks, plus a runtime sanity pass over the promised members. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FSyTJjbP823pjYfueuSsdu
* CLAUDE.md: engines line matches package.json (^22.16.0 || >=24.0.0); the architecture section reflects this wave's state machine — two reject-proof envelope shapes with the ERR symbol tag, pendingCloses, iterative fillQueue, await-idempotent markAsEnded/doCleanup with the first-closer error throw, requestConsumerAbort/deliverAbort, the stale-abort suppression after explicit close, hoisted drainOrContinue; type-coverage exclusion glob updated to test/**/*.js. * benchmark/abort.js: the group comment described 'a shared abortPromise' as the current design — the exact long-lived-promise pattern CLAUDE.md forbids and the per-pull park replaced; reworded (flagged by three review angles as already-drifted documentation). * README: 'external abort always wins' qualified to queued / not-yet-captured errors (fail-fast first-event-wins is pinned by spec and verified coherent); new 'Semantics worth knowing' block — eager prefetch at construction, next(v) values ignored, throw() always terminal (never forwarded to the source), same-realm expectations for AbortSignal/Error instances; Cancellation notes that abort delivery waits for source cleanup (cleanupTimeout bounds it); mergeIterables section documents element validation and string rejection. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FSyTJjbP823pjYfueuSsdu
The reject-proof invariant had one hole left: the envelope discriminator itself. `ERR in result`, `result.done` and `result.value` ran outside any try, so a source resolving a Proxy with a throwing has-trap or a result with a throwing done/value getter rejected the raw bufferPromise. Repro'd consequences: process-fatal unhandledRejection when the slot was spliced un-raced; when raced, EVERY subsequent next() rejected with the same error forever while the source's .return() was never called and the per-callback signal never aborted. Native for-await surfaces these as consumer errors (IteratorComplete/IteratorValue are ?-propagating) — the same defect class as CVE-2024-7652, where an unexpected property access on an iteratorResult broke async-generator promise resolution at engine level. The fix (adversarially reviewed, integration-prototyped against the full suite before landing): * Classification via a reads-only try in both fillOneSlot arms — the try contains ONLY the foreign-object reads, classifying into kind/stepErr/ stepValue locals; ALL bookkeeping and envelope construction sits outside, so our own bugs still crash loudly instead of masquerading as stream errors. An unreadable result routes to the malformed arm (still owed a cleanup-time .return() — the fulfilled-but-unusable case), a rejecting next() to the error arm (closed per protocol). * safeStep() + module-level catch-normalizers: the sync-throw-safe .next() wrapper lives once, with the rejection-normalizer attached in the same synchronous frame; hoisting the two catch callbacks to module scope removes two per-slot closure allocations. * recordPendingClose(): includes-dedupe + isDone gate shared by both arms — fixes the sub-iterator arm calling a non-idempotent .return() once per in-flight malformed slot (4x observed), and stops late malformed resolutions from re-populating the spliced pendingCloses array. * nextValue's isAsyncIterable re-check moves inside the invocation try (a throwing has-trap there escaped raw, bypassing the errors mode) and a falsy [Symbol.asyncIterator]() result now yields the value as-is — which also fixes a livelock: a null iterator previously entered subIterators and the refill loop starved the event loop permanently. * A throwing value-getter on a main-arm result is now attributed to the source read ('Failed to read source iterator next() result') instead of being accidentally caught by the callback-dispatch try and mislabeled 'Unknown callback error' with the source left open. Envelope shapes are unchanged (the two-hidden-class invariant holds — new failure paths reuse terminalEnvelope). Specs: 10 new hostile-result specs incl. the thenable-hybrid behavior-preservation pin; 7 discriminate against the pre-fix build (6 fail + the livelock spec hangs the process there). Perf: interleaved A/B vs HEAD sign-flips within ±2.5%; --trace-deopt shows zero deopts in the rewritten handlers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FSyTJjbP823pjYfueuSsdu
The input dispatch probed protocol presence with the 'in' operator and
read the Symbol.asyncIterator member twice (typeof check + invocation).
Both diverge from for-await's GetIterator(async), which does ONE [[Get]]
via GetMethod (ECMA-262 §7.4.4/§7.3.10): a null/undefined member means
ABSENT (fall back to the sync protocol), non-nullish non-callable throws,
and the read value is what gets called. Matrix-verified against the
native oracle: pre-fix, { [Symbol.asyncIterator]: null } (or an
undefined-valued data property) plus a sync protocol threw at
construction where for-await iterates the sync sequence; a Proxy whose
has/get traps disagree desynced presence from value both ways; and a
stateful getter passed validation then crashed unbranded at invocation.
The dispatch now captures the member once, validates it, and — only
after every other construction check has passed — invokes the CAPTURED
method (receiver preserved); the sync arm re-invokes its captured method
through a shim so the input object is read exactly once, same as
for-await. String primitives stay rejected (the one deliberate
divergence); a boxed String as the INPUT still iterates chars, matching
for-await 4-way (only merge ELEMENTS reject boxed strings, where
heterogeneous arrays make accidental strings likely — the docblock notes
the asymmetry).
mergeIterables element validation is now GetMethod-faithful too: a
non-callable protocol member ({ [Symbol.iterator]: 42 }) is rejected
eagerly with its index instead of surfacing minutes later as a deferred,
unbranded, possibly-AggregateError-wrapped consume-time TypeError after
every healthy source drained — the exact failure mode the eager
validation exists to kill. Boxed String elements (including cross-realm
ones, where instanceof String is blind — hence the
String.prototype.toString brand check) no longer slip past the primitive
check and char-split silently. The element JSDoc gains '& object' on the
Iterable arm so TS consumers get a compile-time signal for primitive
strings; mirrored on bufferedAsyncMap's input type.
isIterable in lib/type-checks.js lost its last caller and is removed
(knip gates unused exports); isAsyncIterable is untouched — the
callback-result classification sites depend on its presence semantics.
Existing specs all pass unchanged (the two pinning fixtures have no sync
fallback); 4 of the 5 new specs fail on the pre-fix build.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FSyTJjbP823pjYfueuSsdu
handleStreamError's fall-through pushed to capturedErrors even in fail-fast mode when abortReason was already set. That push was dead storage: nothing can ever drain-throw capturedErrors in fail-fast (drainOrContinue's drain branch requires no pending abort, and every later markAsEnded call is a non-first closer), so the error silently vanished while looking captured-for-delivery — and any future drain path would have turned it into a second rejection. Adversarial analysis corrected the original rationale: the ordinary abort-timing window cannot reach this state (both handleStreamError call sites are synchronously downstream of the post-race abort re-check — verified across 21-offset sweeps with zero hits). The only reachable route is synchronous user re-entry: a callback result whose [Symbol.asyncIterator]() body aborts the signal and then throws. The capture now only happens in fail-eventually mode, with the fail-fast drop made explicit and documented (Promise.all parity: the committed shutdown reason owns the single rejection). Behaviorally identical across 55 probe scenarios; fillQueue's stop-pulling guard was already covered by abortReason in every reachable case. Specs: the sync-re-entry shape (abort reason delivered exactly once, the re-entry error dropped) and two racing fail-fast errors (first wins, second discarded at the envelope level — previously unpinned). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FSyTJjbP823pjYfueuSsdu
…reason oracle The sweep spec had silently lost its teeth: reverting drainOrContinue's abort-pending guard no longer produced a second rejection (the closed-iterator suppression converts it to done), so every offset passed on the broken build — the guard's remaining observable defect is rejection IDENTITY (the abort reason losing to the captured callback error despite winning the commit race), which the spec never asserted. A naive fired-before-observed ordering assertion is unsound: verified empirically, the drain-throw commits synchronously but its rejection settles many microtasks later, so an abort can fire first and still correctly lose at 8 of 16 offsets on the CORRECT build. The sound commit-point oracle is the per-callback signal's reason, sampled after settlement: requestConsumerAbort writes the external reason, a plain close aborts the internal controller bare, whichever aborts first wins and the reason is immutable — abortWon ⟺ the delivered rejection must be the abort reason. Adjudicated over the alternative listener-count oracle (less implementation coupling, immutable post-settlement sampling, asserts the library's own documented reason-pairing contract through public API). Verified: passes offsets 0-10 on the fixed build; fails at exactly the guard's window (offset 7, identity mismatch) with the guard reverted. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FSyTJjbP823pjYfueuSsdu
- README: the "rejects with signal.reason exactly once" promise gains the explicit-close suppression carve-out (matches native AsyncGenerator's request-queue semantics); the Cancellation section's "always wins" is qualified to queued / not-yet-captured errors, pointing at the Errors section for the committed-fail-fast exception; note pre-aborted signals; one line on large-buffer per-pull cost under bufferSize. - CLAUDE.md: same precedence fix in the contracts bullet; cleanupTimeout invariant repointed at doCleanup (where the race actually lives); NEW invariant bullet pinning the two-envelope-shape design (~12-17% regression when unified) and the never-reject guarantee; stale rejectedWith test-convention claim replaced with the shared collectNextOutcomes/expectSingleRejectionThenDone helpers; cite @watchable/unpromise as completion-queue prior art next to the per-pull park invariant. - index.js comments only: drainOrContinue docblock reframed around rejection identity (the sweep's actual observable); markAsEnded(true) drain call gets the convention-mandated boolean comment; the abort listener guard is re-commented as belt-and-braces naming the addEventListener signal: option as the actual detach mechanism; doCleanup's reasonless abort() documented as deliberate; envelope comment number reconciled with the measured A/B (~12-17%). - test/per-task-signal.spec.js: restore the aborted === false pre-close assertion in the throw() spec, matching its return() twin. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FSyTJjbP823pjYfueuSsdu
deliverAbort was handleAbortIfPending's only caller, and the
{ kind: 'throw' | 'done' } descriptor protocol existed solely to keep the
claim non-throwing so cleanup could run before the reason propagated.
With the two folded together the claim ordering is visible in one
function: claim synchronously (delivered = true plus the isDone
suppression read, no await before it, so concurrent pulls can't both
win), await markAsEnded(), then throw the claimed reason or resolve
done. Behaviour is unchanged — the abort sweep, suppression and
exactly-once specs all pin it.
Also updates the construction-time listener comment and the CLAUDE.md
dispatcher paragraph that described the deleted descriptor protocol.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FSyTJjbP823pjYfueuSsdu
…ures - Replace the hand-rolled outcome-accumulator IIFEs in errors-fail-fast.spec.js (5 sites), abort.spec.js (exactly-once spec + the drain-race sweep's collection loop) and values.spec.js (merge fail-fast spec) with collectNextOutcomes / expectSingleRejectionThenDone from test/utils.js. The sweep keeps its custom per-offset identity assertions (the helper's messages lack the offset context); the exactly-once spec keeps its stronger rejection-lands-first check on top of the helper. - fromArray becomes a re-export of lib/misc.js's makeIterableAsync — the fixture was an exact twin of the library's own sync-to-async shim. - The very-large-bufferSize spec drops from 100_000 to 20_000: ~3x the measured ~7000-frame recursion crash point at roughly a tenth of the runtime, same regression power. The "deliberately NOT exercised" caveat block stays. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FSyTJjbP823pjYfueuSsdu
Review-round follow-ups, all consequence-free for runtime behavior: - index.js: the mergeIterables docblock falsely claimed one-shot getter members are unsupported "same as the main input" — the main input captures its member on a single read and invokes it (pinned by the reads === 1 spec); elements are deliberately stricter. Also add the convention-mandated drain-boolean comment to drainOrContinue's markAsEnded(true) call site. - CLAUDE.md: drop the deleted isIterable from the lib-helpers inventory. - benchmark/fixtures.js: syncRange's docblock described the removed isIterable dispatch branch; it now names the captured-Symbol.iterator shim actually under measurement. - test/utils.js: expectSingleRejectionThenDone now rejects extra enumerable keys on post-rejection results, restoring the strictness of the deep-equal assertions it replaced (a leaked internal envelope with done/value looking right must still fail). - test/values.spec.js: finish the merge fail-fast spec's migration — expectSingleRejectionThenDone pins exactly-once + done-forever, which the hand-rolled tail never asserted. - test/hostile-results.spec.js: reuse promisableTimeout instead of an open-coded setTimeout promise, and correct the fixture docblock's "all timer-free" claim (the file uses one real 20 ms delay; fake timers would deadlock it). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FSyTJjbP823pjYfueuSsdu
Two classification defects in the fillOneSlot arms, both probe-confirmed against native for-await: - A Proxy result whose has-trap answers true for every key spoofed the private ERR-symbol check and was classified as an internal error envelope carrying an undefined error: iteration ended silently mid-stream (no rejection, values dropped) and the still-open source was never .return()ed. The ERR tag is now brand-verified — kind 2 requires the same-realm Error every catch handler attaches via normalizeError (which wraps cross-realm throwables, so the check is loss-free for internal envelopes). A spoofed tag falls through to the done/value reads, matching what native for-await does with such a proxy: falsy done, yield the (undefined) value, keep pulling. A proxy that additionally forges an Error under the symbol read surfaces a loud stream error — equivalent to a rejecting next(); no silent path remains. Throwing has/get traps keep their existing kind-1 routing. - ECMA-262 only requires Type(IteratorResult) is Object, which includes function objects; isObject rejected callables as malformed where native for-await yields their value. Both arms now classify with the new isSpecObject from lib/type-checks.js (callables included), and isObject becomes an internal (unexported) building block since no module-external consumer remains. The twin classification blocks gain explicit keep-in-sync cross-references. bufferSize-scaling benches at or below prior baselines. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FSyTJjbP823pjYfueuSsdu
The dispatch that decides whether a callback result fans out as a nested
iterable used an `in`-presence probe plus a separate re-read invoke,
producing three inconsistent outcomes for the same shapes (all
probe-confirmed):
- `{ [Symbol.asyncIterator]: undefined }` from a sync callback threw an
unbranded TypeError that leaked an internal variable name, while the
identical object from an async callback was yielded as a value.
- A callable member returning `null` yielded the hybrid as data (the
wave-2 livelock fix), while one returning `42` surfaced later as an
unbranded 'Unknown subiterator error'.
It now follows GetMethod: one [[Get]] of the member (a has-trap is never
consulted, matching for-await); callable -> the captured method is
invoked and a non-object return is a branded stream TypeError (for-await
parity, per maintainer decision — this supersedes the wave-2
yield-as-value pin while keeping its no-livelock guarantee); nullish or
non-callable -> the result is plain data and is yielded, from sync and
async callbacks alike.
Spec updates: the null-iterator pin now asserts the branded rejection;
new pins for the truthy non-object iterator, the sync/async consistency,
and the throwing-has hybrid (only [[Get]] is consulted, so it yields;
a throwing member READ still routes through the errors mode).
Accepted residual: a get-trap-throwing proxy reaches this dispatch only
from a sync callback (the envelope flag is probed pre-await); from an
async callback it is yielded unread. Non-silent on both paths;
eliminating it would need an envelope-shape change.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FSyTJjbP823pjYfueuSsdu
GetIteratorFromMethod parity: when the captured Symbol.asyncIterator
method returns a non-object, native for-await throws an immediate
TypeError ('Result of the Symbol.asyncIterator method is not an
object'). The pipeline instead accepted it at construction and the
failure surfaced later as a deferred, unbranded AggregateError wrapping
'Unknown iterator error' (probe-confirmed). Construction now throws the
branded TypeError on the spot.
The sync arm is deliberately not eagerly checked: its captured method is
invoked lazily inside the makeIterableAsync shim (an eager invocation
would add a construction-time side effect) and for-of brands its own
TypeError at that point.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FSyTJjbP823pjYfueuSsdu
The three input-validation sites shared one string ('Expected
asyncIterable to have a Symbol.asyncIterator function') that was
misleading for every case it covered:
- a plain object lacking both protocols was told only about
Symbol.asyncIterator, even though adding Symbol.iterator would have
been accepted -> now 'Expected asyncIterable to have a callable
Symbol.asyncIterator or Symbol.iterator';
- a string input HAS a callable Symbol.iterator, so any protocol-naming
message is false for it -> now a dedicated message mirroring the
mergeIterables string guidance ('spread it first if iterating
characters is intended');
- a non-nullish non-callable Symbol.asyncIterator member deliberately
has NO sync fallback (GetMethod), so naming both protocols would
mislead -> now 'Expected the Symbol.asyncIterator member to be a
function'.
All four existing pins updated; the string rejection gains its first
direct pin.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FSyTJjbP823pjYfueuSsdu
- README mergeIterables: the quoted eager-validation error prefix was
stale ('Expected input[1] to be ...'); quote the actual callability
message.
- README bufferSize: claim what the regression spec pins (20 000) —
'hundreds of thousands' exceeded any tested size after the spec was
right-sized.
- bufferedAsyncMap JSDoc: qualify the per-callback signal's
source-exhaustion bullet — the abort fires during end-of-stream
cleanup after in-flight work drained, so callbacks never observe
aborted === true mid-flight in that case (matching the pinned spec);
the hover-doc previously implied a reachable fast-path there.
- test/abort.spec.js: retitle the 'behaves identically to commit 3'
spec — the branch-internal breadcrumb is meaningless post-merge; the
title now states the actual pin.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FSyTJjbP823pjYfueuSsdu
fillQueue's refill loop kept calling the main iterator's next() after a
done result had already resolved and been classified — up to bufferSize
extra pulls that native for-await would never make. Against a defensive
source ("cursor already closed") those post-done pulls surfaced as
spurious captured errors (an AggregateError of identical errors at
default options); a post-done next() returning a never-settling promise
wedged the consumer forever; and in fail-fast mode a spurious post-done
error could win the unordered race against a slow in-flight value and
silently drop it.
fillOneSlot now signals whether it filled a slot, and returns false
without pulling when the dispatch pick falls through to a main iterator
that has already reported done — fillQueue stops refilling and the
buffered slots drain on their own. Sub-iterators need no equivalent
guard (a done sub leaves the rotation in its own classification arm),
and the construction-time speculative fill is unchanged: up to
bufferSize concurrent pulls before the first result resolves remain the
library's documented prefetch contract (README now spells out the
implication for sources that throw on concurrent or trailing next()).
The over-pull reproduces byte-for-byte on 1.x, so this is an inherited
gap rather than a 2.0.0 regression — but the fail-fast value-drop
manifestation is new with the errors option, hence fixed here. Found by
the adversarial go/no-go review pass via live differential probes
against native for await.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FSyTJjbP823pjYfueuSsdu
- New "Why" opener: the gap between sequential for-await and array-bound concurrency helpers, what bounded buffering/backpressure buys, and two explicit reach-for-something-else cases. - Usage examples are now concrete and complete: a paginated-API fan-in as the lead example, an async-generator fan-out over directories, and a first mergeIterables example (previously undocumented by example). - The deep contract notes (speculative prefetch model, iterator-protocol details, abort delivery/precedence, error-mode mechanics, memory guarantees) move to ADVANCED.md, each README section keeping a short summary plus an anchored link. Section anchors referenced from code comments (Cancellation, Errors, Resource management) are unchanged. (Root-level file rather than docs/ — the boilerplate .gitignore reserves /docs.) - The Performance section drops the pre-release-relative optimisation history (internal baselines no consumer ever saw); the three consumer-relevant findings stay, plus a new long-stream memory bullet. - Similar modules gains p-map and web/Node streams with one-line positioning against each. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FSyTJjbP823pjYfueuSsdu
The README links to it per section; including it in files means the links have a local target inside node_modules, not only on GitHub. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FSyTJjbP823pjYfueuSsdu
Both files live at the repo root, so ../README.md 404s on GitHub and npm. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FSyTJjbP823pjYfueuSsdu
Three explorer agents (new-consumer, reference-accuracy, ecosystem conventions) proposed 23 improvements; an adversarial gatekeeper re-verified every premise and killed or trimmed 9 of them (TOC, second lead example, inline recipes, error-message appendix and other re-bloat of the just-leaned README). The 14 survivors: - declaration.tsconfig.json: removeComments false — the base @voxpelli/tsconfig strips ALL JSDoc from the emitted index.d.ts, so editor hover showed bare signatures; the published declarations now carry the documented contracts (verified by emitting). - index.js: @param/@returns one-liners with inline defaults on the two exported functions, now that they reach consumers. - README accuracy: the API summary no longer reads as "only the first bufferSize items"; the ordered bullet is defined non-circularly (delivery order, concurrency unchanged); the least-targeted load-balancing claim is qualified as unordered-only; string inputs noted as eagerly rejected ("ordinary iterable" overclaimed); the p-map Similar-modules bullet corrected — pMapIterable does accept async-iterable input, so the honest differentiators are strict-ordering-only, no fan-out/merge, no dispose/abort contract. - README structure: one-line npm-install fence at the top of Usage; mergeIterables options deduplicated down to `ordered` plus a cross-link (removing already-drifted bufferSize wording); the thin-wrapper sentence now states the exact bufferedAsyncMap equivalence. - ADVANCED.md: new "Ordered mode" section (delivery-vs-dispatch, generator-value contiguity, abort/error parity — all spec-pinned); concurrent next() request-queue paragraph; the same-realm note extended to non-Error throw normalization (.cause carries the original). - package.json: description replaced with the README tagline (the old one predated v2); keywords expanded with the niche's actual search vocabulary. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FSyTJjbP823pjYfueuSsdu
This was referenced Jul 7, 2026
The Performance section stated unconditionally that the internal Promise.race grows with the buffer, but ordered mode races only the head entry against the park — O(1) per pull regardless of bufferSize. The options-list and ADVANCED.md wordings were already qualified; this brings the third mention in line. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FSyTJjbP823pjYfueuSsdu
The ordered-mode docs stated that callbacks run concurrently up to bufferSize without qualification, but that only holds for plain-value callbacks. Async-generator callbacks run strictly one at a time in ordered mode: the buffer always feeds from the current sub-iterator, so bufferSize only buffers the undispatched generator objects and does not step them concurrently. - ADVANCED.md "Ordered mode": scope the concurrency claim to plain-value callbacks and spell out the generator exception (dispatch, not just delivery), with the "return a value instead of yielding" workaround. - README.md: qualify the `ordered` option note and add a Performance bullet pointing at the same limitation. - test/values.spec.js: pin the serialization directly (maxInFlight === 1 for a generator callback under ordered:true) so the behaviour is deliberate rather than only implied by the nested-timing spec. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FSyTJjbP823pjYfueuSsdu
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The full 2.0.0 release: reconciles three feature branches, then layers on review-driven hardening across five deep review cycles plus a final two-agent go/no-go audit (the last three cycles: multi-angle finder fleets + adversarial design verifiers + architects + primary-source research + judges settling design contradictions + an integration prototyper executing core fixes before they landed, with every confirmed finding probe-verified against native
for awaitbefore fixing), a benchmark suite with measurable perf wins, memory-retention fixes, full JSDoc type tightening, a restructured README + advanced-semantics doc, and a modernised toolchain. ~78 commits, 141 specs.Features
Cancellation —
options.signalAbortSignal. When aborted, the next pending or freshly-callediterator.next()rejects withsignal.reasonexactly once; subsequent calls return{ done: true, value: undefined }..return()runs — and is awaited — as part of abort delivery. External abort wins over queued/not-yet-captured errors; an abort left undelivered when the consumer explicitly closes the iterator is suppressed (laternext()resolves done, matching nativeAsyncGenerator).Per-callback signal —
callback(item, { signal }){ signal }as a second argument that is always present (even with nooptions.signal) and aborts on iterator close —return()/throw()/Symbol.asyncDispose/ source exhaustion / external abort / first fail-fast error. Forward it intofetch/undicito cancel in-flight IO on shutdown. (On natural source exhaustion the abort fires during end-of-stream cleanup, after in-flight work drained — pinned.)Symbol.asyncDisposeSymbol.asyncDisposeforawait using; equivalent toiterator.return()and idempotent.Error mode —
options.errors'fail-eventually'(default) keeps the historical semantics, precisely documented: after the first error no new items are pulled; in-flight items drain (their values surface); then the captured error throws (identity preserved) or anAggregateErrorfor ≥2.'fail-fast'mirrorsPromise.all: first error short-circuits, the next.next()rejects with the original error, the source's.return()runs once. Also likePromise.all, exactly one error owns the rejection: a second racing error — or an error that lost the shutdown race to a synchronous abort — is discarded, never delivered twice.cleanupTimeout— wedged-source escape hatch.return()to settle. Defaultundefinedkeeps the unbounded await (matching nativeAsyncGenerator). Validated up to Node'sTIMEOUT_MAX(values above it would silently clamp to 1 ms).mergeIterablesparity + direct returnbufferedAsyncMap; returns the underlying iterator directly, so it carriesSymbol.asyncDisposeat runtime and the precise return type.'abc'→'a','b','c'interleaved, no error — boxed and cross-realm Strings included), and a non-iterable element surfaces only at consume time as an unbranded engine TypeError with no index, in default mode after every healthy source drained. Validation happens in the wrapper because element failures inside the callback are — by design — stream errors routed through the errors mode; the callability check is GetMethod-shaped so it can never disagree with whatyield *does at iteration.Correctness hardening (review waves 1–3 + final audit)
The final review cycles empirically reproduced and fixed twenty-one bugs — most matching native-
AsyncGenerator/for awaitsemantics the docs promise:next()bodies used to reject raw buffer slots — bypassing both error modes, rejecting every subsequentnext()forever, leaking the source, and crashing the process viaunhandledRejectionon early close. Waves 2–3 extend this to hostile iterator results (the CVE-2024-7652 defect class): results with throwingdone/valuegetters orhas-trap Proxies surface as ordinary stream errors with identity preserved; a Proxy whosehas-trap lies about the internal error tag can no longer silently truncate iteration and leak the source (the tag is brand-verified); spec-legal callable IteratorResults are accepted like nativefor await; a callback result whose[Symbol.asyncIterator]()returns a non-object surfaces as a branded TypeError instead of livelocking the event loop (falsy) or leaking internal variable names (truthy). All failures flow through tagged internal envelopes.{ done: true }, refills never call itsnext()again — native parity. Pre-fix, up tobufferSizepost-done pulls turned defensive sources ("cursor already closed") into spuriousAggregateErrors, a never-settling post-donenext()wedged the consumer forever, and infail-fastmode a spurious post-done error could win the race against a slow in-flight value and silently drop it. The construction-time speculative prefetch (the library's documented contract) is unchanged and now documented underbufferSize.for await): the input'sSymbol.asyncIteratoris read exactly once and the captured method invoked — a stateful getter can't desync probing from invocation; anull/undefinedmember falls back to sync iteration; a non-callable member gets a branded TypeError with no sync fallback; the captured method returning a non-object throws immediately at construction (native parity) instead of a deferred unbrandedAggregateError; the callback-result fan-out dispatch does a single[[Get]](nohas-probe) so sync and async callbacks returning the same object behave identically.markAsEndedis await-idempotent (every closer awaits the first closer's cleanup, soawait usingscopes can't exit while the source'sfinallyis still running);return(thenable)closes the iterator synchronously; an abort racing the fail-eventually drain-throw can no longer produce two consecutive rejections — asserted through a sound oracle (the per-callback signal's immutable reason as the commit-point record); iterators that produced malformed results are still.return()ed during cleanup, exactly once each.bufferSizevalues above ~7000 no longer crash construction with a stack overflow (iterative refill); every input-validation rejection carries an accurate per-case message (the protocols hint appears only where the sync fallback genuinely exists; strings get their own guidance).Docs
for awaitand array-bound helpers (with explicit when-NOT-to-use cases); concrete, complete usage examples (paginated-API fan-in lead example, async-generator fan-out, a firstmergeIterablesexample); pre-release-relative perf history cut in favour of consumer-relevant findings.ADVANCED.mdholds the precise contracts — prefetch/speculation model, iterator-protocol details, abort delivery and precedence, error-mode mechanics, memory guarantees — each linked per-section from the README and pointing at the spec file that pins it. Shipped in the npmfileslist so links resolve insidenode_modulestoo.Performance & memory
mitata benchmark suite (
npm run bench) guarding:PromiseReactionretention (nodejs/node#51452); pinned bytest/memory.spec.js.Types & toolchain
BufferedAsyncIterableIteratortypedef (exported); its assignability toAsyncIterableIteratoris now enforced at compile time by a type-contract spec, not a comment.@voxpelli/tsconfigv16 / TypeScript 5.9 /node22preset;exportsmap fornode16/nodenextresolution;bench:smokeCI job.Breaking changes
>=18.6.0→^22.16.0 || >=24.0.0(nativeSymbol.asyncDispose; Node 20 EOL, 23 non-LTS). CI matrix: 22 + 24. TypeScript consumers need TS ≥5.9 (declared inengines).callback(item, { signal })— runtime-compatible.bufferSizevalidation tightened:0, negatives, floats,NaN,Infinitynow throw at construction (previously they silently behaved like1, andInfinitycrashed later).cleanupTimeoutrejects values above2147483647(Node's timer maximum).next()resolving a non-object now surfaces through the configured error mode (drain-time in default mode, possiblyAggregateError-wrapped) with new messages, instead of rejecting the racingnext()immediately withTypeError('Expected an object value').'Expected asyncIterable to have a Symbol.asyncIterator function'string is replaced by three accurate per-case messages (non-callable member / string input / neither protocol usable). Don't match on the old string.mergeIterablesrequires an array input, validates elements eagerly at call time (bad or non-callable-protocol elements throw immediately with an indexed TypeError instead of surfacing as deferred stream errors), rejects string elements (including cross-realm boxed Strings), and starts prefetching at construction (1.x was fully lazy).for await: dual-protocol inputs are consumed via their async iterator; a nullishSymbol.asyncIteratormember falls back to sync iteration instead of throwing; aSymbol.asyncIteratormethod returning a non-object throws a branded TypeError at construction.next()on an exhausted source up tobufferSizeextra times; 2.0.0 stops at the first observed{ done: true }(speculative concurrent prefetch before done resolves remains, as documented).Tests & review
lib/at 100% coverage. Five full review cycles ending in a final 9-angle sweep (3 correctness angles, 3 cleanup angles, altitude, conventions, full-branch cohesion) plus a two-agent go/no-go audit (constructive release-readiness + adversarial differential fuzzing vs nativefor await, race storms, livelock and memory probes — both returned GO; its single actionable finding, the post-done over-pull, is fixed above). Every fix commit's regression specs were verified failing pre-fix.release-pleasewill cut2.0.0from thefeat!:history on merge (the commit-override block below feeds the generated changelog).BEGIN_COMMIT_OVERRIDE
feat!: AbortSignal cancellation, Symbol.asyncDispose, error modes, cleanupTimeout
feat:
options.signal— abort delivery with exactly-once rejection semanticsfeat: per-callback
{ signal }second argument, always present, aborting on iterator closefeat:
Symbol.asyncDisposeon the returned iterator forawait usingfeat:
options.errors: 'fail-eventually' | 'fail-fast'error modesfeat:
options.cleanupTimeoutescape hatch for wedged sourcesfix: exception-proof buffer pipeline against sync throws and hostile iterator results (spoofed tags, throwing getters, callable results)
fix: GetMethod-aligned dispatch for inputs, callback results and
mergeIterableselements, with eager branded validationfix: never pull the source again once it has reported done
fix: detach the external abort listener on close; await-idempotent cleanup
fix: iterative buffer refill (no stack overflow at large
bufferSize)fix: accurate per-case input-validation messages
docs: why-first README restructure plus ADVANCED.md contract reference
BREAKING CHANGE: Node engine floor is now
^22.16.0 || >=24.0.0(nativeSymbol.asyncDisposerequired; Node 18/20 and non-LTS 23 dropped). TypeScript consumers need TS ≥5.9.BREAKING CHANGE:
bufferSizeis strictly validated —0, negatives, floats,NaNandInfinitythrow at construction.BREAKING CHANGE:
cleanupTimeoutrejects values above Node's timer maximum (2147483647).BREAKING CHANGE: malformed source/sub-iterator results surface through the configured error mode with new messages instead of immediately rejecting the racing
next().BREAKING CHANGE: input-validation TypeErrors are reworded per case; the 1.x 'Expected asyncIterable to have a Symbol.asyncIterator function' string is gone.
BREAKING CHANGE:
mergeIterablesrequires an array input, validates elements eagerly with indexed TypeErrors, rejects string elements, and prefetches at construction.BREAKING CHANGE: input dispatch follows
for await— dual-protocol inputs use their async iterator; a nullishSymbol.asyncIteratormember falls back to sync iteration; a non-object iterator from the method throws at construction.BREAKING CHANGE: the source is never pulled again once it has reported done (1.x made up to bufferSize post-done next() calls).
END_COMMIT_OVERRIDE
https://claude.ai/code/session_01FSyTJjbP823pjYfueuSsdu