Skip to content

Name the argument or option in HeadersInit conversion errors - #42961

Open
robobun wants to merge 4 commits into
mainfrom
robobun/6c0e93c1/name-headers-init-errors
Open

robobun wants to merge 4 commits into
mainfrom
robobun/6c0e93c1/name-headers-init-errors

Conversation

@robobun

@robobun robobun commented Sep 16, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • A headers value of the wrong type throws TypeError: Type error or Value is not a sequence. No message names the option, so fetch(url, { headers, proxy: { url, headers } }) does not say which one is wrong.
  • new Headers(), fetch, Request, Response, Bun.FetchSession, Bun.serve and WebSocket all use the generic union converter. Its last step is throwTypeError() with no message (src/jsc/bindings/webcore/JSDOMConvertUnion.h:383).

Fix

  • Add convertHeadersInit (src/jsc/bindings/webcore/JSFetchHeaders.cpp) for all of these call sites. It runs the steps of the union converter, and its TypeErrors name the option and the entry:
    The "proxy.headers" property must be an instance of Headers, Array, or Object. Received type number (1)
    The "headers[1]" argument must be an instance of Array. Received type string ('bc')
  • Correct because every changed case already threw a TypeError. The error path runs no user code, and an exception from user code propagates unchanged. Tests pin both.
  • Verified: test/js/web/fetch/headers.test.ts, fetch-session.test.ts, websocket-custom-headers.test.ts (five tests fail without the fix). The Notes have the other suites and a benchmark.
  • Self-reviewed: 4 concerns raised, 4 addressed. The Notes name them.

Background

  • HeadersInit is the Web IDL type of every headers value: a sequence of [name, value] pairs, or a record. When no member of a union matches, Web IDL requires a TypeError and no message.
  • FetchHeaders::fill validates names and values after the conversion. Its errors name the header already, and they do not change.
  • Node names the index of a bad entry, never the option.
Notes

Origin. No user reported this. A sweep of the Bun.FetchSession option bag found it during the work on #42937: proxy: { url, headers: 1 } was the only key whose error did not name itself. #42937 left it out because the message comes from the shared conversion.

Messages, before and after (release build of main, then this branch):

input before after
new Headers(1) Type error The "init" argument must be an instance of Headers, Array, or Object. Received type number (1)
fetch(url, { headers: "x" }) Type error The "headers" argument must be ... Received type string ('x')
new Bun.FetchSession({ proxy: { url, headers: true } }) Type error The "proxy.headers" property must be ... Received type boolean (true)
new Response("", { headers: [["a","1"], "bc"] }) Value is not a sequence The "headers[1]" argument must be an instance of Array. Received type string ('bc')
new WebSocket(url, { proxy: { url, headers: [{}] } }) Type error The "proxy.headers[0]" property must be an instance of Array

All are TypeError with code: "ERR_INVALID_ARG_TYPE". Value is not a sequence had that code already, and so does signal is not of type AbortSignal. in fetch. "argument" or "property" follows the rule of Bun::ERR::INVALID_ARG_INSTANCE: a name with a dot is a property.

FFI. WebCore__FetchHeaders__createFromJS takes a HeadersInitName enum (headers or proxy.headers), mirrored in headers-handwritten.h. The Rust create_from_js keeps its two-argument signature and names headers, so its seven callers do not change. create_from_js_named is for the one proxy.headers caller, in FetchSession.rs. On the C++ side convertHeadersInit takes the same enum, and only JSFetchHeaders.cpp maps it to the option names. bindings.cpp forwards the enum, and the WebSocket constructor passes the enum values. The Headers constructor, in the same file, names its argument init.

No user code on the error path. Bun::ERR::INVALID_ARG_INSTANCE describes an object with constructor.name, which can run a getter or a Proxy trap. So the converter echoes a primitive only. An object entry gets the message with no Received part. A test wraps an entry in a Proxy and checks that the only read is Symbol.iterator, and that a throwing constructor getter is never called.

Benchmark. Release builds, new Headers(init) and new Response("", { headers }), best of 5 interleaved runs, ns per call:

