fix: Bun.write with new Response(req.body) no longer hangs#28112
fix: Bun.write with new Response(req.body) no longer hangs#28112robobun wants to merge 1 commit into
Bun.write with new Response(req.body) no longer hangs#28112Conversation
|
Updated 5:17 PM PT - May 12th, 2026
❌ @robobun, your commit a2a7c97 has 3 failures in
🧪 To try this PR locally: bunx bun-pr 28112That installs a local version of the PR into your bun-28112 --bun |
|
Note Reviews pausedIt 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 Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
WalkthroughPipes 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
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Comment |
There was a problem hiding this comment.
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
📒 Files selected for processing (2)
src/bun.js/webcore/Blob.zigtest/regression/issue/13237.test.ts
|
Found 6 issues this PR may fix:
🤖 Generated with Claude Code |
|
@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 |
|
Updated 12:53 AM PT - Mar 15th, 2026
✅ All gates pass on CI: 55/55 test suites green. 3 failures are infra flakes present on all recent merged PRs ( Classification: Bug fix (infinite hang in Test proof:
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 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. |
There was a problem hiding this comment.
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
📒 Files selected for processing (1)
test/regression/issue/13237.test.ts
|
@robobun crashes on windows in ci |
|
🔍 Verification in progress — head Gate 1 (CI): 58/62 checks still pending (0 failures, 2 success, 1 neutral, 1 skipped). Waiting for CI to finish. ⏳ Waiting for CI to complete before final verdict. |
|
⏳ CI still running (58/62 checks pending, Lint JS ✅, Format ✅). No unresolved review threads. No new pushes on |
|
❌ Waiting for fixer push — head Gate 1 (CI): ❌ Build #39713 — 3 Windows failures in ⏳ Waiting for fixer to push a fix for the Windows assertion failure. Will re-check all gates when new commit lands. |
924fb9d to
d627ffc
Compare
There was a problem hiding this comment.
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
📒 Files selected for processing (3)
src/bun.js/webcore/Blob.zigsrc/bun.js/webcore/FileSink.zigtest/regression/issue/13237.test.ts
|
⏳ CI still running (61 checks pending, 2 passed) on |
… 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
d627ffc to
a2a7c97
Compare
|
Rebased onto current Same approach as before, now against
Tests refreshed: 6 subprocess-isolated cases in |
| 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; |
There was a problem hiding this comment.
🔴 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
- On Linux: write 10 bytes to
/tmp/x.txt(e.g.Bun.write('/tmp/x.txt', '0123456789')). - Call
const w = Bun.file('/tmp/x.txt').writer(); w.write('abc'); await w.end(); getWriter(POSIX branch) buildsstreams.Start{.FileSink = .{.input_path = .{.path = '/tmp/x.txt'}}}withtruncatedefaulting totrue.sink.start()→setup()→openForWriting(..., NONBLOCK|CLOEXEC|CREAT|WRONLY|TRUNC, ...)→ file is truncated to 0 bytes on open.- After
end(), the file containsabc(3 bytes). Before this PR, the open usedNONBLOCK|CLOEXEC|CREAT|WRONLY(no TRUNC), so the file would containabc3456789(10 bytes). - On Windows, repeat the same steps:
getWriteropens withWRONLY|CREAT|NONBLOCK(no TRUNC, line ~2935), so the file still containsabc3456789.
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.
What
Bun.write(path, new Response(req.body))— and any otherBun.writewhose source is a Response/Request backed by aReadableStream— hung forever. The.Lockedbody path inwriteFileInternalonly installed anonReceiveValuecallback and returned; nothing ever read the stream, so the returned promise never settled.Repro
Also hangs:
Bun.write(path, new Response(new ReadableStream({...})))Bun.write(path, req)after touchingreq.bodyBun.file(path).write(new Response(stream))Fix
In
Blob.writeFileInternal's.Lockedbranch for file destinations, if the body already has (or exposes) aReadableStream, pipe it throughpipeReadableStreamToBlob— the same mechanism the S3 branch already uses. The passiveWriteFileWaitFromLockedValueTaskpath is kept as a fallback for the case where the body is still buffering server-side with no stream yet.Related fixes to
pipeReadableStreamToBlobNow that this path is reachable from the common
Bun.writecase (previously only S3→file used it), a few latent issues had to be addressed:file_sink.writteninstead of hardcoded0.makeLibUVOwnedForSyscallso theFileSinkwriter (libuv) can use it; previously asserted.O_TRUNC(both the Windows open andFileSink.Options.flags(), whosetruncatefield was present but ignored) so overwriting a larger file with a smaller stream doesn't leave stale bytes.bun.assert(!signal.isDead())— when the stream drains synchronously viasink.end(),$startDirectStreamis never called and the signal legitimately stays dead.onFileStreamRejectRequestStreamonly deref'd the sink; now callsFileStreamWrapper.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):Bun.write(path, new Response(ReadableStream))Bun.write(path, new Request(url, { body: ReadableStream }))Bun.file(path).write(new Response(ReadableStream))Bun.write(path, new Response(req.body))inside a server handlerBun.write(path, new Response(ReadableStream))truncates the destinationBun.write(path, req)after touchingreq.bodyinside a server handlerWithout the fix: 0 pass / 6 fail (timeout). With the fix: 6 pass / 0 fail.
Fixes #13237