Repository navigation
node:http2: count a refused stream where the session refuses it, not in rstStream #44248
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
052cf97
b45a84a
5f739d7
9ac99b9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -3452,10 +3452,30 @@ impl H2FrameParser { | |||||||||
| }); | ||||||||||
| self.enter_stream_dispatch(stream) | ||||||||||
| .set_context(returned, &global); | ||||||||||
| } else if returned.is_number() && self.count_rejected_stream(stream_identifier) { | ||||||||||
| // streamStart refused the stream and returned the RST_STREAM code that answers it. | ||||||||||
| let mut refused = self.enter_stream_dispatch(stream); | ||||||||||
| self.end_stream(&mut refused, ErrorCode(returned.to_u32())); | ||||||||||
| } | ||||||||||
| Some(stream) | ||||||||||
| } | ||||||||||
|
|
||||||||||
| /// Returns false when this used up maxSessionRejectedStreams and the session sent its GOAWAY. | ||||||||||
| fn count_rejected_stream(&self, stream_id: u32) -> bool { | ||||||||||
| self.rejected_streams.set(self.rejected_streams.get() + 1); | ||||||||||
| if self.max_rejected_streams.get() <= self.rejected_streams.get() { | ||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟣 pre-existing, not blocking: pre-existing: a server configured with Why this was flaggedA client opens streams past SETTINGS_MAX_CONCURRENT_STREAMS on a server created with Verification: pre-existing (the base already fails by the same route, but this diff re-centralizes the comparison in a new helper and adds a test that pins the off-by-one). Trigger: a server created with
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Confirmed, and it is not new. Budget 3 ends the session at the 3rd rejection, and budget 0 or 1 ends it at the 1st. That is the same on Bun 1.4.2, on a release build of main For the rejection that node counts (a stream over I did not change it in this PR. The comparison decides when a session ends for every producer that comes through the helper: the over-limit refusal, the refusal over The new test pins the boundary as it is, and its comment says that node differs. The boundary is also in the Notes of the description. I leave this thread open for a maintainer to decide. |
||||||||||
| self.send_go_away( | ||||||||||
| stream_id, | ||||||||||
| ErrorCode::ENHANCE_YOUR_CALM, | ||||||||||
| b"ENHANCE_YOUR_CALM", | ||||||||||
| self.last_stream_id.get(), | ||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift Use the last processed peer stream in the rejection GOAWAY.
🤖 Prompt for AI Agents
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Confirmed, and it is not new. With
The values are the same on Bun 1.4.2, on a release build of main I did not change it in this PR. The PR is a fix for a patch release, and it keeps every frame of the over-limit refusal as 1.4.2 writes it. The correct value is the last stream that was delivered to the handler. That needs a new tracked id. For the callers that come through The measured values are in the Notes of the description. I leave this thread open for a maintainer to decide.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The finding remains valid, but I will leave this thread open for the maintainer’s scope decision. I will not ask for a Last-Stream-ID change in this patch-release fix. ✏️ Learnings added
You are interacting with an AI system.
Comment on lines
+3467
to
+3471
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟣 pre-existing, not blocking: An RFC-compliant client that retries streams above the GOAWAY's last_stream_id will never retry the request the server refused, because the GOAWAY names that refused stream as possibly processed. The new helper at h2_frame_parser.rs:3471 passes self.last_stream_id, which line 3379 already advanced to the refused stream id before streamStart ran, and no RST_STREAM is sent for it (see 3474). Fix: the GOAWAY sent for a refusal must carry the highest stream id actually delivered to JS (last_peer_stream_id of an accepted stream, or the id before this one), so the refused request is above it and retryable. Why this was flaggedTrigger: over-limit refusal that exhausts maxSessionRejectedStreams, entering via handle_received_stream_id (h2_frame_parser.rs:3367). Line 3378-3380 sets last_stream_id = stream_identifier before the callback. streamStart refuses (http2.ts:4055), count_rejected_stream at 3464 sends GOAWAY with Verification: pre-existing — the base branch produces the identical GOAWAY by the identical route, so merging changes nothing here. Mechanism as claimed: /home/claude/bun/src/runtime/api/bun/h2_frame_parser.rs:3378-3380 sets
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Confirmed, and it is not new. The Last-Stream-ID is the id of the refused stream (3, 7 and 201 for budgets 0, 3 and 100) on Bun 1.4.2, on a release build of main The reason why this PR keeps the value is in my reply to the other comment on this line: #44248 (comment) I leave this thread open for a maintainer to decide. |
||||||||||
| true, | ||||||||||
| ); | ||||||||||
| return false; | ||||||||||
| } | ||||||||||
| true | ||||||||||
| } | ||||||||||
|
|
||||||||||
| fn to_writer(&self) -> DirectWriterStruct { | ||||||||||
| DirectWriterStruct { | ||||||||||
| writer: bun_ptr::BackRef::new(self), | ||||||||||
|
|
@@ -4165,18 +4185,7 @@ impl crate::api::h2::connection::Sink for H2FrameParser { | |||||||||
| } | ||||||||||
|
|
||||||||||
| fn on_stream_rejected(&self, stream_id: u32) { | ||||||||||
| // maxSessionRejectedStreams: counts only locally-initiated rejections (oversized or | ||||||||||
| // malformed header blocks) - peer-sent RST_STREAM frames must not consume the budget. | ||||||||||
| self.rejected_streams.set(self.rejected_streams.get() + 1); | ||||||||||
| if self.max_rejected_streams.get() <= self.rejected_streams.get() { | ||||||||||
| self.send_go_away( | ||||||||||
| stream_id, | ||||||||||
| ErrorCode::ENHANCE_YOUR_CALM, | ||||||||||
| b"ENHANCE_YOUR_CALM", | ||||||||||
| self.last_stream_id.get(), | ||||||||||
| true, | ||||||||||
| ); | ||||||||||
| } | ||||||||||
| self.count_rejected_stream(stream_id); | ||||||||||
| } | ||||||||||
|
|
||||||||||
| fn on_stream_reset(&self, stream_id: u32, code: u32) { | ||||||||||
|
|
@@ -5044,25 +5053,6 @@ impl H2FrameParser { | |||||||||
| } | ||||||||||
| let error_code = error_arg.to_u32(); | ||||||||||
|
|
||||||||||
| // maxSessionRejectedStreams: a REFUSED_STREAM reset from the JS layer (the | ||||||||||
| // max-concurrent-streams refusal in streamStart) is the same rejection class the engine | ||||||||||
| // counts; budget it identically so a flood of refused streams still tears the session | ||||||||||
| // down. Server-side only: a client's GOAWAY sweep resets its own unprocessed streams | ||||||||||
| // with REFUSED_STREAM and must not consume the budget. | ||||||||||
| if error_code == ErrorCode::REFUSED_STREAM.0 && this.is_server.get() { | ||||||||||
| this.rejected_streams.set(this.rejected_streams.get() + 1); | ||||||||||
| if this.max_rejected_streams.get() <= this.rejected_streams.get() { | ||||||||||
| this.send_go_away( | ||||||||||
| stream_id, | ||||||||||
| ErrorCode::ENHANCE_YOUR_CALM, | ||||||||||
| b"ENHANCE_YOUR_CALM", | ||||||||||
| this.last_stream_id.get(), | ||||||||||
| true, | ||||||||||
| ); | ||||||||||
| return Ok(JSValue::UNDEFINED); | ||||||||||
| } | ||||||||||
| } | ||||||||||
|
|
||||||||||
| let Some(stream) = this.streams.get().get(&stream_id).copied() else { | ||||||||||
| // Streams the legacy bookkeeping never registered (e.g. peer-initiated pushed streams | ||||||||||
| // surfaced by the rewrite engine) get the RST_STREAM written directly. The frame is | ||||||||||
|
|
||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟣 pre-existing, not blocking: pre-existing: a long-lived connection whose server occasionally refuses a stream is torn down after 99 refusals in total, even with thousands of accepted streams in between; node only ends a session after consecutive refusals. count_rejected_stream at h2_frame_parser.rs:3465 only ever increments rejected_streams, and the accept branch at h2_frame_parser.rs:3444 never resets it, whereas node's OnBeginHeadersCallback sets rejected_stream_count_ = 0 each time it creates a stream. Fix: reset rejected_streams to 0 whenever a peer stream is accepted (the returned.is_object() branch, and the engine's accepted-stream path that feeds on_stream_rejected's siblings), so the budget bounds consecutive rejections as in node. …
A small fix can ride a push you are already making; otherwise a short reply is enough.
Why this was flagged
…The PR moves the count next to the accept branch but ports only the increment half of node's semantics.
A persistent HTTP/2 connection (proxy upstream, gRPC channel) to a server created with settings.maxConcurrentStreams or one that periodically exceeds maxSessionMemory. Each over-limit HEADERS reaches handle_received_stream_id, streamStart returns NGHTTP2_REFUSED_STREAM (http2.ts:4055) and count_rejected_stream increments rejected_streams (h2_frame_parser.rs:3465); the same helper runs for session-memory refusals via on_stream_rejected (h2_frame_parser.rs:4188, connection.rs:1357). Nothing ever writes rejected_streams back to 0: the accept branch at h2_frame_parser.rs:3444-3454 stores the context and moves on. So after 99 refusals accumulated over the connection's lifetime, the 100th sends GOAWAY(ENHANCE_YOUR_CALM) with emit_error, killing every in-flight stream. node v26.3.0 node_http2.cc OnBeginHeadersCallback does
rejected_stream_count_++ > max_rejected_streamsand thensession->rejected_stream_count_ = 0when a stream is created, so interleaved accepted streams keep the…Verification: pre-existing. acknowledged in diff: the PR description says "The count is also cumulative here. Node sets it to 0 on every stream it creates" — that statement is accurate, and nothing in the code bounds or mitigates it. Triggering condition: a long-lived server connection on which the peer occasionally opens a stream past SETTINGS_MAX_CONCURRENT_STREAMS (streamStart returns… | pre-existing…
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Confirmed, and it is not new. The count has had no reset since 1.4.0, and the Notes of the description say so.
I did not add the reset in this PR, because it cannot go in alone. A request whose header block is malformed or over the header list limit is accepted by
streamStartfirst, andfinish_header_blockrejects it afterwards (connection.rs:1419and:1430). A reset in the accept branch sets the count to 0 before each of those rejections, so the count never passes 1. For an oversized header list this count is the only bound of the session, so with a budget of 2 or more a flood of them never ends the session. Node can reset at creation because it does not count those blocks against this budget.The reset, node's comparison and the set of rejections that count have to change together. That is a change of policy for every producer of this budget. This PR is the fix for the count of resets only. I leave this thread open for a maintainer to decide.