fix(http2): don't emit 'error' for RST_STREAM with NO_ERROR/CANCEL#29035
fix(http2): don't emit 'error' for RST_STREAM with NO_ERROR/CANCEL#29035cirospaciari wants to merge 2 commits intomainfrom
Conversation
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`.
WalkthroughUpdated HTTP/2 stream error handling to distinguish between error and non-error closure codes ( Changes
🚥 Pre-merge checks | ✅ 2✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Comment |
|
Updated 10:23 PM PT - Apr 13th, 2026
❌ @cirospaciari, your commit 90e2c71 has some failures in 🧪 To try this PR locally: bunx bun-pr 29035That installs a local version of the PR into your bun-29035 --bun |
There was a problem hiding this comment.
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
📒 Files selected for processing (2)
src/js/node/http2.tstest/js/node/test/parallel/test-http2-server-rst-stream.js
| 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) { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| @@ -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; | |||
There was a problem hiding this comment.
🟡 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:
error_instance = streamErrorFromCode(error)— inside theif (typeof error === "number")branch.streamErrorFromCodereturns anErrorobject, never a number.error_instance = error— inside theelse if (stream.listenerCount("error") > 0)branch. This branch is only reached whentypeof error \!== "number", soerroris already narrowed by TypeScript toError(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
emitStreamErrorNTis called witherror = 5(NGHTTP2_STREAM_CLOSED, a number).- The
typeof error === "number"branch is entered. isErrorCode = 5 \!== 0 && 5 \!== 8→true.stream.rstCode = 5is set;error_instance = streamErrorFromCode(5)→ assigns anError.error_instanceis now anError, never a number.
Alternatively, with error as an Error object:
- The
else if (stream.listenerCount("error") > 0)branch fires. error_instance = error—errorhere has typeError(TypeScript narrowed it to non-number).- Again, never a number.
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.