fix(peer, shared, fetch): end a waiting next() with done: true when an event stream is stopped - #108
Conversation
…est body When a handler called `return()` on an event-stream request body while a `next()` was still waiting for data, that `next()` never settled. The server dropped the body's queue without closing it, so neither new stream messages nor `peer.close()` could reach the waiting read. The handler hung, and `ServerPeer.message()` never resolved. The server now aborts the queue on cancel, as the client peer already does. ## Fixes - A waiting `next()` now rejects with `AbortError` instead of hanging, and later `next()` calls return `done: true` - The handler and `ServerPeer.message()` finish normally after the body is cancelled ## Testing - New event-stream test for `return()` during a pending `next()`; it timed out before the fix - New octet-stream test for `reader.cancel()` during a pending read, which covers the abort on that path
…rator is stopped
When `return()` or `throw()` stopped an iterator while a `next()` was
still waiting, that `next()` rejected with an `AbortError` (fetch, node,
aws-lambda and peer adapters). A `for await` loop stopped from outside
therefore threw, and in a server handler an uncaught `AbortError` failed
the whole request instead of just ending the body stream. The waiting
`next()` now resolves `{ done: true }`, matching native async generators,
where a loop stopped from outside ends normally and only the caller of
`throw()` sees its error.
## Behavior change
- A `next()` waiting when `return()`, `throw()` or dispose is called
resolves `{ done: true, value: undefined }` instead of rejecting; a value
that arrives after that is dropped
- `throw(error)` still rejects with `error` for its caller
- Streams cut off by the other side (peer closed, client cancel,
response sent early) still reject the waiting `next()` as before
## Testing
- Shared iterator tests for a waiting `next()` whose call resolves or
rejects after `return()` / `throw()`
- Fetch and peer server tests now expect `done: true`
@standard-server/aws-lambda
@standard-server/core
@standard-server/fastify
@standard-server/fetch
@standard-server/node
@standard-server/peer
@standard-server/shared
commit: |
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
Merging this PR will not alter performance
Comparing Footnotes
|
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
- Shared iterator settles a waiting
next()after stop —AsyncIteratorClassnow returns{ done: true, value: undefined }afterawait next()and in thecatchwhenisDonewas set byreturn()/throw()/dispose while the call was in flight, instead of leaking the late value/error. The in-flightfinallystill never double-runs cleanup, because every path that setsisDonealso setsisExecuteCompletesynchronously first. - Fetch adapter drops the
AbortError-on-cancel throw —toAsyncIteratorObjectno longer tracksisCancelled; a cancelledreader.read()resolvingdoneis now handled solely by the shared iterator. Node re-exports this and aws-lambda imports it, so all three adapters inherit the behavior. - Peer server settles a pending read on handler stop — the
toStandardBodyonDone callback aborts the event/octet request queues whenkind === 'cancelled'and a stream is still active, so a pendingqueue.pull()rejects and the waitingnext()resolves;stream/cancelis still emitted in the same branch. The client already had the analogousabortByIdpath. - Tests — shared iterator gains resolve/reject-after-stop cases that fail on the old code; peer server gains pending
next()/read()settle tests; fetch updates its return-while-waiting assertion.
I verified this end to end: pnpm exec vitest run packages/shared packages/fetch packages/peer packages/node (454 pass), tests/data-transfer.test.ts + tests/signal-and-cancel.test.ts (526 pass), and eslint plus tsc -b on the three packages are clean.
ℹ️ Non-blocking observations
Two informational notes, no change needed:
- The PR body's "native async generators make the same trade-off" framing is a little loose. A native generator resolves the still-pending
next()with the in-flight value first and only then processesreturn()/throw(); this implementation deliberately ends the pendingnext()asdoneand drops the value (which the body also describes as the idle-stream-safe choice). The code comments themselves don't overclaim, so this is only a documentation nuance for downstream readers. - The peer client has no dedicated test for
return()during a pendingnext(); the author already notes this in the PR description. The behavior follows from the shared iterator plus the pre-existing clientabortByIdcleanup, so this is a coverage gap rather than a correctness concern.
DeepSeek Flash (default — pick a model for stronger reviews) | 𝕏

When code stopped reading an event-stream body with
return()orthrow()while anext()was still waiting, the peer server left thatnext()pending forever, so the handler andServerPeer.message()hung. The other adapters rejected it with anAbortErrorinstead, which made afor awaitloop stopped from outside throw, and failed the whole request when a server handler didn't catch it. A waitingnext()now resolves{ done: true }on every adapter, like a native async generator: the loop ends normally, and only the caller ofthrow()sees its error.Fixes
ServerPeer.message()resolvesfor awaitloop stopped from outside now ends normally on the fetch, node, aws-lambda and peer adaptersBehavior change
next()that is waiting whenreturn(),throw()or dispose is called resolves{ done: true, value: undefined }instead of rejecting withAbortError. A value that arrives after that is dropped: the stream is cancelled right away rather than after the in-flight value, as a native generator would do, which would hang on an idle stream.throw(error)itself still rejects witherror.next().Testing
return()during a pendingnext(); it timed out before the fixnext()whose call resolves or rejects afterreturn()/throw(); they fail on the old iteratordone: true