Skip to content

node:http2: make the stream lifecycle getters total, like node - #41945

Open
robobun wants to merge 6 commits into
mainfrom
robobun/329b048c/http2-getter-lifecycle
Open

robobun wants to merge 6 commits into
mainfrom
robobun/329b048c/http2-getter-lifecycle

Conversation

@robobun

@robobun robobun commented Sep 8, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • stream.endAfterHeaders throws Invalid stream id on a stream that has no id yet (a request issued before the session connects, or one queued behind maxConcurrentStreams). Node returns false. The same getter flips from true to false once the native stream is freed, so a server stream that received an END_STREAM request reports the wrong value in its own 'close' handler. Node keeps the value.
  • stream.rstCode reads undefined until the stream is reset. Node reads 0 (NGHTTP2_NO_ERROR). A client stream's headersSent reads undefined. Node reads true from construction.
  • The http2.connect(url, options, listener) listener receives (session, undefined). Node passes (session, socket), the same arguments as the 'connect' event.

Fix

  • endAfterHeaders becomes a JS state flag, set from the END_STREAM bit of the header block that opens the stream (the request block on a server). That is the one place node's onSessionHeaders writes it: a client request stream keeps false even for an END_STREAM response, and trailers never change it. It is a plain field, so it answers before the stream has an id and after the stream closed. The native end_after_headers flag and its getEndAfterHeaders() host function are removed: nothing reads them now, and the native flag was also set by trailers and by an outgoing END_STREAM, which node never reports.
  • rstCode initializes to 0. Every existing check reads it for truthiness, so the reset paths are unchanged. ClientHttp2Stream declares headersSent = true (node sets STREAM_FLAGS_HEADERS_SENT in its constructor). ServerHttp2Stream keeps the field it already had, now inherited.
  • The connect() listener gets this[bunHTTP2Socket] as its second argument, the raw socket, as the 'connect' event already does.
  • Verified: test/js/node/http2/node-http2.test.js (4 new tests, all fail on bun 1.4.3). Also the rest of test/js/node/http2/ and node's 256 test-http2-*.js files.

Background

  • Node keeps each stream's flags in a JS object (stream[kState]), so a getter keeps answering after the C++ stream is gone. Bun answers some getters by calling into the native session, which only knows about live streams, so the same read throws or changes answer at the end of the stream's life.
  • endAfterHeaders means "the header block that opened this stream carried END_STREAM", so the peer will send no body. Node sets it in onSessionHeaders only when that block creates the stream object, from the same frame flags that the 'stream' event carries as its flags argument.
  • rstCode is the RST_STREAM error code. 0 is NGHTTP2_NO_ERROR, which is what a stream that was never reset reports.
Notes

Probe, node v26.3.0 against bun 1.4.3 and this branch. A client request is issued before 'connect', so it starts with no id:

                          node                 bun 1.4.3                      this PR
pending endAfterHeaders   false                THREW Invalid stream id        false
pending rstCode           0                    undefined                      0
pending headersSent       true                 undefined                      true
open/closed (all three)   false, 0, true       false, 0/undefined, undefined  false, 0, true
connect listener args     (session, Socket)    (session, undefined)           (session, Socket)

Server side, a GET request (END_STREAM on the request block), read in the 'stream' handler and again in 'close':

node:      [{"endAfterHeaders":true,"rstCode":0,"headersSent":false},{"endAfterHeaders":true,"rstCode":0,"headersSent":true}]
this PR:   [{"endAfterHeaders":true,"rstCode":0,"headersSent":false},{"endAfterHeaders":true,"rstCode":0,"headersSent":true}]
bun 1.4.3: endAfterHeaders true then false, headersSent undefined then true

POST with a body and trailers, server responds 204 with endStream: true (flags 5 on the response HEADERS):

node:      server {"atStream":false,"atTrailers":false,"atEnd":false,"atClose":false}  client {"atResponse":false,"responseFlags":5,"atClose":false}
this PR:   same as node
bun 1.4.3: server {"atStream":false,"atTrailers":true,"atEnd":true,"atClose":false}    client {"atResponse":true,"responseFlags":5,"atClose":false}

Two more faces from the same report are already covered by open PRs and are not in this diff: session.originSet throwing inside a session 'error' handler (#33792, which defers encrypted to connect) and session.state returning undefined after destroy() (#33597).

The listener's socket is the raw net.Socket. session.socket is a Proxy over the session, in node as well, so the two are not the same object in either runtime.

Suites run with the debug build: bun bd test test/js/node/http2/ (502 pass, 6 skip) and test/js/node/test/parallel/test-http2-*.js (256 pass).

endAfterHeaders threw ERR for a stream with no id yet, and flipped from true
to false once the native stream was freed. It is now a JS state flag set from
the END_STREAM bit of the received header block, which is what node tracks in
onSessionHeaders. The native end_after_headers flag and its getter go away.

rstCode reads 0 until the stream is reset (node's NGHTTP2_NO_ERROR), not
undefined. A client stream reports headersSent true from construction, as
node's ClientHttp2Stream constructor does. The http2.connect() listener
receives the socket as its second argument.
@coderabbitai

coderabbitai Bot commented Sep 8, 2026 •

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

Warning

Review limit reached

  • Run on-demand review

On-demand reviews are free for the next 12 days. After that, they cost $0.25 per reviewed file.

Or wait 2 minutes for your next included review.

Check out review usage here.

View limit details

Limit details: You’ve used all 10 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Advanced

Run ID: 8e2972af-fc62-4849-8ccd-b5e1a8a38415

📥 Commits

Reviewing files that changed from the base of the PR and between 8b74c06 and f410867.

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

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Advanced

Run ID: c231225e-af6a-4d2a-aa14-9655a1447b4c

📥 Commits

Reviewing files that changed from the base of the PR and between d3fd2ac and 8b74c06.

📒 Files selected for processing (1)
  • test/js/node/http2/node-http2.test.js

Included review availability: Your plan provides up to 10 included reviews per hour; 1 remains after this review.


Walkthrough

Changes

HTTP/2 stream lifecycle

Layer / File(s) Summary
Stream state tracking
src/js/node/http2.ts, src/runtime/api/bun/h2_frame_parser.rs, test/js/node/http2/node-http2.test.js
HTTP/2 streams now track endAfterHeaders, rstCode, and headersSent with JavaScript fields. Header handlers record the initial END_STREAM flag. Lifecycle tests cover pending, open, and closed streams.
Connect socket callback
src/js/node/http2.ts
The http2.connect() listener now receives the session and its raw socket. The socket is captured before connection cleanup runs.

Suggested reviewers: cirospaciari

Merge Risk: ⚪ Minimal · up to 8b74c

HTTP/2 stream lifecycle getters now retain Node-compatible state and connect listeners receive the socket. The changed behavior is covered across pending, open, and closed stream states, with no outstanding merge-readiness risk.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the primary change: making node:http2 stream lifecycle getters behave like Node.js. It is concise and specific.
Description check ✅ Passed The description explains the problem, implementation, behavior differences, verification steps, test results, and known unrelated CI failure. Although it does not use the exact template headings, it p…

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

@github-actions github-actions Bot added the claude label Sep 8, 2026
@robobun

robobun commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator Author

CI on 2bacef1: the only red lane is test/js/node/test/parallel/test-crypto-dh-leak.js on debian 13 x64-asan, an RSS assertion that is red on main and on every other PR build this week. It is reported for main-break triage. This diff does not touch crypto. test/js/node/http2/ passes on every lane, the three other entries are flaky retries that passed.

Ready for a maintainer.

@claude claude Bot left a comment

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.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Comment thread src/js/node/http2.ts Outdated
Comment thread test/js/node/http2/node-http2.test.js Outdated
…onnect()

close() before the connection completes makes #onConnect() destroy the
session, which drops the socket. The 'connect' event captures it first, so
the listener does too. The test wires the session's error and close events
to reject.
Comment thread src/js/node/http2.ts Outdated
Comment thread src/js/node/http2.ts Outdated
Comment thread src/js/node/http2.ts Outdated

@claude claude Bot left a comment

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.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Comment thread test/js/node/http2/node-http2.test.js
@robobun

robobun commented Sep 8, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 5:48 AM PT - Sep 8th, 2026

❌ @robobun, your commit f410867 has 1 failures in Build #112809 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 41945

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

bun-41945 --bun

@claude claude Bot left a comment

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.

This pull request has been reviewed before and this review found new issues. Where they share a root cause, one fix may close them together.

Comment thread src/js/node/http2.ts Outdated
…ream

node's onSessionHeaders (lib/internal/http2/core.js) writes endAfterHeaders
only when the header block creates the stream object. A client request
stream already exists when its response arrives, so it keeps false even when
the response HEADERS frame carries END_STREAM, and a trailers block never
changes the value. Drop the client-side write and cover both cases.
Comment thread src/js/node/http2.ts Outdated

@claude claude Bot left a comment

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.

LGTM — every earlier thread is addressed, and the new third test settles the trailers question against real Node: onSessionHeaders writes endAfterHeaders only when the block creates the stream, so my previous comment there was wrong.

Verified the follow-ups landed: the connect listener captures bunHTTP2Socket before #onConnect() can null it; the server test awaits the server stream's own 'close' before reading states[1]; the connect-listener test wires error/close to reject.
Native side is pure deletion — end_after_headers, its on_headers_complete write, the request-path write, and the getEndAfterHeaders host fn + .classes.ts entry have no remaining readers.
Also looked at pushed client streams keeping endAfterHeaders === false — base's native flag was set by trailers and the outgoing END_STREAM too, so that path was already wrong and this PR doesn't make it worse.

Extended reasoning...

Overview

The PR moves three Http2Stream getters (endAfterHeaders, rstCode, headersSent) from native-backed lookups or undefined defaults to JS-side class fields so they answer correctly across the whole stream lifecycle (pending → open → closed), matching Node. It also passes the raw socket as the second argument to the http2.connect() inline listener. Native side deletes the now-dead end_after_headers field, its writes in on_headers_complete and the request path, the getEndAfterHeaders host function, and its .classes.ts entry. Four tests in the existing node-http2.test.js cover client pending/open/closed, server persistence past close, the "only the opening block sets it" semantics (trailers and response HEADERS do not), and the connect-listener signature.

Security risks

None. This is Node-compat surface for read-only getters and a callback argument. No parsing of untrusted input changed; the native deletions remove code rather than add it. The connect listener already had access to session.socket; passing the raw socket alongside is what Node does.

Level of scrutiny

Moderate — node:http2 is a compat module where observed Node behavior is the spec, and the change touches both JS builtins and the Rust frame parser. This is the sixth review round; the earlier five produced four inline threads, all now addressed by commits: socket captured before #onConnect() (d9a2f91), server-close race awaited (8b74c06), connect-listener test wires failure events, and the endAfterHeaders/trailers question resolved by 1feb320 with a test that pins Node's actual semantics (my prior claim that Node overwrites on trailers was incorrect — Node only assigns in the stream === undefined branch of onSessionHeaders). The hunt exited on dry_streak.

Other factors

The native changes are strictly subtractive and satisfy REVIEW.md's "delete dead code in the same PR that makes it dead". JS-side additions follow src/js/ conventions: private symbol for internal state, class-body field defaults, .$call for the listener. Tests use port: 0, loopback only, Promise.withResolvers on events with error paths wired to reject, and try/finally cleanup registered before assertions. The one investigated-and-dismissed candidate (pushed client streams keeping endAfterHeaders === false) is not a regression against base — the removed native flag was itself set from the wrong frames (trailers, outgoing END_STREAM), so base was already divergent there and this PR doesn't widen the gap. No outstanding third-party objections in the timeline.

cirospaciari pushed a commit that referenced this pull request Sep 11, 2026
… carries END_STREAM (#42348)

### Problem

- A `node:http2` stream emits no `'end'` when a listener calls
`close(code)` with an error code (2, 7, 11) after the peer's half ended
on a HEADERS frame: server `'stream'` and `'trailers'`, client
`'response'` and `'trailers'`, the `pushStream()` callback. Node v26.3.0
and Bun 1.4.2 emit `'end'`, `'error'`, `'close'`. Main skips `'end'`.
- Both `streamHeaders` handlers (`src/js/node/http2.ts`) emit the event
inside the HEADERS dispatch. The frame's END_STREAM reaches the readable
only in the later `streamEnd` dispatch, after the listener's ticks
drain. By then the error has destroyed the stream and suppressed
`'end'`. A pushed server stream never got an EOF.
- `close()` hid this with an unconditional `push(null)` until #33607
(fc479fb) gated it.

### Fix

- `endInboundHalf()` gives the readable its EOF. Both `streamHeaders`
handlers call it before the event of a HEADERS frame that carries
END_STREAM. `pushStream()` calls it for the new stream. Node's
`onSessionHeaders` and `pushStream` do the same.
- The `close()` gate stays. With the peer's half still open, an error
code gives no `'end'`, as on Node.
- Verified: `test/js/node/http2/node-http2-client-close.test.ts` (56 new
cases, 24 fail on main, all 68 pass on Node v26.3.0). Also
`test/js/node/http2/`, Node's `test-http2-*`, grpc-js, `serve-http2`,
fetch HTTP/2.

### Background

- The native frame parser calls JS once per event: `streamHeaders` for a
header block, then `streamEnd` if the frame carried END_STREAM. The
`nextTick` queue drains between the two.
- `push(null)` gives a Readable its EOF. `'end'` fires a tick later,
unless the stream has an error by then.
- `close(code)` with a code other than NO_ERROR or CANCEL destroys the
stream with `ERR_HTTP2_STREAM_ERROR`.

<details><summary>Notes</summary>

The regression is unreleased. A fuzz ledger found it, no user reported
it.

"Before" below is `1.4.3-canary.1+4ff919377`, the last build before
#33607. The report measured the same sequences on 1.4.2.

Server-side events for `close(2)` inside the `'stream'` listener. The
client sent `request({':path':'/'}).end()` with no body:

| when | Node v26.3.0 | before | main | this PR |
| --- | --- | --- | --- | --- |
| sync | aborted, finish, end, error, close | as Node | aborted, finish,
error, close | as Node |
| nextTick | aborted, end, finish, error, close | as Node | aborted,
finish, error, close | as Node |
| setImmediate | end, aborted, finish, error, close | as Node | as Node
| as Node |
| setTimeout | end, aborted, finish, error, close | as Node | as Node |
as Node |

Codes 7 and 11 behave like 2. Codes 0 and 8 agree on all four builds.

The other sites, `close(2)` or `close(11)` in the listener or one tick
later. The column says whether `'end'` fires before `'error'`:

| listener | Node v26.3.0 | before | main | this PR |
| --- | --- | --- | --- | --- |
| server `'stream'`, after `respond({endStream: true})` | yes | yes | no
| yes |
| server `'trailers'` | yes | yes | no | yes |
| client `'response'`, response ended on HEADERS (204) | yes | yes | no
| yes |
| client `'trailers'` | yes | yes | no | yes |
| `pushStream()` callback | yes | yes | no | yes |

Three probe scripts cover these sites with codes 0, 8, 2, 11 (and 7 for
the push), sync and nextTick. Their output on this branch is identical
to Node v26.3.0 on all 82 cells.

Cells that #33607 moved to Node's sequence and that this PR keeps
(`'end'` for `close(2)` on a server stream):

| request | close(2) runs | Node v26.3.0 | before | main and this PR |
| --- | --- | --- | --- | --- |
| `end('hello')` | sync or nextTick in `'stream'` | no `'end'` | `'end'`
| no `'end'` |
| `end('hello')` | in `'data'`, or a tick after it | no `'end'` |
`'end'` | no `'end'` |
| never ended | sync, nextTick, setImmediate, setTimeout | no `'end'` |
`'end'` | no `'end'` |

END_STREAM on a DATA frame is not part of this change. nghttp2 ignores
frames for a stream that `close()` already reset, so Node emits no
`'end'` in the `'data'` rows above, and main already agrees.

Sites left out on purpose:

- The client `'headers'` event (a 1xx block). A 1xx block with
END_STREAM is malformed. Node reports it as `'response'`. Nothing
changes there.
- The client `streamPush` handler (PUSH_PROMISE). A PUSH_PROMISE cannot
carry END_STREAM.

The two shapes the report proposed, and why this PR uses neither:

- "Apply the gate only to client streams" brings back `'end'` on server
streams whose request is still open. Node does not emit it there. It
also leaves the client `'response'` and `'trailers'` cells broken.
- "Apply the gate only when the readable has not received END_STREAM":
in the failing cells the JS readable has not received it yet when
`close()` runs. That is the defect. `close()` would have to ask the
native layer, and any other code that looks at the readable inside the
listener would still see the stale state.

Related open PRs:

- #33380 (server-side RST_STREAM) is why the client still sees
`rstCode=0` and no `'error'` in the no-body cells. It does not touch
these hunks. With it, `close()` no longer sends END_STREAM first, so
some `'stream'` cells would pass without this change. The `after
respond()` cells stay red without it on both main and #33380.
- #41945 gives `rstCode` a default of 0. If it lands, the `rstCode`
guard in `endInboundHalf()` is dead and can go.

`endInboundHalf()` also sets `rstCode = 0` when no reset set it, as the
`streamEnd` handlers did. Without that, a listener that resumes the
stream inside `'stream'` reads `rstCode === undefined` in its `'end'`
handler, because `'end'` now fires before the `streamEnd` dispatch. Node
reports 0.

The Node cross-check inside the test file is now skipped on musl.
Alpine's `node` segfaults at a random point of this file under `node
--test` (alpine 3.23 aarch64, also on main in build 114123, before this
change). The CI runner fails a file for any new core dump, whichever
process wrote it. The glibc, macOS and Windows lanes keep the
cross-check.

Suites run on the debug build: `test/js/node/http2/` (576 pass, 6 skip),
261 `test-http2-*` and 18 `test-diagnostics-channel-http2-*` Node tests,
`serve-http2`, `serve-http2-protocol`, `serve-http2-lifecycle`,
`node-http2-ping-flood-staged`, `fetch-http2-client`,
`fetch-http2-adversarial`, `fetch-http2-leak`, `undici-h2`, `wpt-h2`,
`grpc-js`, the http2 regression tests (25589, 24924, 26915, 29073).

Local failures that also occur without the change: the
`test-outlier-detection.test.ts` ejection tests (5 s timeouts on a
loaded debug build, they flip between runs on main too), `test tonic
server`, the grpc-js DNS tests (no network), `http2-wrapper.test.ts`
(ECONNREFUSED, on the release build too), one
`AsyncLocalStorage.test.ts` timing test (debug build timeout).

</details>

<!-- robobun:evidence:begin -->

---

**no test proof** · iteration 0 · platform-specific test(s) that do not
run on this machine, deferring to CI, which covers all platforms:
test/js/node/http2/node-http2-client-close.test.ts

<!-- robobun:evidence:end -->

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants