Skip to content

Bun.write: stop truncating a caller's fd (stdout, stderr, Bun.file(fd)) on an empty source - #32890

Open
robobun wants to merge 16 commits into
mainfrom
claude/farm/dfa2101f/bun-write-empty-blob-no-truncate
Open

robobun wants to merge 16 commits into
mainfrom
claude/farm/dfa2101f/bun-write-empty-blob-no-truncate

Conversation

@robobun

@robobun robobun commented Jun 27, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #13477

Problem

  • Bun.write(fd, <empty source>) calls ftruncate(fd, 0) and resolves 0. It applies to Bun.stdout, Bun.stderr, Bun.file(fd) and a numeric fd, for "", Uint8Array(0), Blob([]), [] and an empty Response (a 204 from fetch(), a closed stream).
  • bun app.js >> app.log with one Bun.write(Bun.stdout, "") empties the log. With writes in flight the file fills with NUL bytes. On Windows the call rejects with EISDIR.
  • Cause: write_file_with_empty_source_to_destination (src/runtime/webcore/Blob.rs:4127) truncates every destination. The fast paths set truncate = NEEDS_OPEN || input.is_empty(): the second term fires only for an fd.

Fix

  • An empty source to an fd resolves 0 with no syscall. The fast paths set truncate = NEEDS_OPEN: Bun resizes only a file it opened.
  • Path destinations do not change. Bun.write(path, "") still empties or creates the file.
  • Verified: test/js/bun/io/bun-write.test.js. Five new tests fail on 1.4.3-canary, three guard path behavior, all pass here.

Background

  • An empty source is a Blob with no Store, which routes it to write_file_with_empty_source_to_destination. A destination is PathOrFileDescriptor::Fd or ::Path.
  • ftruncate changes the file length, not the fd offset, so later writes land past a hole of NUL bytes.
  • Considered: route an empty source through the normal write job (O_TRUNC at open, fd untouched). That changes timing and errors for path destinations: separate change.

Downsides

  • Breaking: code that truncates a file with an empty write to its fd must call fs.ftruncateSync(fd, 0).
  • Still open: Bun.write(Bun.file("/dev/null"), new Blob([])) rejects with EINVAL, while "" resolves 0.
  • Cost: one enum compare per empty write to a path, nothing else.
Notes

Verified on stock 1.4.3-canary.1+367d939d9 against a debug build of this branch (Linux x64):

printf 'line 1\nline 2\n' > a.log
bun -e 'await Bun.write(Bun.stdout, "")' >> a.log; wc -c < a.log            # stock: 0    fixed: 14
(echo header; bun -e 'await Bun.write(Bun.stdout, "")'; echo footer) > b.log
od -c b.log | head -1                                                        # stock: 7 NUL bytes, then footer    fixed: header, footer
printf 0123456789 > f
bun -e 'const fs=require("fs"),fd=fs.openSync("f","a");
  console.log(await Bun.write(Bun.file(fd),"abc"), fs.fstatSync(fd).size);         # 3 13 (both)
  console.log(await Bun.write(Bun.file(fd),new Blob([])), fs.fstatSync(fd).size)'   # stock: 0 0    fixed: 0 13

Entry points: Bun.write(Bun.file(fd), src), Bun.write(fd, src) and Bun.file(fd).write(src), with six empty sources ("", Uint8Array(0), ArrayBuffer(0), Blob([]), Response(""), []) and an fd opened r+ or a. Stock truncates the file in all 36 cases. The fix keeps the 8 bytes in all 36.

Empty bodies: 19 shapes of an empty Response or Request (a stream closed in start() or in the first pull(), a byte stream, Blob([]).stream(), a clone(), Response(null), Response(), Response(""), Response(Blob([])), fetch() with Content-Length: 0, 204, HEAD, chunked with no chunk, and five shapes that close later) written to Bun.file(fd) on a and r+ and to a numeric fd. Stock truncates the file for 14 of the 19 shapes on all three destinations. The fix keeps it in 57 of 57 cases, in 3 of 3 runs. Each of these becomes a Blob with no Store and reaches the same early return. A path destination is still emptied by all 19. The stream shapes regressed between the canaries of 09-06 and 09-15, and the fetch() shapes truncate on 1.3.14 and later.

