Skip to content

fix(http2): server refuses streams beyond maxConcurrentStreams with REFUSED_STREAM#29054

Draft
cirospaciari wants to merge 1 commit intomainfrom
claude/h2-server-refuse-over-maxconcurrentstreams
Draft

fix(http2): server refuses streams beyond maxConcurrentStreams with REFUSED_STREAM#29054
cirospaciari wants to merge 1 commit intomainfrom
claude/h2-server-refuse-over-maxconcurrentstreams

Conversation

@cirospaciari
Copy link
Copy Markdown
Member

RFC 9113 §5.1.2: an endpoint that receives a HEADERS frame causing its advertised concurrent-stream limit to be exceeded MUST treat this as a stream error of type PROTOCOL_ERROR or REFUSED_STREAM. Bun's server accepted unlimited concurrent streams regardless of localSettings.maxConcurrentStreams.

Now handleReceivedStreamID counts open streams and writes RST_STREAM(REFUSED_STREAM) before allocating a Stream when the limit is reached, so the JS 'stream' event never fires for the refused request and the client sees NGHTTP2_REFUSED_STREAM.

Ports Node's test/parallel/test-http2-max-concurrent-streams.js.

…EFUSED_STREAM

RFC 9113 Section 5.1.2: an endpoint that receives a HEADERS frame causing
its advertised concurrent-stream limit to be exceeded MUST treat this as
a stream error (PROTOCOL_ERROR or REFUSED_STREAM). Bun's server accepted
unlimited concurrent streams regardless of localSettings.maxConcurrentStreams.

Now handleReceivedStreamID counts open streams and writes
RST_STREAM(REFUSED_STREAM) before allocating a Stream when the limit is
reached, so the JS 'stream' event never fires for the refused request.

Ports Node test test/parallel/test-http2-max-concurrent-streams.js.
@robobun
Copy link
Copy Markdown
Collaborator

robobun commented Apr 8, 2026

Updated 7:09 PM PT - Apr 8th, 2026

@cirospaciari, your commit a045dba has 3 failures in Build #44591 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 29054

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

bun-29054 --bun

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 8, 2026

Walkthrough

Added HTTP/2 RFC 9113 Section 5.1.2 enforcement in the H2FrameParser to reject streams exceeding the server's maxConcurrentStreams limit by sending RST_STREAM frames with REFUSED_STREAM error codes. Includes a corresponding test validating behavior with concurrent stream attempts.

Changes

