Repository navigation
Conversation
… stream id request() refuses some requests before the stream gets an id (a :path with a control character or a space, content-length on a GET, HEAD or DELETE that ends the stream). The stream still emits 'ready'. close() waited for 'ready' and then called rstStream() with an undefined id from setImmediate, which threw an uncaught "Invalid stream id".
|
Status
|
|
Updated 11:10 AM PT - Oct 12th, 2026
✅ @robobun, your commit 72a2d3979e3898ff5015803403703754d2472580 passed in 🧪 To try this PR locally: bunx bun-pr 45076That installs a local version of the PR into your bun-45076 --bun |
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.
Additional findings (outside the current diff — GitHub can't attach inline comments there):
-
🟣
src/js/node/http2.ts— Callers that pass options.signal to a request Bun refuses get neither validation nor abort wiring, unlike every other request() path. src/js/node/http2.ts:6209 and http2.ts:6224 return before validateAbortSignal at http2.ts:6239 and setupRequestEndAndSignal at http2.ts:6283/6315. A non-signal value does not throw ERR_INVALID_ARG_TYPE and endStream is never applied, so an aborted signal reports a stream error instead of an AbortError. Fix: run the signal validation and the already-aborted check before the two refusal returns so refused requests honor the same signal contract as accepted ones.Why this was flagged
Trigger: client.request({':path': '/a b'}, { signal }) or the content-length refusal with any options.signal. Both early returns at http2.ts:6209 and http2.ts:6224 sit above the signal block at http2.ts:6232-6248. A caller passing signal: 123 gets no synchronous ERR_INVALID_ARG_TYPE; a caller passing an already-aborted signal gets ERR_HTTP2_STREAM_ERROR from http2.ts:3752 rather than the AbortError the path at http2.ts:6245 produces for every other request. The PR description names axios with signal plus abort() as the motivating population, so this is exactly the input mix that reaches these lines. The base branch has the same ordering, but the base crashed the process on this path, so the inconsistency is newly observable. Remedy: hoist validateAbortSignal and the signal.aborted fast path above both refusal returns.
Verification: pre-existing — the mechanism is real but lives in request() code this diff does not modify (the base file at 772f87f has the identical ordering). Trigger: client.request(headers, { signal }) where headers hit either refusal path. In src/js/node/http2.ts the :path refusal returns at line 6209 and the content-length refusal at 6224, both above validateAbortSignal at 6239 and both setupRequestEndAndSignal calls at 6283 and 6315.
A request that request() refuses fails one tick later. If user code closed or destroyed the stream before that, the stream keeps its code and gets no 'error', as in node. Without this, close() with no 'error' listener on a DELETE that has content-length still ended the process. The test moves into node-http2-client-close.test.ts, where it also runs under node.
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.
LGTM — the second commit addresses everything from the earlier review (test moved into the module file, and a stream the user already closed or destroyed now keeps its rstCode and gets no 'error' on both refusal paths), and the remaining inline comment is a small duplication nit.
Extended reasoning...
The diff touches src/js/node/http2.ts (a typeof-id guard in sendRstOnReady plus a req.closed early-destroy guard in the two up-front refusal helpers, with the invalid-:path refusal moved into a new rejectInvalidPathNT) and adds a 2x2x2 matrix to test/js/node/http2/node-http2-client-close.test.ts; no security-sensitive surface is involved. I traced both the close() path (markStreamClosed is synchronous, the nextTick helper then calls destroy() with rstCode 0 and no id, so no RST and exactly one 'close') and the destroy() path (_destroy runs synchronously, the helper's second destroy() is a no-op), and the closed predicate matches node's onStreamClose rule. The change is small and self-contained, the test file also runs under node as an oracle, no CODEOWNERS entry covers these paths, and the only open inline finding is a nit.
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.
The single rejectRequestNT helper and the move of the matrix into the module's test file address both of my earlier nits. Beyond the inline finding, I also checked that the req.closed guard is live for both calls: close() sets the Closed bit at src/js/node/http2.ts:2591 and destroy() sets it synchronously via _destroy at http2.ts:2655, so the next-tick guard sees it either way; and that routing the content-length refusal through emitStreamErrorNT still yields ERR_HTTP2_STREAM_ERROR with no 'end' (the readable is errored inside the synchronous _destroy before endReadableNT runs), which is what the ported test-http2-multi-content-length.js asserts.
Extended reasoning...
The diff touches two refusal sites in ClientHttp2Session.request() and the deferred RST sender in src/js/node/http2.ts, plus a new 2x2x2 test matrix; no security-sensitive surface. An inline pre-existing finding is already posted, so this body only records what else was examined and ruled out. I could not run the file under node locally to confirm the close:0/no-error contract on real node, so the existing node cross-check at the bottom of the test file is what settles that in CI.
|
Follow-up that is not in this PR This PR fixes only the refused request. The defect that led to it is still on main: a bare A fix is on the branch
What the branch holds, for whoever merges that group into one change:
|
Problem
req.close()in the same turn as arequest()that Bun refuses ends the process:error: Invalid stream idatrstNextTick (node:http2). Bun 1.3.14 does not crash. 1.4.0 to 1.4.3 do (node:http/https/http2: raise Node v26.3.0 compat to ~94%, sync the upstream suites, and fix the Windows/macOS transport-layer teardown bugs they exposed #32488).request()refuses a:pathwith a space, andcontent-lengthon a DELETE. The stream gets no id and still emits'ready'.close()waits for'ready', thensendRstOnReady(src/js/node/http2.ts:2249) callsrstStream(undefined, code).close()ordestroy()and emits'error'. With no'error'listener, that ends the process too.Fix
sendRstOnReadyreturns when the stream has no id. node has the same check insubmitRstStream(core.js:497).request(). A stream that user code closed or destroyed before that keeps its code and gets no'error', as in node'sonStreamClose.test/js/node/http2/node-http2-client-close.test.ts(8 new tests, main fails all, node v26.3.0 passes all). Alsotest/js/node/http2/and five vendoredtest-http2-*.js.Background
close()before that sends RST_STREAM after'ready'.axios.delete(url, { httpVersion: 2, signal, data }), thenabort()in the same tick. No user reported it. node:http2 client: close() on a request that was rejected before it got a stream id throws an uncaught "Invalid stream id" #44479 describes it.Downsides
close()ordestroy()in the same tick, a refused request emits no'error'for the refusal, only'close'.:path,req.rstCodeis 1 one tick afterrequest(), not at once.Notes
Repro (
bun repro.cjs, exit code 1 on Bun 1.4.3 and on main, 0 on Bun 1.3.14 and on node v26.3.0):What the stream reports.
erroris the'error'event, the number isrstCodeat'close'. The two refusals give the same rows.request()'error'listenerclose()Invalid stream idclose()Invalid stream id, or an uncaughtERR_HTTP2_STREAM_ERROR(content-length)destroy():path). 1 (content-length)close(2)ERR_HTTP2_STREAM_ERRORWhich requests. Only two paths of
request()emit'ready'for a stream with no id: the:pathcheck (a character<= 0x20or0x7f) and thecontent-lengthcheck for GET, HEAD and DELETE. The other returns with no id (a destroyed session, an aborted signal, no stream id left) emit no'ready'. Bun 1.3.14 did not refuse these requests. It sent them.One function for both refusals.
rejectRequestNT(session, req)replacesrejectNoPayloadContentLengthNTand the directemitStreamErrorNTcall of the:pathcheck. The:pathcheck setrstCodeinsiderequest(). The function sets it one tick later, so that adestroy()in between sees no code.axios. Checked with axios 1.20.0 and
httpVersion: 2against a local server:axios.delete(url, { signal, data })andaxios.get(url, { signal, data })withabort()in the same tick, and aparamsSerializerthat leaves a space in the query. Each ends the process on Bun 1.4.3. With this change each rejects withERR_CANCELED, as on node.The test is not a spawned fixture. An uncaught exception fails the test in the same process, and the file also runs under
node --test, so node checks the expected events. A child that loadsnode:http2needs 4.6 s to 5.0 s on a debug build, against the 5 s limit of a test.Suites. Debug build:
test/js/node/http2/(624 pass, 0 fail, with a 60 s limit: on this debug build the tests that start a secondbunneed more than the default 5 s). Vendored:test-http2-client-unescaped-path.js,test-http2-multi-content-length.js,test-http2-client-destroy.js,test-http2-head-request.js,test-http2-client-request-options-errors.js.node --teston the test file: node v26.3.0 and v26.11.1 pass all 76.Left alone.
'aborted'and'ready'and no'finish'(node:'finish', no'aborted', and'ready'insiderequest()).options.signal: a value that is not a signal does not throw, and an aborted signal givesERR_HTTP2_STREAM_ERROR, not anAbortError. main does the same.close()still replaces the code of the stream. That is the same node rule at other call sites.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