Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
82 commits
Select commit Hold shift + click to select a range
4ab2a5a
fix: aggregate captured errors instead of dropping after the first
claude May 8, 2026
0e97e07
feat: add Symbol.asyncDispose for await using support
claude May 8, 2026
dd4bfe4
feat: forward AbortSignal to callbacks, abort on iterator close
claude May 8, 2026
7a82a16
feat: accept options.signal for external cancellation
claude May 8, 2026
a8da4aa
feat: add errors: 'fail-fast' option
claude May 8, 2026
cb565eb
test: un-skip and align iterator.throw() spec
claude May 8, 2026
26b32b3
docs: cancellation, error contract, and async resource management
claude May 8, 2026
eecb036
chore: remove obsolete AbortController TODOs
claude May 8, 2026
dd3ccde
chore: drop redundant @types/chai-quantifiers devDep
claude May 8, 2026
11fbb41
docs: add CLAUDE.md with build commands and architecture notes
claude May 8, 2026
1d87f8e
feat!: reconcile abort-signal with type-safety and efficiency refactors
claude May 8, 2026
89e98fa
docs: refresh CLAUDE.md lib helper roster after reconciliation
claude May 9, 2026
dd1c1af
fix: hoist abortPromise, tighten yield type, document load-bearing in…
claude May 9, 2026
c523dae
style: eslint --fix object-curly-newline on lib/type-checks import
claude May 9, 2026
fbc0328
refactor: drop "AC" abbreviation everywhere
claude May 9, 2026
d64949e
feat: extend mergeIterables with signal/errors/ordered options
claude May 12, 2026
14fd459
fix: align CI matrix with engines, strengthen ordered merge test, aut…
claude May 12, 2026
dd775d7
fix: run markAsEnded before propagating abort reason from nextValue
claude May 12, 2026
cf4c7a8
chore: final polish for the 2.0.0 deliverable
claude May 14, 2026
92f35f0
test: add mitata benchmark suite for the major design decisions
claude May 14, 2026
b019aad
test: deepen and harden the mitata benchmark suite
claude May 14, 2026
fe277d4
perf: skip the load-balancer when there are no sub-iterators
claude May 14, 2026
0f87222
perf: replace the long-lived abortPromise race with a per-pull park
claude May 14, 2026
9321172
chore: resolve stale TODO comments
claude May 14, 2026
87c89c8
fix: handle a malformed async iterable returned from a callback
claude May 14, 2026
810345c
fix: support the return(value) argument per the iterator protocol
claude May 14, 2026
e476682
fix: run cleanup when return(value) receives a rejecting promise
claude May 15, 2026
dc36685
docs: annotate the two non-obvious nextValue/return signposts
claude May 15, 2026
3dce69c
docs: refresh CLAUDE.md architecture & invariants for the session's work
claude May 15, 2026
6f84ba4
fix: validate bufferSize as a positive integer
claude Jun 10, 2026
e4ccb2f
fix: route malformed-result errors through the configured error mode
claude Jun 10, 2026
06e8004
fix: tolerate sync-throwing source/sub-iterator .return() during cleanup
claude Jun 10, 2026
018d52e
fix: preserve non-Error rejection reasons as Error.cause
claude Jun 10, 2026
a6534c8
feat: add cleanupTimeout option for capping source cleanup wait
claude Jun 10, 2026
cb1dd59
docs: clarify fail-eventually as drain-in-flight-then-throw
claude Jun 10, 2026
8455474
chore: tighten package.json metadata and ship the bench:smoke CI job
claude Jun 10, 2026
349d200
chore: bump tsconfig base to node22 and TypeScript to 5.9
claude Jun 10, 2026
aae7d05
chore: pin engines to ^22.16.0 || >=24.0.0
claude Jun 10, 2026
fcceb15
chore: tighten JSDoc types to ≥99% strict coverage
claude Jun 10, 2026
f72dda7
refactor: mergeIterables returns the underlying iterator directly
claude Jun 10, 2026
473e351
docs: refresh README for the 2.0.0 surface
claude Jun 10, 2026
aaf67e6
docs: stop overselling await using — for-await already cleans up
claude Jun 10, 2026
83adc47
fix: omit Error cause for nullish thrown values
claude Jun 30, 2026
f5a1b70
fix: clear the cleanupTimeout timer once cleanup settles
claude Jun 30, 2026
d4f2c01
perf: align fillQueue envelope field order for a stable hidden class
claude Jun 30, 2026
a2c3649
docs: sync CLAUDE.md with the single-arg markAsEnded and 99% gate
claude Jul 2, 2026
ef2852c
fix: detach the external-signal abort listener on iterator close
claude Jul 7, 2026
9dc310b
fix: make buffer slots reject-proof and collision-proof
claude Jul 7, 2026
29a0a15
fix: still close iterators that produced malformed results
claude Jul 7, 2026
5349880
fix: deliver a pending abort instead of the drain-throw
claude Jul 7, 2026
a534034
fix: don't deliver a stale abort through an explicitly closed iterator
claude Jul 7, 2026
844de5c
fix: make concurrent closers await the first closer's cleanup
claude Jul 7, 2026
fdf718e
fix: close the iterator synchronously when return() receives a thenable
claude Jul 7, 2026
00d8586
fix: refill the buffer iteratively instead of recursively
claude Jul 7, 2026
4472466
fix: reject cleanupTimeout beyond the setTimeout maximum
claude Jul 7, 2026
d035917
fix: prefer async iteration for dual-protocol inputs
claude Jul 7, 2026
483a2ee
fix: validate mergeIterables input as an array of iterables at call time
claude Jul 7, 2026
897ad22
refactor: unify the abort writers and delivery path
claude Jul 7, 2026
b33afe0
perf: hoist drainOrContinue out of nextValue
claude Jul 7, 2026
35da93e
test: consolidate shared fixtures and drop duplicate specs
claude Jul 7, 2026
b46602a
chore: gate the public iterator type's assignability at compile time
claude Jul 7, 2026
c89e950
docs: sync architecture docs, bench comments and README semantics
claude Jul 7, 2026
e6eed7a
fix: make the envelope pipeline exception-proof end to end
claude Jul 7, 2026
7194bc6
fix: align input dispatch and merge validation with GetMethod semantics
claude Jul 7, 2026
509de73
fix: explicitly discard a fail-fast error that lost the shutdown race
claude Jul 7, 2026
9154d02
test: restore the drain-race sweep's regression power via the signal-…
claude Jul 7, 2026
e20abb6
docs: reconcile the remaining doc/code contradiction set
claude Jul 7, 2026
1ed1839
refactor: inline handleAbortIfPending into deliverAbort
claude Jul 7, 2026
9b38352
test: migrate outcome loops to the shared helpers and right-size fixt…
claude Jul 7, 2026
82b1ade
docs: fix review-found doc drift and strengthen shared test assertions
claude Jul 7, 2026
6330f21
fix: harden result classification against spoofed and callable results
claude Jul 7, 2026
02e077d
fix: align the callback-result sub-iterator dispatch with GetMethod
claude Jul 7, 2026
e802e93
fix: validate the input Symbol.asyncIterator method result eagerly
claude Jul 7, 2026
9873d71
fix: give each input-validation rejection an accurate message
claude Jul 7, 2026
d0264e3
docs: align remaining README/JSDoc claims with pinned behavior
claude Jul 7, 2026
79c742f
fix: never pull the source again once it has reported done
claude Jul 7, 2026
d5e5d41
docs: restructure README around a why-first, lean-reference shape
claude Jul 7, 2026
c3b6479
docs: ship ADVANCED.md in the published package
claude Jul 7, 2026
e9742af
docs: fix ADVANCED.md back-link left over from the docs/ location
claude Jul 7, 2026
2d9956c
docs: adversary-vetted documentation round
claude Jul 7, 2026
a10e0c4
docs: qualify the bufferSize per-pull cost caveat as unordered-only
claude Jul 10, 2026
ee2a29f
docs: clarify async-generator callbacks run serially in ordered mode
claude Jul 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion .github/workflows/nodejs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,17 @@ jobs:
test:
uses: voxpelli/ghatemplates/.github/workflows/test.yml@main
with:
node-versions: '18,20,21'
node-versions: '22,24'

