Compare commits

...

3 Commits

Author SHA1 Message Date
Ciro Spaciari
c7062c1dce Merge branch 'main' into claude/fix-bun-write-response-gc 2026-01-26 11:18:31 -08:00
Jarred Sumner
4e4b0b05f7 Merge branch 'main' into claude/fix-bun-write-response-gc 2026-01-23 19:04:45 -08:00
Claude Bot
859ceb74a9 fix(fetch): prevent silent failure of Bun.write with Response body
When `Bun.write(file, Response)` is called with a Response whose body is
still being downloaded (locked state), the body's `onReceiveValue` and
`task` fields are set to receive the data. However, if the Response JS
object gets garbage collected before the body finishes downloading, the
finalizer only checked for `promise` being set before deciding to ignore
the remaining body.

Since `Bun.write` sets `onReceiveValue`/`task` but not `promise`, the
finalizer incorrectly called `ignoreRemainingResponseBody()`, which set
`ignore_data = true` and unreffed the poll_ref, allowing the event loop
to exit prematurely with code 0 without completing the write.

This fix adds a check for `onReceiveValue` and `task` being set before
ignoring the body.

Fixes #26395

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 14:28:07 +00:00
2 changed files with 95 additions and 3 deletions

View File

@@ -946,13 +946,15 @@ pub const FetchTasklet = struct {
log("onResponseFinalize", .{});
if (this.native_response) |response| {
const body = response.getBodyValue();
// Three scenarios:
// Four scenarios:
//
// 1. We are streaming, in which case we should not ignore the body.
// 2. We were buffering, in which case
// 2a. if we have no promise, we should ignore the body.
// 2b. if we have a promise, we should keep loading the body.
// 3. We never started buffering, in which case we should ignore the body.
// 4. Someone is waiting via onReceiveValue callback (e.g., Bun.write),
// in which case we should keep loading the body.
//
// Note: We cannot call .get() on the ReadableStreamRef. This is called inside a finalizer.
if (body.* != .Locked or this.readable_stream_ref.held.has()) {
@@ -960,11 +962,16 @@ pub const FetchTasklet = struct {
return;
}
if (body.Locked.promise) |promise| {
const locked = &body.Locked;
if (locked.promise) |promise| {
if (promise.isEmptyOrUndefinedOrNull()) {
// Scenario 2b.
// Scenario 2a.
this.ignoreRemainingResponseBody();
}
// Scenario 2b - promise is set and valid, keep loading.
} else if (locked.onReceiveValue != null or locked.task != null) {
// Scenario 4 - someone is waiting via callback (e.g., Bun.write).
return;
} else {
// Scenario 3.
this.ignoreRemainingResponseBody();

View File

@@ -0,0 +1,85 @@
import { expect, test } from "bun:test";
import { bunEnv, bunExe, tempDir } from "harness";
// Regression test for https://github.com/oven-sh/bun/issues/26395
// Bun.write(file, Response) fails silently when the Response body is still being downloaded.
// The issue was that when the Response JS object gets garbage collected before the body finishes
// downloading, the finalizer would incorrectly ignore the remaining body because it only checked
// for `promise` being set, but Bun.write sets `onReceiveValue` instead.
test("Bun.write should complete when writing a Response with a locked body", async () => {
using dir = tempDir("issue-26395", {
"test.js": `
const server = Bun.serve({
port: 0,
fetch(req) {
// Create a response with a streaming body that takes time to complete
const stream = new ReadableStream({
async start(controller) {
// Send data in chunks with small delays to simulate a slow download
for (let i = 0; i < 10; i++) {
controller.enqueue(new TextEncoder().encode("chunk" + i + "\\n"));
await Bun.sleep(20);
}
controller.close();
}
});
return new Response(stream, {
headers: { "Content-Type": "text/plain" }
});
}
});
const outputPath = Bun.argv[2];
async function triggerBug() {
const res = await fetch(server.url);
// Start the write operation (sets onReceiveValue on the Response body)
// but don't await it yet - the response body is now "locked"
const writePromise = Bun.write(outputPath, res);
// Force GC aggressively to try to collect the Response object
// while the body is still being downloaded
for (let i = 0; i < 10; i++) {
Bun.gc(true);
await Bun.sleep(10);
}
return writePromise;
}
await triggerBug();
// Verify the file was written completely
const content = await Bun.file(outputPath).text();
const expectedContent = Array.from({ length: 10 }, (_, i) => "chunk" + i + "\\n").join("");
if (content !== expectedContent) {
console.error("FAIL: Content mismatch");
console.error("Expected:", JSON.stringify(expectedContent));
console.error("Got:", JSON.stringify(content));
process.exit(1);
}
console.log("SUCCESS");
server.stop();
`,
});
const outputPath = `${dir}/downloaded.txt`;
await using proc = Bun.spawn({
cmd: [bunExe(), "test.js", outputPath],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).toBe("");
expect(stdout.trim()).toBe("SUCCESS");
expect(exitCode).toBe(0);
});