Skip to content

fix: error in the response body should destroy the response stream in Node#3012

Open
gmaclennan wants to merge 2 commits intoardatan:masterfrom
gmaclennan:fix/abort-error-handling
Open

fix: error in the response body should destroy the response stream in Node#3012
gmaclennan wants to merge 2 commits intoardatan:masterfrom
gmaclennan:fix/abort-error-handling

Conversation

@gmaclennan
Copy link
Contributor

Description

When a response stream is errored, the
reader.read() promise rejects. Previously, this
rejection was unhandled, causing an unhandled rejection. Also, this normally results in the request stream closing with an error, which was resulting in an unhandled error when trying to cancel (the already errored) response body stream.

This fix:

  • Catches error and destroys the response stream / closes the socket
  • Catches any error from reader.cancel() to prevent unhandled rejections

Related #3011

Type of change

Please delete options that are not relevant.

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as
    expected)
  • This change requires a documentation update

Screenshots/Sandbox (if appropriate/relevant):

Adding links to sandbox or providing screenshots can help us understand more about this PR and take
action on it as appropriate

How Has This Been Tested?

I have tested this in our codebase and it fixes the issue which was being caught in our tests.

I have tried to write a test for this but I was unable to get the tests running locally and I could not find any instructions in the repo.

Test Environment:

  • OS: MacOS
  • package-name: @whatwg-node/server
  • NodeJS: v22.20.0

Checklist:

  • I have followed the
    CONTRIBUTING doc and the
    style guidelines of this project
  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests and linter rules pass locally with my changes
  • Any dependent changes have been merged and published in downstream modules

Further comments

If this is a relatively large or complex change, kick off the discussion by explaining why you chose
the solution you did and what alternatives you considered, etc...

…nt unhandled rejections

When a response stream is errored, the
reader.read() promise rejects. Previously, this
rejection was unhandled, causing 'Unexpected error
while handling request' to be logged.

This fix:
- Catches error and destroys the response stream / closes the socket
- Catches any error from reader.cancel() to prevent unhandled rejections

fixes ardatan#3011
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Dec 19, 2025

📝 Walkthrough

Summary by CodeRabbit

  • Bug Fixes

    • Improved error handling for stream cancellations and connection closures to prevent unhandled error scenarios.
    • Enhanced robustness of response streaming when downstream connections encounter errors or are interrupted.
  • Tests

    • Added test coverage to verify proper socket closure behavior when response streams are aborted.

✏️ Tip: You can customize this high-level summary in your review settings.

Walkthrough

The changes enhance error handling in the streaming response pipeline by destroying the server response when downstream errors occur during chunk writing, and by gracefully ignoring failures from reader cancellation. A test verifies that aborting a response stream properly closes the underlying socket.

Changes

Cohort / File(s) Summary
Streaming error handling
packages/server/src/utils.ts
Added error handling to gracefully ignore reader.cancel() failures with a catch block, and added downstream error handling in the pump flow to destroy the serverResponse when errors occur during safeWrite, or re-throw if already destroyed.
Abort and socket closure test
packages/server/test/abort.spec.ts
Added new test case verifying that aborting a response stream (via TransformStream and AbortController) properly closes the underlying socket and completes the read flow.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

  • Requires careful verification that error propagation does not cause double-destruction or introduce memory leaks
  • Need to understand interaction between reader.cancel(), safeWrite(), and response.destroy() semantics
  • Test case must properly exercise all new error paths, particularly the serverResponse destruction logic in different states

Possibly related issues

Possibly related PRs

Suggested reviewers

  • enisdenjo
  • EmrysMyrddin
  • dotansimha

Poem

🐰 A stream flows downstream with grace and care,
But when errors bubble through the air,
We catch them close and destroy with might,
The socket sleeps and all is right!
hop hop

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main fix: error handling in the response body that destroys the response stream in Node, which aligns with the core changes made to sendReadableStream.
Description check ✅ Passed The description clearly explains the bug, the fix, and provides context about error handling in response streams and reader cancellation, all of which relate to the changes made.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

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.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between c91f052 and 75927e1.

📒 Files selected for processing (2)
  • packages/server/src/utils.ts (2 hunks)
  • packages/server/test/abort.spec.ts (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
packages/server/test/abort.spec.ts (2)
packages/promise-helpers/src/index.ts (2)
  • resolve (114-116)
  • reject (117-119)
packages/server/src/createServerAdapter.ts (1)
  • createServerAdapter (556-556)
🪛 GitHub Check: unit / bun
packages/server/test/abort.spec.ts

[failure] 85-85: error: Expected reader to be closed

  at <anonymous> (/home/runner/work/whatwg-node/whatwg-node/packages/server/test/abort.spec.ts:85:30)

[failure] 44-44: AbortError: The operation was aborted.

  at /home/runner/work/whatwg-node/whatwg-node/packages/server/test/abort.spec.ts:44
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (11)
  • GitHub Check: server (undici)
  • GitHub Check: unit / node 24
  • GitHub Check: unit / node 18
  • GitHub Check: unit / node 25
  • GitHub Check: unit / node 20
  • GitHub Check: server (ponyfill)
  • GitHub Check: node-fetch (noConsumeBody)
  • GitHub Check: node-fetch (consumeBody)
  • GitHub Check: unit / deno
  • GitHub Check: server (native)
  • GitHub Check: server (uws)
🔇 Additional comments (2)
packages/server/src/utils.ts (1)

400-401: LGTM! Prevents unhandled rejections from reader cancellation.

The .catch(() => {}) properly handles cases where reader.cancel() fails (e.g., stream already closed or errored), preventing unhandled promise rejections.

packages/server/test/abort.spec.ts (1)

40-100: Test expectations may not align with stream error propagation behavior.

The test expects reader.read() to throw an error after abort, but aborting a pipeTo operation cancels the source and aborts the destination stream, which may cause the reader to complete normally (done=true) rather than throwing. Verify whether the abort signal propagation through the transform stream properly errors the client-side reader, or if it only causes a graceful EOF. Check if serverResponse destruction sends an error frame (e.g., RST_STREAM with error code) or just closes the connection, as this affects whether the client reader receives an error or normal completion.

Comment on lines +410 to +418
)
.catch(err => {
if (!serverResponse.destroyed) {
serverResponse.destroy(err);
} else {
// If the serverResponse is already destroyed, re-throw the error to be handled upstream
throw err;
}
});
Copy link
Contributor

@coderabbitai coderabbitai bot Dec 19, 2025

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Potential unhandled rejection when re-throwing after serverResponse is already destroyed.

The re-throw at line 416 could cause an unhandled rejection because sendReadableStream() returns this promise (line 420), but the caller at line 385 in sendNodeResponse() doesn't handle the returned promise. When serverResponse is already destroyed and the error is re-thrown, there's no upstream catch handler.

Consider removing the else branch and the re-throw. If serverResponse is already destroyed, there's nothing more to do—the socket is closed and further error propagation won't help.

🔎 Proposed fix
      .catch(err => {
        if (!serverResponse.destroyed) {
          serverResponse.destroy(err);
-       } else {
-         // If the serverResponse is already destroyed, re-throw the error to be handled upstream
-         throw err;
        }
      });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
)
.catch(err => {
if (!serverResponse.destroyed) {
serverResponse.destroy(err);
} else {
// If the serverResponse is already destroyed, re-throw the error to be handled upstream
throw err;
}
});
)
.catch(err => {
if (!serverResponse.destroyed) {
serverResponse.destroy(err);
}
});
🤖 Prompt for AI Agents
In packages/server/src/utils.ts around lines 410 to 418, the catch handler
re-throws the error when serverResponse is already destroyed which can cause an
unhandled promise rejection because the caller doesn't await or catch the
returned promise; remove the else branch and the re-throw so that when
serverResponse.destroyed is true the error is not re-thrown (optionally
log/debug it instead), leaving only the serverResponse.destroy(err) path and
ensuring the promise settles without propagating the error upstream.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@ardatan not sure what you want to do here. Previously the error was thrown (potentially unhandled), and this change ensures the serverResponse is destroyed when there is an error, but I re-throw to maintain existing behaviour (this thrown error is caught by the wrapper). I could change this to swallow the error, but not sure that's the right approach?

Copy link
Contributor

Choose a reason for hiding this comment

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

Seems like the humans are having a chat. I'll hop back into my burrow for now. If you need me again, just tag @coderabbitai in a new comment, and I'll come hopping out!

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