Skip to content

fix: Bun.write with new Response(req.body) no longer hangs#28112

Open
robobun wants to merge 1 commit into
mainfrom
claude/fix-bun-write-response-body-hang-v2
Open

fix: Bun.write with new Response(req.body) no longer hangs#28112
robobun wants to merge 1 commit into
mainfrom
claude/fix-bun-write-response-body-hang-v2

Conversation

@robobun

@robobun robobun commented Mar 14, 2026

Copy link
Copy Markdown
Collaborator

What

Bun.write(path, new Response(req.body)) — and any other Bun.write whose source is a Response/Request backed by a ReadableStream — hung forever. The .Locked body path in writeFileInternal only installed an onReceiveValue callback and returned; nothing ever read the stream, so the returned promise never settled.

Repro

const server = Bun.serve({
  port: 0,
  async fetch(req) {
    await Bun.write("out.txt", new Response(req.body)); // hangs
    return new Response("ok");
  },
});
await fetch(server.url, { method: "POST", body: "hello" });

Also hangs:

  • Bun.write(path, new Response(new ReadableStream({...})))
  • Bun.write(path, req) after touching req.body
  • Bun.file(path).write(new Response(stream))

Fix

In Blob.writeFileInternal's .Locked branch for file destinations, if the body already has (or exposes) a ReadableStream, pipe it through pipeReadableStreamToBlob — the same mechanism the S3 branch already uses. The passive WriteFileWaitFromLockedValueTask path is kept as a fallback for the case where the body is still buffering server-side with no stream yet.

Related fixes to pipeReadableStreamToBlob

Now that this path is reachable from the common Bun.write case (previously only S3→file used it), a few latent issues had to be addressed:

  • Byte count: resolve with file_sink.written instead of hardcoded 0.
  • Windows: convert the opened fd via makeLibUVOwnedForSyscall so the FileSink writer (libuv) can use it; previously asserted.
  • Truncation: open the destination with O_TRUNC (both the Windows open and FileSink.Options.flags(), whose truncate field was present but ignored) so overwriting a larger file with a smaller stream doesn't leave stale bytes.
  • Sync completion: drop the bun.assert(!signal.isDead()) — when the stream drains synchronously via sink.end(), $startDirectStream is never called and the signal legitimately stays dead.
  • Reject-path leak: onFileStreamRejectRequestStream only deref'd the sink; now calls FileStreamWrapper.deinit() so the promise strong-ref and allocation are freed too.

Tests

test/regression/issue/13237.test.ts — six subprocess-isolated cases (so a hang becomes a clean failure, not a stuck runner):

  1. Bun.write(path, new Response(ReadableStream))
  2. Bun.write(path, new Request(url, { body: ReadableStream }))
  3. Bun.file(path).write(new Response(ReadableStream))
  4. Bun.write(path, new Response(req.body)) inside a server handler
  5. Bun.write(path, new Response(ReadableStream)) truncates the destination
  6. Bun.write(path, req) after touching req.body inside a server handler

Without the fix: 0 pass / 6 fail (timeout). With the fix: 6 pass / 0 fail.

Fixes #13237

@robobun

robobun commented Mar 14, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 5:17 PM PT - May 12th, 2026

@robobun, your commit a2a7c97 has 3 failures in Build #53884 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 28112

That installs a local version of the PR into your bun-28112 executable, so you can run:

bun-28112 --bun

@coderabbitai

coderabbitai Bot commented Mar 14, 2026

Copy link
Copy Markdown
Contributor

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Pipes ReadableStream bodies directly into destination blobs, returns actual written byte counts, refines error and deinit paths for stream rejection, adds O_TRUNC for path opens, adjusts FileSink flag composition for truncate, and adds regression tests covering Response/ReadableStream write scenarios.

Changes

Cohort / File(s) Summary
Blob stream & write path
src/bun.js/webcore/Blob.zig
Detects when body is a ReadableStream (e.g., Response(req.body) / Request(..., { body })) and pipes it directly to the destination via pipeReadableStreamToBlob with disturbed/locked checks, bypassing prior onReceiveValue flow. Improved error propagation and return of actual written byte counts.
Stream resolve/reject adjustments
src/bun.js/webcore/Blob.zig
onFileStreamResolveRequestStream resolves with this.sink.written; onFileStreamRejectRequestStream uses deinit for cleanup while preserving error propagation.
pipeReadableStreamToBlob internals
src/bun.js/webcore/Blob.zig
Adds O_TRUNC for path-based opens, improves error paths to reject with path context, and ensures all successful paths return the number of bytes written instead of 0. Notes synchronous completion race where signals may be dead are documented.
File sink flags
src/bun.js/webcore/FileSink.zig
Changes FileSink.Options.flags to build flags in a local mask and include TRUNC only when truncate is true (previously always included).
Regression tests
test/regression/issue/13237.test.ts
Adds four tests verifying Bun.write behavior with new Response(req.body), ReadableStream bodies, request-body handling inside fetch, and overwrite/truncate semantics; checks returned byte counts and file contents.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main fix: resolving an indefinite hang when Bun.write is called with new Response(req.body).
Linked Issues check ✅ Passed The PR directly addresses issue #13237 by fixing the hang when Bun.write receives new Response(req.body), implementing direct stream piping, and providing comprehensive regression tests across all platforms.
Out of Scope Changes check ✅ Passed All changes directly address the root cause and related issues: stream piping logic, byte count reporting, Windows fd conversion, assertion removal for synchronous completion, memory cleanup, and file truncation—all within scope of fixing the hang.
Description check ✅ Passed The PR description comprehensively covers the issue, root cause, fix, and testing approach. Both required sections are present and detailed.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/bun.js/webcore/Blob.zig`:
- Around line 1416-1429: The direct piping fast-path for locked bodies can
bypass slice/offset semantics on sliced destination Blobs; update the branch
that uses response.getBodyReadableStream(globalThis) / bodyValue.Locked.readable
to only allow piping when the target destination_blob has offset == 0 (or
explicitly return an error/throw for non-zero offsets) before calling
destination_blob.pipeReadableStreamToBlob(globalThis, readable, ...); ensure you
perform the same guard in the analogous block around lines ~1494-1506 and keep
existing calls to destination_blob.detach() / readable.isDisturbed checks
intact.

In `@test/regression/issue/13237.test.ts`:
- Around line 12-13: The test currently calls Bun.write(outFile, new
Response(req.body)) and only verifies the file contents; update the regression
tests to also assert the return value of Bun.write (the byte count) to lock in
corrected semantics: capture the result of Bun.write in the blocks where
Response(req.body) is written (the occurrences using Bun.write with outFile and
new Response(req.body)) and add assertions that the returned number equals the
expected number of bytes written (e.g., the length of req.body or
expectedBuffer.byteLength); apply the same change to the other Bun.write
occurrences with Response(req.body) in this file so each write call has a
matching byte-count assertion.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: b08a93c4-d5ac-4b56-a4d2-3a67537cf738

📥 Commits

Reviewing files that changed from the base of the PR and between 8fb62c0 and 95e6ee6.

📒 Files selected for processing (2)
  • src/bun.js/webcore/Blob.zig
  • test/regression/issue/13237.test.ts

Comment thread src/bun.js/webcore/Blob.zig Outdated
Comment thread test/regression/issue/13237.test.ts Outdated
@github-actions

Copy link
Copy Markdown
Contributor

Found 6 issues this PR may fix:

  1. pipeReadableStreamToBlob assertion failure on Windows when readStreamIntoSink completes synchronously #28090 - pipeReadableStreamToBlob assertion failure on Windows (directly related to Windows stream handling fixes in this PR)
  2. Reusing an already consumed ReadableStream should always cause a ReadableStream is locked error #6860 - ReadableStream locked error handling (core ReadableStream locking behavior addressed by this PR)
  3. Bun.write writing a Response from a fetch leaks memory #10686 - Bun.write Response memory leak (same API pattern and memory leak fixes included in this PR)
  4. Bun crash with Bun.write and fetch #20740 - Bun crash with Bun.write and fetch (similar stream/fetch/write interactions that could benefit from ReadableStream handling fixes)
  5. Bun write from empty Response doesn't create any file. #6827 - Bun write empty Response no file (same API pattern with Response objects)
  6. Freeze interaction with Bun.file().slice().stream() #21175 - Freeze with Bun.file().slice().stream() (related stream hanging behavior)

If this is helpful, consider adding 'Fixes #' to the PR description to auto-close the issue on merge.

🤖 Generated with Claude Code

Comment thread src/bun.js/webcore/Blob.zig Outdated
Comment thread src/bun.js/webcore/Blob.zig Outdated
@alii

alii commented Mar 14, 2026

Copy link
Copy Markdown
Member

@robobun ci failure

@claude, your commit 95e6ee6 has 2 failures in Build #39631 (All Failures):

test/js/bun/util/v8-heap-snapshot.test.ts - SIGKILL on 🐧 25.04 x64
test/regression/issue/13237.test.ts - pid 968 internal assertion failure on 🪟 2019 x64-baseline (new)
test/regression/issue/13237.test.ts - pid 5756 internal assertion failure on 🪟 2019 x64 (new)
test/regression/issue/13237.test.ts - pid 836 internal assertion failure on 🪟 11 aarch64 (new)

@robobun

robobun commented Mar 14, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 12:53 AM PT - Mar 15th, 2026

✅ All gates pass on 924fb9d (build #39653).

CI: 55/55 test suites green. 3 failures are infra flakes present on all recent merged PRs (buildkite/bun rollup, upload-benchmark.mjs, linux-aarch64-musl-build-cpp — confirmed on #28089, #28085, #28084, #28082, #28076).

Classification: Bug fix (infinite hang in Bun.write with locked Response/Request bodies).

Test proof:

  • Baked binary (main): all 4 new tests in test/regression/issue/13237.test.ts fail (hang → timeout). ✅
  • PR binary (debug): all 4 tests pass. ✅
  • PR binary (release): all 4 tests pass. ✅

Diff: Fix correctly addresses root cause — locked body streams were never piped to the destination blob. No TODOs/FIXMEs in added lines. O_TRUNC added to FileSink.Options.flags() and overwrite test uses ReadableStream-backed Response to exercise the piped path.

Bot threads: 6/6 resolved (coderabbit nitpicks addressed, claude[bot] findings triaged — pre-existing limitations noted, not regressions).

Reviewer requested: @cirospaciari

Awaiting human review to merge.

Comment thread src/bun.js/webcore/Blob.zig Outdated
Comment thread test/regression/issue/13237.test.ts Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@test/regression/issue/13237.test.ts`:
- Around line 67-95: In the "Bun.write with Response overwrites file completely"
test, capture and assert the return values from the two Bun.write calls (the
first call that writes 1000 bytes and the second that writes 100 bytes) to
ensure the function returns the number of bytes written; assign their results to
variables (e.g., write1, write2) and add expectations like
expect(write1).toBe(1000) and expect(write2).toBe(100) alongside the existing
file-content and length assertions on result from Bun.file(outFile).text(); this
uses the existing symbols Bun.write, outFile, and result to lock in return-value
semantics.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 8d0469db-ffb0-412c-96b5-721f9c599451

📥 Commits

Reviewing files that changed from the base of the PR and between b416912 and 924fb9d.

📒 Files selected for processing (1)
  • test/regression/issue/13237.test.ts

Comment thread test/regression/issue/13237.test.ts Outdated
@alii

alii commented Mar 15, 2026

Copy link
Copy Markdown
Member

@robobun crashes on windows in ci

@robobun

robobun commented Mar 15, 2026

Copy link
Copy Markdown
Collaborator Author

🔍 Verification in progress — head 924fb9d

Gate 1 (CI): 58/62 checks still pending (0 failures, 2 success, 1 neutral, 1 skipped). Waiting for CI to finish.
Gate 2 (Classification): Bug fix — test proof required.
Gate 3 (Test proof): Baked binary (main) confirmed FAIL on new test ✅ | PR debug binary confirmed PASS ✅ | PR release binary confirmed PASS ✅
Gate 4 (Diff): No TODO/FIXME/HACK markers. Fix matches root cause. ✅
Gate 5 (Bot convergence): coderabbit reviewed, threads resolved. ✅
Gate 6 (Hygiene): Clean scope, adequate PR body. ✅

⏳ Waiting for CI to complete before final verdict.

@robobun

robobun commented Mar 15, 2026

Copy link
Copy Markdown
Collaborator Author

⏳ CI still running (58/62 checks pending, Lint JS ✅, Format ✅). No unresolved review threads. No new pushes on 924fb9d. Waiting for CI to finish — will re-check when results land.

@robobun

robobun commented Mar 15, 2026

Copy link
Copy Markdown
Collaborator Author

Waiting for fixer push — head d627ffc

Gate 1 (CI): ❌ Build #39713 — 3 Windows failures in test/regression/issue/13237.test.ts (internal assertion failure in makeLibUVOwnedForSyscall). Brief posted to fixer. No push yet.
Gate 2 (Classification): Bug fix — test proof required.
Gate 4 (Diff): No TODO/FIXME in added lines. ✅
Gate 5 (Bots): All review threads resolved. ✅
Gate 6 (Hygiene): Branch name OK, PR body adequate. ✅

⏳ Waiting for fixer to push a fix for the Windows assertion failure. Will re-check all gates when new commit lands.

@robobun robobun force-pushed the claude/fix-bun-write-response-body-hang-v2 branch from 924fb9d to d627ffc Compare March 16, 2026 01:48

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/bun.js/webcore/Blob.zig`:
- Around line 1416-1429: The fast-path that handles locked-body readable streams
(the branch using response.getBodyReadableStream and bodyValue.Locked.readable)
currently calls destination_blob.pipeReadableStreamToBlob(...) passing only
options.extra_options which drops important file-creation semantics
(createPath/mkdirp_if_not_exists and options.mode); update this path to forward
the full WriteFileOptions (the same structure used by
WriteFileWaitFromLockedValueTask) into pipeReadableStreamToBlob, or, if
pipeReadableStreamToBlob cannot accept WriteFileOptions, call the existing
WriteFileWaitFromLockedValueTask path instead so that
createPath/mkdirp_if_not_exists and mode are preserved; ensure you reference and
thread WriteFileOptions, mkdirp_if_not_exists, options.mode,
destination_blob.pipeReadableStreamToBlob, and WriteFileWaitFromLockedValueTask
when making the change.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: e75e0a2d-b07d-4759-a494-892f589a2811

📥 Commits

Reviewing files that changed from the base of the PR and between 924fb9d and d627ffc.

📒 Files selected for processing (3)
  • src/bun.js/webcore/Blob.zig
  • src/bun.js/webcore/FileSink.zig
  • test/regression/issue/13237.test.ts

Comment thread src/bun.js/webcore/Blob.zig Outdated
@robobun

robobun commented Mar 16, 2026

Copy link
Copy Markdown
Collaborator Author

⏳ CI still running (61 checks pending, 2 passed) on d627ffc. 0 unresolved review threads. All other gates verified — waiting for CI to complete before final verdict. (last checked: 12:04 UTC)

… file

When a Response or Request body is backed by a ReadableStream (e.g.
`new Response(req.body)`, `new Response(new ReadableStream(...))`, or
a server Request after `.body` was accessed), Bun.write installed an
`onReceiveValue` callback and returned — but nothing ever read the
stream, so the promise never settled.

In the `.Locked` body case for file destinations, detect an available
ReadableStream and pipe it through the existing `pipeReadableStreamToBlob`
path (the S3 branch already did this). Fall back to the
`WriteFileWaitFromLockedValueTask` wait only when no stream exists yet.

Related fixes to `pipeReadableStreamToBlob` now that it's reachable
from the common `Bun.write` path:
- Resolve with the actual bytes written (`sink.written`) instead of 0
- Windows: convert the opened fd via `makeLibUVOwnedForSyscall`
- Open with `O_TRUNC` so overwriting a larger file replaces it fully
- Drop the `!signal.isDead()` assert that fires when the stream drains
  synchronously
- `onFileStreamRejectRequestStream`: deinit the whole `FileStreamWrapper`
  instead of only deref'ing the sink (promise/allocation were leaked)
- `FileSink.Options.flags()`: honor the `truncate` field

Fixes #13237
@robobun robobun force-pushed the claude/fix-bun-write-response-body-hang-v2 branch from d627ffc to a2a7c97 Compare May 12, 2026 23:24
@robobun

robobun commented May 12, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto current main (src/bun.js/webcore/src/runtime/webcore/ move had left this with merge conflicts).

Same approach as before, now against a2a7c97:

  • .Locked body with a ReadableStream → pipe via pipeReadableStreamToBlob instead of passively waiting for onReceiveValue
  • pipeReadableStreamToBlob: return real byte count, Windows libuv fd conversion, O_TRUNC, drop sync-completion assert, fix FileStreamWrapper leak in reject handler
  • FileSink.Options.flags(): honor the truncate field

Tests refreshed: 6 subprocess-isolated cases in test/regression/issue/13237.test.ts. Gate verified locally — 0/6 pass without the fix, 6/6 with it. zig:check-all clean on all targets.

Comment on lines 68 to +71
pub fn flags(this: *const Options) i32 {
_ = this;

return bun.O.NONBLOCK | bun.O.CLOEXEC | bun.O.CREAT | bun.O.WRONLY;
var f: i32 = bun.O.NONBLOCK | bun.O.CLOEXEC | bun.O.CREAT | bun.O.WRONLY;
if (this.truncate) f |= bun.O.TRUNC;
return f;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Honoring truncate here is correct for pipeReadableStreamToBlob, but flags() is also reached from Bun.file(path).writer() on POSIX (Blob.zig:3008-3019 → sink.start()setup()openForWriting(..., options.flags(), ...)), so that API now opens with O_TRUNC where it previously did not. The Windows getWriter branch (Blob.zig:~2935) still opens with WRONLY | CREAT | NONBLOCK and bypasses flags(), so this introduces a POSIX-vs-Windows divergence for Bun.file().writer() — worth either adding O_TRUNC there too or calling this out as an intentional behavior change.

Extended reasoning...

What changed and why it has wider reach

This PR changes FileSink.Options.flags() from dead code (_ = this; return NONBLOCK|CLOEXEC|CREAT|WRONLY) to actually honoring the truncate: bool = true field by adding O_TRUNC when set. The intent is to fix pipeReadableStreamToBlob so that overwriting a file with a shorter ReadableStream-backed Response doesn't leave stale trailing bytes.

However, flags() is not only used by pipeReadableStreamToBlob. It is also reached from FileSink.setup() (line 312: bun.io.openForWriting(..., options.flags(), ...)), and setup() is called from FileSink.start() for any streams.Start{.FileSink = ...}. The public API Bun.file(path).writer() on POSIX takes exactly this path.

The affected call chain

On non-Windows platforms, Blob.getWriter (Blob.zig:3008-3019) constructs:

var stream_start: streams.Start = .{ .FileSink = .{ .input_path = input_path } };

This leaves truncate at its struct default of true. It then calls sink.start(stream_start)FileSink.start()FileSink.setup()bun.io.openForWriting(bun.FD.cwd(), options.input_path, options.flags(), ...). openForWritingImpl (src/io/openForWriting.zig:60) passes input_flags directly to bun.sys.openatA(dir, path, input_flags, mode) for the .path case, so the new O_TRUNC reaches the actual open() syscall.

The same applies to streams.Start.fromJSWithTag for the .FileSink case (streams.zig:117-173): it reads highWaterMark/path/fd from JS but never reads a truncate option, so it stays at the default true. There is no JS-side way to opt out.

The cross-platform divergence this introduces

On Windows, Blob.getWriter takes a completely separate branch (Blob.zig:~2928-2989). For path-backed blobs it opens directly via:

bun.sys.open(path, bun.O.WRONLY | bun.O.CREAT | bun.O.NONBLOCK, write_permissions)

This bypasses FileSink.Options.flags() entirely and does not include O_TRUNC. So before this PR, neither platform truncated; after this PR, POSIX truncates but Windows does not. The PR added O_TRUNC to the Windows open in pipeReadableStreamToBlob (Blob.zig:2665) but not to the Windows open in getWriter.

Step-by-step proof

  1. On Linux: write 10 bytes to /tmp/x.txt (e.g. Bun.write('/tmp/x.txt', '0123456789')).
  2. Call const w = Bun.file('/tmp/x.txt').writer(); w.write('abc'); await w.end();
  3. getWriter (POSIX branch) builds streams.Start{.FileSink = .{.input_path = .{.path = '/tmp/x.txt'}}} with truncate defaulting to true.
  4. sink.start()setup()openForWriting(..., NONBLOCK|CLOEXEC|CREAT|WRONLY|TRUNC, ...) → file is truncated to 0 bytes on open.
  5. After end(), the file contains abc (3 bytes). Before this PR, the open used NONBLOCK|CLOEXEC|CREAT|WRONLY (no TRUNC), so the file would contain abc3456789 (10 bytes).
  6. On Windows, repeat the same steps: getWriter opens with WRONLY|CREAT|NONBLOCK (no TRUNC, line ~2935), so the file still contains abc3456789.

Result: POSIX and Windows now disagree on what Bun.file(path).writer() does to existing file contents.

Why existing code doesn't prevent it

The truncate: bool = true field has always existed with that default — _ = this; was clearly incomplete dead code, so honoring it is arguably the long-intended behavior. CI is green because all existing FileSink/writer tests write to fresh temp files and never overwrite. But this is still an undocumented behavior change to a public API surface (Bun.file().writer()) that is unrelated to the Bun.write-hangs-on-locked-body bug this PR fixes, and the new cross-platform inconsistency is a concrete defect introduced here.

How to fix

The cleanest minimal fix is to add bun.O.TRUNC to the Windows getWriter open at Blob.zig:~2935 so both platforms agree (and match the field's stated default). Alternatively, if you want to avoid touching Bun.file().writer() semantics in this PR, you could revert flags() and instead pass an explicit truncating flag set only from pipeReadableStreamToBlob's POSIX path — but since truncate-by-default is almost certainly what users expect from a file writer, aligning Windows is probably the right call.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bun.write with new Response(req.body) hangs

2 participants