fix(event-stream): correct stream teardown on close and client disconnect#1484
Conversation
`onClosed` was implemented as `writer.closed.then(cb).catch(_noop)`. When the client disconnects, the readable side of the `TransformStream` is cancelled, which errors the writable side, so `closed` rejects and the callback was swallowed by `.catch(_noop)`. Autoclose was additionally wired only to `runtime.node.res`, so on web-standard runtimes (Deno, Bun, Workerd, plain `app.fetch()`) a disconnect never stopped the stream. Close callbacks are now queued and fired from the single `closed` settlement handler, which runs for both graceful `close()` and cancellation, and that handler marks the stream disposed when `autoclose` is enabled. Fixes #1478 Co-Authored-By: Claude Opus 4.8 <[email protected]>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (2)
🚧 Files skipped from review as they are similar to previous changes (2)
📝 WalkthroughWalkthrough
ChangesSSE disconnect cleanup
Estimated code review effort: 3 (Moderate) | ~20 minutes Possibly related PRs
Suggested reviewers: Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/utils/internal/event-stream.ts`:
- Around line 15-16: Replace the private _closeCallbacks field with a JavaScript
`#closeCallbacks` field in the event-stream class, and update all references in
the surrounding methods to use `#closeCallbacks` while preserving its callback
storage and cleanup behavior.
In `@test/sse.test.ts`:
- Around line 82-85: Update the Promise.race timeout around onClosed so the
setTimeout handle is stored and cleared when onClosed settles, including when it
resolves before the 2-second limit. Preserve the existing timeout rejection
behavior if onClosed never fires.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 68c44450-2aee-4e29-a658-b1ac12d0fb9d
📒 Files selected for processing (2)
src/utils/internal/event-stream.tstest/sse.test.ts
…acks The previous commit invoked close callbacks inside a plain `try/catch`, which only catches synchronous throws. The old `closed.then(cb).catch()` chain also swallowed rejections from async callbacks; losing that turned a rejecting async cleanup into an unhandled rejection, which is fatal by default since Node v15 — and the documented `onClosed` example is async. Co-Authored-By: Claude Opus 4.8 <[email protected]>
`close()` never flushed `_unsentData`, and `flush()` short-circuits once the stream is closed, so anything queued by a `push()` during `pause()` was unrecoverable — a handler that paused for backpressure, queued a final message and then closed left the client with a cleanly ended stream and a missing message, with no error anywhere. `close()` now unpauses and flushes before closing the writer. This adds no new blocking: `_writer.close()` already awaited the queue draining. Co-Authored-By: Claude Opus 4.8 <[email protected]>
onClosed and autoclose on client disconnect# Conflicts: # test/sse.test.ts
Fixes #1478
Problem
onClosedwas implemented as:When the client disconnects, the readable side of the
TransformStreamis cancelled, which puts the writable side into an errored state — sowriter.closedrejects and the callback is routed into.catch(_noop), never running. It only fired on a gracefulclose().Autoclose was additionally wired exclusively to the Node runtime (
runtime?.node?.res?.once("close", ...)), so on web-standard runtimes (Deno, Bun, Workerd, service workers, plainapp.fetch()consumers) a client disconnect neither closed the stream nor ran cleanup. Timers, DB handles, and generator loops leaked per connection.Fix
_writer.closed.catch(_noop).finally(...)handler, so they run for both graceful close and cancellation-induced rejection.autoclose !== false, so a disconnect stops further pushes on every runtime, not just Node.onClosedregistered after the stream already closed now fires on a microtask instead of silently never running..catch(_noop)behavior.reslistener is kept as a belt-and-braces signal for the Node path.Also in this PR
Two adjacent defects in the same teardown path, each with its own commit:
Async
onClosedcallbacks leaked rejections. Swallowing only synchronous throws left anasynccallback that rejects to surface as an unhandled rejection, and it aborted the remaining callbacks in the queue. Callbacks are now invoked through a helper that absorbs both, so one bad callback can no longer take down the process or starve the ones behind it.close()dropped data buffered while paused.close()never flushed_unsentData, andflush()short-circuits once the stream is closed — so anything queued by apush()duringpause()was unrecoverable. A handler that paused for backpressure, queued a final message and then closed left the client with a cleanly ended stream and a silently missing message.close()now unpauses and flushes before closing the writer. This adds no new blocking, since_writer.close()already awaited the queue draining.Test
test/sse.test.ts— the client reads one chunk, cancels the reader, and assertsonClosedfires. UnderdescribeMatrixit failed onweband passed onnodebefore the fix (exactly the reported asymmetry); both pass now.The two follow-up fixes are covered by their own regression tests — an integration test per matrix target plus a unit test for the paused-close flush, all failing before their respective commits. Full suite green.
🤖 Generated with Claude Code
Summary by CodeRabbit
onClosedhandlers now fire consistently, including after disconnects and when closing a paused stream.onClosederror scenarios.