case main this branch
new Headers, 1 pair 291.2 266.6
new Headers, 8 pairs 1890.7 1826.0
new Headers, 32 pairs 11790.1 11798.3
new Headers, record of 8 1638.9 1600.6
new Response, 8 pairs 2142.9 2032.8
new Response, record of 8 1900.8 1778.3

A first version did a Symbol.iterator lookup on every entry, and that cost 12 to 13% for 8 and 32 pairs. The converter now sends a fast array entry to the sequence converter directly, which is the call that ran before this PR. The record path did not change, so its spread shows the noise between two builds.

Why a HeadersInit converter and not a change to the union converter. The union converter is a port of WebKit's and serves about 60 other call sites, most of them in src/jsc/bindings/webcrypto/. The Type error for [{}] does not come from it. It comes from JSC::forEachInIterable, which finds no iterator. To name that entry, the caller must do the Symbol.iterator lookup, and that is what this converter does. The other unions that can reach the message-less TypeError (mostly the WebCrypto BufferSource unions) are out of scope here.

WebSocket keeps IDLByteString. A switch to IDLDOMString would turn an invalid proxy.headers value from a throw into a silent drop until #37638 lands, because setupProxy discards a fill failure today. A value outside Latin-1 still throws Type error there.

Not changed. The fill errors, including Header sub-sequence must contain exactly two items. server.upgrade() keeps its own upgrade options.headers must be a Headers or an object.

Self-review. A review of the first version raised four concerns, and all four are addressed:

  • the constructor.name read (fixed, with a test)
  • a required third argument on create_from_js that touched seven call sites and conflicted with open PRs (now one call site)
  • no benchmark (added, and it found the regression above)
  • the origin of the report and the excluded union sites were not stated (stated above)

Suites run on the debug ASAN build. The three files above (147 pass), also under BUN_JSC_validateExceptionChecks=1. fetch_headers.test.js, fetch-args.test.ts, headers.undici.test.ts, request-subclass.test.ts, request.test.ts, test/js/deno/fetch/{headers,request,response}.test.ts, bun-serve-static.test.ts, proxy.test.ts, websocket-proxy.test.ts: all pass. response.test.ts "handle stack overflow" times out at 5 s on this machine with and without the change. Two tests in websocket.test.js need the public internet and fail the same way on main.


no test proof · iteration 1 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/web/fetch/fetch-session.test.ts

A `headers` value that is not an object threw `TypeError: Type error`.
An entry that is not a pair threw `Value is not a sequence`, and `[{}]`
threw `Type error` again. No message named the option. `new Headers()`,
`fetch`, `Request`, `Response`, `Bun.FetchSession`, `Bun.serve` and
`WebSocket` all converted the value with the generic IDLUnion converter.

Add `convertHeadersInit`, one converter for HeadersInit. It runs the
steps of the union converter. The TypeErrors that it raises name the
argument or the option, and the index of the entry. A primitive is
echoed. An object entry is not described, because that reads
`constructor.name`. An exception from user code propagates unchanged. A
fast array entry takes the same path as before, with no Symbol.iterator
lookup.

`WebCore__FetchHeaders__createFromJS` takes a `HeadersInitName`.
`create_from_js` keeps its signature and names `headers`.
`create_from_js_named` is for `proxy.headers`.
@coderabbitai

coderabbitai Bot commented Sep 16, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

  • Run on-demand review

On-demand reviews are free for the next 4 days. After that, they cost $0.25 per reviewed file.

Or wait 4 minutes for your next included review.

Check out review usage here.

View limit details

Limit details: You’ve used all 10 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 1dce195d-c477-49e3-983f-645d25eb620a

📥 Commits

Reviewing files that changed from the base of the PR and between a8e4e90 and 18782ac.

📒 Files selected for processing (14)
  • src/jsc/FetchHeaders.rs
  • src/jsc/bindings/bindings.cpp
  • src/jsc/bindings/headers-handwritten.h
  • src/jsc/bindings/headers.h
  • src/jsc/bindings/webcore/JSFetchHeaders.cpp
  • src/jsc/bindings/webcore/JSFetchHeaders.h
  • src/jsc/bindings/webcore/JSWebSocket.cpp
  • src/jsc/lib.rs
  • src/runtime/webcore.rs
  • src/runtime/webcore/Response.rs
  • src/runtime/webcore/fetch/FetchSession.rs
  • test/js/web/fetch/fetch-session.test.ts
  • test/js/web/fetch/headers.test.ts
  • test/js/web/websocket/websocket-custom-headers.test.ts

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

