webcore: copy internal state in new Request(request) instead of calling getters
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 withRequest.clone()and is not changed here - an
Emptyinput 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, ornull), 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 asinit, the remaining path that still tees a locked stream; the input-position form it used before is covered by the new constructor testsrequest-subclass.test.ts(fetch()-getter behavior from #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