Skip to content

fix(http2): propagate session.destroy(error) to in-flight stream 'error' listeners#29056

Open
cirospaciari wants to merge 3 commits intomainfrom
claude/h2-session-destroy-error-to-streams
Open

fix(http2): propagate session.destroy(error) to in-flight stream 'error' listeners#29056
cirospaciari wants to merge 3 commits intomainfrom
claude/h2-session-destroy-error-to-streams

Conversation

@cirospaciari
Copy link
Copy Markdown
Member

Node's closeSession destroys open streams with the original session error (lib/internal/http2/core.js:1222). Bun's destroy() called parser.emitErrorToAllStreams(code), which surfaced to streams as a numeric code that emitStreamErrorNT mapped to ERR_HTTP2_STREAM_ERROR (or nothing for code 0), losing the original Error.

Now destroy() stores the Error on the session via a private symbol before tearing down streams; emitStreamErrorNT reads it and uses that as the stream's error when an 'error' listener exists, matching Node semantics.

This is a partial step toward Node tests test-http2-propagate-session-destroy-code.js and test-http2-server-shutdown-before-respond.js (those also need session.closed timing changes shipped separately).

…or' listeners

Node's closeSession destroys open streams with the original session error
(lib/internal/http2/core.js:1222-1224). Bun's destroy() called
parser.emitErrorToAllStreams(code), which surfaced to streams as a
numeric code that emitStreamErrorNT mapped to ERR_HTTP2_STREAM_ERROR
(or nothing for code 0), losing the original Error.

Now destroy() stores the Error on the session via
bunHTTP2SessionDestroyError before tearing down streams; emitStreamErrorNT
reads it and uses that as the stream's error_instance when an 'error'
listener exists, matching Node semantics.

Partial step toward Node tests test-http2-propagate-session-destroy-code.js
and test-http2-server-shutdown-before-respond.js (those also need
session.closed timing changes).
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 8, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: f46db042-1ee0-469c-a3f0-64ba8d8906a8

📥 Commits

Reviewing files that changed from the base of the PR and between 2f4c595 and 7ed9d91.

📒 Files selected for processing (1)
  • src/js/node/http2.ts

Walkthrough

Persist a session-scoped destroy-time error in HTTP/2 sessions and prefer that stored error when emitting per-stream "error" during teardown; numeric destroy codes remain mapped to stream rstCode. Server and client destroy() flows now record non-numeric caller errors to the session symbol before propagating errors to streams.

Changes

Cohort / File(s) Summary
HTTP/2 session core
src/js/node/http2.ts
Added a session-scoped symbol to retain the most relevant destroy-time error. emitStreamErrorNT now prefers the stored session destroy error over the incoming error when a stream has an "error" listener; if the incoming error is numeric and a session error is used, stream.rstCode is set to that numeric value. ServerHttp2Session.destroy() records caller-supplied non-numeric Error (as streamDestroyError) to the session symbol before parser.emitErrorToAllStreams(...); numeric destroy codes continue via the existing code path. ClientHttp2Session.destroy() stores error on the session symbol when it is not undefined.
HTTP/2 session test
test/js/node/http2/h2-session-destroy-stream-error.test.ts
New test creating a server and client, starting an in-flight stream, calling client.destroy() with a custom Error (code: "ERR_HTTP2_SESSION_ERROR"), and asserting the in-flight stream's "error" listener receives that exact Error instance. Includes cleanup of client and server.
🚥 Pre-merge checks | ✅ 1 | ❌ 1

❌ Failed checks (1 inconclusive)

Check name Status Explanation Resolution
Description check ❓ Inconclusive The PR description comprehensively explains what was changed and why, but does not include the 'How did you verify your code works?' section required by the template. Add a 'How did you verify your code works?' section describing the testing approach (e.g., reference to the new test file or Node test compatibility).
✅ Passed checks (1 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: propagating session.destroy(error) to in-flight stream error listeners in HTTP/2, matching the core objective of this pull request.

✏️ 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:09 PM PT - Apr 13th, 2026

@cirospaciari, your commit 7ed9d91 has 1 failures in Build #45594 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 29056

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

bun-29056 --bun

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.

LGTM — clean targeted fix with appropriate test coverage.

Extended reasoning...

Overview

The PR modifies src/js/node/http2.ts to fix error propagation when session.destroy(error) is called with an Error object. It introduces a module-private symbol bunHTTP2SessionDestroyError, stores the original error on the session before tearing down streams, then reads it back in emitStreamErrorNT so stream error listeners receive the original Error rather than a numeric code. A new test file is added to verify the behavior.

Security risks

No security concerns. The fix deals with error propagation semantics in HTTP/2 stream teardown and does not touch auth, crypto, or permissions. The new symbol is not exported via Symbol.for, keeping it module-private.

Level of scrutiny

Low. This is a small, targeted bug fix (two nearly identical one-liners in the destroy paths, one conditional reordering in emitStreamErrorNT) that aligns Bun's behavior with Node's documented semantics. The change is mechanical and the test directly validates the new behavior.

Other factors

No bugs were flagged by the automated hunting system. The test cleanly exercises the fix using Promise.withResolvers and properly tears down both client and server. The PR description transparently notes what is left out of scope for follow-up work.

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.

LGTM — clean, well-tested Node-semantics fix for HTTP/2 session destroy error propagation.

Extended reasoning...

Overview

Modifies src/js/node/http2.ts to propagate the original Error passed to session.destroy(error) down to in-flight streams' error listeners, matching Node.js behavior. The change is ~10 lines across three call sites: a new private symbol bunHTTP2SessionDestroyError, a write in both ServerHttp2Session.destroy and ClientHttp2Session.destroy, and a read in emitStreamErrorNT. A new test file validates same-instance identity of the error.

Security Risks

None. This is purely error-propagation logic with no auth, crypto, or permission-model involvement.

Level of Scrutiny

Low. The change is small, symmetrically applied to both session classes, and has a direct test. The logic is straightforward and the description clearly references the upstream Node.js source for the intended behavior.

Other Factors

No bugs found by the automated bug-hunting system. CodeRabbitAI also found no actionable issues. The CI build failures in the timeline are cpp/zig failures on the preceding commit; this PR only touches TypeScript, so those failures are unrelated. The autofix.ci commit resolved formatting concerns.

Comment thread src/js/node/http2.ts
Comment on lines 2670 to 2680
if (stream) {
let error_instance: Error | number | undefined = undefined;
if (stream.listenerCount("error") > 0) {
if (typeof error === "number") {
const sessionErr = self?.[bunHTTP2SessionDestroyError];
if (sessionErr !== undefined) {
error_instance = sessionErr;
if (typeof error === "number") stream.rstCode = error;
} else if (typeof error === "number") {
stream.rstCode = error;
if (error != 0) {
error_instance = streamErrorFromCode(error);
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 session-level symbol bunHTTP2SessionDestroyError is set before emitErrorToAllStreams is called but is never cleared afterward. Any emitStreamErrorNT callbacks that were already queued via process.nextTick before session.destroy(myErr) was called will incorrectly receive myErr as their error instead of the stream's actual close reason (e.g., the RST_STREAM code). To fix this, either clear the symbol immediately after emitErrorToAllStreams returns, or pass the error explicitly as a parameter through the call chain rather than via a session-level side channel.

Extended reasoning...

What the bug is and how it manifests

The PR introduces bunHTTP2SessionDestroyError, a session-scoped symbol set on the session object before parser.emitErrorToAllStreams(code) is called. The function emitStreamErrorNT reads this symbol to decide which error to deliver to stream 'error' listeners. The symbol is never cleared after emitErrorToAllStreams returns.

The specific code path that triggers it

Native stream close/error callbacks (streamClose/streamError) schedule emitStreamErrorNT asynchronously:

process.nextTick(emitStreamErrorNT, self, stream, error, true, ...)

These nextTick entries are queued immediately when the native parser fires its callback, before the current synchronous call stack unwinds.

Why existing code doesn't prevent it

If a stream was already closing (its streamClose/streamError native callback had already fired) before session.destroy(myErr) is invoked in the same synchronous turn, the nextTick for that stream is already in the queue. When destroy() then sets session[bunHTTP2SessionDestroyError] = myErr and calls emitErrorToAllStreams, the native layer may skip the already-closed stream. When the pre-queued nextTick fires, emitStreamErrorNT reads the still-set symbol and substitutes myErr — completely overwriting the stream's original error reason.

Concrete step-by-step proof

  1. Server sends RST_STREAM(CANCEL) for stream A → native parser fires streamClose(session, streamA, NGHTTP2_CANCEL) synchronously → JS callback schedules process.nextTick(emitStreamErrorNT, session, streamA, NGHTTP2_CANCEL, ...).
  2. In the same I/O callback (or a synchronous event handler triggered by it, e.g., streamEnd calling self.destroy() as already done at line ~2869), user code calls client.destroy(myErr).
  3. ClientHttp2Session.destroy() sets session[bunHTTP2SessionDestroyError] = myErr (line 3811) and calls parser.emitErrorToAllStreams(0). Stream A is already closed at the native layer, so it is not re-processed.
  4. The nextTick from step 1 fires. emitStreamErrorNT reads self[bunHTTP2SessionDestroyError] which is still myErr. It sets error_instance = myErr and emits myErr to stream A's 'error' listener.
  5. Stream A reports myErr (e.g., ERR_HTTP2_SESSION_ERROR) instead of ERR_HTTP2_STREAM_ERROR / CANCEL — the wrong error for a stream that closed before the session was destroyed.

Impact

Application code relying on the stream error's code or message to distinguish why a stream failed will receive incorrect information. A stream that received a valid server RST_STREAM (e.g., rate limiting, request cancellation) would show the client-side session destroy error instead, making error handling and diagnostics incorrect.

How to fix it

Option A (simpler): Clear the symbol immediately after emitErrorToAllStreams returns:

if (streamDestroyError \!== undefined) this[bunHTTP2SessionDestroyError] = streamDestroyError;
parser.emitErrorToAllStreams(code || constants.NGHTTP2_NO_ERROR);
this[bunHTTP2SessionDestroyError] = undefined; // clear after use

This prevents any pre-queued nextTicks from picking up the symbol since they fire after the current synchronous block.

Option B (more robust): Pass the error explicitly through emitErrorToAllStreamsemitStreamErrorNT as a parameter instead of using a session-level side channel, eliminating the shared mutable state entirely.

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