bench-smoke:
name: Benchmark smoke (node --check)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
# Pure syntax check — the bench dir isn't covered by mocha/tsc-strict,
# so this guards against the bench files rotting silently when mitata's
# API changes or someone touches benchmark/* without running it.
- run: npm run bench:smoke
3 changes: 1 addition & 2 deletions .knip.jsonc
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
{
"$schema": "https://unpkg.com/knip@5/schema.json",
"entry": [
"index.js!",
"benchmark/*.js"
"benchmark/index.js"
]
}
5 changes: 5 additions & 0 deletions .mocharc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"node-option": [
"expose-gc"
]
}
164 changes: 164 additions & 0 deletions ADVANCED.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
# Advanced semantics

The precise contracts behind the summaries in the [README](README.md).
Everything on this page is pinned by the test suite — section notes point at
the relevant spec files where it helps.

## Construction and the prefetch model

**Construction starts work immediately.** Up to `bufferSize` items are pulled
from the source and their callbacks dispatched before the first `.next()` —
that is the prefetching the library exists for. Construct close to
consumption, and pair early construction with `await using` (or an explicit
`return()`) so an error thrown between construction and the loop doesn't
strand in-flight work.

**Prefetching is speculative.** Up to `bufferSize` concurrent `next()` calls
can be in flight before an earlier one has resolved `done`. Once a
`{ done: true }` result *has* resolved, the source is never pulled again
(native `for await` parity — pinned by `test/basic.spec.js`). Async-generator
sources handle the speculation natively: their request queue serializes the
calls, and they answer trailing pulls with `done` forever. A hand-rolled
iterator object that *throws* on concurrent or trailing `next()` calls should
use `bufferSize: 1` or be wrapped in an async generator (which restores the
serializing queue).

