Skip to content

fix(peer, shared, fetch): end a waiting next() with done: true when an event stream is stopped - #108

Merged
dinwwwh merged 2 commits into
mainfrom
claude/event-stream-queue-cleanup-4d033d
Sep 26, 2026
Merged

dinwwwh merged 2 commits into
mainfrom
claude/event-stream-queue-cleanup-4d033d

Conversation

@dinwwwh

@dinwwwh dinwwwh commented Sep 26, 2026

Copy link
Copy Markdown
Member

When code stopped reading an event-stream body with return() or throw() while a next() was still waiting, the peer server left that next() pending forever, so the handler and ServerPeer.message() hung. The other adapters rejected it with an AbortError instead, which made a for await loop stopped from outside throw, and failed the whole request when a server handler didn't catch it. A waiting next() now resolves { done: true } on every adapter, like a native async generator: the loop ends normally, and only the caller of throw() sees its error.

Fixes

  • Peer server handlers that stop reading an event-stream request body mid-read no longer hang, and ServerPeer.message() resolves
  • A for await loop stopped from outside now ends normally on the fetch, node, aws-lambda and peer adapters

Behavior change

  • A next() that is waiting when return(), throw() or dispose is called resolves { done: true, value: undefined } instead of rejecting with AbortError. 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 with error.
  • Streams cut off by the other side (peer closed, client cancel, response sent before the body was read) still reject the waiting next().
  • This reverses the throw added in fix(standard-server): throw error for waiting operation if event iterator cancelled orpc#974, which kept a cancelled stream from looking finished. Native async generators make the same trade-off, so the code that stops a stream is expected to track that itself.

Testing

  • New peer server test for return() during a pending next(); it timed out before the fix
  • Shared iterator tests for a waiting next() whose call resolves or rejects after return() / throw(); they fail on the old iterator
  • Fetch and peer server tests updated to expect done: true
  • The peer client gets the new behavior through the shared iterator but has no dedicated test for this case
  • Full suite (1210 tests), eslint, type check, and the Deno and Bun suites pass

…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`
@pkg-pr-new

pkg-pr-new Bot commented Sep 26, 2026

Copy link
Copy Markdown
@standard-server/aws-lambda

npm i https://pkg.pr.new/@standard-server/aws-lambda@108

@standard-server/core

npm i https://pkg.pr.new/@standard-server/core@108

@standard-server/fastify

npm i https://pkg.pr.new/@standard-server/fastify@108

@standard-server/fetch

npm i https://pkg.pr.new/@standard-server/fetch@108

@standard-server/node

npm i https://pkg.pr.new/@standard-server/node@108

@standard-server/peer

npm i https://pkg.pr.new/@standard-server/peer@108

@standard-server/shared

npm i https://pkg.pr.new/@standard-server/shared@108

commit: cd5c521

@codecov

codecov Bot commented Sep 26, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@codspeed

codspeed Bot commented Sep 26, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 26 untouched benchmarks
⏩ 108 skipped benchmarks1


Comparing claude/event-stream-queue-cleanup-4d033d (cd5c521) with main (901bb47)

Open in CodSpeed

Footnotes

  1. 108 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes

  • Shared iterator settles a waiting next() after stop — AsyncIteratorClass now returns { done: true, value: undefined } after await next() and in the catch when isDone was set by return()/throw()/dispose while the call was in flight, instead of leaking the late value/error. The in-flight finally still never double-runs cleanup, because every path that sets isDone also sets isExecuteComplete synchronously first.
  • Fetch adapter drops the AbortError-on-cancel throw — toAsyncIteratorObject no longer tracks isCancelled; a cancelled reader.read() resolving done is 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 toStandardBody onDone callback aborts the event/octet request queues when kind === 'cancelled' and a stream is still active, so a pending queue.pull() rejects and the waiting next() resolves; stream/cancel is still emitted in the same branch. The client already had the analogous abortById path.
  • 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 processes return()/throw(); this implementation deliberately ends the pending next() as done and 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 pending next(); the author already notes this in the PR description. The behavior follows from the shared iterator plus the pre-existing client abortById cleanup, so this is a coverage gap rather than a correctness concern.

Pullfrog  | View workflow run | Using DeepSeek Flash (default — pick a model for stronger reviews) | 𝕏

@dinwwwh
dinwwwh merged commit 90876d0 into main Sep 26, 2026
11 checks passed
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.

1 participant