Skip to content

http: parse Connection/Transfer-Encoding/Content-Encoding/Upgrade as token lists - #36777

Merged
Jarred-Sumner merged 2 commits into
mainfrom
claude/http-header-token-lists
Aug 2, 2026
Merged

http: parse Connection/Transfer-Encoding/Content-Encoding/Upgrade as token lists#36777
Jarred-Sumner merged 2 commits into
mainfrom
claude/http-header-token-lists

Conversation

@Jarred-Sumner

@Jarred-Sumner Jarred-Sumner commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator

fetch() compared several list-valued headers as whole strings. The user-visible one: a response with Connection: close, keep-alive was returned to the keep-alive pool, and the next request on that socket failed with "The socket connection was closed unexpectedly" / ECONNRESET.

All sites now go through the existing HeaderValueIterator (which now impl Iterator).

Header Before After
Connection (fetch req + resp) exact close / keep-alive only any close token disables pooling; close wins over keep-alive
Connection (WebSocket 101) must equal Upgrade; only first header read must contain upgrade, across multiple Connection headers
Transfer-Encoding (resp) gzip, chunkedUnsupportedTransferEncoding chunked framing; chunked not-last or unknown coding rejected
Content-Encoding (resp) identity, gzip / gzip, identity not decoded identity ignored; stacked codings (gzip, br) pass through raw with header intact
Upgrade (fetch req) case-sensitive in one of two call sites shared, case-insensitive

Tests extend the existing matrices in fetch-keepalive.test.ts, fetch-gzip.test.ts, websocket-client.test.ts; the new rows fail on current canary.

Fixes #31463
Closes #31464
Closes #33727

…token lists

fetch() compared these header values as whole strings, so a response with
`Connection: close, keep-alive` was returned to the keep-alive pool and the
next request on it failed with "socket connection was closed unexpectedly".

- Connection: any `close` token disables pooling (request and response side)
- Transfer-Encoding: `gzip, chunked` is chunked framing; `chunked` not last is rejected
- Content-Encoding: `identity` tokens are ignored; stacked codings pass through undecoded
- WebSocket client: 101 `Connection` must contain `upgrade`, across multiple headers
- HeaderValueIterator now implements Iterator

Fixes #31463
@coderabbitai

coderabbitai Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

HTTP header protocol handling

