From 6c66f3a85cda2883e80df7acc73a8875c797954b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 02:43:38 +0000 Subject: [PATCH 01/13] Bun.write(path, fetch()): opt into BufferAll so a streaming body can complete The receive-backpressure change in #29831 made fetch pause the transport after the first body chunk unless a consumer calls on_start_buffering. Bun.write's file-destination Locked arm registered on_receive_value and overwrote locked.task without ever signalling the producer, so the transport parked at ~128 KB and resolve() never fired. The awaited promise hung forever and no file was created. Call on_start_buffering with the original producer task before replacing locked.task, mirroring ValueBufferer. --- src/runtime/webcore/Blob.rs | 7 +++++++ test/js/bun/io/bun-write.test.js | 16 ++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/src/runtime/webcore/Blob.rs b/src/runtime/webcore/Blob.rs index ec3d76a55648..ec8f689ba84e 100644 --- a/src/runtime/webcore/Blob.rs +++ b/src/runtime/webcore/Blob.rs @@ -5244,6 +5244,13 @@ pub fn write_file_internal( let BodyValue::Locked(locked) = (unsafe { &mut *body_value }) else { unreachable!() }; + // Tell the producer to buffer the whole body before `task` is + // repurposed; otherwise a paused fetch never reaches `resolve()`. + if let (Some(on_start_buffering), Some(producer_task)) = + (locked.on_start_buffering.take(), locked.task) + { + on_start_buffering(producer_task); + } locked.task = Some(task.cast::()); locked.on_receive_value = Some(WriteFileWaitFromLockedValueTask::then_wrap); // SAFETY: `task` was just heap-allocated; consumed in `then_wrap`. diff --git a/test/js/bun/io/bun-write.test.js b/test/js/bun/io/bun-write.test.js index a4c3fb2f551b..7b5d5825b006 100644 --- a/test/js/bun/io/bun-write.test.js +++ b/test/js/bun/io/bun-write.test.js @@ -367,6 +367,22 @@ const IS_UV_FS_COPYFILE_DISABLED = await gcTick(); }); + it("Bun.write(path, fetch()) with a streaming body", async () => { + using tmpbase = tempDir("bun-write-fetch-streaming", {}); + const out = join(String(tmpbase), "dl.out"); + const body = Buffer.alloc(1_000_000, "x").toString(); + await using server = Bun.serve({ + port: 0, + fetch: () => new Response(body), + }); + const resp = await fetch(server.url); + expect(resp.status).toBe(200); + const written = await Bun.write(out, resp); + expect(written).toBe(body.length); + expect((await Bun.file(out).bytes()).length).toBe(body.length); + expect(await Bun.file(out).text()).toBe(body); + }); + it("Response -> Bun.file -> Response -> text", async () => { await gcTick(); const file = path.join(import.meta.dir, "fetch.js.txt"); From af8ceba491a4f06152a9bc14118c45cd8aea0938 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 03:27:18 +0000 Subject: [PATCH 02/13] Guard on_start_buffering on on_start_streaming still being present The unguarded call dereferences a freed FetchTasklet when the body's ByteStream has already been materialised (e.g. after resp.clone().text()): once on_start_streaming is taken the producer delivers via the ByteStream, may have already dropped its last ref, and check_body_stream_ref can move the readable out of locked.readable, so neither locked.readable nor locked.task is a safe witness. on_start_streaming.is_some() is: it is take()'d exactly when the ByteStream is created, and while it is present the producer is guaranteed live and will resolve() this body. --- src/runtime/webcore/Blob.rs | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/src/runtime/webcore/Blob.rs b/src/runtime/webcore/Blob.rs index ec8f689ba84e..110c6c30b324 100644 --- a/src/runtime/webcore/Blob.rs +++ b/src/runtime/webcore/Blob.rs @@ -5244,12 +5244,21 @@ pub fn write_file_internal( let BodyValue::Locked(locked) = (unsafe { &mut *body_value }) else { unreachable!() }; - // Tell the producer to buffer the whole body before `task` is - // repurposed; otherwise a paused fetch never reaches `resolve()`. - if let (Some(on_start_buffering), Some(producer_task)) = - (locked.on_start_buffering.take(), locked.task) - { - on_start_buffering(producer_task); + // Opt the producer into BufferAll before `task` is repurposed + // so a paused fetch (#29831) resumes and eventually `resolve()`s. + // Only valid while `on_start_streaming` hasn't been taken: + // once the ByteStream is materialised the producer delivers + // through it (never via `resolve()`), may already have dropped + // its ref, and `check_body_stream_ref` can have moved the + // readable out of `locked.readable`, so neither that slot nor + // `locked.task` is a reliable witness here. The + // materialised-stream case is the pre-existing #13237 hang. + if locked.on_start_streaming.is_some() { + if let (Some(on_start_buffering), Some(producer_task)) = + (locked.on_start_buffering.take(), locked.task) + { + on_start_buffering(producer_task); + } } locked.task = Some(task.cast::()); locked.on_receive_value = Some(WriteFileWaitFromLockedValueTask::then_wrap); From ce75fb70bc2919576cdea6099048550ba35edf9f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 03:30:34 +0000 Subject: [PATCH 03/13] Trim comment per comment-cop --- src/runtime/webcore/Blob.rs | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/src/runtime/webcore/Blob.rs b/src/runtime/webcore/Blob.rs index 110c6c30b324..9d11da8faf9e 100644 --- a/src/runtime/webcore/Blob.rs +++ b/src/runtime/webcore/Blob.rs @@ -5244,15 +5244,9 @@ pub fn write_file_internal( let BodyValue::Locked(locked) = (unsafe { &mut *body_value }) else { unreachable!() }; - // Opt the producer into BufferAll before `task` is repurposed - // so a paused fetch (#29831) resumes and eventually `resolve()`s. - // Only valid while `on_start_streaming` hasn't been taken: - // once the ByteStream is materialised the producer delivers - // through it (never via `resolve()`), may already have dropped - // its ref, and `check_body_stream_ref` can have moved the - // readable out of `locked.readable`, so neither that slot nor - // `locked.task` is a reliable witness here. The - // materialised-stream case is the pre-existing #13237 hang. + // Opt the producer into BufferAll before `task` is repurposed so a + // paused fetch reaches `resolve()`. `on_start_streaming` still present + // is the witness that `task` is the live producer and not stale. if locked.on_start_streaming.is_some() { if let (Some(on_start_buffering), Some(producer_task)) = (locked.on_start_buffering.take(), locked.task) From e14ba81300e151a9adba78346516bfd54e8592e1 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 03:31:08 +0000 Subject: [PATCH 04/13] Drop inline comment; rationale is in the commit message --- src/runtime/webcore/Blob.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/runtime/webcore/Blob.rs b/src/runtime/webcore/Blob.rs index 9d11da8faf9e..805502f5a7b0 100644 --- a/src/runtime/webcore/Blob.rs +++ b/src/runtime/webcore/Blob.rs @@ -5244,9 +5244,6 @@ pub fn write_file_internal( let BodyValue::Locked(locked) = (unsafe { &mut *body_value }) else { unreachable!() }; - // Opt the producer into BufferAll before `task` is repurposed so a - // paused fetch reaches `resolve()`. `on_start_streaming` still present - // is the witness that `task` is the live producer and not stale. if locked.on_start_streaming.is_some() { if let (Some(on_start_buffering), Some(producer_task)) = (locked.on_start_buffering.take(), locked.task) From 73d2c5f66a1569aa6635d3c9cdc986b446d8be7f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 04:04:03 +0000 Subject: [PATCH 05/13] Clear remaining producer hooks before repurposing locked.task After locked.task is overwritten with the WriteFileWaitFromLockedValueTask pointer, on_start_streaming / on_readable_stream_available / on_stream_cancelled / on_stream_drained still pointed at FetchTasklet callbacks. A subsequent resp.body or resp.clone() reached to_readable_stream / tee and invoked them with the wrong pointer type (ASAN: heap-buffer-overflow in on_start_streaming_http_response_body_callback). Pre-existing on main; clearing them here degrades that case to the known issue 13237 hang instead of UB. Covered by an ASAN-gated test. --- src/runtime/webcore/Blob.rs | 4 ++++ test/js/bun/io/bun-write.test.js | 18 ++++++++++++++++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/runtime/webcore/Blob.rs b/src/runtime/webcore/Blob.rs index 805502f5a7b0..dfa270f1e3b9 100644 --- a/src/runtime/webcore/Blob.rs +++ b/src/runtime/webcore/Blob.rs @@ -5251,6 +5251,10 @@ pub fn write_file_internal( on_start_buffering(producer_task); } } + locked.on_start_streaming = None; + locked.on_readable_stream_available = None; + locked.on_stream_cancelled = None; + locked.on_stream_drained = None; locked.task = Some(task.cast::()); locked.on_receive_value = Some(WriteFileWaitFromLockedValueTask::then_wrap); // SAFETY: `task` was just heap-allocated; consumed in `then_wrap`. diff --git a/test/js/bun/io/bun-write.test.js b/test/js/bun/io/bun-write.test.js index 7b5d5825b006..64f02b8f08e2 100644 --- a/test/js/bun/io/bun-write.test.js +++ b/test/js/bun/io/bun-write.test.js @@ -1,6 +1,6 @@ import { describe, expect, it, test } from "bun:test"; import fs, { mkdirSync } from "fs"; -import { bunEnv, bunExe, exampleHtml, exampleSite, gcTick, isWindows, tempDir, withoutAggressiveGC } from "harness"; +import { bunEnv, bunExe, exampleHtml, exampleSite, gcTick, isASAN, isWindows, tempDir, withoutAggressiveGC } from "harness"; import path, { join } from "path"; let i = 0; @@ -370,7 +370,7 @@ const IS_UV_FS_COPYFILE_DISABLED = it("Bun.write(path, fetch()) with a streaming body", async () => { using tmpbase = tempDir("bun-write-fetch-streaming", {}); const out = join(String(tmpbase), "dl.out"); - const body = Buffer.alloc(1_000_000, "x").toString(); + const body = Buffer.alloc(300_000, "x").toString(); await using server = Bun.serve({ port: 0, fetch: () => new Response(body), @@ -383,6 +383,20 @@ const IS_UV_FS_COPYFILE_DISABLED = expect(await Bun.file(out).text()).toBe(body); }); + it.skipIf(!isASAN)("Bun.write(path, fetch()) then resp.body does not crash", async () => { + using tmpbase = tempDir("bun-write-fetch-then-body", {}); + const out = join(String(tmpbase), "dl.out"); + await using server = Bun.serve({ + port: 0, + fetch: () => new Response(Buffer.alloc(300_000, "y")), + }); + const resp = await fetch(server.url); + const p = Bun.write(out, resp); + expect(resp.body).toBeInstanceOf(ReadableStream); + expect(() => resp.clone()).not.toThrow(); + await Promise.race([p, Bun.sleep(1)]); + }); + it("Response -> Bun.file -> Response -> text", async () => { await gcTick(); const file = path.join(import.meta.dir, "fetch.js.txt"); From 88a884d86ad1368677b334fcba2c190480dcd5b4 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 25 Jul 2026 04:06:22 +0000 Subject: [PATCH 06/13] [autofix.ci] apply automated fixes --- test/js/bun/io/bun-write.test.js | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/test/js/bun/io/bun-write.test.js b/test/js/bun/io/bun-write.test.js index 64f02b8f08e2..3d915f756d02 100644 --- a/test/js/bun/io/bun-write.test.js +++ b/test/js/bun/io/bun-write.test.js @@ -1,6 +1,16 @@ import { describe, expect, it, test } from "bun:test"; import fs, { mkdirSync } from "fs"; -import { bunEnv, bunExe, exampleHtml, exampleSite, gcTick, isASAN, isWindows, tempDir, withoutAggressiveGC } from "harness"; +import { + bunEnv, + bunExe, + exampleHtml, + exampleSite, + gcTick, + isASAN, + isWindows, + tempDir, + withoutAggressiveGC, +} from "harness"; import path, { join } from "path"; let i = 0; From 032cf77852afd497283fe05bb752d19a01128e05 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 07:28:29 +0000 Subject: [PATCH 07/13] on_response_finalize: treat on_receive_value as a live consumer When the Response JS wrapper is collected while the body is still Locked, on_response_finalize decides whether anything is waiting for it. It checked locked.promise but not locked.on_receive_value, so Bun.write's WriteFileWaitFromLockedValueTask (which registers on_receive_value without setting locked.promise) was treated as 'never started buffering': the body was dropped via ignore_remaining_response_body and the Bun.write promise never settled. This surfaced under BUN_GARBAGE_COLLECTOR_LEVEL=1 with describe.concurrent, where resp becomes unreachable after Bun.write(out, resp) returns and is collected before the body completes. resp.arrayBuffer() was unaffected because set_promise sets locked.promise. Also clear on_start_buffering unconditionally alongside the other four producer hooks, completing the set. --- src/runtime/webcore/Blob.rs | 1 + src/runtime/webcore/fetch/FetchTasklet.rs | 7 ++++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/runtime/webcore/Blob.rs b/src/runtime/webcore/Blob.rs index dfa270f1e3b9..ad1c4188741e 100644 --- a/src/runtime/webcore/Blob.rs +++ b/src/runtime/webcore/Blob.rs @@ -5251,6 +5251,7 @@ pub fn write_file_internal( on_start_buffering(producer_task); } } + locked.on_start_buffering = None; locked.on_start_streaming = None; locked.on_readable_stream_available = None; locked.on_stream_cancelled = None; diff --git a/src/runtime/webcore/fetch/FetchTasklet.rs b/src/runtime/webcore/fetch/FetchTasklet.rs index 9bff5a530a83..d0b86cbf508a 100644 --- a/src/runtime/webcore/fetch/FetchTasklet.rs +++ b/src/runtime/webcore/fetch/FetchTasklet.rs @@ -2528,9 +2528,14 @@ impl FetchTasklet { } if let BodyValue::Locked(locked) = body { + if locked.on_receive_value.is_some() { + // Scenario 2b: a native consumer (Bun.write / ValueBufferer) + // is awaiting resolve() via on_receive_value. + return; + } if let Some(promise) = locked.promise { if promise.is_empty_or_undefined_or_null() { - // Scenario 2b. + // Scenario 2a. this.ignore_remaining_response_body(true); } } else { From 3176c838a34d775819a6049392882f36bea021c1 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 07:43:01 +0000 Subject: [PATCH 08/13] Trim scenario comment to match surrounding style --- src/runtime/webcore/fetch/FetchTasklet.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/runtime/webcore/fetch/FetchTasklet.rs b/src/runtime/webcore/fetch/FetchTasklet.rs index d0b86cbf508a..da24697b7a97 100644 --- a/src/runtime/webcore/fetch/FetchTasklet.rs +++ b/src/runtime/webcore/fetch/FetchTasklet.rs @@ -2529,8 +2529,7 @@ impl FetchTasklet { if let BodyValue::Locked(locked) = body { if locked.on_receive_value.is_some() { - // Scenario 2b: a native consumer (Bun.write / ValueBufferer) - // is awaiting resolve() via on_receive_value. + // Scenario 2b. return; } if let Some(promise) = locked.promise { From f58cdfec7f3009b13a77790c043091e717737c35 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 08:50:26 +0000 Subject: [PATCH 09/13] Run the resp.body-after-Bun.write ASAN repro in a subprocess Isolates the crash from the concurrent describe block so a regression shows up as a single attributable failure, and the hung write promise is cleaned up by process exit. --- test/js/bun/io/bun-write.test.js | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/test/js/bun/io/bun-write.test.js b/test/js/bun/io/bun-write.test.js index 3d915f756d02..17aa34b5ee65 100644 --- a/test/js/bun/io/bun-write.test.js +++ b/test/js/bun/io/bun-write.test.js @@ -394,17 +394,21 @@ const IS_UV_FS_COPYFILE_DISABLED = }); it.skipIf(!isASAN)("Bun.write(path, fetch()) then resp.body does not crash", async () => { - using tmpbase = tempDir("bun-write-fetch-then-body", {}); - const out = join(String(tmpbase), "dl.out"); - await using server = Bun.serve({ - port: 0, - fetch: () => new Response(Buffer.alloc(300_000, "y")), - }); - const resp = await fetch(server.url); - const p = Bun.write(out, resp); - expect(resp.body).toBeInstanceOf(ReadableStream); - expect(() => resp.clone()).not.toThrow(); - await Promise.race([p, Bun.sleep(1)]); + using dir = tempDir("bun-write-fetch-then-body", {}); + const out = JSON.stringify(join(String(dir), "dl.out")); + const fixture = ` + const server = Bun.serve({ port: 0, fetch: () => new Response(Buffer.alloc(300_000, "y")) }); + const resp = await fetch(server.url); + const p = Bun.write(${out}, resp); + if (!(resp.body instanceof ReadableStream)) throw new Error("expected ReadableStream"); + resp.clone(); + await Promise.race([p, Bun.sleep(1)]); + console.log("OK"); + process.exit(0); + `; + await using proc = Bun.spawn({ cmd: [bunExe(), "-e", fixture], env: bunEnv, stderr: "pipe" }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout: stdout.trim(), stderr, exitCode }).toEqual({ stdout: "OK", stderr: "", exitCode: 0 }); }); it("Response -> Bun.file -> Response -> text", async () => { From e4a16b1acad057208c4af28052011f066d8111c3 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 20:52:49 +0000 Subject: [PATCH 10/13] Extract take_over_as_buffering_consumer and mirror in then() re-register arm Introduce PendingValue::take_over_as_buffering_consumer(), which performs the on_start_buffering handshake with the producer's own task pointer (matching set_promise / ValueBufferer) and clears the five producer hooks. Call it from both the write_file_internal Locked arm and the WriteFileWaitFromLockedValueTask::then() Locked re-register arm. Expand the test coverage into a five-case acceptance suite: Content-Length body settles, chunked body settles, abort mid-transfer rejects with AbortError, abort mid-transfer rejects with the signal's TimeoutError reason, and abort after a fully received body still settles. The abort cases force GC on the Response wrapper before aborting so the on_response_finalize path is exercised deterministically instead of depending on timing. --- src/runtime/webcore/Blob.rs | 13 +--- src/runtime/webcore/Body.rs | 19 +++++ src/runtime/webcore/blob/write_file.rs | 1 + test/js/bun/io/bun-write.test.js | 96 ++++++++++++++++++++++---- 4 files changed, 104 insertions(+), 25 deletions(-) diff --git a/src/runtime/webcore/Blob.rs b/src/runtime/webcore/Blob.rs index ad1c4188741e..c6c1568ad855 100644 --- a/src/runtime/webcore/Blob.rs +++ b/src/runtime/webcore/Blob.rs @@ -5244,18 +5244,7 @@ pub fn write_file_internal( let BodyValue::Locked(locked) = (unsafe { &mut *body_value }) else { unreachable!() }; - if locked.on_start_streaming.is_some() { - if let (Some(on_start_buffering), Some(producer_task)) = - (locked.on_start_buffering.take(), locked.task) - { - on_start_buffering(producer_task); - } - } - locked.on_start_buffering = None; - locked.on_start_streaming = None; - locked.on_readable_stream_available = None; - locked.on_stream_cancelled = None; - locked.on_stream_drained = None; + locked.take_over_as_buffering_consumer(); locked.task = Some(task.cast::()); locked.on_receive_value = Some(WriteFileWaitFromLockedValueTask::then_wrap); // SAFETY: `task` was just heap-allocated; consumed in `then_wrap`. diff --git a/src/runtime/webcore/Body.rs b/src/runtime/webcore/Body.rs index d2402bc94dd5..0c176a9aa404 100644 --- a/src/runtime/webcore/Body.rs +++ b/src/runtime/webcore/Body.rs @@ -295,6 +295,25 @@ impl PendingValue { bun_opaque::opaque_deref(self.global) } + /// A consumer that wants the complete body (not a stream) is about to + /// overwrite `task`/`on_receive_value`. Fire the producer's + /// `on_start_buffering` handshake with the producer's own `task` so it + /// switches to `BufferAll` (matching `set_promise` / `ValueBufferer`), + /// then clear the remaining producer hooks so they can't be invoked + /// against the consumer's `task` after it's been repurposed. + pub(crate) fn take_over_as_buffering_consumer(&mut self) { + if let (Some(on_start_buffering), Some(producer_task)) = + (self.on_start_buffering.take(), self.task) + { + on_start_buffering(producer_task); + } + self.on_start_buffering = None; + self.on_start_streaming = None; + self.on_readable_stream_available = None; + self.on_stream_cancelled = None; + self.on_stream_drained = None; + } + /// For Http Client requests /// when Content-Length is provided this represents the whole size of the request /// If chunked encoded this will represent the total received size (ignoring the chunk headers) diff --git a/src/runtime/webcore/blob/write_file.rs b/src/runtime/webcore/blob/write_file.rs index 1f6795af3729..59b90f3ef73f 100644 --- a/src/runtime/webcore/blob/write_file.rs +++ b/src/runtime/webcore/blob/write_file.rs @@ -1420,6 +1420,7 @@ impl WriteFileWaitFromLockedValueTask { // Re-registering for a future callback — `this` stays alive. // Restore the moved-out blob so the next `then()` has its store. this_ref.file_blob = file_blob; + locked.take_over_as_buffering_consumer(); locked.on_receive_value = Some(Self::then_wrap); locked.task = Some(this.cast::()); } diff --git a/test/js/bun/io/bun-write.test.js b/test/js/bun/io/bun-write.test.js index 17aa34b5ee65..67657213cbdb 100644 --- a/test/js/bun/io/bun-write.test.js +++ b/test/js/bun/io/bun-write.test.js @@ -377,20 +377,90 @@ const IS_UV_FS_COPYFILE_DISABLED = await gcTick(); }); - it("Bun.write(path, fetch()) with a streaming body", async () => { - using tmpbase = tempDir("bun-write-fetch-streaming", {}); - const out = join(String(tmpbase), "dl.out"); - const body = Buffer.alloc(300_000, "x").toString(); - await using server = Bun.serve({ - port: 0, - fetch: () => new Response(body), + describe("Bun.write(path, fetch()) with a streaming body", () => { + it("Content-Length body settles", async () => { + using dir = tempDir("bun-write-fetch-cl", {}); + const out = join(String(dir), "dl.bin"); + const body = Buffer.alloc(500_000, "x"); + await using server = Bun.serve({ port: 0, fetch: () => new Response(body) }); + const res = await fetch(server.url); + expect(res.headers.get("content-length")).toBe(String(body.length)); + const written = await Bun.write(out, res); + expect(written).toBe(body.length); + expect(await Bun.file(out).bytes()).toEqual(new Uint8Array(body)); + }); + + it("chunked body settles", async () => { + using dir = tempDir("bun-write-fetch-chunked", {}); + const out = join(String(dir), "dl.bin"); + const chunk = Buffer.alloc(80_000, "y"); + const chunks = 4; + const { promise: fetched, resolve: markFetched } = Promise.withResolvers(); + await using server = Bun.serve({ + port: 0, + fetch: () => + new Response(async function* () { + yield chunk; + await fetched; + for (let i = 1; i < chunks; i++) yield chunk; + }), + }); + const res = await fetch(server.url); + markFetched(); + expect(res.headers.get("content-length")).toBeNull(); + const written = await Bun.write(out, res); + expect(written).toBe(chunk.length * chunks); + expect((await Bun.file(out).bytes()).length).toBe(chunk.length * chunks); + }); + + it.each([ + ["AbortError", undefined], + ["TimeoutError", new DOMException("The operation timed out.", "TimeoutError")], + ])("rejects with %s when the signal aborts mid-transfer", async (name, reason) => { + using dir = tempDir("bun-write-fetch-abort", {}); + const { promise: gate, resolve: openGate } = Promise.withResolvers(); + await using server = Bun.serve({ + port: 0, + fetch: () => + new Response(async function* () { + yield Buffer.alloc(200_000, "z"); + await gate; + }), + }); + try { + const ac = new AbortController(); + // Scope the Response so it is collectible once Bun.write has + // registered on the body; on an unfixed build the finalizer then drops + // the body and the abort never reaches the write promise. + async function start() { + const res = await fetch(server.url, { signal: ac.signal }); + return { write: Bun.write(join(String(dir), "dl.bin"), res) }; + } + const { write } = await start(); + await gcTick(); + Bun.gc(true); + ac.abort(reason); + let caught; + await write.catch(e => (caught = e)); + expect(caught).toBeInstanceOf(DOMException); + expect(caught.name).toBe(name); + } finally { + openGate(); + } + }); + + it("settles when aborted after the body has been fully received", async () => { + using dir = tempDir("bun-write-fetch-late-abort", {}); + const out = join(String(dir), "dl.bin"); + const body = Buffer.alloc(500_000, "q"); + await using server = Bun.serve({ port: 0, fetch: () => new Response(body) }); + const ac = new AbortController(); + const res = await fetch(server.url, { signal: ac.signal }); + const written = await Bun.write(out, res); + ac.abort(); + expect(written).toBe(body.length); + expect((await Bun.file(out).bytes()).length).toBe(body.length); }); - const resp = await fetch(server.url); - expect(resp.status).toBe(200); - const written = await Bun.write(out, resp); - expect(written).toBe(body.length); - expect((await Bun.file(out).bytes()).length).toBe(body.length); - expect(await Bun.file(out).text()).toBe(body); }); it.skipIf(!isASAN)("Bun.write(path, fetch()) then resp.body does not crash", async () => { From dd6a39868c2da9cfee88f4f3a1c4bbf568337706 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 21:02:05 +0000 Subject: [PATCH 11/13] Trim take_over_as_buffering_consumer doc comment --- src/runtime/webcore/Body.rs | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/runtime/webcore/Body.rs b/src/runtime/webcore/Body.rs index 0c176a9aa404..d0ea81357859 100644 --- a/src/runtime/webcore/Body.rs +++ b/src/runtime/webcore/Body.rs @@ -295,12 +295,6 @@ impl PendingValue { bun_opaque::opaque_deref(self.global) } - /// A consumer that wants the complete body (not a stream) is about to - /// overwrite `task`/`on_receive_value`. Fire the producer's - /// `on_start_buffering` handshake with the producer's own `task` so it - /// switches to `BufferAll` (matching `set_promise` / `ValueBufferer`), - /// then clear the remaining producer hooks so they can't be invoked - /// against the consumer's `task` after it's been repurposed. pub(crate) fn take_over_as_buffering_consumer(&mut self) { if let (Some(on_start_buffering), Some(producer_task)) = (self.on_start_buffering.take(), self.task) From 969afa8ad24252f1a26f2efee7863a78afc8c56d Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 21:17:00 +0000 Subject: [PATCH 12/13] Drop dead on_start_buffering = None store; .take() already clears it --- src/runtime/webcore/Body.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/runtime/webcore/Body.rs b/src/runtime/webcore/Body.rs index d0ea81357859..f358ae31dc55 100644 --- a/src/runtime/webcore/Body.rs +++ b/src/runtime/webcore/Body.rs @@ -301,7 +301,6 @@ impl PendingValue { { on_start_buffering(producer_task); } - self.on_start_buffering = None; self.on_start_streaming = None; self.on_readable_stream_available = None; self.on_stream_cancelled = None; From cb1d02dfa274c30990eae59745bf76e599c39de6 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 23:22:46 +0000 Subject: [PATCH 13/13] ci: retrigger