Skip to content

fix(http2): don't emit 'error' for RST_STREAM with NO_ERROR/CANCEL#29035

Open
cirospaciari wants to merge 2 commits intomainfrom
claude/h2-rst-no-error-on-cancel
Open

fix(http2): don't emit 'error' for RST_STREAM with NO_ERROR/CANCEL#29035
cirospaciari wants to merge 2 commits intomainfrom
claude/h2-rst-no-error-on-cancel

Conversation

@cirospaciari
Copy link
Copy Markdown
Member

RFC 9113 §8.1: NO_ERROR(0) and CANCEL(8) are non-error stream closures. Node.js emits 'aborted' + 'close' for these but NOT 'error'. Bun's emitStreamErrorNT generated ERR_HTTP2_STREAM_ERROR for any non-zero code. Now skips it for NO_ERROR and CANCEL while still setting rstCode (now also set when no 'error' listener is attached).

Ports Node's test/parallel/test-http2-server-rst-stream.js.

RFC 9113 §8.1: NO_ERROR(0) and CANCEL(8) are non-error stream closures. Node.js emits 'aborted' + 'close' for these but NOT 'error'. Bun's emitStreamErrorNT generated ERR_HTTP2_STREAM_ERROR for any non-zero code. Now skips it for NO_ERROR and CANCEL while still setting rstCode (now also set when no 'error' listener is attached).

Ports Node's `test/parallel/test-http2-server-rst-stream.js`.
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 8, 2026

Walkthrough

Updated HTTP/2 stream error handling to distinguish between error and non-error closure codes (NGHTTP2_NO_ERROR, NGHTTP2_CANCEL), recording stream reset codes while conditionally synthesizing error instances only when error listeners are present. Added a comprehensive HTTP/2 RST stream test suite.

Changes

Cohort / File(s) Summary
HTTP/2 Stream Error Handling
src/js/node/http2.ts
Modified emitStreamErrorNT() to always record numeric closure codes in stream.rstCode, but only construct Error instances for actual error codes when error listeners are registered. Treats NGHTTP2_NO_ERROR and NGHTTP2_CANCEL as non-error closures to prevent unnecessary crashes.
HTTP/2 RST Stream Testing
test/js/node/test/parallel/test-http2-server-rst-stream.js
New test suite that verifies HTTP/2 RST stream behavior across different closure codes, confirming that req.rstCode matches sent codes and error events are emitted only for actual error conditions.
🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the primary change: preventing 'error' events for HTTP/2 RST_STREAM with NO_ERROR/CANCEL codes, which matches the main focus of the changeset.
Description check ✅ Passed The description addresses the template requirements by explaining what the PR does (RFC 9113 compliance for RST codes) but lacks explicit verification details about how the changes were tested.

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


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

@robobun
Copy link
Copy Markdown
Collaborator

robobun commented Apr 8, 2026

Updated 10:23 PM PT - Apr 13th, 2026

@cirospaciari, your commit 90e2c71 has some failures in Build #45570 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 29035

That installs a local version of the PR into your bun-29035 executable, so you can run:

bun-29035 --bun

Comment thread src/js/node/http2.ts
Copy link
Copy Markdown
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

🤖 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/js/node/http2.ts`:
- Around line 2671-2684: The code currently avoids setting stream.rstCode when a
numeric RST arrives and there is no "error" listener, which loses the peer's
reset reason; change the logic in the numeric-error branch so stream.rstCode =
error is always assigned for numeric rst codes (i.e., unconditionally set
stream.rstCode), but only create/suppress a synthesized Error (via
streamErrorFromCode) when stream.listenerCount("error") > 0; then update
Http2Stream._destroy (and any usage of rstNextTick) to check the same
hasErrorListener condition before synthesizing or emitting
ERR_HTTP2_STREAM_ERROR so no synthetic error is produced when no error listener
exists while the actual rstCode is preserved.
🪄 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: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 861c662c-cad2-4d6a-a50b-83edd4b596aa

📥 Commits

Reviewing files that changed from the base of the PR and between 1afabdd and 90e2c71.

📒 Files selected for processing (2)
  • src/js/node/http2.ts
  • test/js/node/test/parallel/test-http2-server-rst-stream.js

Comment thread src/js/node/http2.ts
Comment on lines +2671 to +2684
if (typeof error === "number") {
// RFC 9113 §8.1: NO_ERROR and CANCEL are non-error closures. For those we
// always record rstCode so it is observable from 'close'. For real error
// codes we only record rstCode (and build an Error) when an 'error'
// listener exists; otherwise _destroy would synthesize an unhandled
// ERR_HTTP2_STREAM_ERROR and crash the process.
const isErrorCode = error !== NGHTTP2_NO_ERROR && error !== NGHTTP2_CANCEL;
if (!isErrorCode || stream.listenerCount("error") > 0) {
stream.rstCode = error;
if (error != 0) {
if (isErrorCode) {
error_instance = streamErrorFromCode(error);
}
} else {
error_instance = error;
}
} else if (stream.listenerCount("error") > 0) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Don't drop the received rstCode when no "error" listener is attached.

For real RST codes, this guard skips stream.rstCode = error unless the stream already has an "error" listener. Http2Stream._destroy() then treats that as “no reset code”, falls back to 0, and close observers lose the peer's actual reason. It also makes the deferred rstNextTick() use 0 instead of the received reset code. Keep rstCode unconditional here and only suppress the synthesized ERR_HTTP2_STREAM_ERROR.

🔧 Suggested direction
-      const isErrorCode = error !== NGHTTP2_NO_ERROR && error !== NGHTTP2_CANCEL;
-      if (!isErrorCode || stream.listenerCount("error") > 0) {
-        stream.rstCode = error;
-        if (isErrorCode) {
-          error_instance = streamErrorFromCode(error);
-        }
-      }
+      const isErrorCode = error !== NGHTTP2_NO_ERROR && error !== NGHTTP2_CANCEL;
+      const hasErrorListener = stream.listenerCount("error") > 0;
+      stream.rstCode = error;
+      if (isErrorCode && hasErrorListener) {
+        error_instance = streamErrorFromCode(error);
+      }

Http2Stream._destroy() then needs the matching suppression check so it does not synthesize ERR_HTTP2_STREAM_ERROR when hasErrorListener is false.

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

In `@src/js/node/http2.ts` around lines 2671 - 2684, The code currently avoids
setting stream.rstCode when a numeric RST arrives and there is no "error"
listener, which loses the peer's reset reason; change the logic in the
numeric-error branch so stream.rstCode = error is always assigned for numeric
rst codes (i.e., unconditionally set stream.rstCode), but only create/suppress a
synthesized Error (via streamErrorFromCode) when stream.listenerCount("error") >
0; then update Http2Stream._destroy (and any usage of rstNextTick) to check the
same hasErrorListener condition before synthesizing or emitting
ERR_HTTP2_STREAM_ERROR so no synthetic error is produced when no error listener
exists while the actual rstCode is preserved.

Copy link
Copy Markdown
Contributor

@claude claude Bot left a comment

Choose a reason for hiding this comment

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

The follow-up commit addressing my previous concern is in place — the assignment is correctly guarded. Remaining open item is a nit (stale in the type annotation).

Extended reasoning...

Overview

This PR fixes RFC 9113 §8.1 compliance: NO_ERROR (0) and CANCEL (8) RST_STREAM codes should not emit an error event on the stream. The changes are confined to emitStreamErrorNT in src/js/node/http2.ts and an added test file porting Node's test-http2-server-rst-stream.js.

The fix from my previous review

My prior review flagged that the original commit e0c0cea moved stream.rstCode = error to an unconditional position, which could crash processes receiving non-NO_ERROR/CANCEL RST frames on streams without error listeners. The follow-up commit 90e2c71 ("guard rstCode assignment for error codes behind listener check") correctly addresses this: the assignment now sits inside if (\!isErrorCode || stream.listenerCount("error") > 0), matching the semantics of the old code for actual error codes while adding the new observability for NO_ERROR/CANCEL.

Security risks

None — this is HTTP/2 stream lifecycle logic with no auth, crypto, or permissions involved.

Level of scrutiny

Medium. HTTP/2 stream error handling is nuanced, but the logic is well-commented and the change is targeted. The three code paths (NO_ERROR/CANCEL always-set, error code with listener, error code without listener) all behave correctly. The outstanding nit is a stale | number in the Error | number | undefined type annotation — error_instance can only hold Error | undefined after the refactor — but this has no runtime impact.

Other factors

The ported test validates NO_ERROR, CANCEL, PROTOCOL_ERROR, REFUSED_STREAM, and INTERNAL_ERROR cases. It does not test the no-listener path for error codes, but since that path intentionally produces no error (same as before), the absence is acceptable. The code's inline comment explicitly documents the reasoning.

Comment thread src/js/node/http2.ts
@@ -2668,15 +2668,21 @@ function emitConnectNT(self, socket) {
function emitStreamErrorNT(self, stream, error, destroy, destroy_self) {
if (stream) {
let error_instance: Error | number | undefined = undefined;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 The type annotation let error_instance: Error | number | undefined at line 2670 includes a stale number branch that can never be assigned — error_instance is only ever set to an Error (from streamErrorFromCode) or left undefined, so the annotation should be narrowed to Error | undefined.

Extended reasoning...

What the bug is

In emitStreamErrorNT (line 2670), error_instance is declared as Error | number | undefined. After the PR's refactoring, the number type in this union is dead code — error_instance can never actually hold a number value.

The specific code paths

There are exactly two places where error_instance is assigned after the refactor:

  1. error_instance = streamErrorFromCode(error) — inside the if (typeof error === "number") branch. streamErrorFromCode returns an Error object, never a number.
  2. error_instance = error — inside the else if (stream.listenerCount("error") > 0) branch. This branch is only reached when typeof error \!== "number", so error is already narrowed by TypeScript to Error (not a number).

Neither path can assign a number to error_instance.

Why existing code doesn't prevent it

This is purely a TypeScript type annotation issue. The stale | number in the union declaration is not guarded by any runtime check because it is never reachable — TypeScript would not raise an error at the assignment sites (they're all compatible), but the declared type is wider than necessary.

Historical context / pre-existing nature

One verifier notes this was technically stale even before the PR: in the old code, the else branch also only executed when typeof error \!== "number". The PR refactored the surrounding logic but did not clean up the annotation. However, since this PR directly modifies this exact line and restructures the relevant control flow, it is the natural place to fix the annotation.

Impact

No runtime impact — TypeScript type annotations are erased at runtime. However, the overly-wide type could mislead future readers or inhibit TypeScript's ability to narrow error_instance in downstream code (e.g., forcing unnecessary typeof guards).

How to fix it

Change line 2670 from:

let error_instance: Error | number | undefined = undefined;

to:

let error_instance: Error | undefined = undefined;

Step-by-step proof

  1. emitStreamErrorNT is called with error = 5 (NGHTTP2_STREAM_CLOSED, a number).
  2. The typeof error === "number" branch is entered.
  3. isErrorCode = 5 \!== 0 && 5 \!== 8true.
  4. stream.rstCode = 5 is set; error_instance = streamErrorFromCode(5) → assigns an Error.
  5. error_instance is now an Error, never a number.

Alternatively, with error as an Error object:

  1. The else if (stream.listenerCount("error") > 0) branch fires.
  2. error_instance = errorerror here has type Error (TypeScript narrowed it to non-number).
  3. Again, never a number.

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