Skip to content

fix(event-stream): do not emit unhandled rejection when readable side is canceled#1376

Merged
pi0 merged 4 commits into
h3js:mainfrom
dyeske61283:main
Apr 27, 2026
Merged

fix(event-stream): do not emit unhandled rejection when readable side is canceled#1376
pi0 merged 4 commits into
h3js:mainfrom
dyeske61283:main

Conversation

@dyeske61283

@dyeske61283 dyeske61283 commented Apr 25, 2026

Copy link
Copy Markdown
Contributor

Per WHATWG Streams, cancelling a TransformStream's readable errors its writable with the same reason. writer.closed therefore rejects with the string 'resource closed'.

H3's .then(() => { this._writerIsClosed = true }) has no .catch, so the rejection bubbles to a top-level unhandled promise rejection and Deno terminates the process.

H3 does correctly .catch(...) the per-write rejections (this._writer.write(...).catch(() => { this._writerIsClosed = true })), so the design intent is clearly to swallow these — it just forgot the same .catch on .closed. Adding one to that .then would fix this regardless of runtime.

Summary by CodeRabbit

  • Bug Fixes
    • Improved event-stream closure handling so the stream-writer's closed state is recognized whether closure succeeds or fails, reducing unhandled promise rejections.
    • Makes downstream checks for writer closure more reliable, preventing spurious runtime errors when streams close unexpectedly.
    • No changes to public APIs; behavior is more robust without altering interfaces.

@dyeske61283
dyeske61283 requested a review from pi0 as a code owner April 25, 2026 17:57
@coderabbitai

coderabbitai Bot commented Apr 25, 2026

Copy link
Copy Markdown

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The EventStream constructor now sets _writerIsClosed = true in a .finally(...) handler on this._writer.closed, ensuring the flag is updated whether the writable stream’s closed promise fulfills or rejects.

Changes

Cohort / File(s) Summary
EventStream writer closed handling
src/utils/internal/event-stream.ts
Use .catch(_noop).finally(...) on this._writer.closed to set _writerIsClosed = true regardless of promise resolution or rejection, ensuring closed state is recorded and avoiding unhandled rejections.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

Possibly related issues

Possibly related PRs

Poem

🐰 I found a dangling promise on the trail at night,
I wrapped it in a finally and set its flag aright.
No stray rejections now, the stream can safely sleep,
I hop off through the grasses — the code is tidy, neat. ✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title accurately describes the main change: preventing unhandled rejections when the readable side is canceled by adding a catch handler to the writer.closed promise.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

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

🧹 Nitpick comments (1)
src/utils/internal/event-stream.ts (1)

24-28: LGTM — fix is correct and consistent with existing pattern.

Attaching .catch(_noop) to the writer.closed chain correctly prevents the unhandled rejection that occurs when the underlying TransformStream's readable side is cancelled (which rejects writer.closed per WHATWG Streams). The pattern mirrors the existing onClosed implementation at line 164 (this._writer.closed.then(cb).catch(_noop)) and the per-write .catch(...) handlers throughout the class, so the intent to swallow these errors is consistent.

One minor note for consideration: the .then(...) callback sets _writerIsClosed = true only on resolution, but on rejection (the cancel case) _writerIsClosed is left as false. In practice the per-write .catch handlers also flip the flag and close() guards on _disposed, so this is unlikely to cause issues — but if you want the flag to reflect "writer is no longer usable" in both outcomes, you could set it in the catch as well:

Optional refinement
     this._writer.closed
       .then(() => {
         this._writerIsClosed = true;
       })
-      .catch(_noop);
+      .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 24 - 28, The writer.closed
promise only sets this._writerIsClosed = true on resolution, leaving the flag
false on rejection; update the writer.closed handling (the
this._writer.closed.then(...).catch(_noop) block) so that this._writerIsClosed
is set when the promise rejects as well (either by setting the flag in the catch
or using a finally-equivalent) to reflect that the writer is no longer usable;
refer to this._writer.closed, this._writerIsClosed, _noop and mirror the pattern
used by onClosed and the per-write .catch handlers.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@src/utils/internal/event-stream.ts`:
- Around line 24-28: The writer.closed promise only sets this._writerIsClosed =
true on resolution, leaving the flag false on rejection; update the
writer.closed handling (the this._writer.closed.then(...).catch(_noop) block) so
that this._writerIsClosed is set when the promise rejects as well (either by
setting the flag in the catch or using a finally-equivalent) to reflect that the
writer is no longer usable; refer to this._writer.closed, this._writerIsClosed,
_noop and mirror the pattern used by onClosed and the per-write .catch handlers.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 3e5ed762-e4a4-45c8-b75c-b28da2791ad5

📥 Commits

Reviewing files that changed from the base of the PR and between e2f8cbd and f243081.

📒 Files selected for processing (1)
  • 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.

🧹 Nitpick comments (1)
src/utils/internal/event-stream.ts (1)

24-30: LGTM — correctly handles the rejection of writer.closed.

This addresses the unhandled rejection cleanly and is consistent with the existing .catch(() => { this._writerIsClosed = true }) pattern used on per-write calls (lines 78, 95, 114, 137). On Deno specifically, an unhandled rejection from the closed promise (e.g., "resource closed" after the readable side is cancelled) would terminate the process, so handling it here is the right fix.

♻️ Optional: collapse the duplicated body into a single handler

Both branches do the same thing, so you can equivalently write it as:

-    this._writer.closed
-      .then(() => {
-        this._writerIsClosed = true;
-      })
-      .catch(() => {
-        this._writerIsClosed = true;
-      });
+    this._writer.closed.finally(() => {
+      this._writerIsClosed = true;
+    });

Note: .finally() still propagates the rejection, so chain a .catch(_noop) if you adopt this — or keep the current explicit two-handler form, which is already clear and correct.

-    this._writer.closed
-      .then(() => {
-        this._writerIsClosed = true;
-      })
-      .catch(() => {
-        this._writerIsClosed = true;
-      });
+    this._writer.closed
+      .finally(() => {
+        this._writerIsClosed = true;
+      })
+      .catch(_noop);
🤖 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 24 - 30, The current
.then(...).catch(...) on this._writer.closed sets this._writerIsClosed in both
branches; simplify by replacing the two-branch pattern with a single finally
handler followed by a swallow-catch to avoid propagating rejections: call
this._writer.closed.finally(() => set this._writerIsClosed = true) and then
chain a .catch(_) noop to swallow any error; reference this._writer.closed and
the this._writerIsClosed flag when making the change.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@src/utils/internal/event-stream.ts`:
- Around line 24-30: The current .then(...).catch(...) on this._writer.closed
sets this._writerIsClosed in both branches; simplify by replacing the two-branch
pattern with a single finally handler followed by a swallow-catch to avoid
propagating rejections: call this._writer.closed.finally(() => set
this._writerIsClosed = true) and then chain a .catch(_) noop to swallow any
error; reference this._writer.closed and the this._writerIsClosed flag when
making the change.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 7f7b98b1-cf03-482a-838d-374ba59166c8

📥 Commits

Reviewing files that changed from the base of the PR and between f243081 and 7a9a115.

📒 Files selected for processing (1)
  • 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

🤖 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`:
- Around line 24-27: The call to this._writer.closed.finally(...) can still
produce an unhandled rejection if the closed promise rejects; update the chain
to swallow rejections by adding .catch(this._noop) after the .finally(...) so
that this._writerIsClosed = true is still set but any rejection from
this._writer.closed is handled (use the existing _noop helper, consistent with
the pattern used in onClosed()).
🪄 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: 0f35a60b-68f9-4653-91fc-fe06be00f7c0

📥 Commits

Reviewing files that changed from the base of the PR and between 7a9a115 and 3200ba3.

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

Comment thread src/utils/internal/event-stream.ts Outdated

@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)
src/utils/internal/event-stream.ts (1)

24-27: ⚠️ Potential issue | 🔴 Critical

.finally() alone does not prevent the unhandled rejection — the bug from #1375 is still present.

Promise.prototype.finally(onFinally) returns a promise that adopts the original promise's settled state. If this._writer.closed rejects (which is exactly what happens when the TransformStream is cancelled, per WHATWG Streams), the promise returned by .finally(...) rejects with the same reason. Since nothing catches it, the unhandled rejection still escapes and Deno will still terminate the process — defeating the stated purpose of this PR.

The PR description even says "adds a noop .catch", but the implementation uses .finally() without one. The file already exposes _noop (line 4) and the correct pattern is used in onClosed() at line 163 (.then(cb).catch(_noop)).

🔧 Suggested fix
     this._writer.closed
       .finally(() => {
         this._writerIsClosed = true;
-      });
+      })
+      .catch(_noop);
🤖 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 24 - 27, The current use of
this._writer.closed.finally(() => { this._writerIsClosed = true; }) still allows
rejections to escape; update the chain to swallow rejections by appending a
catch that uses the existing _noop (e.g., this._writer.closed.finally(() => {
this._writerIsClosed = true; }).catch(_noop)) so the rejection from the
TransformStream cancellation is handled (mirror the pattern used in onClosed()).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@src/utils/internal/event-stream.ts`:
- Around line 24-27: The current use of this._writer.closed.finally(() => {
this._writerIsClosed = true; }) still allows rejections to escape; update the
chain to swallow rejections by appending a catch that uses the existing _noop
(e.g., this._writer.closed.finally(() => { this._writerIsClosed = true;
}).catch(_noop)) so the rejection from the TransformStream cancellation is
handled (mirror the pattern used in onClosed()).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: b10d8b37-2b5e-4dc7-88ad-8644a2dc981b

📥 Commits

Reviewing files that changed from the base of the PR and between 3200ba3 and b19d086.

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

@pi0

pi0 commented Apr 26, 2026

Copy link
Copy Markdown
Member

Can you please add a regression test? 🙏🏼

@dyeske61283

Copy link
Copy Markdown
Contributor Author

Done @pi0 :)

Kevin Dyes and others added 3 commits April 27, 2026 22:35
Per WHATWG Streams, cancelling a TransformStream's readable errors its writable with the same reason. writer.closed therefore rejects with the string 'resource closed'.
H3's .then(() => { this._writerIsClosed = true }) has no .catch, so the rejection bubbles to a top-level unhandled promise rejection and Deno terminates the process.
H3 does correctly .catch(...) the per-write rejections (this._writer.write(...).catch(() => { this._writerIsClosed = true })), so the design intent is clearly to swallow these — it just forgot the same .catch on .closed. Adding one to that .then would fix this regardless of runtime.
@pi0 pi0 changed the title resolves #1375: add a noop catch to prevent an uncaught exception fix(event-stream): do not emit unhandled rejection when readable side is canceled Apr 27, 2026

@pi0 pi0 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

thnx!

@pkg-pr-new

pkg-pr-new Bot commented Apr 27, 2026

Copy link
Copy Markdown

Open in StackBlitz

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

commit: a2f016a

@pi0
pi0 merged commit d77b673 into h3js:main Apr 27, 2026
6 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.

2 participants