Layer / File(s) Summary
Header token parsing contracts
src/http/HeaderValueIterator.rs, src/http_types/Encoding.rs, src/http/lib.rs
HeaderValueIterator implements Iterator with OWS trimming and empty-token skipping. Encoding tokens and shared header helpers are exposed publicly.
Fetch connection and transfer processing
src/http/lib.rs, src/runtime/webcore/fetch.rs, test/js/web/fetch/*
Fetch parsing handles tokenized connection, content-encoding, and transfer-encoding values. Tests cover decoding, stacked codings, framing, rejection, and sticky connection closure.
WebSocket Connection token validation
src/http_jsc/websocket_client/WebSocketUpgradeClient.rs, test/js/web/websocket/websocket-client.test.ts
WebSocket validation scans all Connection headers for an Upgrade token and tests multiple token-list formats.

Possibly related PRs

  • oven-sh/bun#36370: Modifies related HTTPClient response keep-alive handling and fetch keep-alive tests.
  • oven-sh/bun#36433: Also changes request-header construction in src/http/lib.rs.

Suggested reviewers: robobun

🚥 Pre-merge checks | ✅ 3 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning Rust token-list handling and regression coverage are present, but the required equivalent Zig implementation is not evidenced. Add equivalent token-list parsing and close-precedence handling to the Zig HTTP implementation, with parity regression coverage or explicit evidence of delivery elsewhere.
✅ Passed checks (3 passed)
Check name Status Explanation
Out of Scope Changes check ✅ Passed The changes to transfer encoding, content encoding, upgrade handling, WebSocket validation, and tests support the stated token-list parsing objectives.
Title check ✅ Passed The title clearly and concisely describes parsing the affected HTTP headers as token lists.
Description check ✅ Passed The description explains the changes, affected behaviors, linked issues, and test coverage, but it omits the template headings and a clear verification procedure.

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. HTTP keep-alive bug causes wildly bad networking performance with fetch in bun vs nodejs when switching between similar endpoints #9034 - Debug logs show fetch reusing a keep-alive connection the server has already closed, causing ~12s timeouts on subsequent requests — consistent with the Connection header token-list parsing bug this PR fixes.

If this is helpful, copy the block below into the PR description to auto-close this issue on merge.

Fixes #9034

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. Fix fetch handling of Connection header close tokens #31464 - Fixes the same issue (Bun canary Rust fetch reuses sockets after Connection: close appears in a token list #31463) with Connection header close-token parsing in fetch
  2. websocket: accept Connection header as a token list in the 101 response #33727 - Strict subset: parses Connection header as a token list in WebSocket upgrade client (same file: WebSocketUpgradeClient.rs)
  3. fetch: decode every coding in a multi-coding Content-Encoding response #33270 - Overlapping: parses and decodes multi-coding Content-Encoding in fetch, which http: parse Connection/Transfer-Encoding/Content-Encoding/Upgrade as token lists #36777 subsumes with token-list parsing
  4. http: reject compression Transfer-Encoding response headers in fetch() #33877 - Overlapping: rejects compression Transfer-Encoding in fetch, which requires the same token-list parsing http: parse Connection/Transfer-Encoding/Content-Encoding/Upgrade as token lists #36777 introduces

🤖 Generated with Claude Code

Comment thread src/http/lib.rs
A response with `Connection: close` and a separate `Connection: keep-alive`
line was pooled because the second line re-enabled keep-alive.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 1

🤖 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 `@test/js/web/websocket/websocket-client.test.ts`:
- Around line 632-638: Update the websocket handshake parsing in the socket data
callback to handle a missing `sec-websocket-key` match explicitly: store the
regex result, and when it is absent, call the test promise’s `reject` with the
failure instead of using a non-null assertion or throwing from the callback.
Preserve the existing accept-hash flow when the header is present.
🪄 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: 1f7b78b7-885c-4081-89b9-b3c99ee1993e

📥 Commits

Reviewing files that changed from the base of the PR and between 322f3ab and 050e7ee.

📒 Files selected for processing (8)
  • src/http/HeaderValueIterator.rs
  • src/http/lib.rs
  • src/http_jsc/websocket_client/WebSocketUpgradeClient.rs
  • src/http_types/Encoding.rs
  • src/runtime/webcore/fetch.rs
  • test/js/web/fetch/fetch-gzip.test.ts
  • test/js/web/fetch/fetch-keepalive.test.ts
  • test/js/web/websocket/websocket-client.test.ts

Comment on lines +632 to +638
sock.on("data", chunk => {
buf += chunk.toString("latin1");
if (!buf.includes("\r\n\r\n")) return;
const key = /sec-websocket-key: *([^\r\n]+)/i.exec(buf)![1];
const accept = createHash("sha1")
.update(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11")
.digest("base64");

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.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Route the missing-header case through reject instead of throwing.

The non-null assertion on .exec(buf)![1] throws inside the socket data callback if the regex fails to match. Wire this failure into the test's reject instead of letting it throw inside the event callback.

🛠️ Proposed fix
       sock.on("data", chunk => {
         buf += chunk.toString("latin1");
         if (!buf.includes("\r\n\r\n")) return;
-        const key = /sec-websocket-key: *([^\r\n]+)/i.exec(buf)![1];
+        const match = /sec-websocket-key: *([^\r\n]+)/i.exec(buf);
+        if (!match) {
+          reject(new Error("missing Sec-WebSocket-Key header"));
+          return;
+        }
+        const key = match[1];
         const accept = createHash("sha1")

As per path instructions, "In asynchronous tests, await the actual observable condition and wire every failure event to reject the awaited promise; never throw inside event callbacks."

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
sock.on("data", chunk => {
buf += chunk.toString("latin1");
if (!buf.includes("\r\n\r\n")) return;
const key = /sec-websocket-key: *([^\r\n]+)/i.exec(buf)![1];
const accept = createHash("sha1")
.update(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11")
.digest("base64");
sock.on("data", chunk => {
buf += chunk.toString("latin1");
if (!buf.includes("\r\n\r\n")) return;
const match = /sec-websocket-key: *([^\r\n]+)/i.exec(buf);
if (!match) {
reject(new Error("missing Sec-WebSocket-Key header"));
return;
}
const key = match[1];
const accept = createHash("sha1")
.update(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11")
.digest("base64");
🧰 Tools
🪛 ast-grep (0.45.0)

[warning] 635-635: Do not use weak hash functions (MD5/SHA1)
Context: createHash("sha1")
Note: [CWE-328] Use of Weak Hash.

(insecure-hash-typescript)


[warning] 635-635: Avoid SHA1 security protocol
Context: createHash("sha1")
Note: [CWE-327] Use of a Broken or Risky Cryptographic Algorithm (SHA-1).

(avoid-crypto-sha1-typescript)

🪛 OpenGrep (1.26.0)

[ERROR] 635-635: Dynamic command passed to child_process.exec/execSync. Use child_process.execFile or spawn with an argument array instead.

(coderabbit.command-injection.exec-js)

🤖 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 `@test/js/web/websocket/websocket-client.test.ts` around lines 632 - 638,
Update the websocket handshake parsing in the socket data callback to handle a
missing `sec-websocket-key` match explicitly: store the regex result, and when
it is absent, call the test promise’s `reject` with the failure instead of using
a non-null assertion or throwing from the callback. Preserve the existing
accept-hash flow when the header is present.

Source: Path instructions

@claude claude Bot left a comment

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.

Thanks for addressing the multi-field-line Connection: close stickiness (050e7ee) — both the response and request arms now only ever clear, and the new close\r\nConnection: keep-alive test row covers it. No further findings; deferring to a human since this rewrites parsing for five list-valued headers in the core HTTP client path.

What was reviewed:

  • connection_header_keep_alive / upgrade_header_is_not_h2 token semantics and all four call sites (req/resp/WS/fetch.rs).
  • state.transfer_encoding consumers — only ever compared to Chunked/Identity, so dropping the old Gzip/Deflate/… writes is dead-code removal (and stops tripping the debug_assert at lib.rs:4467).
  • allow_keepalive defaults to true, so removing the Some(true) write is behavior-preserving for HTTP/1.1.
  • Content-Encoding fallthrough for unknown/chunked/stacked codings — resolves to identity + header intact, matching the pre-PR pass-through.
Extended reasoning...

Overview

Refactors five list-valued HTTP header parses (Connection request+response, Transfer-Encoding, Content-Encoding, Upgrade, plus WebSocket 101 Connection) to iterate comma-separated tokens via HeaderValueIterator (now impl Iterator) instead of whole-string equality. Adds Encoding::from_token, connection_header_keep_alive, and upgrade_header_is_not_h2 helpers. Since my prior review the author pushed 050e7ee making close sticky across multiple Connection field lines on both request and response sides, with a covering test row.

Security risks

None identified. The change is strictly more permissive on Connection: …, Upgrade for WebSocket 101 (RFC 6455 §4.1 requires contains, not equals) and strictly more conservative on keep-alive pooling (close now wins). Transfer-Encoding rejects unknown codings and chunked-not-last, which is the fail-closed direction. Content-Encoding stacked/unknown codings pass through raw with the header intact rather than partially decoding — same exposure as before for unknown codings, and safer than half-decoding a stack.

Level of scrutiny

Moderate-to-high: this is the response-header parse loop of the production HTTP client that every fetch() traverses. The individual changes are small and RFC-cited, but there are several independent semantic shifts (TE compression codings now no-op instead of being written to a field nobody read; response keep-alive no longer explicitly sets the flag; CE stacking passes through). I traced each one against downstream consumers and found no regression, but the aggregate is enough surface that a maintainer should confirm the design calls (particularly the TE Some(_) => {} arm and the CE stacked-coding pass-through policy).

Other factors

Tests extend existing matrices in the right files and cover the new rows including the fix for my prior nit. The one candidate flagged by finders (un-awaited async toThrow racing server.close()) was verified as a non-issue — Bun's toThrow awaits async callbacks and the assertion is itself awaited inside try before finally closes the server. The state.transfer_encoding change quietly fixes a pre-existing debug_assert!(== Identity) trip for a bare Transfer-Encoding: gzip response.

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.

Bun canary Rust fetch reuses sockets after Connection: close appears in a token list

1 participant