Deduplicate http client and websocket init internals#32020
Conversation
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (2)
💤 Files with no reviewable changes (1)
WalkthroughThis PR consolidates HTTP/2 header field validation, refactors HTTP client completion and redirect handling, simplifies WebSocket initialization, extracts socket-stub infrastructure for reuse, and refactors HTTP/2 frame-parser stream ID handling and header encoding logic. ChangesHTTP/2 Validation Extraction and Client/Parser Consolidation
Socket Stub Infrastructure Extraction and Application
Suggested reviewers
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Comment |
|
Updated 8:28 PM PT - Jun 17th, 2026
❌ @robobun, your commit abe1037 has 2 failures in
🧪 To try this PR locally: bunx bun-pr 32020That installs a local version of the PR into your bun-32020 --bun |
|
@robobun adopt |
|
Adopted. Merged main after the node:http2 inbound-engine rewrite (#31584); resolution details in the PR body. All affected suites green locally (node-http2.test.js 287/0, h2-conformance 23/23, websocket/fetch/proxy all pass). Final CI on build 63270: 285 of 286 jobs passed with zero test failures; the single red is one darwin 26 aarch64 shard whose |
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/http/lib.rs (1)
3671-3680:⚠️ Potential issue | 🟠 Major | ⚡ Quick winKeep-alive reuse still misclassifies in-flight stream/sendfile uploads as drained.
request_side_drainedonly checks the unsent slice forHTTPRequestBody::Bytes(_); every other body kind returnstrue. That still lets an HTTP/1.1 socket be pooled after an early response while aStreamorSendfileupload is mid-flight, so the next request can reuse a connection whose previous request body is still being written/read.Suggested fix
- let request_side_drained = match &this.state.original_request_body { - HTTPRequestBody::Bytes(_) => this.state.request_body.is_empty(), - _ => true, - }; + let request_side_drained = match &this.state.original_request_body { + HTTPRequestBody::Bytes(_) => this.state.request_body.is_empty(), + HTTPRequestBody::Stream(_) | HTTPRequestBody::Sendfile(_) => { + this.state.request_stage == RequestStage::Done + } + };As per coding guidelines, "Fix the whole class in the same PR."
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/http/lib.rs` around lines 3671 - 3680, The pooling check incorrectly treats non-Bytes bodies as drained; change the request_side_drained logic so it returns true only when the original_request_body is a fully-sent Bytes (and request_body.is_empty()) or when it is explicitly an Empty/no-body variant, and return false for streaming/Sendfile variants so in-flight uploads block pooling; update the match on this.state.original_request_body (and any HTTPRequestBody variants like Stream, Sendfile, AsyncStream, etc.) to reflect that behavior so is_keep_alive_possible() && !socket.is_closed_or_has_error() && tunnel_poolable only proceeds when request_side_drained is truly drained.Source: Coding guidelines
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/js/internal/http.ts`:
- Around line 522-543: The setters (remoteAddress, remotePort, remoteFamily)
assume this.address() returns an object and crash when it returns
null/undefined; fix each setter to call const addr = this.address(); if (!addr)
create and assign a new backing object (e.g. this._address = {}) then use that
addr to set the property, so you initialize the address object on demand instead
of blindly writing into a possibly null value; apply this pattern in the
remoteAddress, remotePort, and remoteFamily setters.
---
Outside diff comments:
In `@src/http/lib.rs`:
- Around line 3671-3680: The pooling check incorrectly treats non-Bytes bodies
as drained; change the request_side_drained logic so it returns true only when
the original_request_body is a fully-sent Bytes (and request_body.is_empty()) or
when it is explicitly an Empty/no-body variant, and return false for
streaming/Sendfile variants so in-flight uploads block pooling; update the match
on this.state.original_request_body (and any HTTPRequestBody variants like
Stream, Sendfile, AsyncStream, etc.) to reflect that behavior so
is_keep_alive_possible() && !socket.is_closed_or_has_error() && tunnel_poolable
only proceeds when request_side_drained is truly drained.
🪄 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: 2bd442c1-2f2d-421c-a6c3-78c9e8436830
📒 Files selected for processing (8)
src/http/h2_client/dispatch.rssrc/http/lib.rssrc/http_jsc/websocket_client.rssrc/http_types/h2.rssrc/js/internal/http.tssrc/js/internal/http/FakeSocket.tssrc/js/node/_http_server.tssrc/runtime/api/bun/h2_frame_parser.rs
|
On the request_side_drained finding (outside the diff): that match and its comment exist verbatim on main (src/http/lib.rs, "Stream/Sendfile are left as-is, they do not track an unsent slice here"); this PR only renames self to this while moving the block into the closure. Changing the pooling predicate for Stream/Sendfile bodies would be a behavior change, which this PR deliberately avoids, and the proposed request_stage gate is unvalidated (the comment above it explains why Bytes cannot use request_stage: a fully sent small request parks at .body). If that predicate needs tightening it should be its own change with its own test. |
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/http/lib.rs (1)
4349-4358:⚠️ Potential issue | 🟠 MajorAdd validation of redirect URL scheme and length in
apply_redirect_url.The protocol-relative redirect path (lines 4690-4706) validates the URL length before normalization. The relative redirect path (else branch at 4709-4721) calls
bun_url::joinwithout a length check, then passes the result directly toapply_redirect_url. Sinceapply_redirect_urlperforms no validation, an adversarially long relative redirect can bypass the length limit that applies to protocol-relative redirects. Additionally, although WHATWG URL joining from an http/https base should preserve the scheme, explicit validation inapply_redirect_urlcloses the gap and ensures only http/https redirects are installed, matching the precondition enforcement on the protocol-relative path.Proposed fix
fn apply_redirect_url(&mut self, new_href: Vec<u8>) -> bool { + if new_href.len() > MAX_REDIRECT_URL_LENGTH { + return false; // or return Err if signature changes to Result + } // SAFETY: self-borrow — `new_href` is moved into `self.redirect` // below, which lives as long as `self` (≥ `'a`). let new_url: URL<'a> = unsafe { URL::parse(&new_href).erase_lifetime() }; + let protocol = new_url.display_protocol(); + if protocol != b"http" && protocol != b"https" { + return false; // or return Err if signature changes to Result + } let is_same_origin = strings::eql_case_insensitive_ascii( strings::without_trailing_slash(new_url.origin), strings::without_trailing_slash(self.url.origin), true, );🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/http/lib.rs` around lines 4349 - 4358, The apply_redirect_url method needs to add two validations to prevent security issues: first, validate that the redirect URL scheme is either http or https to match the precondition enforcement in the protocol-relative redirect path, and second, validate the URL length before installation to prevent adversarially long relative redirects from bypassing the length limits that apply to protocol-relative redirects. Add these checks at the beginning of apply_redirect_url before the URL is assigned to self.url.Source: Coding guidelines
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/http/lib.rs`:
- Around line 1327-1335: The has_unsent_request_body method does not account for
sendfile uploads, which leave request_body() empty but still have data in
flight. When a sendfile is active in RequestStage::Body and a peer FIN arrives,
the function incorrectly returns false, allowing a graceful close instead of the
required reset. Add a check for active sendfile uploads (similar to the
is_streaming_request_body flag check) that returns true if HTTPRequestBody
contains a Sendfile variant, ensuring in-flight sendfile operations are treated
as unsent request bodies.
---
Outside diff comments:
In `@src/http/lib.rs`:
- Around line 4349-4358: The apply_redirect_url method needs to add two
validations to prevent security issues: first, validate that the redirect URL
scheme is either http or https to match the precondition enforcement in the
protocol-relative redirect path, and second, validate the URL length before
installation to prevent adversarially long relative redirects from bypassing the
length limits that apply to protocol-relative redirects. Add these checks at the
beginning of apply_redirect_url before the URL is assigned to self.url.
🪄 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: 6725f7a2-49e6-4035-a758-3f555701fd9a
📒 Files selected for processing (5)
src/http/lib.rssrc/http_jsc/websocket_client.rssrc/js/internal/http.tssrc/js/node/_http_server.tssrc/runtime/api/bun/h2_frame_parser.rs
💤 Files with no reviewable changes (1)
- src/runtime/api/bun/h2_frame_parser.rs
There was a problem hiding this comment.
Caution
Inline review comments failed to post. This is likely due to GitHub's internal server error or limits when posting large numbers of comments. If you are seeing this consistently it is likely a permissions issue. Please check "Moderation" -> "Code review limits" under your organization settings.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/http/lib.rs (1)
4349-4358:⚠️ Potential issue | 🟠 MajorAdd validation of redirect URL scheme and length in
apply_redirect_url.The protocol-relative redirect path (lines 4690-4706) validates the URL length before normalization. The relative redirect path (else branch at 4709-4721) calls
bun_url::joinwithout a length check, then passes the result directly toapply_redirect_url. Sinceapply_redirect_urlperforms no validation, an adversarially long relative redirect can bypass the length limit that applies to protocol-relative redirects. Additionally, although WHATWG URL joining from an http/https base should preserve the scheme, explicit validation inapply_redirect_urlcloses the gap and ensures only http/https redirects are installed, matching the precondition enforcement on the protocol-relative path.Proposed fix
fn apply_redirect_url(&mut self, new_href: Vec<u8>) -> bool { + if new_href.len() > MAX_REDIRECT_URL_LENGTH { + return false; // or return Err if signature changes to Result + } // SAFETY: self-borrow — `new_href` is moved into `self.redirect` // below, which lives as long as `self` (≥ `'a`). let new_url: URL<'a> = unsafe { URL::parse(&new_href).erase_lifetime() }; + let protocol = new_url.display_protocol(); + if protocol != b"http" && protocol != b"https" { + return false; // or return Err if signature changes to Result + } let is_same_origin = strings::eql_case_insensitive_ascii( strings::without_trailing_slash(new_url.origin), strings::without_trailing_slash(self.url.origin), true, );🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/http/lib.rs` around lines 4349 - 4358, The apply_redirect_url method needs to add two validations to prevent security issues: first, validate that the redirect URL scheme is either http or https to match the precondition enforcement in the protocol-relative redirect path, and second, validate the URL length before installation to prevent adversarially long relative redirects from bypassing the length limits that apply to protocol-relative redirects. Add these checks at the beginning of apply_redirect_url before the URL is assigned to self.url.Source: Coding guidelines
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/http/lib.rs`:
- Around line 1327-1335: The has_unsent_request_body method does not account for
sendfile uploads, which leave request_body() empty but still have data in
flight. When a sendfile is active in RequestStage::Body and a peer FIN arrives,
the function incorrectly returns false, allowing a graceful close instead of the
required reset. Add a check for active sendfile uploads (similar to the
is_streaming_request_body flag check) that returns true if HTTPRequestBody
contains a Sendfile variant, ensuring in-flight sendfile operations are treated
as unsent request bodies.
---
Outside diff comments:
In `@src/http/lib.rs`:
- Around line 4349-4358: The apply_redirect_url method needs to add two
validations to prevent security issues: first, validate that the redirect URL
scheme is either http or https to match the precondition enforcement in the
protocol-relative redirect path, and second, validate the URL length before
installation to prevent adversarially long relative redirects from bypassing the
length limits that apply to protocol-relative redirects. Add these checks at the
beginning of apply_redirect_url before the URL is assigned to self.url.
🪄 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: 6725f7a2-49e6-4035-a758-3f555701fd9a
📒 Files selected for processing (5)
src/http/lib.rssrc/http_jsc/websocket_client.rssrc/js/internal/http.tssrc/js/node/_http_server.tssrc/runtime/api/bun/h2_frame_parser.rs
💤 Files with no reviewable changes (1)
- src/runtime/api/bun/h2_frame_parser.rs
🛑 Comments failed to post (1)
src/http/lib.rs (1)
1327-1335:
⚠️ Potential issue | 🟠 Major | ⚡ Quick winTreat in-flight sendfile uploads as unsent request bodies.
HTTPRequestBody::Sendfile(_)leavesrequest_body()empty, so a peer FIN while sendfile is still inRequestStage::Bodyreturnsfalsehere andHTTPContext::on_endcan graceful-close instead of resetting. That reintroduces the queued-FIN-behind-unsent-body hazard this helper is meant to prevent.Proposed fix
pub fn has_unsent_request_body(&self) -> bool { if self.state.request_stage == RequestStage::Done { return false; } - if self.flags.is_streaming_request_body { - // More body chunks may still be produced by JS. - return true; - } - !self.request_body().is_empty() + match &self.state.original_request_body { + HTTPRequestBody::Stream(_) => { + // More body chunks may still be produced by JS. + true + } + HTTPRequestBody::Sendfile(_) => { + // Sendfile progress is tracked by the sendfile object, not + // `request_body`; until RequestStage::Done, bytes may remain. + true + } + HTTPRequestBody::Bytes(_) => !self.request_body().is_empty(), + } }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/http/lib.rs` around lines 1327 - 1335, The has_unsent_request_body method does not account for sendfile uploads, which leave request_body() empty but still have data in flight. When a sendfile is active in RequestStage::Body and a peer FIN arrives, the function incorrectly returns false, allowing a graceful close instead of the required reset. Add a check for active sendfile uploads (similar to the is_streaming_request_body flag check) that returns true if HTTPRequestBody contains a Sendfile variant, ensuring in-flight sendfile operations are treated as unsent request bodies.
|
On the two new findings: has_unsent_request_body (line 1327): this function was added to main by #32462 and merged in here unchanged; this PR does not touch it ( apply_redirect_url validation (line 4349): the helper preserves main's behavior exactly. On main the relative else-arm at 4776-4798 (the |
There was a problem hiding this comment.
I didn't find any bugs, but this is a ~700-line refactor across the core HTTP client (keep-alive pooling, redirect handling), WebSocket init (unsafe FFI/ref-counting), and the HTTP/2 frame parser — worth a pass from someone who owns these subsystems to confirm the behavioral-equivalence claim.
Extended reasoning...
Overview
This PR deduplicates internals across four networking subsystems with a claimed net of −700 lines and zero behavior change: (1) src/http/lib.rs — extracts send_progress_update_inner (shared between HTTP/1.1 and multiplexed paths) and apply_redirect_url/normalize_and_apply_redirect_url (shared across the three Location-header arms); (2) src/http_jsc/websocket_client.rs — extracts new_ws and finish_init shared between init and init_with_tunnel; (3) src/http_types/h2.rs + src/http/h2_client/dispatch.rs + src/runtime/api/bun/h2_frame_parser.rs — centralizes is_lower_tchar/is_malformed_field_value, adds a stream_from_js_arg helper for ~9 host fns, and folds the array/single-value header-encode arms into one closure; (4) src/js/internal/http.ts + FakeSocket.ts + _http_server.ts — extracts installSocketStubs for the duplicated socket-compat prototype members. Two new test files pin the consolidated surface.
Security risks
The consolidated HTTP/2 header-name/value validators are byte-identical to the originals (same tchar set, same NUL/CR/LF rejection), so no relaxation of header-injection guards. The redirect helper preserves the erase_lifetime self-borrow pattern and the prev_redirect swap. The WebSocket finish_init keeps the same ref-count discipline (initial I/O ref from new_ws, C++ ref taken at the tail) and the mimalloc buffer-adoption contract. I don't see new attack surface, but the unsafe blocks moved around enough that a second pair of eyes on the soundness invariants is warranted.
Level of scrutiny
High. This is production-critical networking code: the fetch() keep-alive pool release path, redirect origin comparison, WebSocket FFI init with raw-pointer ref-counting, and the node:http2 frame parser. The PR is explicitly a no-behavior-change refactor, but verifying that requires checking each consolidated path field-by-field against the original — e.g., the encode_value closure threads err_name/encode_err_return to preserve per-arm error messages and return values, and reorders stream.state = CLOSED relative to set_context in the single-value arm to match the array arm; the write_stream host fn now reads close_arg.to_boolean() after the stream lookup instead of before (commented as side-effect-free). These look correct to me but are exactly the kind of subtle equivalence a domain owner should sign off on.
Other factors
The bug-hunting system found nothing. CodeRabbit's one concern (setter null-deref) was correctly identified as a verbatim move of pre-existing code and withdrawn. The PR adds tests that pass on main too (by design — they pin invariants, not new behavior). No CODEOWNERS entries cover these paths. The PR has not yet been reviewed by a human; cirospaciari is the suggested reviewer.
|
Agreed. The two subtle points called out in the extended reasoning (the |
What this does
Extracts the shared WebSocket client init tail (
finish_init, taking an owned buffer — public signatures unchanged and safe), dedupes h2 client dispatch and header-validation helpers (shared viasrc/http_types/h2.rs, used byh2_frame_parser), and consolidates the JSFakeSocket/server-socket stub descriptors insrc/js/internal/http. Net −700 lines.Split from #31912 (whole-repo simplification pass; closing that PR in favor of module-scoped splits). This PR only moves and removes code — zero intended behavior change. Verified there by a per-file behavioral-equivalence audit and full CI; verified here by a standalone full-workspace compile check.
Merge resolution against #31584
The node:http2 inbound-engine rewrite required non-trivial resolution in
h2_frame_parser.rs:is_malformed_field_name/is_malformed_field_valuebecamepub(crate)for the newh2/connection.rscaller. Resolution:is_malformed_field_namekeepspub(crate)visibility with theis_lower_tcharbody;is_malformed_field_valueis apub(crate) usere-export of the sharedbun_http_types::h2definition;is_valid_header_valuestays as a thin inversion wrapper for the one remaining caller in the rewrite's new push-stream path.rst_streamandset_stream_contextchanged lookup semantics (the former now handles unknown stream ids via a direct RST write instead of throwing; the latter now updates the newsctxmap and only best-effort touches the legacy stream). Thestream_from_js_arghelper no longer fits, so both reverted to main's inline code. The other 8 call sites were verified unchanged on main and keep the helper.encode_valueclosure picked up node:http2: rewritten inbound engine, batched write path, server push, +290 node v26.3.0 tests (79% passing) #31584'sis_index_like_namenever-index fast-path (applied identically in both original arms).Tests
Adds coverage pinning the consolidated behavior:
test/js/node/http2/node-http2-header-validation.test.ts: covers both arms of the shared header-encoding closure (control characters in single and array header values, exactERR_HTTP2_INVALID_HEADER_VALUE/ERR_INVALID_HTTP_TOKEN/ERR_HTTP2_HEADER_SINGLE_VALUEcodes and messages) plus name lowercasing and the full tchar set accepted byis_lower_tchar.test/js/node/http/node-http-server-socket-surface.test.ts: pins the stub members installed byinstallSocketStubs(readyState, pending, bufferSize, ref/unref/setNoDelay return values, remote address properties); every assertion also holds under real Node.These tests pass on main too, and that is the point: this PR is a pure dedupe whose contract is byte-equivalent behavior, verified by an arm-by-arm audit of each consolidated path against main (error message and return-value parity per call site, field-for-field identical struct construction, symmetric origin comparison, side-effect-free reorders only). A test that failed on main and passed here would mean the dedupe changed behavior, which would be a bug in this PR, not a feature to prove. The tests instead lock in the invariants the consolidation must preserve so future edits to the shared helpers cannot silently drift. The buffered-handshake WebSocket init path is already covered by the existing "WebSocket buffered handshake data" tests in
websocket-client-short-read.test.ts, and the redirect/keep-alive paths by the existing fetch suites.Post-merge verification:
node-http2.test.js287 pass / 0 fail,h2-conformance.test.ts23/23, websocket/fetch/proxy suites all green.