Concurrent case: 2000 un-awaited Bun.write(Bun.stdout, "C" + i + "\n") calls, one fs.writeSync(1, ...), then one awaited Bun.write(Bun.stdout, ""), with stdout redirected to a file. Stock leaves either a hole of NUL bytes or an empty file, and every promise still fulfills with its byte count. The test checks the exact size and the NUL count. It is skipped on macOS: XNU updates the shared offset of concurrent write(2) calls without O_APPEND non-atomically, which can lose bytes with or without this change.

History: eebcd30067 (2022-10, "Bun.write - truncate empty files") replaced an early "length 0: resolve 0" return with truncate = needs_open or str.len == 0, so that Bun.write(path, "") empties the file. The or term has no effect for a path (needs_open is already true) and truncates a caller's fd. #18561 added the empty-source function.

Path destinations that are not regular files: Bun.write(Bun.file("/dev/null"), src) resolves 0 for "" and Uint8Array(0) (fast path, the ftruncate error is ignored) and rejects with EINVAL (truncate) for Blob([]), Response("") and []. An earlier revision of this PR mapped that EINVAL to a resolved 0. It is removed: on Windows the truncate of a directory also reports EINVAL, and Bun.write(dir, new Blob([])) must keep rejecting there (test block Bun.write early rejections are tracked). The mapping also resolved 0 where a non-empty write fails at open (a FIFO with no reader, a socket path).

An empty write to a closed or read-only fd resolves 0 and does not touch the fd. Before, "" and Uint8Array(0) also resolved 0 (the fast path ignored the ftruncate error), and the Blob/Response/array sources rejected with EBADF or EINVAL.

Related open PRs: #41745 documents the old exception ("an empty data truncates the file to zero length"), and that sentence must go when this lands. #38167 and #42618 edit the same functions, the conflicts are textual.

Windows and macOS are not run locally. The stdout test and the fd test run on both in CI.

Tests in test/js/bun/io/bun-write.test.js:

  • empty source > to a user-provided fd does not truncate
  • empty source > an empty Response or Request body to a user-provided fd does not truncate
  • empty source > to inherited O_APPEND stdout/stderr leaves earlier content intact
  • empty source > to a regular-file path still empties the file (guard)
  • empty source > to a new path still creates an empty file, and its directory (guard)
  • empty source > a shorter write to a path still trims the file (guard)
  • Bun.write(Bun.stdout, <empty source>) > does not truncate the destination (stdout as a file and as a pipe)
  • Bun.write(Bun.stdout, <empty source>) > does not drop concurrent in-flight writes when stdout is a file

no test proof · iteration 2 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/bun/io/bun-write.test.js

An empty Blob/Response/BlobPart[] source routed through
write_file_with_empty_source_to_destination, which unconditionally
called truncate on the destination path or fd. On character devices
and pipes (for example /dev/null or a piped stdout) truncate fails
with EINVAL and the promise was rejected, while the equivalent ""
and new Uint8Array(0) sources resolved via the fast path. On an fd
backed by a regular file (for example Bun.stdout redirected to a
file) truncate succeeded and silently wiped everything already
written to it.

For fd-backed destinations, skip truncation entirely and resolve 0.
For path destinations, keep truncation (it creates/empties regular
files) but treat an EINVAL from truncate as a successful zero-byte
write so non-regular-file paths match the non-empty-source behaviour.

Drop the || is_empty() term from the fast-path truncate condition so
that writing "" or new Uint8Array(0) to a user-supplied fd no longer
truncates it either.
@coderabbitai

coderabbitai Bot commented Jun 27, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository: oven-sh/bun/.coderabbit.yaml
  • Review profile: ASSERTIVE
  • Plan: Essentials
  • Run ID: 833ab06d-bf7c-4af0-ba99-2179f88ac490
📥 Commits

Reviewing files that changed from the base of the PR and between bc7a813 and f5de349.

📒 Files selected for processing (2)
  • src/runtime/webcore/Blob.rs
  • test/js/bun/io/bun-write.test.js

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 2 remain after this review.


