fix: Response.clone() no longer locks body when body was accessed before clone (#25484)

## Summary
- Fix bug where `Response.clone()` would lock the original response's
body when `response.body` was accessed before cloning
- Apply the same fix to `Request.clone()`

## Root Cause
When `response.body` was accessed before calling `response.clone()`, the
original response's body would become locked after cloning. This
happened because:

1. When the cloned response was wrapped with `toJS()`,
`checkBodyStreamRef()` was called which moved the stream from
`Locked.readable` to `js.gc.stream` and cleared `Locked.readable`
2. The subsequent code tried to get the stream from `Locked.readable`,
which was now empty, so the body cache update was skipped
3. The JavaScript-level body property cache still held the old locked
stream

## Fix
Updated the cache update logic to:
1. For the cloned response: use `js.gc.stream.get()` instead of
`Locked.readable.get()` since `toJS()` already moved the stream
2. For the original response: use `Locked.readable.get()` which still
holds the teed stream since `checkBodyStreamRef` hasn't been called yet

## Reproduction
```javascript
const readableStream = new ReadableStream({
  start(controller) {
    controller.enqueue(new TextEncoder().encode("Hello, world!"));
    controller.close();
  },
});

const response = new Response(readableStream);
console.log(response.body?.locked); // Accessing body before clone
const cloned = response.clone();
console.log(response.body?.locked); // Expected: false, Actual: true 
console.log(cloned.body?.locked);   // Expected: false, Actual: false 
```

## Test plan
- [x] Added regression tests for `Response.clone()` in
`test/js/web/fetch/response.test.ts`
- [x] Added regression test for `Request.clone()` in
`test/js/web/request/request.test.ts`
- [x] Verified tests fail with system bun (before fix) and pass with
debug build (after fix)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Bot <claude-bot@bun.sh>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Jarred Sumner <jarred@jarredsumner.com>
This commit is contained in:
robobun
2025-12-15 18:46:02 -08:00
committed by GitHub
parent aef0b5b4a6
commit 344b2c1dfe
4 changed files with 110 additions and 23 deletions

View File

@@ -49,7 +49,7 @@ describe("2-arg form", () => {
test("print size", () => {
expect(normalizeBunSnapshot(Bun.inspect(new Response(Bun.file(import.meta.filename)))), import.meta.dir)
.toMatchInlineSnapshot(`
"Response (4.15 KB) {
"Response (5.83 KB) {
ok: true,
url: "",
status: 200,
@@ -123,3 +123,56 @@ test("handle stack overflow", () => {
f0(f0);
}).toThrow("Maximum call stack size exceeded.");
});
describe("clone()", () => {
test("does not lock original body when body was accessed before clone", async () => {
const readableStream = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode("Hello, world!"));
controller.close();
},
});
const response = new Response(readableStream);
// Access body before clone (this triggers the bug in the unfixed version)
const bodyBeforeClone = response.body;
expect(bodyBeforeClone?.locked).toBe(false);
const cloned = response.clone();
// Both should be unlocked after clone
expect(response.body?.locked).toBe(false);
expect(cloned.body?.locked).toBe(false);
// Both should be readable
const [originalText, clonedText] = await Promise.all([response.text(), cloned.text()]);
expect(originalText).toBe("Hello, world!");
expect(clonedText).toBe("Hello, world!");
});
test("works when body is not accessed before clone", async () => {
const readableStream = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode("Hello, world!"));
controller.close();
},
});
const response = new Response(readableStream);
// Do NOT access body before clone
const cloned = response.clone();
// Both should be unlocked after clone
expect(response.body?.locked).toBe(false);
expect(cloned.body?.locked).toBe(false);
// Both should be readable
const [originalText, clonedText] = await Promise.all([response.text(), cloned.text()]);
expect(originalText).toBe("Hello, world!");
expect(clonedText).toBe("Hello, world!");
});
});

View File

@@ -47,3 +47,30 @@ test("request can receive null signal", async () => {
expect(await request.text()).toBe("bun");
expect(await clone.text()).toBe("bun");
});
test("clone() does not lock original body when body was accessed before clone", async () => {
const readableStream = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode("Hello, world!"));
controller.close();
},
});
const request = new Request("http://example.com", { method: "POST", body: readableStream });
// Access body before clone (this triggers the bug in the unfixed version)
const bodyBeforeClone = request.body;
expect(bodyBeforeClone?.locked).toBe(false);
const cloned = request.clone();
// Both should be unlocked after clone
expect(request.body?.locked).toBe(false);
expect(cloned.body?.locked).toBe(false);
// Both should be readable
const [originalText, clonedText] = await Promise.all([request.text(), cloned.text()]);
expect(originalText).toBe("Hello, world!");
expect(clonedText).toBe("Hello, world!");
});