Skip to content

fix(event-stream): correct stream teardown on close and client disconnect#1484

Merged
pi0 merged 4 commits into
mainfrom
fix/sse-disconnect
Jul 19, 2026
Merged

fix(event-stream): correct stream teardown on close and client disconnect#1484
pi0 merged 4 commits into
mainfrom
fix/sse-disconnect

Conversation

@pi0x

@pi0x pi0x commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

Fixes #1478

Problem

onClosed was implemented as:

this._writer.closed.then(cb).catch(_noop);

When the client disconnects, the readable side of the TransformStream is cancelled, which puts the writable side into an errored state — so writer.closed rejects and the callback is routed into .catch(_noop), never running. It only fired on a graceful close().

Autoclose was additionally wired exclusively to the Node runtime (runtime?.node?.res?.once("close", ...)), so on web-standard runtimes (Deno, Bun, Workerd, service workers, plain app.fetch() consumers) a client disconnect neither closed the stream nor ran cleanup. Timers, DB handles, and generator loops leaked per connection.

Fix

  • Close callbacks are queued and fired from the single _writer.closed.catch(_noop).finally(...) handler, so they run for both graceful close and cancellation-induced rejection.
  • That handler marks the stream disposed when autoclose !== false, so a disconnect stops further pushes on every runtime, not just Node.
  • onClosed registered after the stream already closed now fires on a microtask instead of silently never running.
  • Callback throws stay swallowed, matching the previous .catch(_noop) behavior.
  • The Node res listener 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 onClosed callbacks leaked rejections. Swallowing only synchronous throws left an async callback 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, 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 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 asserts onClosed fires. Under describeMatrix it failed on web and passed on node before 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

  • Bug Fixes
    • Improved server-sent event (SSE) stream cleanup and lifecycle handling on client disconnects.
    • onClosed handlers now fire consistently, including after disconnects and when closing a paused stream.
    • Callbacks registered after the stream is already closed run promptly, even if earlier handlers throw or reject.
    • Closing a paused SSE stream now flushes buffered output so no messages are missed.
  • Tests
    • Added/updated SSE and unit tests covering disconnect behavior, paused-close flushing, and onClosed error scenarios.

`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]>
@pi0x
pi0x requested a review from pi0 as a code owner July 18, 2026 08:03
@coderabbitai

coderabbitai Bot commented Jul 18, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 327110f5-f7ff-4a04-a72a-f5e5718ed695

📥 Commits

Reviewing files that changed from the base of the PR and between d395d81 and bf56b79.

📒 Files selected for processing (2)
  • src/utils/internal/event-stream.ts
  • test/sse.test.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • test/sse.test.ts
  • src/utils/internal/event-stream.ts

📝 Walkthrough

Walkthrough

EventStream now dispatches onClosed callbacks on writer closure, including disconnects, safely isolates callback failures, and flushes paused output before closing. SSE and unit tests cover these behaviors.

Changes

SSE disconnect cleanup

Layer / File(s) Summary
EventStream close callback lifecycle
src/utils/internal/event-stream.ts, test/sse.test.ts
Close callbacks are queued, dispatched on writer closure, scheduled after closure, and isolated from synchronous or asynchronous failures; disconnect and callback-order behavior are tested.
Paused output close handling
src/utils/internal/event-stream.ts, test/sse.test.ts, test/unit/sse.test.ts
Closing unpauses and flushes buffered SSE output, with integration and unit coverage for retained paused data.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

  • h3js/h3#1322: Both changes update EventStream writer-close and onClosed handling.
  • h3js/h3#1411: Both changes modify EventStream closed-state behavior.
  • h3js/h3#1488: Both changes modify EventStream disconnect and end-of-stream cleanup.

Suggested reviewers: pi0

Poem

A bunny watched the stream flow bright,
Then snipped the wire one quiet night.
Callbacks woke, buffered hops took flight,
While async thumps caused no fright.
“Closed!” cried the hare, with ears held high.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main fix: stream teardown on close and client disconnect.
Linked Issues check ✅ Passed The changes address non-Node disconnect cleanup by running onClosed/autoclose on writer closure and adding regression tests.
Out of Scope Changes check ✅ Passed The test updates and close-flush behavior are directly related to the disconnect and teardown fix.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/sse-disconnect

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

📥 Commits

Reviewing files that changed from the base of the PR and between 75ac47d and 37cf160.

📒 Files selected for processing (2)
  • src/utils/internal/event-stream.ts
  • test/sse.test.ts

Comment thread src/utils/internal/event-stream.ts
Comment thread test/sse.test.ts
pi0 and others added 2 commits July 18, 2026 08:12
…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]>
@pi0x pi0x changed the title fix(event-stream): fire onClosed and autoclose on client disconnect fix(event-stream): correct stream teardown on close and client disconnect Jul 18, 2026
@pi0
pi0 merged commit 6257713 into main Jul 19, 2026
9 checks passed
@pi0
pi0 deleted the fix/sse-disconnect branch July 19, 2026 06:50
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.

createEventStream: onClosed/autoclose never fire on client disconnect in non-Node runtimes

2 participants