Skip to content

webcore: copy internal state in new Request(request) instead of calling getters - #37033

Open
robobun wants to merge 7 commits into
mainfrom
farm/57505e18/request-input-internal-state
Open

webcore: copy internal state in new Request(request) instead of calling getters#37033
robobun wants to merge 7 commits into
mainfrom
farm/57505e18/request-input-internal-state

Conversation

@robobun

@robobun robobun commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Problem

new Request(input) only took the internal-state copy path when the input Request's Structure was pristine. A class extends Request instance, or a genuine Request that gained any own property, fell through to the dictionary path and was read through JS-visible getters:

let getterHits = 0;
const r = new Request("http://127.0.0.1:1/original", { headers: { "x-real": "1" } });
Object.defineProperty(r, "url", { get() { getterHits++; return "http://127.0.0.1:2/FROM-GETTER"; } });
const r2 = new Request(r);
console.log(r2.url, getterHits);
// bun:  http://127.0.0.1:2/FROM-GETTER  1
// node: http://127.0.0.1:1/original     0

The fetch spec's Request constructor sets the new request from the input's request directly ("Set request to input's request"), and Node v26/undici never consults getters for a Request passed as input. Getters are only correct for the init dictionary argument.

Relatedly, the spec's unusable-input check was missing: new Request(usedRequest) silently copied a disturbed body where Node throws TypeError: Cannot construct a Request with a Request object that has already been used. (it only threw incidentally, with a different message, when an init object happened to route the disturbed stream through the getter path).

Cause

In Request::construct_into, both constructor arguments flow through the same values_to_try loop, and the internal-state branch was gated on as_direct::<Request>(), whose generated check requires the cell's structure ID to equal the global pristine JSRequest structure. Any structure transition (subclass prototype, added own property) returned null, so the input was parsed as a dictionary.

Fix

The loop now knows which entry is the input argument (always the last entry when the first argument was not a URL):

  • the input uses the inherits-based as_::<Request>() cast, so subclass instances and transitioned structures still take the internal-state copy
  • for the input, url and signal are also copied from internal state, and all fields are marked consumed so none of the dictionary fallbacks run getters against it
  • the input's URL is materialized with ensure_url() first, since a Bun.serve request builds it lazily from the request context (its signal is set eagerly at creation, so no equivalent step is needed there). A detached server request whose context is already gone keeps its empty url and hits the existing "url is required" throw instead of the getter fallback; the one-argument form's silent empty-url copy there is a pre-existing wart shared with Request.clone() and is not changed here
  • an Empty input body (extracted from an empty string) is copied as the non-null empty body it is, instead of being dropped to null
  • when init contributed no body (absent, undefined, or null), a disturbed-or-locked input body now throws the same TypeError as Node, before the tee
  • a Request passed as init (second argument) keeps dictionary semantics, where only a pristine instance may skip the getter path

Same bug class as #37027, which covers the fetch(request) call site; this PR covers the Request constructor.

Verification

These now match Node v26 (url/method/headers/body/signal from internal state, 0 getter hits, same throw behavior for used/locked/replaced bodies, init getter semantics unchanged), with one pinned exception: when init changes the method to GET/HEAD on a used-body input, Node throws its Request with GET/HEAD method cannot have body. error first. That constructor-level GET/HEAD-body check does not exist in bun (bun enforces it at fetch() time), predates this PR, and is a compat-sensitive change of its own, so the unusable-input error fires there instead; a test pins the current precedence.

  • new tests in test/js/web/request/request.test.ts: own-property and subclass getter shadowing (one-arg and two-arg forms), init-wins semantics, internal body/signal copy, a Bun.serve handler request with a shadowed url getter, empty-string-body copy, used/locked-body throws, and the preserved init-position getter semantics. 7 of them fail on bun 1.3.6 and pass with this change
  • test/js/web/fetch/body-clone.test.ts's tee-propagation test now uses a locked Request as init, the remaining path that still tees a locked stream; the input-position form it used before is covered by the new constructor tests
  • request-subclass.test.ts (fetch()-getter behavior from SvelteKit fetch issue #4718), body-clone.test.ts, response.test.ts, fetch.test.ts, undici tests: no new failures

[review] gate passed · iteration 0 · 3 files touched

fails on main (without fix)
ASAN without fix: 10 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/web/fetch/body-clone.test.ts test/js/web/request/request.test.ts
bun test v1.4.0 (0855f3fa2)

test/js/web/request/request.test.ts:
(pass) undefined args don't throw [3.72ms]
(pass) request can receive undefined signal [8.41ms]
(pass) request can receive null signal [7.29ms]
(pass) clone() does not lock original body when body was accessed before clone [18.82ms]
(pass) RequestInit signal presence > new Request(request, { signal: null }) detaches from input's signal [5.01ms]
(pass) RequestInit signal presence > new Request(request, { signal: undefined }) inherits input's signal [3.16ms]
(pass) RequestInit signal presence > new Request(request, {}) inherits input's signal [3.12ms]
(pass) RequestInit signal presence > signal: "" throws TypeError [3.61ms]
(pass) RequestInit signal presence > signal: {} throws TypeError [0.83ms]
(pass) RequestInit signal presence > signal: 0 throws TypeError [0.51ms]
(pass) RequestInit signal presence > signal: false throws TypeError [0.46ms]
(pass) RequestInit signal presence > signal: true throws T
... (truncated)

release without fix: 10 FAILED
bun test v1.4.0-canary.1 (b58cd4685)

test/js/web/request/request.test.ts:
(pass) undefined args don't throw [0.11ms]
(pass) request can receive undefined signal [0.20ms]
(pass) request can receive null signal [0.09ms]
(pass) clone() does not lock original body when body was accessed before clone [0.38ms]
(pass) RequestInit signal presence > new Request(request, { signal: null }) detaches from input's signal [0.07ms]
(pass) RequestInit signal presence > new Request(request, { signal: undefined }) inherits input's signal [0.03ms]
(pass) RequestInit signal presence > new Request(request, {}) inherits input's signal [0.03ms]
(pass) RequestInit signal presence > signal: "" throws TypeError [0.06ms]
(pass) RequestInit signal presence > signal: {} throws TypeError
(pass) RequestInit signal presence > signal: 0 throws TypeError
(pass) RequestInit signal presence > signal: false throws TypeError
(pass) RequestInit signal presence > signal: true throws TypeError [0.02ms]
(pass) RequestInit signal presence > signal: null does not throw [0.04ms]
(pass) RequestInit signal presence > signal: undefined does not throw
(pass) RequestInit signal presence > fetch(new Request(request,
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/web/fetch/body-clone.test.ts test/js/web/request/request.test.ts
bun test v1.4.0 (0855f3fa2)

test/js/web/request/request.test.ts:
(pass) undefined args don't throw [3.95ms]
(pass) request can receive undefined signal [9.00ms]
(pass) request can receive null signal [7.09ms]
(pass) clone() does not lock original body when body was accessed before clone [18.79ms]
(pass) RequestInit signal presence > new Request(request, { signal: null }) detaches from input's signal [5.06ms]
(pass) RequestInit signal presence > new Request(request, { signal: undefined }) inherits input's signal [3.20ms]
(pass) RequestInit signal presence > new Request(request, {}) inherits input's signal [2.83ms]
(pass) RequestInit signal presence > signal: "" throws TypeError [3.69ms]
(pass) RequestInit signal presence > signal: {} throws TypeError [0.85ms]
(pass) RequestInit signal presence > signal: 0 throws TypeError [0.48ms]
(pass) RequestInit signal presence > signal: false throws TypeError [0.45ms]
(pass) RequestInit signal presence > signal: true throws T
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     0855f3fa22
  features     baseline

22 deps, 106 codegen, 1175 objects in 867ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1238] install /workspace/bun
bun install v1.4.0-canary.1 (b58cd4685)

Checked 124 installs across 170 packages (no changes) [15.00ms]
[2/1238] gen ErrorCode+*.h
[3/1238] install /workspace/bun/packages/bun-error
bun install v1.4.0-canary.1 (b58cd4685)

Checked 1 install across 2 packages (no changes) [4.00ms]
[4/1238] gen bindgenv2
[5/1238] fetch zlib
[zlib] up to date
[6/1238] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[7/1238] fetch tinycc
[tinycc] up to date
[8/1237] fetch picohttpparser
[picohttpparser] up to date
[9/1237] gen .bind.ts → GeneratedBindings.cpp
[10/1237] install /workspace/bun/src/node-fallbacks
bun install v1.4.0-canary.1 (b58cd4685)

Checked 129 installs across 147 packages (no changes) [21.00ms]
[11/1237] gen ProcessBindingConstants.lut.h
Generating /workspace/bun/build/release/codegen/ProcessBindingConstants.lut.h from 
... (truncated)
diff hotspot
src/runtime/webcore/Request.rs       |  70 ++++++++-
 test/js/web/fetch/body-clone.test.ts |  14 +-
 test/js/web/request/request.test.ts  | 291 +++++++++++++++++++++++++++++++++++
 3 files changed, 361 insertions(+), 14 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                  reads  edits  tests
src/runtime/webcore/Request.rs           11     18      0
test/js/web/fetch/body-clone.test.ts      1      1      0
test/js/web/request/request.test.ts       1     11      0

When the input Request's structure is not pristine (a subclass instance,
or an instance carrying shadowing own properties), the constructor fell
through to the dictionary path and read url, method, headers, body and
signal through JS-visible getters. The fetch spec says to copy the
input's request state directly, and Node does the same; only the init
argument has dictionary (getter) semantics.

Also throw the spec's TypeError when the input's body is already
disturbed or locked and init supplies no replacement body.
@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

Request construction

Layer / File(s) Summary
Input classification and body handling
src/runtime/webcore/Request.rs, test/js/web/request/request.test.ts, test/js/web/fetch/body-clone.test.ts
Request distinguishes input requests from init dictionaries, validates disturbed or locked bodies, and preserves applicable empty bodies.
Internal state copying and init precedence
src/runtime/webcore/Request.rs, test/js/web/request/request.test.ts
Internal URL, signal, headers, and body state is copied without getter fallbacks. Explicit init values override copied state. Dictionary getter behavior remains supported.

Possibly related PRs

Suggested reviewers: jarred-sumner

🚥 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 change: copying internal Request state instead of invoking getters.
Description check ✅ Passed The description explains the problem, cause, fix, behavior, scope, and verification results in detail.

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

@github-actions github-actions Bot added the claude label Aug 6, 2026
Comment thread src/runtime/webcore/Request.rs
…te copy

A Bun.serve request's URL is synthesized from the request context on
first access, so the input-position copy has to run ensure_url() before
reading the internal field, as clone_into() already does. Otherwise the
two-argument path fell back to the JS url getter.
Comment thread src/runtime/webcore/Request.rs Outdated
Comment thread src/runtime/webcore/Request.rs Outdated
Comment thread src/runtime/webcore/Request.rs Outdated
Comment thread src/runtime/webcore/Request.rs Outdated
Comment thread src/runtime/webcore/Request.rs Outdated
Comment thread src/runtime/webcore/Request.rs
Comment thread src/runtime/webcore/Request.rs
Comment thread src/runtime/webcore/Request.rs Outdated
Comment thread src/runtime/webcore/Request.rs
An empty-string body extracts to BodyValue::Empty, which is a non-null
body. The input-position field marking suppressed the dictionary
fallback that used to materialize it, so the copy's body became null.
Clone Empty for input copies; init entries keep the fallback.

Also pin the used-input + GET-method init precedence with a test.
Comment thread src/runtime/webcore/Request.rs
@robobun

robobun commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 4:52 AM PT - Aug 6th, 2026

@robobun, your commit 0855f3fa229ff6cd417d7cb09fa09539fa93f192 passed in Build #89591! 🎉


🧪   To try this PR locally:

bunx bun-pr 37033

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

bun-37033 --bun

Comment thread src/runtime/webcore/Request.rs Outdated
The unusable-input check already treats a null init body as absent, per
the spec's initBody-is-null reading. Apply the same predicate to the
body copy so new Request(input, { body: null }) carries the input's
body like Node does, instead of throwing about a body it then drops.
Comment thread src/runtime/webcore/Request.rs

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

Both earlier findings are addressed — ensure_url() before the internal read (7f843ef) and the shared input_body_applies predicate so the throw check and body-copy gate agree on {body: null} (d7b44a9) — and I found nothing new on this revision. Deferring to a maintainer for the user-visible compat changes: new Request(usedInput) now throws, and {body: null} now copies the input body.

What was reviewed:

  • Traced is_input_argument/input_body_applies through single-arg, two-arg, subclass, own-property, and Request-as-init paths; the disturbed-body check runs before the clone_into fast path so the one-arg form is covered too.
  • Verified body_stream_check returns true for Value::Used and locked streams, and false for fresh string/blob bodies, so the throw matches the tests.
  • The repurposed body-clone.test.ts case still reaches the readableStreamTee bridge via the init-position Request, which keeps as_direct and still tees a locked stream.
Extended reasoning...

Overview

The PR changes Request::construct_into in src/runtime/webcore/Request.rs so a Request passed as the input (first) argument is copied from internal state via an inherits-based cast (as_) instead of the pristine-structure as_direct, adds the fetch spec's unusable-input throw, and fixes two body-copy edge cases (Empty bodies and init.body: null). It adds ~230 lines of tests to request.test.ts and repurposes one body-clone.test.ts test whose old form is now covered by the new throw.

Prior review follow-up

I raised two issues on earlier revisions; both are fixed with dedicated tests:

  • The two-arg path now calls request.ensure_url() before reading the internal URL, mirroring clone_into, so a Bun.serve request's lazily-materialized URL is captured (test: "a Bun.serve request's lazily materialized url is copied").
  • The disturbed-input check and the body-copy gate now share one input_body_applies predicate, so new Request(freshInput, {body: null}) copies the input body and new Request(usedInput, {body: null}) throws about a body it would actually use (test: "init body: null contributes no body").

I re-traced the loop on d7b44a9 for the single-arg (values_to_try.len()==1 → disturbed check → clone_into), two-arg init-first, and new Request(url, requestAsInit) (is_first_argument_a_url keeps is_input_argument false, so init-position Requests retain as_direct dictionary semantics) shapes and did not find further gaps.

Security risks

None identified. The change narrows what user JS can observe/influence during construction (getters on the input are no longer consulted), which is strictly a reduction in attacker-controlled surface.

Level of scrutiny

Moderate-to-high. Request construction is a hot, pervasively-used Web API path; the change is compat-sensitive (new throw for used/locked input bodies; {body: null} now copies input body). Both are spec- and Node-aligned, but they are user-visible behavior changes that a maintainer should consciously sign off on. The pinned test documenting the GET/HEAD-body precedence divergence from Node is another deliberate compat call.

Other factors

Test coverage is thorough (11 new tests spanning own-property/subclass/one-arg/two-arg/Bun.serve/empty-body/used/locked/init-position). The one open inline thread is a comment-cop lint the author declined; the comments in question are short spec notes, not workaround justifications. Not approving because the behavior changes and the intricacy of the values_to_try bookkeeping warrant a human maintainer's sign-off, but I have no outstanding correctness concerns.

A Bun.serve request kept past a synchronous response has an empty
internal url and a torn-down request context, so ensure_url() cannot
materialize it. Leaving Fields::Url unset let the dictionary fallback
run the input's url getter. Mark the field consumed unconditionally so
the empty url reaches the existing "url is required" throw instead,
the same outcome the unshadowed case already had.

Also cover two review gaps with tests: redirect/cache/mode copies
asserted with non-default values, and init body "" (a real empty
body) winning over the input's body where null does not.
Comment thread src/runtime/webcore/Request.rs Outdated

@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 `@src/runtime/webcore/Request.rs`:
- Line 1238: Update the request construction flow around ensure_url() to
propagate its AllocError instead of discarding it, routing the allocation
failure through the designated OOM handler. Preserve normal URL materialization
and valid server-request construction when ensure_url() succeeds.
🪄 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: e093d765-38cf-4b33-8536-0952546ef277

📥 Commits

Reviewing files that changed from the base of the PR and between 80e6dba and 5d01801.

📒 Files selected for processing (3)
  • src/runtime/webcore/Request.rs
  • test/js/web/fetch/body-clone.test.ts
  • test/js/web/request/request.test.ts

Comment thread src/runtime/webcore/Request.rs Outdated
Discarding the AllocError turned an allocation failure into a
misleading "url is required" TypeError. Crash controlledly instead,
in both the constructor's input copy and clone_into.
Comment thread src/runtime/webcore/Request.rs

@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 issues found in this pass; both earlier findings (lazy Bun.serve URL and the body: null copy/throw asymmetry) are addressed, as is the ensure_url OOM routing. Deferring to a human because this changes user-visible Request constructor semantics — new TypeError on disturbed/locked input, and {body: null} now copying the input body — which is compat-sensitive enough to want eyes on.

What was reviewed:

  • is_input_argument derivation against every values_to_try layout (URL/non-URL first arg, one/two-arg) — the last-index predicate is correct in each.
  • input_body_applies gating for body absent / undefined / null / "" / real value — the throw and copy sites now share one predicate; the Empty-body match arm keeps init-position behavior unchanged.
  • The rewritten body-clone.test.ts case still reaches the tee (init-position pristine Request → clone_intoclone_body_value_via_cached_stream), so the C++ tee exception path stays covered.
Extended reasoning...

Overview

Changes Request::construct_into in src/runtime/webcore/Request.rs so that when the first constructor argument is a Request (including subclasses / instances with own properties), all fields are copied from internal state rather than via JS getters, matching the fetch spec and Node/undici. Adds the spec's unusable-input check (throws TypeError when the input body is disturbed or locked and init contributes no body). Fixes {body: null} to fall through to the input body instead of dropping to null. Also routes ensure_url() allocation failure through unwrap_or_oom() at both call sites. ~70 net Rust lines plus ~290 lines of new tests in test/js/web/request/request.test.ts, and a small rewrite of one body-clone.test.ts case whose original path now throws before reaching the tee.

Security risks

None identified. The change narrows attack surface slightly by no longer invoking user-controlled getters on the input Request during construction. No new untrusted-input parsing, no auth/crypto/permissions surface.

Level of scrutiny

High. This is core Web API surface (new Request(...)) with observable behavioral changes: (1) code that previously silently constructed from a used/locked input now throws; (2) new Request(input, {body: null}) now carries the input body where it previously produced a null body; (3) subclass/shadowed getters on the input are no longer consulted. All three align with Node v26 and the fetch spec, and the PR's evidence shows the new tests fail on main and pass on the branch (ASAN + release), but any change in throw behavior on a hot constructor is compat-sensitive.

Other factors

I traced the values_to_try layout against is_input_argument = !is_first_argument_a_url && index == len-1: for a non-URL first arg the input is always the last slot (len 1 → index 0; len 2 → index 1 with init at 0), and for a URL first arg the flag is always false — so a Request passed as init correctly keeps as_direct / dictionary semantics. The new disturbed check reuses the existing body_stream_check(|s,g| s.is_disturbed(g) || s.is_locked(g)) shape from throw_if_body_unusable, so no new JS-reentrancy pattern is introduced. The Empty-body match arm's if !is_input_argument guard preserves pre-existing init-position behavior while letting the input path copy an empty-string body as non-null. Both of my earlier inline findings (missing ensure_url() on the two-arg input path; the throw-vs-copy predicate mismatch for {body: null}) were fixed with follow-up commits and regression tests, and the CodeRabbit OOM-routing note was applied at both ensure_url sites. Given the scope — a spec-driven behavioral change to Request construction — a maintainer should sign off.

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