-
Notifications
You must be signed in to change notification settings - Fork 3.8k
Description
What version of Bun is running?
1.3.4
What platform is your computer?
Darwin 25.1.0 arm64 arm
What steps can reproduce the bug?
const readableStream = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode("Hello, world!"));
},
});
const response = new Response(readableStream);
console.log(response.body?.locked); // Accessing body before clone
const cloned = response.clone();
console.log(response.body?.locked); // Bug: becomes true
console.log(cloned.body?.locked);Save this as test.ts and run with bun test.ts.
What is the expected behavior?
Both response.body.locked and cloned.body.locked should be false after cloning. According to the Fetch API specification, cloning a Response should not lock the original response's body.
Expected output:
false
false
false
What do you see instead?
Actual output:
false
true ← Bug: original response body becomes locked
false
The original response's body becomes locked after calling clone(), but only when response.body was accessed before cloning.
Workaround: If the first console.log(response.body?.locked) is commented out (i.e., body is never accessed before cloning), both behave correctly and output false, false.
Additional information
Note: The following is my analysis of the potential root cause. It may not be entirely accurate, but hopefully provides a useful reference for investigation.
Possible Root Cause: This regression appears to have been introduced in PR #23313.
The issue occurs in the interaction between cloneValue, tee, and checkBodyStreamRef functions in Response.zig:
- When
response.bodyis accessed, the ReadableStream is cached in the JS layer viajs.gc.stream.set - During
clone(), the code retrieves this cached stream from JS and performs a tee operation on it - The tee operation locks the original JS stream object and creates two new streams
- However, existing JS references to the old stream are not updated, so they still point to the locked stream
- The new unlocked streams are stored internally, but the JS-level
bodyproperty still references the old locked stream
Relevant code in Response.zig::cloneValue:
var body = brk: {
if (this.#js_ref.tryGet()) |js_ref| {
if (js.gc.stream.get(js_ref)) |stream| { // ← Get cached JS stream
var readable = try jsc.WebCore.ReadableStream.fromJS(stream, globalThis);
if (readable != null) {
break :brk try this.#body.cloneWithReadableStream(globalThis, &readable.?);
// ← This tees the cached stream, locking it
}
}
}
break :brk try this.#body.clone(globalThis);
};After teeing, the cached JS stream reference becomes locked, but this stale reference is still returned when accessing response.body from JavaScript.
Impact: This breaks the Fetch API specification compliance and affects any code that:
- Accesses
response.bodybefore cloning - Expects to use both the original and cloned response bodies
- Checks the
lockedstate of streams