@robobun

robobun commented Sep 16, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: ready for review. The diff is green, and the red CI job is not from this PR.

Reproduced on bun 1.4.3-canary (c6b7fcb):

new Headers(1);                                      // TypeError: Type error
await fetch(url, { proxy: { url, headers: 1 } });    // rejects with TypeError: Type error
new Response("", { headers: [["a", "1"], "bc"] });   // TypeError: Value is not a sequence

Without the fix, this command fails 5 tests. With this branch, bun bd test on the same files passes all 147.

USE_SYSTEM_BUN=1 bun test test/js/web/fetch/headers.test.ts test/js/web/fetch/fetch-session.test.ts test/js/web/websocket/websocket-custom-headers.test.ts

CI, build #116693: 180 of 181 jobs pass.

  • The one red job is darwin aarch64 - test-bun, shard 1 of 2. The macOS VM got a cancellation signal before it ran a test, so the job has no test output. Shard 2 of 2 passes.
  • Every test that failed in this build passed on its retry. None of them is in the three test files above, and no lane reports a failure in those files.
  • The build before it, #116680, had one red test, test/js/bun/spawn/spawn.test.ts on x64-asan. This PR does not touch Bun.spawn, and the test passes in #116693.

PR: #42961

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

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Beyond the inline finding, I also diffed the new convertHeadersInit against the old IDLUnion dispatch in JSDOMConvertUnion.h — the acceptance set is unchanged (object with Symbol.iterator → sequence, other object → record, non-object/null → TypeError), and the entry-level Symbol.iterator lookup is only done off the FastArray path, so the record path and fast-array entries take the same converter calls as before.

Extended reasoning...

Checked the pre-change union converter (src/jsc/bindings/webcore/JSDOMConvertUnion.h steps 11–17) against the new helper in src/jsc/bindings/webcore/JSFetchHeaders.cpp: both call iteratorMethod on the top-level object, route to the sequence converter when defined and to the record converter otherwise, and throw a TypeError for primitives and null. The new per-entry branch only differs for non-FastArray entries, where it does the Symbol.iterator lookup that forEachInIterable would otherwise do internally, so accepted inputs and iteration order are preserved. The remaining inline finding (new helper placed in monolithic bindings.cpp) and the ruled-out candidates from the hunt cover the rest; nothing further to add.

2 verified lower-impact observations (convention, logging or cleanup points) were not posted.

Comment thread src/jsc/bindings/bindings.cpp Outdated
`convertHeadersInit` takes a `HeadersInitName`, so `bindings.cpp` only
forwards the enum and the WebSocket constructor uses the same values.
The names `headers` and `proxy.headers` now exist in one place.
Comment thread src/jsc/FetchHeaders.rs Outdated
Comment thread src/jsc/bindings/webcore/JSFetchHeaders.cpp Outdated
Comment thread src/jsc/bindings/webcore/JSFetchHeaders.cpp Outdated
Comment thread src/jsc/bindings/webcore/JSFetchHeaders.h Outdated

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

I re-reviewed after 339040e and 29b3e93: the enum-to-name mapping now lives in convertHeadersInit in src/jsc/bindings/webcore/JSFetchHeaders.cpp and the bindings.cpp helper is gone, which addresses my earlier comment, and I found no bugs in this version. Because this replaces the generic Web IDL union converter with a hand-written iterator walk for every HeadersInit entry point (Headers, Request, Response, fetch, FetchSession, Bun.serve, WebSocket) and changes a C ABI signature, a human look would still be worthwhile.

What was reviewed:

  • Exception-scope discipline in convertHeadersInitNamed/throwEntryIsNotPair: every throwing call (iteratorMethod, forEachInIterable, both Converter::convert paths) is followed by RETURN_IF_EXCEPTION/RELEASE_AND_RETURN, and the lambda mirrors the pattern in JSDOMConvertSequences.h:218-225.
  • Behavior parity with the old union converter: object check, then Symbol.iterator lookup, sequence-vs-record dispatch, and the fast-array entry path call the same Converter<IDLSequence>::convert(global, value) the generic converter used, so only the error messages change.
  • The C ABI: HeadersInitName is uint8_t/#[repr(u8)] with matching discriminants, WebCore__FetchHeaders__createFromJS is the only extern with the new parameter and all callers (Rust extern, headers.h, bindings.cpp) were updated together.
  • Tests pin exact name/code/message, cover the user-code-propagation and no-constructor-read contracts, and the WebSocket cases throw in the constructor before any dial.
Extended reasoning...

Overview

The PR replaces the generic IDLUnion<IDLSequence<IDLSequence<S>>, IDLRecord<S,S>> conversion for HeadersInit values with a hand-written convertHeadersInit<IDLStringType> in src/jsc/bindings/webcore/JSFetchHeaders.cpp so that conversion TypeErrors are ERR_INVALID_ARG_TYPE errors naming the argument/option (init, headers, proxy.headers) and the bad entry index. It adds a HeadersInitName enum mirrored between headers-handwritten.h and src/jsc/FetchHeaders.rs, threads it through WebCore__FetchHeaders__createFromJS (new trailing parameter), adds create_from_js_named wrappers in Rust with create_from_js delegating to Headers, and updates the C++ call sites in the Headers constructor and JSWebSocket.cpp. Tests were added to the three existing suites for the affected modules.

Since my prior review, commit 339040e removed the headersInitNameLiteral helper from bindings.cpp and moved the enum-to-literal switch into convertHeadersInit in JSFetchHeaders.cpp, which is what my inline comment asked for. Commit 29b3e93 only shortened comments.

Security risks

The converter parses user-supplied JS values in native code. I checked that no user code runs on the error path (the constructor.name read of INVALID_ARG_INSTANCE is only reached for primitives; objects get a message with no Received part), that getIterationMode on a non-cell value is safe and routes to the generic branch, and that the only user-observable reads are the Symbol.iterator lookups Web IDL already requires. Header name/value validation is unchanged and still happens in FetchHeaders::fill. No injection, auth, or data-exposure surface is introduced.

Level of scrutiny

Moderate-to-high. This is a new IDL converter in JSC bindings on a hot path used by every fetch/Headers/Response construction, plus an FFI signature change. The exception-check pattern matches the existing SequenceConverter::convert lambdas, the fast-array path makes the same Converter<IDLSequence<S>>::convert(global, value) call the generic converter made, and the enum mirroring follows the BunErrorKind/UWSResponseKind precedent in the same header. I did not find a defect, but a hand-written replacement for a spec-derived converter is the kind of change a maintainer should eyeball rather than have a bot approve outright.

Other factors

The bug hunt ran dry with no findings and no ruled-out candidates. Tests assert exact name/code/message triples, cover the negative contract (user exceptions propagate unchanged, constructor is never read), and the WebSocket cases throw synchronously before connecting so no server is needed. The fetch-session.test.ts suite did not run on the author's machine per the evidence marker and is deferred to CI. No CODEOWNERS entry covers the changed files. No outstanding third-party objections are visible in the timeline; the only reviews are bot comments and my own prior inline note, which the code now addresses.

@robobun

robobun commented Sep 16, 2026

Copy link
Copy Markdown
Collaborator Author

One correction to the note on test/js/web/fetch/fetch-session.test.ts in the review above: the file did run locally, on linux x64.

  • Debug ASAN build of this branch (29b3e93): 32 pass, 0 fail.
  • bun 1.4.3-canary (c6b7fcb), without the fix: Bun.FetchSession > rejects invalid options fails with Received message: "Type error". The other 31 pass.

The file has three tests with a Windows skipIf or todoIf. None of them is the test that this PR changes.

@robobun

robobun commented Sep 16, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 1:16 PM PT - Sep 16th, 2026

❌ @robobun, your commit 18782ac has some failures in Build #116693 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 42961

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

bun-42961 --bun

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant