Repository navigation
Conversation
|
Updated 3:34 AM PT - Oct 18th, 2026
❌ @robobun, your commit d8c5baf has 1 failures in
🧪 To try this PR locally: bunx bun-pr 44337That installs a local version of the PR into your bun-44337 --bun |
|
Status: ready for review at d8c5baf. The code, the tests and the measurements are complete. It needs a maintainer now. CI: the diff is green. In build 128078 180 jobs passed and 1 failed. The jobs that passed are every Linux lane (Alpine, Debian, Debian ASAN, Ubuntu), both Windows lanes, macOS x64, and one of the two macOS aarch64 shards. The job that failed did not run a test: the other macOS aarch64 shard stopped on This PR is now one PR on
Removed since the last status: the rule that held a slot until the end of the read. A slot is free when its stream closes, as on Bun 1.4.3. The PR body says why. Two questions wait for a maintainer. Both are in the PR body:
How I reproduced the bug: a server with
The tests: |
06a91c9 to
c93464b
Compare
8a7cfea to
e6a90b7
Compare
c93464b to
676ab8a
Compare
e6a90b7 to
9ee2fe9
Compare
676ab8a to
39e6256
Compare
d3eb28f to
ad5459a
Compare
|
The tests of this PR on Node.js v26.3.0, for the rule from #37588 and #41305 (a test must pass on Bun and on node). The tests are in node test/js/node/http2/node-http2-max-concurrent-streams.test.ts
bun bd test test/js/node/http2/node-http2-max-concurrent-streams.test.ts
The 7 tests that node skips are marked Bun only.
What changed since the first version of this report:
|
39e6256 to
0c708b6
Compare
ad5459a to
c9a12cb
Compare
c9a12cb to
feab025
Compare
feab025 to
329dbbb
Compare
|
The 11 comment-cop threads of 02:59 UTC (on I pushed the new head, which is on current |
…in rstStream rstStream counted every REFUSED_STREAM reset that JS submits on a server against maxSessionRejectedStreams. close() and the destroy that follows it each submit one, so a server whose handler calls stream.close(NGHTTP2_REFUSED_STREAM) sent GOAWAY(ENHANCE_YOUR_CALM) at the 50th refusal and closed its own session. A RST_STREAM(REFUSED_STREAM) from the peer was counted too. The count was written for one case: the stream that streamStart refuses over SETTINGS_MAX_CONCURRENT_STREAMS. streamStart now returns the code for that stream, and the native caller resets the stream and counts it. rstStream counts nothing.
The tests have their own raw client with no timers. A wait ends with a frame or with the end of the connection.
The helper arms the dispatch guard for the borrow, as the branch above it does. No behaviour change.
329dbbb to
5d80eec
Compare
5d80eec to
f3cca1e
Compare
|
Warning Review limit reachedYou've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. Next included review available in 1 minute. View limit details
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
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 connection-level flow control for DATA frames on a refused stream id: handle_data in src/runtime/api/bun/h2/connection.rs still debits recv_window (line 1651) before the was_refused early return (line 1662), and replenish_windows works from that same window at batch end, so the early return does not drift the connection window relative to the base's STREAM_CLOSED path. The HPACK-table sync for refused and ignored header blocks is covered by the new refused_block_keeps_the_hpack_table_in_sync unit test.
Extended reasoning...
The change moves SETTINGS_MAX_CONCURRENT_STREAMS enforcement for node:http2 servers from JS into the Rust HTTP/2 engine (connection.rs, h2_frame_parser.rs), deletes the JS-side counters, and adds two large test files. It touches adversarial wire-input handling (refused-id tracking, invalid-frame budget, per-stream slot accounting) and six inline findings are already posted; this note only records one additional concern that was examined and ruled out.
…harness in the test
There was a problem hiding this comment.
I re-reviewed the follow-up commit (d8c5baf) and found no new bugs; a human maintainer should still look at this one, since it moves SETTINGS_MAX_CONCURRENT_STREAMS enforcement into the native HTTP/2 engine and the description leaves two budget/semantics questions open for a maintainer.
What was reviewed in this pass:
- The new
MAX_REFUSED_IDScap inremember_refused(connection.rs:946-953): the drain of the oldest half keeps the list sorted, sowas_refused's binary search stays valid; the unit test exercises the overflow with the invalid-frame budget set to u32::MAX, so it reaches the cap. - The decision order in
handle_headers(connection.rs:1106-1119): refused ids are checked before the concurrency and memory gates, and only new server-side odd streams take the refusal path, so client sessions and continuation of existing streams are unaffected. - The subprocess test now spawns through
bunExe()/bunEnvand asserts a combined{ stdout, exitCode, stderr }object instead of a negative stderr check.
Extended reasoning...
The PR touches src/runtime/api/bun/h2/connection.rs and h2_frame_parser.rs, the native engine that parses inbound HTTP/2 frames from untrusted peers, plus the node:http2 JS layer and two new test files (+2040/-91 across 5 files). It changes which session budget (maxSessionInvalidFrames vs maxSessionRejectedStreams) a concurrency refusal is charged to, which is a DoS-bound behavior change the description itself flags as a question for the merging maintainer of #31584. The follow-up commit addressed the unbounded refused_ids growth and the harness-spawn feedback from the prior review, and the hunt ran dry with no findings, but the size, the wire-parsing surface, and the open maintainer questions rule out approving without a human look.
Problem
node:http2server ends the session with GOAWAY(ENHANCE_YOUR_CALM) andERR_HTTP2_SESSION_ERRORat the 100th stream over the SETTINGS_MAX_CONCURRENT_STREAMS it advertised. Node keeps the session until refusal 1002.src/js/node/http2.ts:4053) and chargesmaxSessionRejectedStreams. node:http2: rewritten inbound engine, batched write path, server push, +290 node v26.3.0 tests (79% passing) #31584 chose that budget on a wrong premise about nghttp2 (Notes).http2.connect(). No user report.Fix
Connection::handle_headersrefuses the stream before it makes a stream entry: RST_STREAM(REFUSED_STREAM), charged tomaxSessionInvalidFrames, like node.H2FrameParsercounts the open peer streams in aCell<u32>. The JS count is deleted.test/js/node/http2/node-http2-max-concurrent-streams.test.ts(22 tests fail before). Its last test runs the file on Node.js. Also all oftest/js/node/http2/and node'stest-http2-*tests.Background
connection.rs) parses inbound frames.H2FrameParserembeds it and writes responses.refused_idskeeps later frames on its id without a second answer (Notes).Downsides
maxSessionInvalidFrames(default 1000), notmaxSessionRejectedStreams(default 100). A server that set either option sees another count..text, +59 instructions per request.Notes
What is in this PR. It targets
mainand has one commit for each part:Connection::count_invalid_frame,H2FrameParser::close_unsent). No behaviour change. This was node:http2: pin the slot accounting of maxConcurrentStreams #44335.Earlier attempt: #32675 (closed). This PR keeps its decision point (refuse in
handle_headersbeforeon_stream_open) and the O(1) count that was asked for there.Reach. Two kinds of client, measured with the public API against a server with limit 10.
http2.connect(), 150 at once, handler answers after 100 msA client on nghttp2 holds its first flight to 100 streams, so it gets at most 100 refusals at once and sees no change. A client with no such cap (Bun's own, and grpc-js on Bun) loses every call of the burst on 1.4.x, the admitted ones too. On 1.4.x the server option
maxSessionRejectedStreams: 1002gives node's count.History. Bun 1.3.14 did not enforce the limit: every request reached a handler. #31584 added the enforcement in JS and, after two review findings (r3337691981, r3345228319), the charge to
maxSessionRejectedStreams. The second finding says "nghttp2 counts concurrency-limit REFUSED_STREAM toward the rejected-streams cap". Node'srejected_stream_count_counts the streams that node itself turns away for memory (node_http2.cc:1035). A stream over the limit never reaches that code: nghttp2 refuses it and reports an invalid frame (nghttp2_session.c:3905), and node chargesmaxSessionInvalidFrameswithcount++ > max(node_http2.cc:1128). The flood bound that #31584 wanted stays, at 1000 in place of 100.Repro. Server
http2.createServer({ settings: { maxConcurrentStreams: 10 } }), handlerstream.respond({ ":status": 200 }); stream.write("hello"). A raw client sends the preface, an empty SETTINGS frame and N complete requests in one write, then a PING.ERR_HTTP2_TOO_MANY_INVALID_FRAMESERR_HTTP2_TOO_MANY_INVALID_FRAMES, GOAWAY(last 19, code 2)Two questions for a maintainer.
maxSessionRejectedStreams(100) tomaxSessionInvalidFrames(1000), which is what node does and the opposite of what node:http2: rewritten inbound engine, batched write path, server push, +290 node v26.3.0 tests (79% passing) #31584 chose. cirospaciari merged node:http2: rewritten inbound engine, batched write path, server push, +290 node v26.3.0 tests (79% passing) #31584, so the question is for him: is that move right?ERR_HTTP2_ERRORand GOAWAY(INTERNAL_ERROR). Bun 1.4.x refuses the stream and counts it (99 are kept). This PR refuses the stream and counts it on the other budget (1001 are kept). RFC 9113 5.1.2 asks for a stream error. The choices are node's connection error, the old budget for this one case, or what this PR does. No test pins it.Where it still differs from node.
respond(); end(); destroy()node serves 300 of 300 requests of a node client and the rule served 210. It also took 90 requests from a node client that Bun 1.4.3 serves. It is removed.goaway()orclose()still get RST_STREAM(7), now on the invalid-frame budget. Node ignores them. node:http2: ignore a request that opens after the server's GOAWAY #43475 adds that.close(),goaway()anddestroy()is right.CanAddStream(a refusal for memory, RST_STREAM(ENHANCE_YOUR_CALM)) is tested after the limit as here, and nghttp2's glitch rate limit (node:http2: add nghttp2's glitch rate limit for ignored frames #42522).streamResetBurst). Node counts it. The refusal is counted.What else changes.
The refusal is the same on every entry point: core API, compat API, TLS,
performServerHandshake()over a Duplex.session.settings()andserver.updateSettings()set the limit that native code reads, from the call on.A refused stream is not the last processed stream.
session.state.lastProcStreamIDand the GOAWAY ofclose(),goaway()anddestroy()name the last admitted stream. 1.4.3 names the refused stream, so a client cannot know that it can send the request again.A refused request with a malformed block gets one RST_STREAM. 1.4.3 sends two.
A frame on a refused stream gets no second answer and no second charge, in the same read and in a later one. That is DATA (an empty frame and a frame that a read cuts in two also) and a trailer block. Frames on stream 3, which the server refused at limit 1:
A slot is taken only for a stream that the engine admitted. A
Streamthat a host function makes for an id with no entry takes none.The JS count had two defects. A
respond()that failed and a later reset or end of that stream gave two slots back, so a limit of 1 admitted 2 streams. An'aborted'listener that throws kept the slot for the rest of the session. Both have a test.A limit that was never sent to the peer is not enforced:
{ maxConcurrentStreams: 5, settings: { maxConcurrentStreams: undefined } }.When the allowance is used up the engine stops at that frame. 1.4.3 parsed and allocated for every remaining HEADERS frame of the chunk.
Not changed. Three other rejections still end the session at the 100th: a malformed block, an oversized header list and a refusal for
maxSessionMemory. 150 malformed requests: node answers 150 and keeps the session, Bun ends it at the 100th. Node's rule sets its count back to 0 for each admitted stream, which takes away a bound that Bun has today, so that change needs its own decision. A first flight with large responses therefore still ends on the memory budget.The count.
open_peer_streamsgoes up wherehandle_received_stream_idmakes theStreamof a request that the engine admitted.Stream::release_peer_slottakes the bit and goes down, so a second call does nothing. It is called fromfree_resourcesand fromclose_unsent, and from nowhere else. It never readsStream.state: a local half-close can write over CLOSED after a reset.assert_peer_slotschecks in builds with debug assertions, once per read, that the count is the number of streams with the bit and that no CLOSED stream has it.The refused ids. A refused stream has no engine entry, so a later frame on its id finds nothing. On 1.4.3 the engine still has the entry of a refused stream for the rest of the read, so the frames that follow in the same write get no answer. Node gives them no answer in any read. One of the pins holds that, and without a record of the refused ids this PR fails it (DATA gets RST_STREAM(STREAM_CLOSED), a trailer block is refused a second time).
refused_idsis that record: a sortedVec<u32>with one entry for each refused stream.handle_headersand the two DATA paths read it, and the first test is one compare with the last id. It keeps the last 2048 ids at most (8 KB). The default budgets end a session before it has refused that many streams, so only a server that raised them can drop an id. A frame on a dropped id gets the reply thatmaingives today.A review asked for the commit of #37985 in place of the list. That commit reads every client id at or below the highest id, with no entry, as a closed stream. I did not take it. Jarred's note on #31359 (r3295037991) says why that rule needs care: Bun's own client can write stream ids out of order (#37568 is the client half). #45801 leaves that rule out for the same reason. The list is exact for the ids that this server refused and says nothing about any other id. When #37985 and #45801 land, the list can go.
Both runtimes. The tests are written for
node:testandnode:assert. In Bun the last test of the file runs the same file on Node.js, asnode-http-agent-free-socket.test.tsdoes, so CI checks both. Node.js v26.3.0: 59 tests, 52 pass, 0 fail, 7 are skipped as Bun only. Six of those need arespond()that fails, and the calls that make it fail in Bun (weight: 0, a parent out of range, options that are not an object, a header block overmaxSendHeaderBlockLength) do not fail on node. The seventh pins the slot timing above. This PR: 60 pass. Bun 1.4.3: 22 of the 26 tests of the new block fail. The test of #44248 that pinned the old budget is rewritten in place, as its comment asks.The self-review. Three reviews. The first asked for tests that pass on node, and for node's slot timing or a maintainer's word. The second asked for
node:test, and found approximations in the slot timing. The third measured that the slot timing cannot follow node and costs requests that Bun serves today, so it is removed. It also asked for this packaging: one PR onmain, and the two questions above. Of its 48 findings, 35 are taken. Six are left as they are and are in the lists above (the other rejection budgets, the client cap, the answered upload, the frame-header timing,#connections). One needs no action. Not taken:refused_ids(3 findings). The reason is under "The refused ids".connection.rs. CI type-checks them and runs none of them, as for the other tests of that file. I ran them with the engine sources in a standalone crate: 28 pass.node-http2-rejected-streams.test.tsin place of the rewrite.Suites on a debug build of this head, with a 60 s test timeout: the 261
test-http2-*files oftest/js/node/testall exit 0. All 13 files oftest/js/node/http2/pass (node-http2.test.js405 pass and 6 skip,h2-conformance.test.ts84,node-http2-client-close.test.ts69,node-http2-max-concurrent-streams.test.ts60,node-http2-rejected-streams.test.ts16). Of the 29 grpc-js files, 27 pass.test-client(3 tests) andtest-resolver(5 tests) fail in this container: they need DNS, or they have a 100 ms deadline that a debug build misses. On release builds ofmainand of this head the same number of their tests fail (2, and 5 or 6).Measurements. Release builds of
main(d0831bc) and of this PR on that commit, same toolchain and build directory:main.text(size -A).rodata.bun_builtins(the JS modules)size_ofH2FrameParser(it holds theConnection)size_ofConnectionsize_oflegacyStreamThe three
size_ofrows are from debug builds (gdb). Host functions +0, codegen entries +0.Instructions that each function executes for one request that is admitted, callees not counted. The same release builds, gdb single-step, the same count on every repeat.
mainConnection::receive(the read with the HEADERS frame)Connection::finish_header_blockhandle_received_stream_id(2 calls)Stream::free_resourcesrewrite_readhttp2.connect()requestmainConnection::receive(the read with the response)Connection::finish_header_blockhandle_received_stream_id(3 calls)Stream::free_resourcesrewrite_readNo line is new on the admitted path of
finish_header_block. Its +30 and +16 come with the two new match arms: the compiler lays the function out in another way. A DATA frame pays one more test (refused_idsis empty, or the id is above its last entry). The server loses the JS count for each stream: 3% 2tests, 4 private-field reads, 2 writes and 1 call of thestream.idgetter.The numbers below are from 9ee2fe9, an earlier head of this PR with the same refusal path, against its base of that time.
Debug builds, gdb breakpoint hit counts, first flight at limit 10:
Box<Stream>Per admitted stream nothing changes: 1
Box<Stream>, 1 map insert, the same JS calls.sendtocalls of the server for a first flight of 150 at limit 10 (maxSessionRejectedStreamsraised so that the base session stays open): 153 on base, 13 with this PR.The engine alone, 200,000 refused requests in reads of 20,000 (the engine sources in a scratch crate, release profile): 244 ns per refusal, 5.1 hash slots examined per refusal in read 1 and in read 10, 0 inserts. The same engine driven as 1.4.3 drives it (open, then refuse from the embedder): 840 ns per refusal in read 1 and 61,000 ns in read 10.
Server CPU from
process.cpuUsage(), N requests in one write at limit 100, the handler holds its stream. Each cell is the median of 5 sessions. Two server processes per build, run in turns:That is about 6 us per refused stream on base and about 0.2 us with this PR.
A server with no limit, 20,000 requests in one write, handler
respond()thenend(), the same method: base 323, 315 and 263 ms. This PR 293, 296 and 350 ms. The difference is inside the noise.perf_event_openis not permitted in the build container, andvalgrind,straceandbloatyare not installed. So there is no whole-process instruction count.Left for later.
node:http2client does not hold its first flight to 100 streams before the SETTINGS of the server arrive (peerMaxConcurrentStreams). For that client the session still ends, at refusal 1002.ClientHttp2Session.request()reads#remoteSettings?.maxConcurrentStreams, which is undefined until then.session.settings({ enableConnectProtocol })inside a handler applies from the next read. Node applies it from the call.#connections, the JS count of the streams of a session, is given back twice after arespond()that fails.respond()that fails, the server writes no RST_STREAM, and the entries of the stream stay until the peer resets it.maindoes the same. The slot is given back at once, as on 1.4.3. node:http2: ignore request() options in respond(), like node #43478 and node:http2: do not refuse respond() when the session is over maxSessionMemory #43419 remove two of the ways to makerespond()fail.streamStartmakes no JS stream (it threw, or an exception was pending) stays open until the peer resets it, and it holds its slot for that time. The JS count of 1.4.3 did not count such a stream. Node resets it.respond()fails in Bun forweight: 0and for a parent out of range. Node accepts both.