Skip to content

http: replace picohttpparser with a Rust parser; classify headers once and build fetch Response headers lazily - #37132

Draft
dylan-conway wants to merge 8 commits into
mainfrom
claude/picohttpparser-replacement-7287be
Draft

http: replace picohttpparser with a Rust parser; classify headers once and build fetch Response headers lazily#37132
dylan-conway wants to merge 8 commits into
mainfrom
claude/picohttpparser-replacement-7287be

Conversation

@dylan-conway

@dylan-conway dylan-conway commented Aug 7, 2026

Copy link
Copy Markdown
Member

What does this PR do?

Bun's HTTP client (fetch, bun install, the WebSocket upgrade client) parsed HTTP/1.1 response heads and Transfer-Encoding: chunked bodies with the vendored C library picohttpparser. This replaces both entry points we used with Rust in bun_picohttp, drops the vendored dependency (build plumbing, process.versions.picohttpparser), and then uses owning the parser to cut the per-response header work in fetch.

Commit 1 — parser/decoder replacement (behavior parity)

  • Response::parse validates every byte it has, rejects obs-fold inline, and scans field values with a new highway kernel (highway_index_of_http_ctl) plus a two-word inline fast path, so arm64 gets SIMD too (pico only had SSE4.2).
  • ChunkedDecoder follows upstream picohttpparser HEAD rather than our 2021 snapshot: CRLF required after chunk-size and chunk-data (bare LF / CR CR LF rejected, as Node does), non-hex garbage after chunk-size rejected. Upstream's framing-overhead heuristic is deliberately not carried over (it rejects long streams of 1-byte chunks).
  • The now-unused provides.sources dep mechanism is removed from scripts/build.

Commit 3 — classify once, materialise lazily

  • Header names are classified against WebCore's HTTPHeaderName set once, in Header::new (bun_http_types::HeaderName::classify, length bucket → one byte → one case-insensitive compare). handle_response_metadata / build_request switch on the tag instead of a cascade of wyhash compares, and createFromPicoHeaders skips findHTTPHeaderName and the map's duplicate scan for first occurrences.
  • A fetched Response keeps the parsed head (Init.wire_headers) and only builds FetchHeaders when something reads them; blob()/formData() get Content-Type from the wire form directly. Every accessor that hands out init.headers goes through realize_headers().

