Skip to content

fix(http2): stream.session/state return undefined/{} after session.destroy()#29049

Open
cirospaciari wants to merge 3 commits intomainfrom
claude/h2-stream-session-state-after-destroy
Open

fix(http2): stream.session/state return undefined/{} after session.destroy()#29049
cirospaciari wants to merge 3 commits intomainfrom
claude/h2-stream-session-state-after-destroy

Conversation

@cirospaciari
Copy link
Copy Markdown
Member

Node's Http2Stream session getter returns undefined once the session is destroyed (kSession is cleared), and state returns {} when there is no native handle (lib/internal/http2/core.js:2113, 2178). Bun returned the destroyed session object and the numeric constant NGHTTP2_STREAM_STATE_CLOSED.

Prerequisite for porting test/parallel/test-http2-server-stream-session-destroy.js.

… {} 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.
@robobun
Copy link
Copy Markdown
Collaborator

robobun commented Apr 8, 2026

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

@cirospaciari, your commit 14ec499 has 3 failures in Build #45579 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 29049

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

bun-29049 --bun

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 8, 2026

Warning

Rate limit exceeded

@cirospaciari has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 10 minutes and 13 seconds before requesting another review.

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 @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 835964cd-28f3-4568-ad62-040723b5b05d

📥 Commits

Reviewing files that changed from the base of the PR and between 9fb4fa9 and 14ec499.

📒 Files selected for processing (1)
  • test/js/node/http2/h2-stream-after-destroy.test.ts

Walkthrough

Modified HTTP/2 stream getters in the native binding layer to return undefined for session when destroyed and an empty object for state when no native handle exists, with corresponding test coverage for post-destruction stream state behavior.

Changes

Cohort / File(s) Summary
HTTP/2 Stream State Management
src/js/node/http2.ts
Updated Http2Stream.state getter to return an empty object {} when the session lacks a native handle, and updated Http2Stream.session getter to return undefined when the underlying session is null/undefined or marked as destroyed.
HTTP/2 Stream Lifecycle Tests
test/js/node/http2/h2-stream-after-destroy.test.ts
Added new test verifying that after destroying the session with serverStream.session?.destroy(), the stream's session property becomes undefined and its state becomes an empty object {}.
🚥 Pre-merge checks | ✅ 1 | ❌ 1

❌ Failed checks (1 inconclusive)

Check name Status Explanation Resolution
Description check ❓ Inconclusive The description covers what the PR does (aligning with Node behavior) and references prerequisites, but lacks verification details as specified in the template. Add a 'How did you verify your code works?' section explaining the testing approach, such as the new test file that validates the fix.
✅ Passed checks (1 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: fixing Http2Stream.session and .state to return undefined and {} after session.destroy(), matching Node.js behavior.

✏️ 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.

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 `@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

📥 Commits

Reviewing files that changed from the base of the PR and between 1afabdd and 9fb4fa9.

📒 Files selected for processing (2)
  • src/js/node/http2.ts
  • test/js/node/http2/h2-stream-after-destroy.test.ts

Comment thread test/js/node/http2/h2-stream-after-destroy.test.ts Outdated
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 — 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.

Comment thread test/js/node/http2/h2-stream-after-destroy.test.ts Outdated
Comment thread test/js/node/http2/h2-stream-after-destroy.test.ts Outdated
Comment thread test/js/node/http2/h2-stream-after-destroy.test.ts Outdated
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 — 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.

Comment thread src/js/node/http2.ts
Comment on lines 2071 to 2078
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 {};
}
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 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

  1. A client socket error fires ClientHttp2Session.#onError.
  2. Line 3526: this[bunHTTP2Socket] = nullsession.destroyed is now true.
  3. Line 3532: this.destroy(error) is called.
  4. Inside destroy(), line 3802: parser.emitErrorToAllStreams() runs; this.#parser is still non-null.
  5. streamError handler fires synchronously: self.#connections--, then process.nextTick(emitStreamErrorNT, stream, error).
  6. At this point (still inside step 5's synchronous frame): stream.sessionsession.destroyed === trueundefined; stream.statesession is truthy, session[bunHTTP2Native] is non-null → returns live {localWindowSize: N, ...}.
  7. Line 3805: this.#parser = null. Only now would stream.state return {}.
  8. process.nextTick fires emitStreamErrorNT; 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 {};
}

Comment on lines +7 to +11
let resolveStream: (s: any) => void;
const streamP = new Promise<any>(r => (resolveStream = r));
server.on("stream", s => {
resolveStream(s);
});
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 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

  1. Open test/CLAUDE.md line 106: rule is unambiguous — single-callback promise creation must use Promise.withResolvers.
  2. Open h2-stream-after-destroy.test.ts lines 7–8: let resolveStream + new Promise(r => resolveStream = r) — exactly the pattern the rule disallows.
  3. Compare to node-http2.test.js: const { promise, resolve } = Promise.withResolvers() used throughout — the project norm.
  4. The resolveStream variable is used in the callback at line 11 (resolveStream(s)) — a single callback, matching the rule's scope exactly.
  5. Conclusion: this is a CLAUDE.md style violation. No refutations were raised; all four verifiers confirmed it at nit severity.

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