fix(http2): stream.session/state return undefined/{} after session.destroy()#29049
fix(http2): stream.session/state return undefined/{} after session.destroy()#29049cirospaciari wants to merge 3 commits intomainfrom
Conversation
… {} after session.destroy()
Node's Http2Stream session getter returns undefined once the session is
destroyed (kSession is cleared), and state returns {} when there is no
native handle. Bun returned the destroyed session object and the numeric
constant NGHTTP2_STREAM_STATE_CLOSED.
This is a prerequisite for test-http2-server-stream-session-destroy.js.
|
Updated 10:09 PM PT - Apr 13th, 2026
❌ @cirospaciari, your commit 14ec499 has 3 failures in
🧪 To try this PR locally: bunx bun-pr 29049That installs a local version of the PR into your bun-29049 --bun |
|
Warning Rate limit exceeded
Your organization is not enrolled in usage-based pricing. Contact your admin to enable usage-based pricing to continue reviews beyond the rate limit, or try again in 10 minutes and 13 seconds. ⌛ How to resolve this issue?After the wait time has elapsed, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout. Please see our FAQ for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (1)
WalkthroughModified HTTP/2 stream getters in the native binding layer to return Changes
🚥 Pre-merge checks | ✅ 1 | ❌ 1❌ Failed checks (1 inconclusive)
✅ Passed checks (1 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Comment |
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 `@test/js/node/http2/h2-stream-after-destroy.test.ts`:
- Around line 9-25: The test currently destroys serverStream.session after the
server stream has already been ended, making the destroy a no-op; reorder the
sequence so you obtain the live stream (server.on("stream" ...)/resolveStream ->
serverStream) and call serverStream.session?.destroy() while the stream is still
open (i.e., before s.end() or before awaiting req 'end'), then verify session
was cleared; also remove the brittle Bun.sleep and avoid subscribing to
once(req, "end") too late — use the existing req/client/request flow to ensure
the destroy happens deterministically (references: server.on("stream", s => {
... }), resolveStream, serverStream, req, client).
🪄 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: e685b40a-b85b-4a98-886c-2f6f104e5da0
📒 Files selected for processing (2)
src/js/node/http2.tstest/js/node/http2/h2-stream-after-destroy.test.ts
There was a problem hiding this comment.
LGTM — small targeted fix aligning session and state getters with Node.js behavior.
Extended reasoning...
Overview
Two getter fixes in src/js/node/http2.ts and one new test file. The session getter now returns undefined when the session is destroyed (previously returned the destroyed session object), and the state getter now returns {} when there is no native handle (previously returned the numeric constant NGHTTP2_STREAM_STATE_CLOSED). Both changes are direct Node.js compatibility fixes.
Security risks
None. These are read-only getter changes on an internal HTTP/2 stream object with no auth, crypto, or permission logic involved.
Level of scrutiny
Low. The changes are minimal (< 10 lines of production code), the intent is clear and documented with Node.js source references, and the behavior being fixed is straightforward to verify against Node.js internals.
Other factors
The only finding is a nit in the test (use of Bun.sleep(50) instead of an event-based wait), flagged as an inline comment. The production code changes are correct and unambiguous. The test anti-pattern is minor enough not to block approval.
There was a problem hiding this comment.
LGTM — the previous feedback has been addressed and the fix correctly aligns stream.session/state behavior with Node.js.
Extended reasoning...
Overview
Small, focused fix to src/js/node/http2.ts modifying two getters on Http2Stream: session now returns undefined when the underlying session is null or destroyed, and state now returns {} when no native handle exists. Accompanied by a new test file verifying post-destroy stream behavior.
Security risks
None. This is a pure behavioral alignment with Node.js semantics — no auth, crypto, or permission changes.
Level of scrutiny
Low. The change is small, the intent is clear, and it directly mirrors Node.js core behavior as cited in the PR description (lib/internal/http2/core.js:2113, 2178). The previous reviewer concerns (Bun.sleep, missing try/finally, test reliability) were all addressed in commit 14ec499.
Other factors
Two remaining nits were flagged: a defensive session.destroyed check in the state getter (not observable in practice due to nextTick deferral) and the Promise.withResolvers style convention. Neither is blocking — the core fix is correct and the test is now deterministic and properly structured.
| get state() { | ||
| const session = this[bunHTTP2Session]; | ||
| if (session) { | ||
| return session[bunHTTP2Native]?.getStreamState(this.#id); | ||
| const native = session[bunHTTP2Native]; | ||
| if (native) return native.getStreamState(this.#id) ?? {}; | ||
| } | ||
| return constants.NGHTTP2_STREAM_STATE_CLOSED; | ||
| return {}; | ||
| } |
There was a problem hiding this comment.
🟡 The state getter does not check session.destroyed, creating an inconsistency with the session getter introduced by this PR: during ClientHttp2Session error teardown, stream.session returns undefined while stream.state can still return live data. Add || session.destroyed to the condition in the state getter to match the session getter's logic.
Extended reasoning...
What the bug is
In Http2Stream.state (lines 2071-2078), after this PR the getter checks if (session) (truthy) but never checks session.destroyed. The session getter added in the same PR correctly returns undefined when session.destroyed === true. This creates a window where the two getters can return inconsistent values.
The specific code path that triggers it
ClientHttp2Session.#onError sets this[bunHTTP2Socket] = null (making session.destroyed === true) before calling this.destroy(error). Inside destroy(), parser.emitErrorToAllStreams() runs while this.#parser (bunHTTP2Native) is still non-null — it is only cleared on the line after emitErrorToAllStreams returns. The streamError handler runs synchronously during this call. During that synchronous window: stream.session returns undefined (new PR check fires because bunHTTP2Socket is null), but stream.state still returns live data because session is non-null (truthy) and session[bunHTTP2Native] is non-null.
Addressing the refutation
The refutation argues that ClientHttp2Session.#Handlers.streamError only does self.#connections-- then defers the actual error emission via process.nextTick(emitStreamErrorNT, ...), so by the time user code runs the inconsistent window is closed (both #parser and bunHTTP2Socket are null). This is a valid and specific technical point. If emitErrorToAllStreams() exclusively calls the internal streamError handler (which defers via nextTick), then user-registered 'error' listeners cannot observe the inconsistency, making this unobservable in practice.
Why it is still worth fixing
Even if the current execution path defers user callbacks via nextTick (making the gap unobservable today), the state getter's missing session.destroyed check is a defensive gap. If any future code path synchronously emits on stream objects during teardown, the inconsistency becomes observable immediately. More importantly, the PR itself treats session.destroyed as a meaningful signal for the session getter — omitting the same check from state is an incomplete implementation of the stated intent.
Step-by-step proof of the window
- A client socket error fires
ClientHttp2Session.#onError. - Line 3526:
this[bunHTTP2Socket] = null—session.destroyedis nowtrue. - Line 3532:
this.destroy(error)is called. - Inside
destroy(), line 3802:parser.emitErrorToAllStreams()runs;this.#parseris still non-null. streamErrorhandler fires synchronously:self.#connections--, thenprocess.nextTick(emitStreamErrorNT, stream, error).- At this point (still inside step 5's synchronous frame):
stream.session→session.destroyed === true→undefined;stream.state→sessionis truthy,session[bunHTTP2Native]is non-null → returns live{localWindowSize: N, ...}. - Line 3805:
this.#parser = null. Only now wouldstream.statereturn{}. process.nextTickfiresemitStreamErrorNT; at this point both getters agree — but the inconsistency existed in the synchronous window of step 6.
The fix
In the state getter, change if (session) to if (session && \!session.destroyed):
get state() {
const session = this[bunHTTP2Session];
if (session && \!session.destroyed) {
const native = session[bunHTTP2Native];
if (native) return native.getStreamState(this.#id) ?? {};
}
return {};
}| let resolveStream: (s: any) => void; | ||
| const streamP = new Promise<any>(r => (resolveStream = r)); | ||
| server.on("stream", s => { | ||
| resolveStream(s); | ||
| }); |
There was a problem hiding this comment.
🟡 The test uses the let resolveStream + new Promise(r => resolveStream = r) pattern instead of Promise.withResolvers, violating the explicit convention in test/CLAUDE.md. Replace with const { promise: streamP, resolve: resolveStream } = Promise.withResolvers<any>() to match the pattern used consistently across all other http2 tests in this directory.
Extended reasoning...
What the bug is
At lines 7–8 of test/js/node/http2/h2-stream-after-destroy.test.ts, the test declares:
let resolveStream: (s: any) => void;
const streamP = new Promise<any>(r => (resolveStream = r));This is the old-style escape-hatch pattern for turning a callback into a promise. test/CLAUDE.md line 106 explicitly forbids it: "When callbacks must be used and it's just a single callback, use Promise.withResolvers to create a promise that can be resolved or rejected from a callback."
The specific code path
The server.on('stream', s => { resolveStream(s); }) handler at lines 9–11 is exactly the single-callback case the CLAUDE.md rule targets. The recommended replacement is:
const { promise: streamP, resolve: resolveStream } = Promise.withResolvers<any>();which eliminates the mutable let binding entirely.
Why existing code doesn't prevent it
There is no linter or compiler rule enforcing the CLAUDE.md convention; it is a style guideline enforced by code review only.
Impact
This is a purely stylistic issue — the test is functionally correct. The Promise constructor executes synchronously, so TypeScript (and the runtime) can guarantee resolveStream is assigned before the stream handler fires. No real TypeScript strict-mode error occurs here. However, the pattern is inconsistent with every other test in the test/js/node/http2/ directory: http2-helpers.cjs, node-http2-memory-leak.js, node-http2-upgrade.test.mts, and node-http2.test.js (15+ occurrences) all use Promise.withResolvers. This file is the sole outlier.
How to fix it
Replace lines 7–8:
// before
let resolveStream: (s: any) => void;
const streamP = new Promise<any>(r => (resolveStream = r));
// after
const { promise: streamP, resolve: resolveStream } = Promise.withResolvers<any>();Step-by-step proof of the violation
- Open
test/CLAUDE.mdline 106: rule is unambiguous — single-callback promise creation must usePromise.withResolvers. - Open
h2-stream-after-destroy.test.tslines 7–8:let resolveStream+new Promise(r => resolveStream = r)— exactly the pattern the rule disallows. - Compare to
node-http2.test.js:const { promise, resolve } = Promise.withResolvers()used throughout — the project norm. - The
resolveStreamvariable is used in the callback at line 11 (resolveStream(s)) — a single callback, matching the rule's scope exactly. - Conclusion: this is a CLAUDE.md style violation. No refutations were raised; all four verifiers confirmed it at nit severity.
Node's
Http2Streamsessiongetter returnsundefinedonce the session is destroyed (kSessionis cleared), andstatereturns{}when there is no native handle (lib/internal/http2/core.js:2113, 2178). Bun returned the destroyed session object and the numeric constantNGHTTP2_STREAM_STATE_CLOSED.Prerequisite for porting
test/parallel/test-http2-server-stream-session-destroy.js.