Commit 4 — when a head arrives across many reads, only re-parse once the new bytes could have completed it (port of pico's is_complete(last_len), which Bun never wired up), so a trickling server can't make header parsing quadratic.

What gets faster, in user terms

This is all on the client side of fetch() (the work Bun does per response, split between its HTTP thread and the JS thread). Nothing changes for Bun.serve, and the network round-trip itself is untouched, so a single await fetch() against a remote server won't feel different. Where it shows up is code that does a lot of fetches — crawlers, proxies, API fan-out, bun install-style workloads — because each response now costs less CPU, so the same core sustains more requests/s.

Measured with release builds of this branch vs. its merge-base against a local server sending a typical API/CDN-style response (14 headers: Content-Type, Cache-Control, ETag, Strict-Transport-Security, a few X-*, …) with a tiny body, 64 requests in flight:

user code client CPU per response of which JS+HTTP-thread user time
const r = await fetch(u); await r.arrayBuffer() — headers never touched 7.2 µs → 6.3 µs (−12%) 3.5 µs → 2.4 µs (−31%)
… plus r.headers.get("content-type") 7.0 µs → 6.5 µs (−8%) 3.5 µs → 2.8 µs (−21%)
… plus for (const [k, v] of r.headers) 8.9 µs → 8.3 µs (−7%) 5.3 µs → 4.8 µs (−9%)

(The rest of the per-response CPU is syscalls and the fetch/promise machinery, which this PR doesn't touch.) In that benchmark the client was already able to push ~5–6% more requests/s through a server that was the bottleneck. Responses with only 2–4 headers have less to save: −3 to −5% CPU when headers aren't read, no measurable change when they are. res.blob() / res.formData() count as "headers never touched" — they read Content-Type without building the Headers object.

Two pathological cases also improve: a response head that arrives in many small reads (slow or adversarial server) no longer gets re-parsed from the start on every read — a 900 KB head in 512-byte writes went from 147 ms to 62 ms of client CPU — and long streams of tiny chunked-encoding chunks are decoded byte-for-byte as before rather than being rejected by upstream pico's new overhead heuristic.

The parser itself, measured standalone against the C library it replaces, is at parity (a bit faster on short heads and long values, ~1.15× slower on many-short-header heads, chunked decoding within ~10%); the wins above come from doing less with the headers after parsing, not from parsing faster.

How did you verify your code works?

  • Unit tests ported from picohttpparser's test.c plus new ones (parser, decoder, classifier, may_be_complete), run under Miri (bun run rust:miri; crate added to the Miri set).
  • Differential fuzzing against the C library (old snapshot for heads, upstream HEAD for chunked): several million generated/mutated inputs with random split points; the only divergences are the intended ones (obs-fold, earlier rejection of already-invalid prefixes).
  • bun bd test on fetch / body / response / blob / chunked-trailing / client-fetch / fetch.stream / fetch-redirect / fetch-gzip / fetch-http2-client / websocket-client / node-http / serve / bun-serve-file / html-rewriter / wasm-streaming / bun-install-registry; new JS tests for strict chunk framing, CTL handling in long values, the deferred-headers paths (blob type, clone, new Response(x, res), served back out of Bun.serve, HTMLRewriter, repeated Content-Type), and a byte-per-write head.
  • bun run rust:check-all (all targets), clang-format/rustfmt/prettier clean.
  • Locally, fetch.test.ts has 2–3 TLS tests and one s3 multipart test that time out / flake identically on an unmodified main debug build.

Not done here (possible follow-ups): letting FetchHeaders.get() answer from the wire block without building the map, and a single-allocation clone_metadata.

  • CI (build 90132): 195/196 jobs green; the remaining failure is worker-transfer-terminate-stress.test.ts on the x64-asan lane, which its own header describes as an intermittent MessagePort/terminate abort and which fails on unrelated branches this week (builds 90005, 89980, 89893).

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Found 4 issues this PR may fix:

  1. Consider using hparse for HTTP parsing #21580 - Directly asks to replace picohttpparser because it is a C library limited to SSE4.2; this PR does exactly that with a Rust parser plus an arm64-capable highway SIMD kernel.
  2. gzip fetch freezes forever in official docker image #19590 - fetch() to a streamed gzip endpoint hangs on Linux x64 but never on macOS arm64, and an x86-only divergence points at the SSE4.2 header-scan path in picohttpparser that this PR replaces.
  3. next start under Bun returns 200 empty bodies for server-side fetch to local PostgREST, while Node returns real data #29515 - Client fetch() returns 200 with a zero-length body where node:http returns the real body, which is a response-head/body-offset parsing failure in the code path this PR rewrites.
  4. start a mcp transport is so slowly in bun runtime, but fastly in nodejs #22396 - SSE transport takes 15s in Bun vs 130ms in Node to surface the first event, consistent with the chunked-decoder buffering behavior for streams of many tiny chunks that this PR rewrites and regression-tests.

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

Fixes #21580
Fixes #19590
Fixes #29515
Fixes #22396

🤖 Generated with Claude Code

…way kernels

No-Verification-Needed: CI allowlist data only, no product code
…tchHeaders only when a Response's headers are actually read
@dylan-conway dylan-conway changed the title http: replace picohttpparser with a Rust response parser and chunked decoder http: replace picohttpparser with a Rust parser; classify headers once and build fetch Response headers lazily Aug 7, 2026
@dylan-conway
dylan-conway marked this pull request as ready for review August 7, 2026 15:07
@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

The change replaces picohttpparser with Rust HTTP response parsing and chunked decoding. It adds typed header classification, Highway control-byte scanning, deferred response-header materialization, updated WebCore bindings, build cleanup, benchmarks, and expanded fetch tests.

Changes

HTTP parser and decoder

Layer / File(s) Summary
Parser, decoder, and header contracts
src/picohttp/*, src/http_types/*, src/highway/lib.rs
Adds incremental HTTP response parsing, RFC 9112 chunked decoding, typed header classification, and SIMD control-byte scanning.
HTTP client parser integration
src/http/*, src/http_jsc/websocket_client/*
Uses typed headers and safe decoder APIs for response parsing, chunked bodies, trailers, and response-head lengths.
Deferred response headers and body metadata
src/runtime/webcore/Body.rs, src/runtime/webcore/Response.rs, src/runtime/webcore/fetch/FetchTasklet.rs
Stores wire metadata until header access and routes Content-Type lookup through response callbacks.
WebCore header conversion and FFI wiring
src/jsc/FetchHeaders.rs, src/jsc/bindings/*
Caches well-known header identifiers, combines repeated headers, preserves cookie handling, and exports the Highway scanner.
Dependency and build graph cleanup
scripts/build/*, .gitignore, CLAUDE.md, LICENSE.md, docs/project/license.mdx
Removes picohttpparser dependency resolution, source compilation, include paths, and obsolete dependency metadata.
Validation, benchmarks, and project metadata
test/js/web/fetch/*, bench/fetch/*, scripts/verify-baseline-static/*, .github/workflows/miri.yml
Adds parser and fetch regression tests, benchmarks, Miri coverage, and SIMD allowlist entries.

Possibly related issues

Possibly related PRs

  • oven-sh/bun#35880 — Both PRs update obsolete workspace dependencies in src/picohttp/Cargo.toml.
  • oven-sh/bun#36068 — Both PRs update chunked-response completion and trailer handling.
  • oven-sh/bun#37062 — Both PRs modify src/picohttp response parsing and chunked decoding.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the main parser replacement, header classification, and lazy Fetch Response header materialization.
Description check ✅ Passed The description includes both required sections and provides detailed change scope, verification results, benchmarks, and known unrelated failures.

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

@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: 7

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/picohttp/lib.rs (1)

62-77: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Correct the stale .bss claim on ZERO.

The comment states that ZERO "evaluates to all-zero bytes" so [Header::ZERO; N] statics land in .bss. name_id is now Self::OTHER (u8::MAX), so the const is no longer all-zero and such arrays move to .data. OTHER cannot be 0, because 0 is the discriminant of HeaderName::Accept. Update the comment so the null-pointer rationale is not attributed to a section placement that no longer applies.

📝 Proposed comment fix
     /// All-zero sentinel — name/value are empty slices. Used by callers to
     /// initialize fixed-size header arrays before filling them.
     ///
-    /// Uses `null()` (not `b"".as_ptr()`) so the const evaluates to all-zero
-    /// bytes — `[Header::ZERO; N]` statics land in `.bss` instead of `.data`.
-    /// `name()`/`value()` go through `ffi::slice`, which tolerates `(null, 0)`.
+    /// Uses `null()` (not `b"".as_ptr()`) so the pointer fields need no
+    /// relocation. `name()`/`value()` go through `ffi::slice`, which tolerates
+    /// `(null, 0)`. `name_id` is `OTHER`, not `0`, because `0` is
+    /// `HeaderName::Accept`.
     pub const ZERO: Self = Self {
🤖 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/picohttp/lib.rs` around lines 62 - 77, Update the documentation for
Header::ZERO to remove the stale claim that it produces all-zero bytes or places
[Header::ZERO; N] statics in .bss. Retain the explanation that null pointers
represent empty slices and are supported by ffi::slice, and leave Header::OTHER
unchanged because it must remain distinct from the Accept discriminant.
🤖 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 `@bench/fetch/response-headers-server.mjs`:
- Around line 38-39: Update the request handling around the response profile
selection to parse req.url with URL and derive the profile from pathname,
excluding query parameters. Validate the resulting kind against responses and
return a clear error Response for unknown profiles instead of silently using an
unprofiled response; preserve the existing nginx default when no profile is
specified.

In `@bench/fetch/response-headers.mjs`:
- Around line 7-15: Validate the parsed CLI arguments before any benchmark
workers start: require a valid port, restrict kind to supported profiles and
mode to the supported values, and require total and conc to be positive
integers. Reject missing, non-numeric, zero, negative, fractional, or unknown
values with a clear usage error, preventing invalid profiles from reaching one()
and causing header access failures.

In `@src/http_types/HeaderName.rs`:
- Around line 116-123: Add a compile-time const assertion near
HeaderName::from_index that verifies NAMES.len() equals the number of contiguous
HeaderName variants, using the last variant’s discriminant as the expected
bound. Keep from_index’s existing COUNT check and transmute unchanged, while
ensuring mismatched names and variants fail compilation.

In `@src/http/lib.rs`:
- Around line 3681-3692: Update the response-reading logic around already_seen
and the to_read.len() < 16 guard so the may_be_complete fast path is used only
when the stored prefix was actually parsed and returned ShortRead. Do not treat
the accumulated 1–15 byte prefix from short_read! as validated; reset or
otherwise gate already_seen until parsing has occurred, while preserving normal
validation for parsed prefixes.
- Around line 4643-4685: Add the crate::Error variant for ChunkedEncodingError
and implement the required error/name mappings and traits so it can be
propagated directly. Update both chunked decoder call sites, including
handleResponseBodyChunkedEncodingFromMultiplePackets and the corresponding path
near the second decoder invocation, to return ChunkedEncodingError instead of
InvalidHTTPResponse when decoding fails.

In `@src/runtime/webcore/Response.rs`:
- Around line 1464-1465: Update Init::clone to take &mut self, call
self.realize_headers() before cloning, and retain the existing cloning behavior
afterward so deferred wire_headers are materialized into headers instead of
being dropped.

In `@test/js/web/fetch/chunked-trailing.test.js`:
- Line 681: Update the raw TCP socket error handlers in
test/js/web/fetch/chunked-trailing.test.js:681 and
test/js/web/fetch/client-fetch.test.ts:710 to tolerate only expected
client-abort errors such as ECONNRESET or EPIPE. Propagate every other socket
error through the awaited helper or test while preserving the original error
object.

---

Outside diff comments:
In `@src/picohttp/lib.rs`:
- Around line 62-77: Update the documentation for Header::ZERO to remove the
stale claim that it produces all-zero bytes or places [Header::ZERO; N] statics
in .bss. Retain the explanation that null pointers represent empty slices and
are supported by ffi::slice, and leave Header::OTHER unchanged because it must
remain distinct from the Accept discriminant.
🪄 Autofix

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: b498bfc0-e973-4e16-b323-c9c63140ea52

📥 Commits

Reviewing files that changed from the base of the PR and between 45ee955 and fb86f21.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (41)
  • .github/workflows/miri.yml
  • .gitignore
  • CLAUDE.md
  • LICENSE.md
  • bench/fetch/response-headers-server.mjs
  • bench/fetch/response-headers.mjs
  • docs/project/license.mdx
  • scripts/build/bun.ts
  • scripts/build/deps/index.ts
  • scripts/build/deps/picohttpparser.ts
  • scripts/build/flags.ts
  • scripts/build/source.ts
  • scripts/rust-miri.ts
  • scripts/verify-baseline-static/allowlist-aarch64.txt
  • scripts/verify-baseline-static/allowlist-x64-windows.txt
  • scripts/verify-baseline-static/allowlist-x64.txt
  • src/highway/lib.rs
  • src/http/InternalState.rs
  • src/http/lib.rs
  • src/http_jsc/websocket_client/WebSocketUpgradeClient.rs
  • src/http_types/HeaderName.rs
  • src/http_types/Method.rs
  • src/http_types/lib.rs
  • src/jsc/FetchHeaders.rs
  • src/jsc/bindings/BunProcess.cpp
  • src/jsc/bindings/bindings.cpp
  • src/jsc/bindings/highway_strings.cpp
  • src/picohttp/Cargo.toml
  • src/picohttp/chunked.rs
  • src/picohttp/lib.rs
  • src/picohttp/parse.rs
  • src/runtime/api/html_rewriter.rs
  • src/runtime/server/RequestContext.rs
  • src/runtime/webcore/Body.rs
  • src/runtime/webcore/Response.rs
  • src/runtime/webcore/fetch/FetchTasklet.rs
  • src/wyhash/lib.rs
  • test/js/node/process/process.test.js
  • test/js/web/fetch/chunked-trailing.test.js
  • test/js/web/fetch/client-fetch.test.ts
  • test/js/web/fetch/fetch.test.ts
💤 Files with no reviewable changes (6)
  • .gitignore
  • src/http_types/Method.rs
  • src/jsc/bindings/BunProcess.cpp
  • scripts/build/deps/picohttpparser.ts
  • test/js/node/process/process.test.js
  • scripts/build/flags.ts

Comment thread bench/fetch/response-headers-server.mjs Outdated
Comment thread bench/fetch/response-headers.mjs
Comment thread src/http_types/HeaderName.rs
Comment thread src/http/lib.rs
Comment thread src/http/lib.rs
Comment thread src/runtime/webcore/Response.rs Outdated
Comment thread test/js/web/fetch/chunked-trailing.test.js
Comment thread src/picohttp/lib.rs
…gate the head prefilter on a parsed prefix, realize headers inside Init::clone
No-Verification-Needed: benchmark script only

@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: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/http/lib.rs (2)

3668-3675: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Enforce the response-header limit before staging input.

When this read still leaves the head incomplete, Line 3634 appends the full incoming_data before the 1 MiB check in Lines 3670-3672. The staging allocation can exceed MAX_RESPONSE_HEADER_BUFFER by the full packet size. Bound the bytes copied into the staging buffer before append_slice_exact. Preserve packets that contain the completed head and body by staging only the bounded head prefix and processing the remaining body bytes separately.

As per coding guidelines, validate untrusted input before allocation and enforce actual resource limits.

🤖 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 3668 - 3675, Update the incomplete
response-head handling around the `incomplete_head!` macro and the
`append_slice_exact` call so the 1 MiB `MAX_RESPONSE_HEADER_BUFFER` limit is
checked before copying `incoming_data` into the staging buffer. Stage only the
bounded head prefix, and process any remaining bytes as body data when the
packet completes the head; preserve existing behavior for packets containing
both the completed head and body.

Source: Coding guidelines


4790-4796: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Parse Content-Type before enabling SSE mode.

starts_with_case_insensitive_ascii matches text/event-streaming and other invalid media types. If this sets is_server_sent_events, a zero-length response can wait for connection close instead of completing. Parse the media type and enable SSE only for the exact text/event-stream type with valid parameters.

🤖 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 4790 - 4796, Update the ContentType branch in
the header-processing logic to parse the media type before setting
is_server_sent_events. Enable SSE only when the parsed type is exactly
text/event-stream and its parameters are valid, rejecting values such as
text/event-streaming while preserving normal response completion behavior.

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 `@bench/fetch/response-headers.mjs`:
- Line 11: Update the CLI validation condition in the response-header benchmark
to require port to be an integer from 1 through 65535 using Number.isInteger,
and require both N and C to be safe integers at least 1 using
Number.isSafeInteger. Preserve the existing mode validation and rejection
behavior.

In `@src/jsc/bindings/bindings.cpp`:
- Around line 2149-2155: Update the header conversion logic around
HTTPHeaderName::SetCookie so every Set-Cookie value is appended to the dedicated
Set-Cookie collection rather than passed to HTTPHeaderMap::add, preserving
repeated fields separately. When constructing FetchHeaders, transfer both the
HTTPHeaderMap and the populated Set-Cookie collection so getSetCookie() returns
all values. Add a regression test covering multiple Set-Cookie fields.

---

Outside diff comments:
In `@src/http/lib.rs`:
- Around line 3668-3675: Update the incomplete response-head handling around the
`incomplete_head!` macro and the `append_slice_exact` call so the 1 MiB
`MAX_RESPONSE_HEADER_BUFFER` limit is checked before copying `incoming_data`
into the staging buffer. Stage only the bounded head prefix, and process any
remaining bytes as body data when the packet completes the head; preserve
existing behavior for packets containing both the completed head and body.
- Around line 4790-4796: Update the ContentType branch in the header-processing
logic to parse the media type before setting is_server_sent_events. Enable SSE
only when the parsed type is exactly text/event-stream and its parameters are
valid, rejecting values such as text/event-streaming while preserving normal
response completion behavior.
🪄 Autofix

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: 36e1b9c2-b167-415b-8278-bef561fc0a78

📥 Commits

Reviewing files that changed from the base of the PR and between fb86f21 and 996c7a4.

📒 Files selected for processing (7)
  • bench/fetch/response-headers-server.mjs
  • bench/fetch/response-headers.mjs
  • src/http/lib.rs
  • src/http_types/HeaderName.rs
  • src/jsc/bindings/bindings.cpp
  • src/picohttp/lib.rs
  • src/runtime/webcore/Response.rs

Comment thread bench/fetch/response-headers.mjs Outdated
Comment thread src/jsc/bindings/bindings.cpp
@dylan-conway

Copy link
Copy Markdown
Member Author

On the two outside-diff notes in the last review: the SSE check now requires the media type to be exactly text/event-stream (optionally followed by parameters) — 58c2a53. The 1 MiB response-head cap being checked after the read is appended is pre-existing behavior; the overshoot is bounded by a single socket read, so I've left it as is here.

No-Verification-Needed: benchmark script only

@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.

No bugs found, but this is a full HTTP/1.1 response-head parser and chunked-decoder rewrite plus lazy FetchHeaders materialization on every fetched Response — hot-path, protocol-boundary code that warrants a human sign-off.

What was reviewed

  • The new parser/decoder against RFC 9112 framing rules and the ported picohttpparser test cases; SWAR index_of_line_end and the highway CTL kernel match the scalar predicate.
  • HeaderName::classify / from_index: the transmute is guarded by the COUNT == XXSSProtection+1 const assert and the round-trip test; the name_id = enum+1 encoding lines up with C++ header.name_id - 1 and Header::ZERO is all-zero again.
  • Lazy wire_headers: every init.headers accessor routes through realized_init()/realize_headers(), Init::clone realizes first, set_init_headers clears wire_headers, and the repeated-Content-Type path falls back to the map so combining is consistent.
  • may_be_complete prefilter: the seen >= 16 gate now excludes prefixes that never reached parse; already_seen is captured before the 16-byte floor short-reads.
Extended reasoning...

Overview

This PR replaces the vendored C picohttpparser with a Rust HTTP/1.1 response-head parser (src/picohttp/parse.rs) and chunked-body decoder (src/picohttp/chunked.rs), adds a length-bucketed HeaderName::classify so field names are tagged once at parse time, wires that tag through to createFromPicoHeaders_ in bindings.cpp (skipping findHTTPHeaderName and the map's duplicate scan for first occurrences), and defers building FetchHeaders for a fetched Response until something reads them (Init::wire_headersrealize_headers()). It also adds a highway IndexOfHttpCtl kernel with an inline two-word SWAR fast path, a may_be_complete prefilter so a trickled head isn't re-parsed quadratically, tightens the SSE Content-Type check to an exact media-type match, and removes the picohttpparser dep (build plumbing, process.versions.picohttpparser, license entry, allowlists). 42 files across src/picohttp, src/http, src/http_types, src/highway, src/runtime/webcore, src/jsc/bindings, build scripts, and tests.

Security risks

The parser sits directly on untrusted network bytes. I checked: bounds are enforced via Cursor::peek/buf.get() (no raw pointer arithmetic on the wire buffer); chunk-size is capped at MAX_HEX_DIGITS before it can overflow usize; obs-fold and bare-LF chunk framing are now rejected (stricter than before, matching Node — reduces smuggling surface); HeaderName::from_index transmutes only after a < COUNT check backed by a const assert, and well_known() uses wrapping_sub(1) so name_id == 0 maps to 255 → None. The new C++ path casts name_id - 1 to HTTPHeaderName under an ASSERT(index < numHTTPHeaderNames) — release builds rely on the Rust side never producing an out-of-range id, which classify cannot. No new unbounded allocation paths; the 1 MiB head cap is unchanged.

Level of scrutiny

High. This is the response parser for fetch, bun install's registry client, and the WebSocket upgrade handshake — every byte off the wire flows through it, and the lazy-headers change threads a new invariant ("every init.headers read goes through realize_headers()") through Response, Body, FetchTasklet, RequestContext, and html_rewriter. The PR is thoroughly tested (Miri, differential fuzzing against the C library, per-byte and every-split-point unit tests, JS integration tests for each deferred-headers path), and all prior review threads are resolved, but a parser rewrite of this scope is exactly the kind of change a maintainer should sign off on rather than an automated approval.

Other factors

  • All eight prior review threads (my Header::ZERO comment and CodeRabbit's findings on may_be_complete, Init::clone, Set-Cookie, bench-script validation) are addressed and resolved; the two intentionally left as-is have stated rationale.
  • CI is 195/196 green with the remaining failure attributed to a known unrelated flake.
  • User-visible behavior changes: process.versions.picohttpparser is removed, chunked bodies with bare-LF framing or non-hex garbage after chunk-size are now rejected, and text/event-stream-foo no longer triggers SSE handling. All are defensible tightenings but worth a human ack.

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.

1 participant