Skip to content

Response.clone() causes inconsistent body.locked state when body was accessed before cloning #25478

@L-Sun

Description

@L-Sun

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:

  1. When response.body is accessed, the ReadableStream is cached in the JS layer via js.gc.stream.set
  2. During clone(), the code retrieves this cached stream from JS and performs a tee operation on it
  3. The tee operation locks the original JS stream object and creates two new streams
  4. However, existing JS references to the old stream are not updated, so they still point to the locked stream
  5. The new unlocked streams are stored internally, but the JS-level body property 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.body before cloning
  • Expects to use both the original and cloned response bodies
  • Checks the locked state of streams

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions