Repository navigation
Conversation
… state ClientHttp2Session#request() checked the options fields first, then the session state, and validated the header block last. Node validates the header block first, then options (parent, exclusive, silent, endStream, signal), applies endStream and the signal, and acts on the session state last. So a doubly-invalid call reported the wrong error, and a destroyed or closed session hid every header error and the options.signal error. Move the options copy and the options checks below the header validation. Move the options.signal check and the session-state checks below them, ahead of the deferred :path and content-length rejections. The stream that a destroyed session fails now gets the same endStream and signal setup as any other request. A closed session still throws ERR_HTTP2_GOAWAY_SESSION synchronously for a valid call.
|
Warning Review paused — included plan limit reachedKeep your review moving with free on-demand reviews.
On-demand reviews are free for one more day.
Reviews can continue after your included limit without a manual trigger. An admin must approve usage-based billing. Promotion and pricing detailsOn-demand reviews are free for one more day. After that, they cost $0.25 per reviewed file. Review limit detailsOr wait 46 seconds for your next included review. Limit details: You’ve used all 10 included reviews currently available. Review configuration: ⚙️ Run configurationConfiguration used: Repository: oven-sh/bun/.coderabbit.yaml Review profile: ASSERTIVE Plan: Essentials Run ID: 📒 Files selected for processing (2)
Comment |
|
Updated 7:37 PM PT - Sep 19th, 2026
✅ @robobun, your commit 15163c25015d54055860f3d63ebc2cf9ab39902e passed in 🧪 To try this PR locally: bunx bun-pr 43536That installs a local version of the PR into your bun-43536 --bun |
|
Status: fix pushed in #43536. Waiting for CI and review. How I reproduced it. Run this with import http2 from "node:http2";
const thrown = fn => { try { fn().on("error", () => {}); return "no throw"; } catch (e) { return e.code; } };
const srv = http2.createServer();
srv.listen(0, "127.0.0.1", () => {
const c = http2.connect("http://127.0.0.1:" + srv.address().port);
c.on("error", () => {});
c.on("connect", () => {
console.log("open ", thrown(() => c.request({ "bad name": "x" }, { endStream: 1 })));
c.destroy();
console.log("destroyed", thrown(() => c.request({ "bad name": "x" })));
console.log("destroyed", thrown(() => c.request({ ":method": "CONNECT" })));
srv.close();
});
});
|
There was a problem hiding this comment.
Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.
Beyond the inline findings, I also checked that the moved blocks stay inside the same try so the connectionsCounted bookkeeping in the finally is unaffected, and that the DEP0194 warning now firing on a closed/destroyed session before the call fails matches Node's order (the warning sits in the options copy, which Node also runs before the session-state check).
Extended reasoning...
The reordering keeps every relocated block inside the existing try/finally, and connectionsCounted is only set after the native submission, so no early return introduced by the move can leak or double-decrement the counter. The setupRequestEndAndSignal helper called on the destroyed branch is the same one the pending/submitted paths already use (req.end() when endStream, plus an abort listener disposed on close), so the destroyed stream's shape now matches the other returned streams. The four candidates in the ruled-out list (send-time rejection streams missing the signal wiring, the duck-typed signal TypeError, the already-aborted signal on a closed session, and the earlier DEP0194 warning) are either pre-existing on the base or are exactly the Node ordering the PR cites, and the inline findings already cover what remains.
Additional findings (outside the current diff — GitHub can't attach inline comments there):
-
🟡
src/js/node/http2.ts— nit, pre-existing: callers whose request nghttp2 would reject at send time get a stream with no endStream applied and no abort listener, unlike the destroyed-session stream this PR fixes. The:pathrejection at src/js/node/http2.ts:6166-6172 and the content-length rejection at 6182-6187 build the stream and return without calling setupRequestEndAndSignal. Fix: call setupRequestEndAndSignal(req, options, signal) on every early-return stream that is destroyed on the next tick, sowritableEnded, the 'aborted' event and options.signal behave the same on all three sites (6137, 6166, 6182).Extended reasoning...
The PR's destroyed-session branch at src/js/node/http2.ts:6136-6141 now calls setupRequestEndAndSignal so the returned stream is ended (endStream) and listening to options.signal before destroyWithInvalidSessionNT runs. The two sibling early returns that immediately follow it were left as they were. request({ ":path": "/a b" }, { signal: controller.signal }) on an open session hits line 6166: a stream is created, emitStreamErrorNT is queued, and the stream is returned with no abort listener and without req.end(). controller.abort() in the same tick therefore does nothing; the stream later fails with ERR_HTTP2_STREAM_ERROR instead of AbortError. For a GET (options.endStream defaulted to true at 6092) the writable side is still open when emitStreamErrorNT destroys the stream, so _destroy at 2568-2574 sets kAborted and emits 'aborted' for a request the peer never saw; on the destroyed-session branch the same GET now correctly has writableEnded true and no 'aborted'. Same for the content-length path at 6182-6187 (rejectNoPayloadContentLengthNT). The base branch behaves the same way on these two…
Verification: nit, pre-existing. Triggering condition: a caller on an open session passes
{ endStream: true }and/or{ signal }together with a request nghttp2 would reject at send time (a:pathcontaining SP/control/DEL, or a content-length on a no-payload method). Mechanism verified in /home/claude/bun/src/js/node/http2.ts at HEAD. The destroyed-session branch (lines 6136-6141) now calls… -
🟡
src/js/node/http2.ts— Callers whose request() on a destroyed session carries a non-string :method (for example a number) now get a synchronous plain TypeError; on the base they got a stream that failed on the next tick with ERR_HTTP2_INVALID_SESSION. The destroyed check moved below src/js/node/http2.ts:6087, so method.toUpperCase() now runs on a destroyed session for a value that is truthy but not a string. Fix: validate :method as a string (or coerce with String()) before NoPayloadMethods.has so both open and destroyed sessions report the same $ERR_* error rather than a bare TypeError.Extended reasoning...
On the base, this.destroyed was checked at old line 5901 before any header processing, so a destroyed session returned a stream regardless of the :method value. Now headers are processed first: src/js/node/http2.ts:6050 reads headers[':method'], a truthy non-string such as 1 or an object passes the !method default, and src/js/node/http2.ts:6087 calls method.toUpperCase(), which throws TypeError 'method.toUpperCase is not a function'. The destroyed branch at http2.ts:6136 is never reached. The finder dismissed it as pre-existing because open sessions threw the same TypeError on the base, but the destroyed-session population is new: code that fires a request during teardown and relies on the stream 'error' channel now gets an uncaught synchronous TypeError. The PR description acknowledges a separate fix (#37796) that is not in this checkout, so the regression lands if this merges first. Remedy: validate typeof method === 'string' before the toUpperCase call, ahead of the session-state checks.
Verification: nit — acknowledged in PR description (not in the diff): "A non-string
:methodthrows a plainTypeErrorfrommethod.toUpperCase(). A destroyed session now reaches that line too. #37796 fixes the line." The note is accurate. Triggering condition: request() on a destroyed ClientHttp2Session with a truthy non-string:method(e.g.{ ":method": 1 }). Mechanism verified in… | nit —…
|
This PR and #43491 both move blocks in #43491 adds the The two fit together. If this PR merges first, #43491 drops its reorder and keeps two lines in |
…y, accept a non-string :method request() and streamRejectedByGoawaySession() built the GOAWAY error by hand. $ERR_HTTP2_GOAWAY_SESSION() exists and gives the same code and message, and String(error) now reads like node's. The two identical branches in request() become one. A :method that is not a string threw a plain TypeError from method.toUpperCase(). With the session-state checks below that line, a destroyed or closed session reached it too. Node sends String(value), and so does the native encoder, so skip the no-payload default for a non-string method.
|
Reply to the review on 8914280. Two of the three findings are fixed in 496891c.
|
|
Follow-up to my note above: #43491 is now stacked on this PR. Its base is this branch, and I merged this branch into it, so On the merged tree, |
Problem
ClientHttp2Session#request(headers, options)reports the wrong error for an invalid call.request({ "bad name": "x" }, { endStream: 1 })throwsERR_INVALID_ARG_TYPE, node v26.3.0 throwsERR_INVALID_HTTP_TOKEN.request({ "bad name": "x" })does not throw on a destroyed session, and throwsERR_HTTP2_GOAWAY_SESSIONon a closed one.request()(src/js/node/http2.ts:5892):optionsfields, session state, header block. Node validates the headers, thenoptions, and acts on the session state last.Fix
optionscopy and checks below the header validation. Move theoptions.signalcheck and the session-state checks below them, but above the deferred:pathandcontent-lengthrejections.sentHeaders,endStreamapplied,signalattached.:methodis sent asString(value), as in node. It threw aTypeError, which a destroyed session would now reach. The GOAWAY error comes from$ERR_HTTP2_GOAWAY_SESSION().test/js/node/http2/node-http2-request-validation.test.ts, 11 new tests. Stock bun fails 10, node passes all. Alsotest/js/node/http2/and the vendoredtest-http2-*.jsfiles. Self-reviewed: 13 concerns raised, 8 addressed.Background
request()throw. A destroyed session returns a stream that fails on the next tick withERR_HTTP2_INVALID_SESSION.close()or a graceful GOAWAY, while streams are in flight.:pathwith a space). Bun reports these fromrequest()as a deferred stream error. Node gets there after the session-state check.Notes
Repro, run on an open session, then after
c.destroy():endStream: 1ERR_INVALID_HTTP_TOKENERR_INVALID_ARG_TYPEERR_INVALID_HTTP_TOKENERR_INVALID_HTTP_TOKENERR_HTTP2_INVALID_SESSIONERR_INVALID_HTTP_TOKEN:authorityERR_HTTP2_CONNECT_AUTHORITYERR_HTTP2_CONNECT_AUTHORITYERR_INVALID_HTTP_TOKENERR_HTTP2_GOAWAY_SESSIONERR_INVALID_HTTP_TOKENsignal: {}ERR_INVALID_ARG_TYPEERR_INVALID_ARG_TYPE{ endStream: 1, parent: "x" }options.parentoptions.endStreamoptions.parentoptionsgetter per call, invalid / valid headersGET:writableEnded, eventstrue,errorclosefalse,abortederrorclosetrue,errorcloseAbortErrorERR_HTTP2_INVALID_SESSIONAbortError{ ":method": 1 }:method: 1TypeError: method.toUpperCase is not a function:method: 1String(error)Error [ERR_HTTP2_GOAWAY_SESSION]: ...(on the stream)Error: ...(thrown)Error [ERR_HTTP2_GOAWAY_SESSION]: ...(thrown)I ran a matrix of 15 header values, 10 option values and 4 session states (open, destroyed, closed, closed by a GOAWAY) on node v26.3.0 and on bun: 224 calls. 136 differed before this change. 54 differ after it. None of the 54 is an order difference between the header block,
optionsand the session state.Tests. They are in their own file, like the other focused http2 suites (
node-http2-client-close.test.ts).node-http2.test.jstakes more than 3 minutes on a debug build and some of its tests time out there. The new file takes 5 s. Ten of the eleven new tests fail on stock bun. The other one (a destroyed session fails every request that has valid arguments) passes before and after. That test and its two closed-session siblings fail when the session-state checks move below the:pathandcontent-lengthrejections. I checked that with the mutation: the streams then fail withERR_HTTP2_STREAM_CANCELandERR_HTTP2_STREAM_ERROR. I ran the same tests on node v26.3.0 through a smalldescribe/it/expectshim: 11 of 11 pass.failureOf()in the tests accepts a thrown error and a stream error, because of the first item below.Not changed in this PR:
ERR_HTTP2_GOAWAY_SESSIONsynchronously for a valid call. Node v26.3.0 reports it on the stream (http2: emit session close before stream close nodejs/node#63414). node:http2: rewritten inbound engine, batched write path, server push, +290 node v26.3.0 tests (79% passing) #31584 chose the throw and the code comment says so.Concerns from the self-review that I did not address here. All five exist on
main:protocol(http2.connect({ host, port }, { protocol: "http:" })),request()readsoptions.protocolfrom the request options for the:schemedefault. In the raw-list form that read comes before the header validation. The read is part of a separate bug: bun sends:scheme: httpson that cleartext session, node sendshttp. No PR covers it yet.options.signal(null,0,"",false) throws from the nativerequest(). Node ignores it. node:http2: free a request's slot when its stream closes on the wire, abort request({ signal }) from JS only #42763 removes that native check.{ aborted: false }signal. It now throws fromaddAbortListeneron a destroyed session, as in node.ERR_HTTP2_INVALID_SESSIONstream.toString()loses to anoptionserror. node:http2: coerce outbound header values before taking a stream id #37568 moves the coercion.Other differences in the matrix, each with an owner:
headersandoptionstype checks (assertIsObject): a separate change is in progress. Node's place for theoptionscheck is right above theoptionscopy.options.signalmessage says "of type AbortSignal", node says "an instance of AbortSignal": node errors: render a single class name in ERR_INVALID_ARG_TYPE as "an instance of" like node #38694. The new test reads the property name from the message, not the full text.parent: 0and a negativeparent: reported separately.Suites: the new file passed 5 of 5 runs on the debug build with the final commit. The 62 vendored
test-http2-*.jsfiles that touchdestroy(),'aborted'or a signal pass with the final build. All 256 passed before the last one-line change. In this container the fullnode-http2.test.jshas timeouts with and without the change, all in tests that spawn a debug-build subprocess (5 s limit).[human-review] gate passed · iteration 1 · 2 files touched
fails on main (without fix)
passes on PR (with fix)
diff hotspot
gate history · 1 passed · 1 rejected · iteration 1
evidence per changed file