Skip to content

fix(sse): mark writer as closed on write failure#1322

Merged
pi0 merged 9 commits into
mainfrom
fix/event-stream-error-handling
Mar 19, 2026
Merged

fix(sse): mark writer as closed on write failure#1322
pi0 merged 9 commits into
mainfrom
fix/event-stream-error-handling

Conversation

@productdevbook

@productdevbook productdevbook commented Mar 14, 2026

Copy link
Copy Markdown
Member

Summary

  • `onClosed(cb)` called `.then(cb)` without a `.catch()`, so if the callback threw an error it became an unhandled promise rejection — now adds `.catch(_noop)` to suppress these
  • Write operations in `_sendEvent`, `_sendEvents`, and `pushComment` used `.catch()` with no callback argument — while technically valid, replaced with explicit `.catch(_noop)` for clarity and to make the intentional error suppression visible

Test plan

  • All SSE integration tests pass (6/6)
  • All SSE unit tests pass (8/8)

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added new public export(s) to aid testing and integration.
  • Bug Fixes

    • Improved error handling in event streaming to prevent unhandled promise rejections.
    • Callbacks registered for stream closure that throw errors no longer trigger unhandled rejections.
    • Writes and comment-pushes attempted after a stream is closed are now safely ignored and won’t retry or crash the process.

…erations

- onClosed() called .then(cb) without .catch(), so if the callback threw,
  it became an unhandled promise rejection
- Write operations used .catch() with no callback argument, which is
  technically valid but unclear — replaced with explicit _noop for clarity

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
@productdevbook
productdevbook requested a review from pi0 as a code owner March 14, 2026 16:04
@coderabbitai

coderabbitai Bot commented Mar 14, 2026

Copy link
Copy Markdown

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: 0aa23493-f00b-4388-a401-784a73808b8c

📥 Commits

Reviewing files that changed from the base of the PR and between 8e6dfae and 5b3594c.

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

📝 Walkthrough

Walkthrough

Replace silent, potentially unhandled writer promise rejections with explicit catches that mark the internal writer as closed; add tests asserting onClosed callback rejection suppression and that writes become no-ops after writer failure, without altering public APIs. (33 words)

Changes

Cohort / File(s) Summary
Event stream implementation
src/utils/internal/event-stream.ts
Added a module _noop; replaced silent .catch() usage with .catch(_noop) for onClosed and .catch(() => { this._writerIsClosed = true; }) on writer write paths to suppress rejections and record the closed state.
Unit tests for SSE/EventStream
test/unit/sse.test.ts
Expanded tests (imports vi, EventStream, mockEvent) to cover onClosed callback rejection handling, push/pushComment behavior post-close, batch push and flush failures, and enforcement of _writerIsClosed to skip subsequent writes.
Exports surface
src/index.ts
Exported mockEvent and exposed EventStream from the internal SSE utility to support the new tests.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐇 I nibble at promises late at night,
A tiny _noop to set things right.
When writers fail, I mark the door,
No more echoes on the floor.
Hop, test, and rest — the stream sleeps tight. 🥕

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'fix(sse): mark writer as closed on write failure' directly and clearly describes the main change in the pull request: handling write failures by marking the writer as closed.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ 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/event-stream-error-handling
📝 Coding Plan
  • Generate coding plan for human review comments

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 and usage tips.

@pkg-pr-new

pkg-pr-new Bot commented Mar 14, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/h3@1322

commit: 5b3594c

@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: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/utils/internal/event-stream.ts (1)

74-106: ⚠️ Potential issue | 🟠 Major

Set _writerIsClosed = true when write operations fail to prevent silent retry loops.

At lines 74, 89, and 106, write errors are suppressed with .catch(_noop) but the instance state is not updated. If a write fails, subsequent push() calls will pass the _writerIsClosed check (still false) and attempt to write again, repeating silently until the writer closes. Update the catch handlers to set _writerIsClosed = true to short-circuit these failed write attempts and prevent silent failures.

Suggested patch
-    await this._writer.write(this._encoder.encode(formatEventStreamComment(comment))).catch(_noop);
+    await this._writer
+      .write(this._encoder.encode(formatEventStreamComment(comment)))
+      .catch(() => {
+        this._writerIsClosed = true;
+      });

-    await this._writer.write(this._encoder.encode(formatEventStreamMessage(message))).catch(_noop);
+    await this._writer
+      .write(this._encoder.encode(formatEventStreamMessage(message)))
+      .catch(() => {
+        this._writerIsClosed = true;
+      });

-    await this._writer.write(this._encoder.encode(payload)).catch(_noop);
+    await this._writer.write(this._encoder.encode(payload)).catch(() => {
+      this._writerIsClosed = true;
+    });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/utils/internal/event-stream.ts` around lines 74 - 106, The write
operations in _sendEvent and _sendEvents (and the comment-writing call that uses
formatEventStreamComment) currently suppress errors with .catch(_noop) which
leaves _writerIsClosed false and allows silent retry loops; update each catch
handler to set this._writerIsClosed = true (and then optionally log or swallow)
so a failed write short-circuits future pushes — modify the three write calls
that call this._writer.write(this._encoder.encode(...)).catch(_noop) to instead
set this._writerIsClosed = true in their catch handlers.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/utils/internal/event-stream.ts`:
- Line 154: The writer.closed promise rejection is ignored; update both places
that call this._writer.closed.then(...) (in the constructor where you set
_writerIsClosed and in onClosed) to invoke the same callback/state-setter on
both fulfillment and rejection so cleanup/state changes always run. Concretely,
change the current .then(cb).catch(_noop) usage to call the callback as the
rejection handler as well (e.g., pass cb as the second argument to .then or call
.catch(cb)), ensuring the code that sets _writerIsClosed and the onClosed
callback always executes even if writer.closed rejects; this will restore
correct behavior for methods like pushComment and _sendEvent and guarantee
cleanup in onClosed.

---

Outside diff comments:
In `@src/utils/internal/event-stream.ts`:
- Around line 74-106: The write operations in _sendEvent and _sendEvents (and
the comment-writing call that uses formatEventStreamComment) currently suppress
errors with .catch(_noop) which leaves _writerIsClosed false and allows silent
retry loops; update each catch handler to set this._writerIsClosed = true (and
then optionally log or swallow) so a failed write short-circuits future pushes —
modify the three write calls that call
this._writer.write(this._encoder.encode(...)).catch(_noop) to instead set
this._writerIsClosed = true in their catch handlers.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 38f24f9d-3672-4156-9e99-8643bbd506f2

📥 Commits

Reviewing files that changed from the base of the PR and between c28356e and 529daff.

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

Comment thread src/utils/internal/event-stream.ts
@pi0

pi0 commented Mar 14, 2026

Copy link
Copy Markdown
Member

Can you please provide reproduction + add regression test? (each line of change should be covered by it. it fails without change and fixes after)

productdevbook and others added 3 commits March 14, 2026 20:31
When a write operation fails, mark _writerIsClosed = true to prevent
subsequent push() calls from silently retrying writes on a broken stream.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
- onClosed: verify throwing callback doesn't cause unhandled rejection
- push/pushComment: verify operations don't throw after stream close
- write failure: verify _writerIsClosed is set to true on write error,
  preventing subsequent push() calls from silently retrying writes

The last test fails without the .catch(() => { this._writerIsClosed = true })
fix and passes with it.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Comment thread src/utils/internal/event-stream.ts
Comment thread src/utils/internal/event-stream.ts

@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: 1

🧹 Nitpick comments (1)
test/unit/sse.test.ts (1)

120-137: Accessing private internals is acceptable here for regression testing.

This test verifies the core mechanism added by this PR: setting _writerIsClosed = true on write failure. While accessing private fields via (stream as any) is fragile and will break if internals are renamed, it's justified here since:

  1. The PR objective specifically requires testing that _writerIsClosed is set on write error
  2. There's no public API to observe this internal state
  3. This is a regression test that "fails without change and fixes after" as requested by the reviewer

Consider adding a brief comment explaining why internal access is necessary:

📝 Suggested documentation
     it("marks writer as closed on write failure to prevent silent retries", async () => {
       const event = mockEvent("/");
       const stream = new EventStream(event);
 
-      // Access internals to verify state
+      // Access internals to verify state — required because _writerIsClosed is private
+      // and this regression test needs to confirm the fix for issue `#1322`
       const writeSpy = vi.fn().mockRejectedValue(new Error("write failed"));
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/unit/sse.test.ts` around lines 120 - 137, Add a short explanatory
comment above the lines that access private internals in the test to justify
using (stream as any) to inspect _writerIsClosed and _writer.write: explain this
is a regression test for EventStream.push that must verify the private flag
_writerIsClosed is set on write failure and there is no public API to observe
this state. Keep the test logic unchanged (references: EventStream, push,
_writer.write, _writerIsClosed, writeSpy) and ensure the comment is concise and
placed immediately before the internal access.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@test/unit/sse.test.ts`:
- Around line 78-95: The test registers a Node-only handler with process.on/off
which will blow up in non-Node runtimes; update the test around EventStream,
stream.onClosed and the unhandledRejection handler to be runtime-safe by
guarding access to process: only call process.on("unhandledRejection",
unhandled) and process.off("unhandledRejection", unhandled) if typeof
globalThis.process !== "undefined" && typeof (globalThis.process as any).on ===
"function" (or use globalThis.process?.on/off), or alternatively skip the test
when process is undefined (e.g., wrap the it(...) with a runtime check). Ensure
the guard surrounds both registration and teardown so the EventStream.onClosed
behavior is still asserted in Node while avoiding ReferenceError in other
runtimes.

---

Nitpick comments:
In `@test/unit/sse.test.ts`:
- Around line 120-137: Add a short explanatory comment above the lines that
access private internals in the test to justify using (stream as any) to inspect
_writerIsClosed and _writer.write: explain this is a regression test for
EventStream.push that must verify the private flag _writerIsClosed is set on
write failure and there is no public API to observe this state. Keep the test
logic unchanged (references: EventStream, push, _writer.write, _writerIsClosed,
writeSpy) and ensure the comment is concise and placed immediately before the
internal access.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 8481988f-4807-47a3-9b61-222f7851d12b

📥 Commits

Reviewing files that changed from the base of the PR and between 529daff and f89af0a.

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

Comment thread test/unit/sse.test.ts
Add dedicated regression tests for each write failure catch handler:
- L74: pushComment write failure sets _writerIsClosed
- L89: push (single event) write failure sets _writerIsClosed
- L110: push (batch events) write failure sets _writerIsClosed

All three tests fail without the .catch(() => { _writerIsClosed = true })
fix and pass with it.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

@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.

♻️ Duplicate comments (1)
test/unit/sse.test.ts (1)

77-95: ⚠️ Potential issue | 🟠 Major

Make this test matrix-safe; raw process.on/off is runtime-fragile.

Line 83 and Line 93 rely on Node-only process APIs. This can fail in non-Node targets and bypasses the standard ctx.errors flow. Please move this case to the describeMatrix pattern and use runtime-aware skipping plus matrix error tracking. Also, ensure listener cleanup runs in finally.

#!/bin/bash
set -euo pipefail

echo "Check for runtime-fragile Node process hooks in this test:"
rg -n 'process\.(on|off)\("unhandledRejection"' test/unit/sse.test.ts

echo
echo "Check whether matrix test utilities are used in this file:"
rg -n 'describeMatrix|ctx\.errors|it\.skipIf\(' test/unit/sse.test.ts

As per coding guidelines: test/**/*.test.ts should use describeMatrix, track unhandled errors with ctx.errors, and use runtime-specific skips with it.skipIf(...).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/unit/sse.test.ts` around lines 77 - 95, The test currently attaches
Node-only process handlers directly which is fragile; refactor the "EventStream
onClosed" test to use describeMatrix and the test matrix utilities: wrap the
case in describeMatrix, use it.skipIf(runtime !== "node") or equivalent to skip
non-Node runtimes, replace raw process.on/off with the matrix error tracker
(ctx.errors) to capture unhandled rejections, register the listener via
ctx.errors (or equivalent helper) when testing EventStream.onClosed and ensure
listener removal occurs in a finally block so cleanup always runs; locate
references to EventStream, stream.onClosed, and the test case in this file to
apply the changes.
🧹 Nitpick comments (2)
test/unit/sse.test.ts (2)

8-8: Avoid barrel import from src/index.ts in this .ts test.

Please import mockEvent directly from its defining module instead of ../../src/index.ts.

As per coding guidelines: **/*.ts: “Do not use barrel files — import directly from specific modules”.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/unit/sse.test.ts` at line 8, The test currently uses a barrel import of
mockEvent from ../../src/index.ts; change the import in test/unit/sse.test.ts to
import mockEvent directly from the module that defines it (the file where
mockEvent is declared) instead of the barrel file — update the import statement
to reference that specific module (the module that exports mockEvent) so the
test imports mockEvent directly from its defining source.

97-118: “Stops retrying” needs an observable assertion.

These tests currently validate “does not throw” but don’t directly prove no retry/write attempt occurred. Consider asserting write-call behavior (similar to Lines 120-166) to make the test intent explicit.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/unit/sse.test.ts` around lines 97 - 118, The tests should assert that no
write attempts are made after the stream is closed rather than only asserting no
throw; update both test cases to spy/mock the underlying writer write method and
verify it was not called after close. Specifically, locate the EventStream
instance and its internal writer (refer to EventStream, close, push,
pushComment) and replace or spyOn the writer.write (or the
WritableStreamDefaultWriter.write) with a jest.fn(), call await stream.close(),
then call stream.push/stream.pushComment and assert the mocked write was not
invoked (call count remains 0) for both messages to prove no retry/write
occurred.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@test/unit/sse.test.ts`:
- Around line 77-95: The test currently attaches Node-only process handlers
directly which is fragile; refactor the "EventStream onClosed" test to use
describeMatrix and the test matrix utilities: wrap the case in describeMatrix,
use it.skipIf(runtime !== "node") or equivalent to skip non-Node runtimes,
replace raw process.on/off with the matrix error tracker (ctx.errors) to capture
unhandled rejections, register the listener via ctx.errors (or equivalent
helper) when testing EventStream.onClosed and ensure listener removal occurs in
a finally block so cleanup always runs; locate references to EventStream,
stream.onClosed, and the test case in this file to apply the changes.

---

Nitpick comments:
In `@test/unit/sse.test.ts`:
- Line 8: The test currently uses a barrel import of mockEvent from
../../src/index.ts; change the import in test/unit/sse.test.ts to import
mockEvent directly from the module that defines it (the file where mockEvent is
declared) instead of the barrel file — update the import statement to reference
that specific module (the module that exports mockEvent) so the test imports
mockEvent directly from its defining source.
- Around line 97-118: The tests should assert that no write attempts are made
after the stream is closed rather than only asserting no throw; update both test
cases to spy/mock the underlying writer write method and verify it was not
called after close. Specifically, locate the EventStream instance and its
internal writer (refer to EventStream, close, push, pushComment) and replace or
spyOn the writer.write (or the WritableStreamDefaultWriter.write) with a
jest.fn(), call await stream.close(), then call stream.push/stream.pushComment
and assert the mocked write was not invoked (call count remains 0) for both
messages to prove no retry/write occurred.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 78b90414-64bc-4971-9cf7-7b9b80a0b4b9

📥 Commits

Reviewing files that changed from the base of the PR and between f89af0a and 8e6dfae.

📒 Files selected for processing (1)
  • test/unit/sse.test.ts

Comment thread test/unit/sse.test.ts Outdated
@pi0

pi0 commented Mar 15, 2026

Copy link
Copy Markdown
Member

This seems like a valid fix but still tests are not covering (reverting some changes in src, still tests pass)

@pi0
pi0 marked this pull request as draft March 15, 2026 18:44
productdevbook and others added 3 commits March 16, 2026 10:41
Mark writer as closed when flush() write fails, consistent with the
other write paths. Remove line number references from test descriptions.
@pi0 pi0 changed the title fix(sse): handle errors in EventStream onClosed and write operations fix(sse): mark writer as closed on write failure Mar 19, 2026
@pi0
pi0 marked this pull request as ready for review March 19, 2026 19:17
@pi0
pi0 merged commit ec16b24 into main Mar 19, 2026
7 of 8 checks passed
@pi0
pi0 deleted the fix/event-stream-error-handling branch March 19, 2026 19:20
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.

2 participants