Walkthrough

Empty-source writes to file descriptors now return zero without truncating existing contents. Writes to paths still truncate existing files or create empty files. The tests cover descriptor APIs, stdout and stderr, redirected output, and queued stdout writes.

Changes

Empty-source writes

Layer / File(s) Summary
Destination-specific write behavior
src/runtime/webcore/Blob.rs, test/js/bun/io/bun-write.test.js
File-descriptor writes of empty input return zero without truncating descriptor contents. Path writes continue to truncate existing files or create empty files. Tests cover descriptor APIs, path writes, inherited stdout and stderr, redirected stdout, and queued stdout writes.

Priority: ➖ Normal

Severity of issue fixed: Medium

Merge Risk: ⚪ Minimal · up to f5de3

Empty writes to stdout, stderr and other caller-provided file descriptors no longer truncate their contents. Writes to paths behave as before. No merge-blocking risk was identified, and cross-platform behavior is left to CI.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed [ #13477 ] requires an empty write to stdout to complete without writing or failing. write_file_with_empty_source_to_destination now returns 0 for fd destinations without truncating, and the strin…
Out of Scope Changes check ✅ Passed The source changes and regression tests address empty writes to caller-provided fds and verify that path writes retain their existing behavior. No unrelated changes appear in the reviewed diff.
Title check ✅ Passed The title clearly summarizes the main change: empty writes to caller-provided file descriptors no longer truncate them. It is specific, though somewhat long.
Description check ✅ Passed The description explains the problem, fix, scope, downsides, and verification in detail. It does not use the template’s exact headings, but it provides the information those sections request.
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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

@robobun

robobun commented Jun 27, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 10:47 PM PT - Oct 2nd, 2026

❌ @robobun, your commit f5de349 has 2 failures in Build #123146 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 32890

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

bun-32890 --bun

@github-actions

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. Bun.write(Bun.stdout, "") tries to truncate on Windows #13477 - Directly matches: writing an empty string to Bun.stdout calls ftruncate, which fails with EISDIR on Windows. This PR stops truncating fd-backed destinations when the source is empty.

If this is helpful, copy the block below into the PR description to auto-close this issue on merge.

Fixes #13477

🤖 Generated with Claude Code

@claude claude 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.

I didn't find any bugs and the fix looks correct, but this changes user-visible Bun.write semantics (fd-backed empty writes become no-ops, path-based EINVAL from truncate is swallowed), so it's worth a human confirming the intended behavior.

Extended reasoning...

Overview

This PR fixes Bun.write(dest, <empty source>) so it no longer truncates fd-backed destinations or rejects with EINVAL on non-regular files. It touches src/runtime/webcore/Blob.rs in three places — write_file_with_empty_source_to_destination (early-return for fd destinations, swallow EINVAL from truncate on paths, remove the now-dead fd arm of the ENOENT recovery) and the two fast paths write_string_to_file_fast / write_bytes_to_file_fast (drop the || input.is_empty() term so truncation is gated solely on NEEDS_OPEN). Five new test cases in bun-write.test.js cover /dev/null, user fds, stdout-to-file, stdout-to-pipe, and two regression guards that path-based empty writes still truncate/create.

Security risks

None identified. No auth, crypto, or permissions surface; the change narrows when ftruncate/truncate is called rather than adding new syscalls or expanding access.

Level of scrutiny

Medium-high. Bun.write is a core, documented I/O API and this is an intentional semantic change: Bun.write(Bun.file(fd), "") used to ftruncate(fd, 0) and now resolves 0 without touching the file. The PR argues convincingly that the old behavior was a bug (it silently wiped redirected stdout and contradicted the adjacent comment), and the regression guards confirm path destinations still truncate. But a maintainer should sign off on the design call — particularly that swallowing EINVAL from path-based truncate is the right trade-off and that no callers were relying on the fd-truncate side effect.

Other factors

The Rust changes are surgical and internally consistent: the early Fd return makes the later file.pathlike.path() call safe (it panics on Fd, but that arm is now unreachable), and the was_eperm bookkeeping it carried is correctly removed as dead code. Test coverage is thorough, including a spawned-child check for both file-redirected and piped stdout. CI is still building; no prior human or bot review on the thread.

@claude claude 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.

I didn't find any issues with the logic, but this is a behavioral change to Bun.write's empty-source semantics on a core I/O path (and CI is currently red on 919f107), so it's worth a human look before merging.

Extended reasoning...

Overview

This PR changes how Bun.write(dest, source) handles an empty source in three places in src/runtime/webcore/Blob.rs:

  • write_file_with_empty_source_to_destination: now early-returns 0 for fd-backed destinations instead of calling ftruncate, and swallows EINVAL from truncate(2) on path-backed destinations (treating it as a no-op for non-regular files). The now-unreachable Fd arm of the ENOENT-recovery match and its was_eperm bookkeeping are removed, with the path extraction collapsed to file.pathlike.path().slice() (safe — Fd is early-returned above, and .path() is documented to panic only on Fd).
  • write_string_to_file_fast / write_bytes_to_file_fast: drop the || input.is_empty() term from the truncate flag so it's gated solely on NEEDS_OPEN, matching the existing comment about not truncating caller-supplied fds.

Tests in bun-write.test.js cover all five empty-source spellings against /dev/null, a user-opened fd, a regular-file path (regression guard for truncate-to-empty), a new path (regression guard for create), and Bun.stdout via a spawned child with both file-redirected and piped stdout.

Security risks

None. The change reduces destructive behavior (stops wiping data on fd-backed destinations) and doesn't introduce any new file creation, permission, or path-handling surface.

Level of scrutiny

Medium-high. Bun.write is a core, widely-used I/O API, and this is an intentional behavioral change: writing an empty source to Bun.file(fd) used to truncate the underlying file and now does nothing. The PR argues convincingly that the old behavior was a bug (it contradicted the adjacent comment and destroyed data on redirected stdout), and the regression-guard tests confirm path-backed destinations still truncate/create as before — but a maintainer should sign off on the semantic shift and on swallowing EINVAL from truncate for path destinations.

Other factors

  • Buildkite #65674 is currently reporting failures on the head commit; worth confirming whether they're related before merging.
  • No CODEOWNERS entry covers this path.
  • The bug-hunting pass found no issues; I verified that the .path() unwrap is guarded by the new Fd early-return and that the was_eperm removal is sound (the only consumer was the now-dead Fd arm).

robobun added 2 commits July 26, 2026 12:46
… regression

When stdout is redirected to a regular file, a burst of fire-and-forget
Bun.write(Bun.stdout, chunk) calls racing with a single
Bun.write(Bun.stdout, "") used to leave the file correctly sized but
mostly NUL-filled: the empty write synchronously ftruncate'd fd 1 while
thread-pool writes were in flight, discarding their bytes without
resetting the kernel file offset, so the remaining writes landed past a
sparse hole. Every promise still fulfilled with its full byte count.
Comment thread src/runtime/webcore/Blob.rs Resolved
Comment thread src/runtime/webcore/Blob.rs Resolved
Comment thread src/runtime/webcore/Blob.rs Resolved
@robobun

robobun commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed an additional regression test for the concurrent-write variant of this bug: a burst of fire-and-forget Bun.write(Bun.stdout, chunk) calls followed by one Bun.write(Bun.stdout, ""), with stdout redirected to a file. Before this fix the ftruncate(fd, 0) fires while thread-pool writes are in flight, so the file ends up correctly sized (every promise fulfilled with its byte count) but mostly NUL bytes, a sparse hole left where the already-written data was discarded without resetting the kernel file offset.

// bun r.mjs > out.txt ; then: tr -cd '\\0' < out.txt | wc -c   -> ~53 000 NULs of 58 890 bytes
const ps = [];
for (let i = 0; i < 10000; i++) ps.push(Bun.write(Bun.stdout, "C" + i + "\n"));
await Bun.write(Bun.stdout, "");
const r = await Promise.allSettled(ps);
process.stderr.write("fulfilled=" + r.filter(x => x.status === "fulfilled").length +
  " bytes=" + r.reduce((a, x) => a + (x.value || 0), 0) + "\n"); // fulfilled=10000 bytes=58890

Also merged current main into the branch.

robobun added 2 commits July 26, 2026 12:59
The binary-size gate compares against the last passing main canary
(#79916 = ae4b17d). This branch has 12 newer main commits merged
in; the ~550KB growth is from those, not the ~45-line Blob.rs change
here.
Comment thread test/js/bun/io/bun-write.test.js Resolved
XNU doesn't serialize the file offset for concurrent write(2) on a
shared fd without O_APPEND, so the strict size/line-set equalities
could flake on macOS. Assert only what the fix guarantees: every
promise fulfils with its byte count and no NUL hole appears.

Also advance the offset with a synchronous writeSync before the
empty write so the pre-fix ftruncate deterministically produces a
hole regardless of thread-pool timing.

[skip size check]
Comment thread test/js/bun/io/bun-write.test.js Resolved
robobun and others added 2 commits July 26, 2026 14:01
XNU's dofilewrite updates fg_offset with a non-atomic += bytecnt RMW,
so concurrent write(2) on a shared fd without O_APPEND can double-count
the increment and leave a sparse NUL hole independently of the ftruncate
fix under test. Linux's f_pos_lock serializes the offset update so the
nulBytes === 0 invariant holds there; gate the test with skipIf(isMacOS)
to keep that check on platforms where it is deterministic.

[skip size check]
autofix.ci pushed c749d3a without the marker; the ~550KB delta is from
the 12 main commits merged in at 0416a42, not this diff's ~45-line
Blob.rs change. See 0ad1cd2 for the breakdown.

@claude claude 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.

The two macOS-flakiness concerns I raised earlier are addressed and I found nothing new this pass, but this changes the user-visible contract of Bun.write(Bun.file(fd), <empty>) from "truncate" to "no-op" — worth a maintainer confirming that's the intended semantics before merge.

What was reviewed:

  • file.pathlike.path() in the ENOENT-recovery branch is safe: the new early return guarantees the Path arm.
  • Path-backed empty writes still truncate/create — verified via the NEEDS_OPEN=true fast path (opens without O_TRUNC, then ftruncate(fd, 0)) and the retained node_fs.truncate call; both regression-guarded.
  • The concurrent-stdout test now skips macOS and its remaining assertions (nulBytes===0, fulfilled=N) hold on Linux (f_pos_lock) and Windows (I/O manager serializes CurrentByteOffset per FILE_OBJECT).
Extended reasoning...

Overview

Two-file change: src/runtime/webcore/Blob.rs and test/js/bun/io/bun-write.test.js. In write_file_with_empty_source_to_destination, an fd-backed destination now resolves 0 immediately instead of calling node_fs.truncate on it; path-backed destinations still truncate but treat EINVAL (non-regular file) as "wrote 0 bytes". The now-unreachable Fd arm of the ENOENT-recovery block and its was_eperm bookkeeping are removed, and file.pathlike.path() is called directly (safe because Fd early-returned above; .path() panics on the Fd variant per src/jsc/node_path.rs:331-335). In the two POSIX fast paths, truncate is now gated on NEEDS_OPEN alone — dropping the || input.is_empty() term that contradicted the adjacent comment about caller-supplied fds.

Tests add a five-spelling matrix ("", Uint8Array(0), Blob([]), Response(""), []) across four destination shapes (/dev/null path, user fd on a regular file, existing regular-file path, new path), plus a spawned-child test covering Bun.stdout as both a redirected file and a pipe, plus a concurrent-writes-then-empty regression test.

Security risks

None. No untrusted-input parsing, no auth/crypto surface. The change narrows what Bun.write does to a user's fd (stops silently discarding their data via ftruncate), which is strictly safer than before.

Level of scrutiny

Medium-high. Bun.write is a core, documented, hot-path API. The fix is small and the mechanism is well-argued, but it changes observable behaviour: Bun.write(Bun.file(fd), "") previously ftruncated the fd (silently, with the error discarded) and now does nothing. That is almost certainly what users want — the existing code comment already said fd destinations should get "manual control", and #13477 is a real user hitting the wrong behaviour — but a maintainer should sign off on the contract change rather than a bot.

Other factors

  • My two earlier inline comments on the concurrent-stdout test (XNU f_pos_lock absence, then the fg_offset += bytecnt double-count producing NUL holes) were both addressed; the test now skips macOS with a comment naming the exact race, and the remaining assertions are the ones the fix actually guarantees on Linux/Windows.
  • The two fast paths are #[cfg(not(windows))]; on Windows the empty-source-to-fd case is covered by the early return in write_file_with_empty_source_to_destination (which is not cfg-gated). The new "to a user-provided fd does not truncate" and "Bun.stdout does not truncate" tests run on Windows and should exercise that path.
  • CI is still building; the binary-size failure is flagged in the commit log as inherited from merged-in main ([skip size check]), not from this ~30-line diff.
  • The comment-cop bot flagged the rationale comments; robobun replied that they document a behavioural contract (not a workaround) and trimmed them in 96b94b1 — the surviving one-liners look proportionate.

@robobun

robobun commented Jul 26, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status for f5de349: CI is green (build #123146), there is no conflict with main, and every review thread is resolved. Ready for a maintainer review.

f5de349 is tests only on top of aa30dd7, which has main bc7a813 merged in.

What the last two pushes changed:

  • Merged main. The only conflict was test/js/bun/io/bun-write.test.js, and both sides are kept. write_file_with_empty_source_to_destination takes cx: &JsThread on main, so the early return passes cx.global().
  • Removed the arm that mapped EINVAL from the path truncate to a resolved 0. On Windows the truncate of a directory also reports EINVAL, and the Bun.write early rejections are tracked tests on main expect Bun.write(dir, new Blob([])) to reject there. This PR now changes fd destinations only. Path destinations behave as on main on every platform.
  • Tests: the fd test also covers Bun.write(fd, src) and Bun.file(fd).write(src). A new test writes an empty fetch() body (Content-Length: 0, 204), a Response and a Request with a closed stream, and Response(null) to an fd. Three guards pin the path behavior. The concurrent stdout test checks the exact file size. The two stdout tests run in a concurrent describe block.

How to reproduce (Linux x64, stock 1.4.3-canary.1+367d939d9 against a debug build of this branch):

printf 'line 1\nline 2\n' > a.log
bun -e 'await Bun.write(Bun.stdout, "")' >> a.log; wc -c < a.log            # stock: 0    this branch: 14
(echo header; bun -e 'await Bun.write(Bun.stdout, "")'; echo footer) > b.log
od -c b.log | head -1                                                        # stock: 7 NUL bytes, then footer    this branch: header, footer
// an answer with no body erases the log behind the fd
const fd = require("fs").openSync("app.log", "a");
await Bun.write(Bun.file(fd), await fetch(url204));                          // stock: app.log is empty    this branch: app.log is intact
  • Bun.write(Bun.file(fd), src), Bun.write(fd, src) and Bun.file(fd).write(src) with six empty sources and an fd opened r+ or a: stock truncates the file in 36 of 36 cases, this branch in 0 of 36.
  • 19 shapes of an empty Response or Request body written to Bun.file(fd) (a, r+) and to a numeric fd: stock truncates the file for 14 of the 19 shapes, this branch keeps it in 57 of 57 cases. A path destination is still emptied by all 19.

Local test run: the eight tests of this PR and the 12 tests of the Bun.write early rejections are tracked block pass on the debug build. Five of the eight fail on stock, the other three are the path guards. Windows and macOS are not run locally.

…bun-write-empty-blob-no-truncate

# Conflicts:
#	test/js/bun/io/bun-write.test.js
…-mode fd

An empty source written to Bun.stdout/Bun.stderr when fd 1/2 were
inherited in append mode (bun app.js >> app.log) used to ftruncate the
log and wipe output from earlier processes. Same for Bun.file(fd) on an
fd opened with 'a'. Both resolve 0 and leave the file intact now.
@robobun robobun changed the title Bun.write: don't truncate the destination when the source is an empty Blob Bun.write: stop truncating fd-backed destinations (stdout, stderr, Bun.file(fd)) on an empty source Sep 6, 2026

@claude claude 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.

Code review found no issues

No high-confidence issues detected in this change.

…bun-write-empty-blob-no-truncate

Conflict in test/js/bun/io/bun-write.test.js: both sides kept.

Blob.rs: write_file_with_empty_source_to_destination now takes
`cx: &JsThread` on main, so the two early returns pass `cx.global()`.
Drop the arm that mapped EINVAL from the path truncate to a resolved 0.
On Windows the truncate of a directory also reports EINVAL, and
`Bun.write(dir, new Blob([]))` must keep rejecting there. How an empty
source treats a path that is not a regular file is a separate question
from the fd truncation, so this change no longer touches it.

Keep the fast path change to the one expression.
…size in the concurrent case

`Bun.write(fd, src)` and `Bun.file(fd).write(src)` also truncated the fd
on an empty source.

Path destinations keep their contract: an empty source still empties or
creates the file (and its directory), and a shorter write still trims
the file.

The concurrent stdout test now checks the file size. Without it the test
passed on an unfixed build when every queued write had finished before
the truncate.
@robobun robobun changed the title Bun.write: stop truncating fd-backed destinations (stdout, stderr, Bun.file(fd)) on an empty source Bun.write: stop truncating a caller's fd (stdout, stderr, Bun.file(fd)) on an empty source Oct 2, 2026

@claude claude 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.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Beyond the inline finding, I also checked: the now-unconditional file.pathlike.path() in the ENOENT/mkdirp loop (src/runtime/webcore/Blob.rs:4189, 4210) is safe because the Fd arm returns at :4150 before it; the async write path at Blob.rs:1546 already sets truncate only for Path destinations, so no sibling site was missed; and the early return is not cfg-gated, so Windows (which skips the fast paths) gets the same no-op. One mismatch to tidy: the PR description still says /dev/null EINVAL from truncate(2) resolves 0 and lists an empty source > to /dev/null resolves 0 test, but after "leave path destinations as they are on main" neither is in the diff — Bun.write(Bun.file("/dev/null"), new Blob([])) still rejects.

Extended reasoning...

The change touches two files: a ~34-line edit to src/runtime/webcore/Blob.rs that makes an empty-source Bun.write a no-op for fd-backed destinations (early return in write_file_with_empty_source_to_destination, truncate = NEEDS_OPEN in both sync fast paths, removal of the dead Fd arm) and ~224 lines of new tests in test/js/bun/io/bun-write.test.js. No security-sensitive surface (no auth, injection, or data exposure); the risk is correctness of fd-vs-path truncation semantics. An inline nit was posted and one further verified finding was withheld, and the PR description no longer matches the code after the latest push, so this is a defer rather than an approve.

Comment thread test/js/bun/io/bun-write.test.js Resolved
… tests concurrently

`Bun.write(fd, await fetch(url))` with an answer that has no body, and a
Response or Request whose stream closed before it gave a chunk, reach the
same empty-source route and truncated the fd.

The two tests that redirect a child's stdout now run in a concurrent
describe block, like the other groups of this file that spawn a child.

@claude claude 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.

Code review completed

Nothing new to post: everything this review found is already covered by existing comments on this pull request or didn't merit a separate one.

@d1dac0

d1dac0 commented Oct 28, 2026

Copy link
Copy Markdown

I reproduced the reported Windows failure on Bun 1.4.4-canary.1+11c367163 with both stdout and stderr configured as pipes. Empty string, zero-length Uint8Array, empty Blob, empty Response, and [] all reject with EINVAL: invalid argument, ftruncate; routing stdout and stderr to the same regular file also truncates existing contents. Non-empty writes work, and empty path writes still truncate as expected.

The fix in this PR addresses the failing behavior. I added focused Windows regression cases locally for both piped descriptors, a shared redirected file, and path truncation. They fail before the fix (10 pipe/file cases) while the five path-preservation checks pass. I could not finish a debug build here: Windows tar.exe rejected zstd archive symlinks, and CRLF checkout copies of codegen .txt inputs failed parsing. The new tests therefore still need a passing run on this PR's fixed binary or CI.

This confirms the issue on Windows and gives additional descriptor coverage for the existing change.

This branch has not been deployed

No deployments
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(Bun.stdout, "") tries to truncate on Windows

3 participants