Cohort / File(s) Summary
HTTP/2 Max Concurrent Streams Enforcement
src/bun.js/api/bun/h2_frame_parser.zig, test/js/node/test/parallel/test-http2-max-concurrent-streams.js
Implemented RFC 9113 Section 5.1.2 enforcement where the H2FrameParser counts active streams and rejects new streams via RST_STREAM with REFUSED_STREAM when maxConcurrentStreams limit is reached. Added test file validating server-side enforcement with simultaneous requests, confirming first request succeeds while second is refused with appropriate error.
🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately and concisely describes the main change: implementing RFC 9113 compliance by refusing HTTP/2 streams beyond the maxConcurrentStreams limit with a REFUSED_STREAM error.
Description check ✅ Passed The description covers both required sections: it clearly explains what the PR does (implements RFC 9113 §5.1.2 compliance by refusing streams exceeding maxConcurrentStreams) and how the work was verified (porting Node's test file).

✏️ 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: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/bun.js/api/bun/h2_frame_parser.zig`:
- Around line 2531-2555: The current pre-dispatch concurrent-stream check in
readBytes()/h2_frame_parser.zig uses null to indicate a REFUSED_STREAM, which
collides with the existing null sentinel handled by handleHeadersFrame()
(causing GOAWAY/PROTOCOL_ERROR and skipping payload consumption); move the
maxConcurrentStreams enforcement out of the pre-dispatch path into the specific
HEADERS handling branch and/or return a distinct sentinel instead of null (e.g.,
a tagged enum or a specific error variant like RefusedStream) so callers like
readBytes() and handleHeadersFrame() can differentiate "refused stream" from the
existing null/connection-error path; update uses of lastStreamID, the code that
writes the RST_STREAM(REFUSED_STREAM) (the FrameHeader creation and write
sequence), and any code that relies on currentFrame/remainingLength to ensure
payload is consumed or skipped correctly when a refusal is issued.

In `@test/js/node/test/parallel/test-http2-max-concurrent-streams.js`:
- Around line 14-17: The current test ends the first HTTP/2 stream immediately
(inside server.on('stream') where stream.respond() and stream.end('hello world')
are called), making the max-concurrent-streams behavior timing-dependent; change
the server stream handler to keep the first stream open (do not call stream.end)
and only close it after the second client request has been observed and
explicitly rejected (e.g., detect REFUSED_STREAM or error on the second request
produced by clientSession.request()); after asserting the second request is
refused, send a follow-up successful request on the same session and assert it
succeeds, then close the first stream and perform the existing session
cleanup—use the existing identifiers server, stream, clientSession/request
handlers to locate the code to modify.
🪄 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: b503d5c7-ba66-40f6-94fb-bf978a597ca7

📥 Commits

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

📒 Files selected for processing (2)
  • src/bun.js/api/bun/h2_frame_parser.zig
  • test/js/node/test/parallel/test-http2-max-concurrent-streams.js

Comment on lines +2531 to +2555
// RFC 9113 Section 5.1.2: an endpoint that receives a HEADERS frame that
// causes its advertised concurrent stream limit to be exceeded MUST treat
// this as a stream error of type PROTOCOL_ERROR or REFUSED_STREAM. We use
// REFUSED_STREAM so the client may retry safely.
if (this.isServer and this.localSettings.maxConcurrentStreams != std.math.maxInt(u32)) {
var open: u32 = 0;
var it = this.streams.valueIterator();
while (it.next()) |s| {
if (s.state != .CLOSED) open += 1;
}
if (open >= this.localSettings.maxConcurrentStreams) {
if (streamIdentifier > this.lastStreamID) this.lastStreamID = streamIdentifier;
var buffer: [FrameHeader.byteSize + 4]u8 = undefined;
@memset(&buffer, 0);
var writerStream = std.io.fixedBufferStream(&buffer);
const writer = writerStream.writer();
var frame: FrameHeader = .{ .type = @intFromEnum(FrameType.HTTP_FRAME_RST_STREAM), .flags = 0, .streamIdentifier = streamIdentifier, .length = 4 };
_ = frame.write(@TypeOf(writer), writer);
var value: u32 = @intFromEnum(ErrorCode.REFUSED_STREAM);
value = @byteSwap(value);
_ = writer.write(std.mem.asBytes(&value)) catch 0;
_ = this.write(&buffer);
return null;
}
}
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.

⚠️ Potential issue | 🔴 Critical

Don't use null here to mean "refused stream".

Line 2553 collides with the existing null sentinel used by the frame handlers: readBytes() passes this result straight into handleHeadersFrame(), whose null branch is the connection-stream error path, so an over-limit request will send RST_STREAM(REFUSED_STREAM) and then still emit GOAWAY(PROTOCOL_ERROR). That fast-fail also skips payload consumption, leaving currentFrame/remainingLength stale. Because this helper runs before frame-type dispatch, the new branch can also refuse fresh non-HEADERS frames. Please move the limit enforcement into the HEADERS path and represent refusal with a distinct state/sentinel instead of null.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/bun.js/api/bun/h2_frame_parser.zig` around lines 2531 - 2555, The current
pre-dispatch concurrent-stream check in readBytes()/h2_frame_parser.zig uses
null to indicate a REFUSED_STREAM, which collides with the existing null
sentinel handled by handleHeadersFrame() (causing GOAWAY/PROTOCOL_ERROR and
skipping payload consumption); move the maxConcurrentStreams enforcement out of
the pre-dispatch path into the specific HEADERS handling branch and/or return a
distinct sentinel instead of null (e.g., a tagged enum or a specific error
variant like RefusedStream) so callers like readBytes() and handleHeadersFrame()
can differentiate "refused stream" from the existing null/connection-error path;
update uses of lastStreamID, the code that writes the RST_STREAM(REFUSED_STREAM)
(the FrameHeader creation and write sequence), and any code that relies on
currentFrame/remainingLength to ensure payload is consumed or skipped correctly
when a refusal is issued.

Comment on lines +14 to +17
server.on('stream', common.mustCall((stream) => {
stream.respond();
stream.end('hello world');
}));
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.

⚠️ Potential issue | 🟠 Major

Make this regression deterministic and prove the session stays alive.

The first stream is ended immediately, so the second request only exercises the limit if timing happens to keep the first one open long enough. Then Lines 22-25 shut both sides down as soon as the two streams close, so this would still pass if the implementation sent REFUSED_STREAM and then GOAWAYed the session. Please hold the first stream open until the second request is refused, then verify a follow-up request still succeeds before cleanup.

Also applies to: 22-25, 31-55

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/js/node/test/parallel/test-http2-max-concurrent-streams.js` around lines
14 - 17, The current test ends the first HTTP/2 stream immediately (inside
server.on('stream') where stream.respond() and stream.end('hello world') are
called), making the max-concurrent-streams behavior timing-dependent; change the
server stream handler to keep the first stream open (do not call stream.end) and
only close it after the second client request has been observed and explicitly
rejected (e.g., detect REFUSED_STREAM or error on the second request produced by
clientSession.request()); after asserting the second request is refused, send a
follow-up successful request on the same session and assert it succeeds, then
close the first stream and perform the existing session cleanup—use the existing
identifiers server, stream, clientSession/request handlers to locate the code to
modify.

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.

The concurrent-stream limit check returns null from handleReceivedStreamID, but every frame handler treats null as a connection-level PROTOCOL_ERROR, so a refused stream triggers both RST_STREAM(REFUSED_STREAM) and GOAWAY(PROTOCOL_ERROR) — killing the entire connection rather than allowing safe client retry. The check also fires for non-HEADERS frames (DATA, PRIORITY, etc.) which do not create streams per RFC 9113 §5.1.2.

Extended reasoning...

Overview

The PR adds a concurrent-stream limit check inside handleReceivedStreamID in h2_frame_parser.zig. When the server is at capacity, it writes RST_STREAM(REFUSED_STREAM) and returns null. A test ported from Node is also added.

Security Risks

No direct security implications. The change affects HTTP/2 stream lifecycle management.

Level of Scrutiny

This is production-critical HTTP/2 networking code. The bug reports identify a real design flaw: the pre-existing invariant is that handleReceivedStreamID returns null only for stream-0 (connection control stream), always a PROTOCOL_ERROR. The PR introduces a second null-return path without updating any callers. Every frame handler — handleHeadersFrame (line 2350), handleContinuationFrame (line 2309), handleDataFrame (line 1994), handlePriorityFrame (line 2271) — has an orelse sendGoAway(PROTOCOL_ERROR) branch. So a refused stream sends both RST_STREAM(REFUSED_STREAM) and GOAWAY(PROTOCOL_ERROR), tearing down the entire connection.

Additionally, the check fires for all frame types at the dispatch site (lines 2607, 2655, 2689), not just HEADERS. RFC 9113 §5.1.2 restricts the concurrent-stream limit to HEADERS frames. Sending RST_STREAM(REFUSED_STREAM) for a PRIORITY frame on an idle stream is semantically wrong and also triggers GOAWAY.

Other Factors

The bundled test may pass due to nghttp2 client-side timing (RST_STREAM processed before GOAWAY), masking the GOAWAY-induced connection teardown. The correct fix is to move the check inside handleHeadersFrame only, send RST_STREAM(REFUSED_STREAM), and return normally rather than null — preserving the existing invariant.

Comment on lines +2531 to +2554
// RFC 9113 Section 5.1.2: an endpoint that receives a HEADERS frame that
// causes its advertised concurrent stream limit to be exceeded MUST treat
// this as a stream error of type PROTOCOL_ERROR or REFUSED_STREAM. We use
// REFUSED_STREAM so the client may retry safely.
if (this.isServer and this.localSettings.maxConcurrentStreams != std.math.maxInt(u32)) {
var open: u32 = 0;
var it = this.streams.valueIterator();
while (it.next()) |s| {
if (s.state != .CLOSED) open += 1;
}
if (open >= this.localSettings.maxConcurrentStreams) {
if (streamIdentifier > this.lastStreamID) this.lastStreamID = streamIdentifier;
var buffer: [FrameHeader.byteSize + 4]u8 = undefined;
@memset(&buffer, 0);
var writerStream = std.io.fixedBufferStream(&buffer);
const writer = writerStream.writer();
var frame: FrameHeader = .{ .type = @intFromEnum(FrameType.HTTP_FRAME_RST_STREAM), .flags = 0, .streamIdentifier = streamIdentifier, .length = 4 };
_ = frame.write(@TypeOf(writer), writer);
var value: u32 = @intFromEnum(ErrorCode.REFUSED_STREAM);
value = @byteSwap(value);
_ = writer.write(std.mem.asBytes(&value)) catch 0;
_ = this.write(&buffer);
return null;
}
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 new null return path in handleReceivedStreamID is treated as a connection-level error by every caller: handleHeadersFrame, handleContinuationFrame, handleDataFrame, and handlePriorityFrame all have an orelse sendGoAway(PROTOCOL_ERROR) branch that fires on null, so a refused stream causes RST_STREAM(REFUSED_STREAM) followed immediately by GOAWAY(PROTOCOL_ERROR), which kills the entire connection and makes safe client retry impossible—directly contradicting the stated RFC 9113 intent. Additionally, the concurrent-stream limit check fires for all incoming frame types (DATA, PRIORITY, etc.), not just HEADERS; only HEADERS frames open new streams, so sending REFUSED_STREAM for a PRIORITY frame on an idle stream (valid per RFC 9113 §5.3.1) is semantically wrong and also triggers the GOAWAY path.

Extended reasoning...

Root cause — broken null invariant

The pre-existing invariant in this file is: handleReceivedStreamID returns null only when the frame targets stream 0 (the connection control stream), which is always a connection-level protocol error. Every frame handler relies on this: handleHeadersFrame (line 2350), handleContinuationFrame (line 2309), handleDataFrame (line 1994), and handlePriorityFrame (line 2271) all contain an orelse { this.sendGoAway(frame.streamIdentifier, ErrorCode.PROTOCOL_ERROR, ..., true); return data.len; } branch. The PR introduces a second null-return path—refused streams—without updating any of these callers.

Concrete HEADERS path (bug_001)

  1. Client sends HEADERS for stream N when open >= maxConcurrentStreams.
  2. readByteshandleReceivedStreamID(N): check fires, writes RST_STREAM(REFUSED_STREAM), returns null.
  3. readByteshandleHeadersFrame(header, bytes, null).
  4. handleHeadersFrame line 2350: var stream = stream_ orelse { this.sendGoAway(frame.streamIdentifier, ErrorCode.PROTOCOL_ERROR, "Headers frame on connection stream", this.lastStreamID, true); return data.len; };
  5. sendGoAway with emitError=true dispatches .onError and .onEnd, terminating the entire HTTP/2 connection.

Net result: the server sends both RST_STREAM(REFUSED_STREAM) and GOAWAY(PROTOCOL_ERROR). The GOAWAY kills the connection for all in-flight streams, defeating the stated goal of allowing the client to retry safely. The same path fires through handleContinuationFrame for multi-frame HEADERS sequences.

Non-HEADERS frame types (bug_002)

handleReceivedStreamID is invoked at lines 2607, 2655, and 2689 for all frame types before the per-type dispatch. The new check fires whenever a frame arrives with an unknown stream ID while the server is at capacity, regardless of frame type. RFC 9113 §5.1.2 restricts the concurrent-stream limit to HEADERS frames (stream creation). Specifically:

  • A PRIORITY frame on an idle stream is explicitly valid per RFC 9113 §5.3.1. The server incorrectly sends RST_STREAM(REFUSED_STREAM), then handlePriorityFrame (line 2271) receives null and fires sendGoAway(PROTOCOL_ERROR), terminating the connection for RFC-valid client behavior.
  • DATA on a non-existent stream is a protocol violation, but the correct response is STREAM_CLOSED, not REFUSED_STREAM (which semantically means "retry is safe").
  • WINDOW_UPDATE on a non-existent stream avoids GOAWAY because its handler uses if (stream) |s|, but still incorrectly emits RST_STREAM(REFUSED_STREAM).

Why the bundled test may pass despite the bug

The nghttp2 client library processes RST_STREAM before GOAWAY if both arrive in the same TCP segment. The second request's aborted event fires from the RST_STREAM, and the countdown.dec() in the close handler may decrement to zero before the GOAWAY-induced connection teardown prevents the first request from completing. This makes the test timing-sensitive rather than a definitive correctness proof.

Fix

Move the concurrent-stream limit check inside handleHeadersFrame (or pass the frame type into handleReceivedStreamID and gate the check on FrameType.HTTP_FRAME_HEADERS). When the limit is exceeded, send RST_STREAM(REFUSED_STREAM) and return normally (do not return null), so no GOAWAY is triggered. This preserves the existing null-means-connection-error invariant and restricts the refused-stream logic to HEADERS frames only, as RFC 9113 §5.1.2 requires.

@cirospaciari cirospaciari marked this pull request as draft April 8, 2026 21:41
@cirospaciari
Copy link
Copy Markdown
Member Author

Converting to draft per review: returning null from handleReceivedStreamID is overloaded — the same return is used for stream-id 0 (connection stream) and now for refused streams, but the frame-dispatch callers at lines 2607/2655/2689 need to consume the incoming HEADERS/CONTINUATION payload bytes regardless. Correct approach: still create the Stream entry, set state = .CLOSED immediately, write the RST_STREAM, and add a refused: bool field that decodeHeaderBlock checks to skip the onStreamStart dispatch. That way the frame payload is consumed normally and only the JS callback is suppressed.

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