**Very large buffers work but don't scale linearly.** Any positive integer is
accepted for `bufferSize`, but each unordered pull races the whole buffer, so
the per-pull cost is O(bufferSize). Sizes in the tens of thousands are
exercised in the test suite (the regression spec pins 20 000) — they're just
increasingly poor trade-offs.

## Iterator-protocol details

**No two-way communication.** Values passed to `next(v)` are ignored —
buffering decouples consumer pulls from source pulls, so there is no
suspension point to resume with a value. `throw(err)` always terminates the
iterator; it is never forwarded to the source, so a source generator cannot
`try/catch` around its `yield` and recover.

**`return(value)` matches `AsyncGenerator.prototype.return`.** It resolves to
`{ done: true, value }`, awaits a thenable `value` so the result never holds
a pending promise, and still runs cleanup if that await rejects. The iterator
closes *synchronously* when `return()` is called — a concurrent `next()`
resolves `{ done: true }` and the source is not pulled during the await
window.

**Concurrent `next()` calls are queued.** Overlapping `next()` calls are
chained — native AsyncGenerator's request-queue behaviour — so each call
resolves a distinct result in pull order, which is what makes the iterator
safe to share between concurrent consumers. A rejected pull does not poison
later calls: the next call observes the post-rejection state (usually
`{ done: true }`).

**Same-realm instances are assumed, and non-`Error` throws are normalized.**
`options.signal` must be an `AbortSignal` from the current realm
(`instanceof` check), and error identity (fail-fast, single-error
fail-eventually) assumes errors are same-realm `Error` instances. Anything
else — a cross-realm error (`node:vm`, some worker setups), `throw 'oops'`,
`throw 42`, a bare `Promise.reject()` — is normalized into a fresh `Error`
with a context-specific message (`Unknown callback error`,
`Unknown iterator error`, …) and the original value preserved on `.cause`
(omitted when the thrown value was nullish, which carries no information).
A consumer matching on a non-`Error` sentinel should check `.cause`.

## Ordered mode

`ordered: true` changes *delivery* order, not *dispatch* — **for plain-value
callbacks.** Their callbacks still run concurrently up to `bufferSize`, and
each result is delivered once every earlier item's result has been delivered,
so a slow first item head-of-line blocks the *results*, not the *work* (the
timing spec in `test/values.spec.js` pins that a full ordered run of
plain-value callbacks completes in roughly the longest item's time plus the
tail, not the serial sum).

**Async-generator callbacks are the exception.** In ordered mode the buffer
always feeds from the current sub-iterator rather than load-balancing, so a
generator callback runs strictly one at a time — `bufferSize` buffers the
undispatched generators but does not step them concurrently, and a slow first
item blocks the *work*, not just the results (the serial timing is pinned by
the nested-ordered spec in `test/values.spec.js`). Values are still delivered
contiguously and in source order: everything item N's generator yields comes
before anything from item N+1. If you need concurrent execution *and*
source-order delivery, return a value (such as an array) from the callback
rather than yielding from a generator.

Abort delivery and both error modes behave identically in ordered and
unordered mode (pinned by `test/abort.spec.js` and
`test/errors-fail-fast.spec.js`).

## Cancellation in depth

**The exactly-once rejection contract.** When `options.signal` aborts, the
next pending or freshly-called `iterator.next()` rejects with `signal.reason`
— exactly once, identity preserved. Every later call resolves
`{ done: true, value: undefined }`. One exception: if the consumer has
already closed the iterator via `return()` / `throw()` /
`Symbol.asyncDispose` before the abort was delivered, the abort is
*suppressed* — a later `next()` resolves done instead of rejecting through an
iterator the consumer already chose to close. This matches native
`AsyncGenerator` behaviour. (Pinned by `test/abort.spec.js`.)

**Pre-aborted signals.** If `options.signal` is already aborted at
construction time the source is never read, and the first `next()` rejects
with `signal.reason` (subject to the same explicit-close suppression).

**Delivery waits for cleanup.** The rejecting `next()` only settles after the
source's `.return()` — its `finally` blocks — has run, the same guarantee
`for await` / `await using` give you on normal completion. If the source
might hang inside `.return()` (a `finally` awaiting an unsettled promise),
set `cleanupTimeout` to bound the wait. The timeout *races* the cleanup, it
does not cancel it: the pending source promises are abandoned (promises are
not cancellable), but the consumer unblocks. The internal timer is cleared as
soon as cleanup wins, so a prompt close doesn't keep the event loop alive for
the rest of the window.

**Abort/error precedence.** External abort takes precedence over queued /
not-yet-captured errors: if the signal aborts while fail-eventually errors
sit captured, the consumer sees `signal.reason`, not the captured errors. The
one exception is a fail-fast error already *committed* as the shutdown reason
— once fail-fast has begun closing the iterator its error owns the single
rejection, and an abort landing mid-cleanup is a no-op (first event wins).
(Pinned by `test/errors-fail-fast.spec.js`.)

**Per-callback signal timing.** The `{ signal }` passed to every callback is
always present — an internal `AbortController` is minted per call even with
no `options.signal` — and aborts on iterator close: `return()`, `throw()`,
`Symbol.asyncDispose`, external abort, first fail-fast error, or
end-of-stream cleanup after natural exhaustion. Callbacks observe
`signal.aborted === true` within one microtask of the close. They continue
running (promises are not cancellable) until they reach an `await` of
something signal-aware (`fetch`, `undici`, …) or voluntarily exit on a
`signal.aborted` check. (Pinned by `test/per-task-signal.spec.js`.)

## Errors in depth

**`'fail-eventually'` drain mechanics.** After the first captured error no
new items are pulled. Items already in flight drain: their successful values
still surface, and further errors among them are captured too. When the
buffer empties, one captured error is thrown directly (identity preserved);
two or more are wrapped in an `AggregateError` in capture order. In-flight
callbacks may still complete in the background after capture — wrap the
callback in `try/catch` for per-item isolation.

**`'fail-fast'` mechanics.** Mirrors `Promise.all`: the first error
short-circuits, the next `next()` rejects with the original error (no
wrapping), the source's `next()` is not called again and its `.return()` runs
exactly once. Also like `Promise.all`, exactly one error owns the rejection —
a second error racing the first, or an error that lost the shutdown race to a
synchronous abort, is discarded rather than delivered twice.

**Malformed results are stream errors.** A source or sub-iterator `next()`
that resolves to a non-object — or a result object whose `done`/`value` reads
throw (hostile getters, Proxy traps) — surfaces through the configured error
mode like any other stream error, identity preserved where an `Error` was
thrown. The offending iterator is still `.return()`ed during cleanup, exactly
once. (Pinned by `test/hostile-results.spec.js`.)

## Memory guarantees

Long-lived / unbounded streams retain ~0 bytes per item: each pull races a
fresh, collectable "park" promise instead of one long-lived abort promise
(the [nodejs/node#51452](https://github.com/nodejs/node/issues/51452)
retention pattern), and exactly one abort listener exists per iterator,
detached on close. `test/memory.spec.js` is the regression guard.
Loading
Loading