Skip to content

Truncate existing destination files in path-opened FileSinks - #31686

Closed
robobun wants to merge 7 commits into
mainfrom
farm/bfeb7e5a/filesink-truncate
Closed

robobun wants to merge 7 commits into
mainfrom
farm/bfeb7e5a/filesink-truncate

Conversation

@robobun

@robobun robobun commented Jun 2, 2026 •

Copy link
Copy Markdown
Collaborator

What does this PR do?

Fixes #31682 — Bun.write(Bun.file(path), s3file) did not truncate a larger pre-existing destination file: the first N bytes were overwritten but the stale tail survived. Also fixes #25968 — Bun.file(path).writer() had the same problem.

Repro

fs.writeFileSync("/tmp/out.bin", Buffer.alloc(16 * 1024 * 1024, 0xee));
await Bun.write(Bun.file("/tmp/out.bin"), s3.file("four-mb-object"));
Bun.file("/tmp/out.bin").size; // 16777216 — expected 4194304

Cause

Path destinations for FileSink (the sink behind Bun.file(path).writer() and the S3→file streaming form of Bun.write) were opened write-only and nothing ever truncated: FileSink.Options has always defaulted truncate: true, but the field was dead on every platform. Every other Bun.write form (bytes→file, file→file, Response→file) replaces the destination; only path-based FileSinks did not. The same dead field existed in the Zig implementation, so this was never correct — not a regression.

Fix

Per review feedback, truncation is deferred to the end of the sink instead of O_TRUNC at open, so rewriting a large file in place does not free and reallocate every block:

  • FileSink gets a truncate_on_end flag, set when the sink opened a path (with Options.truncate, which nothing disables). Fd-provided sinks — Bun.stdout.writer(), Bun.file(fd).writer(), subprocess stdin — never set it, so user fds are never truncated.
  • When the sink ends (end() / endFromJS), it runs ftruncate(fd, lseek(fd, 0, SEEK_CUR)) once: path opens start at offset zero and the writer only writes sequentially (uv_fs_write(…, -1, …) on Windows), so the current offset is exactly the bytes written. Any bytes still buffered at that point flush sequentially afterwards and re-extend the file, leaving the final size exact. The fd's offset is used rather than the written counter because the counter misses synchronous writes (it's why Bun.write resolves with 0 on this path today).
  • lseek failing (FIFO/tty/socket) skips the truncate, matching how O_TRUNC is ignored for those file types.
  • Flush failures (end() error arm) skip the truncate and reject as before.

Verification

  • test/js/bun/s3/s3-download-truncate.test.ts (new): fake S3 endpoint via Bun.serve, 4 MiB pre-existing destination, 1 MiB object — asserts the file ends up exactly the object's bytes. The download runs in a child process with HTTP(S)_PROXY unset because the S3 client resolves the proxy from the environment at startup. (This test caught that Windows had the same bug — its direct opens route through libuv with POSIX open semantics.)
  • test/js/bun/util/filesink.test.ts (new case): Bun.file(path).writer() over a larger existing file leaves only the written bytes.

Both fail without the src/ change (stale bytes survive) and pass with it. Full filesink.test.ts (43), bun-write.test.js (35), streams.test.js (70), blob-write.test.ts, fs.test.ts -t createWriteStream, spawn-streaming-stdin and spawn-stdin-readable-stream suites pass with the change on Linux; cargo check passes for x86_64-unknown-linux-gnu and x86_64-pc-windows-msvc.

…ng files

FileSink.Options has always carried truncate: true, but Options::flags()
ignored it and opened path destinations with only O_WRONLY|O_CREAT. Any
write that streams through a path-based FileSink — Bun.write(Bun.file(path),
s3file) and Bun.file(path).writer() — left stale tail bytes behind when the
destination was already larger than the data written.

OR O_TRUNC into the open flags when truncate is set. Fd-based sinks are
unaffected (they dup the fd and never consult flags), and Windows already
truncates these opens via FILE_OVERWRITE_IF, so this brings POSIX to parity.

Fixes #31682
Fixes #25968
@coderabbitai

coderabbitai Bot commented Jun 2, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@robobun, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 4 minutes and 29 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 8e8eb03f-66e8-47af-9e27-35e86975467b

📥 Commits

Reviewing files that changed from the base of the PR and between 0c5e408 and f9d9123.

📒 Files selected for processing (1)
  • src/runtime/webcore/FileSink.rs

Walkthrough

FileSink now defers truncation for path-opened sinks via a new truncate_on_end flag and helper; Blob write paths set that flag for path-based opens on Windows. Tests verify truncation for direct writer use and S3 downloads into larger existing files.

Changes

File Truncation on Write

Layer / File(s) Summary
FileSink fields and setup
src/runtime/webcore/FileSink.rs
Adds truncate_on_end: Cell<bool>, initializes it in default_fields(), and sets it during setup() only when the destination is opened by path and options.truncate is enabled.
FileSink truncate helper and end paths
src/runtime/webcore/FileSink.rs
Adds truncate_to_end_offset(&self) (no-op on Windows/invalid fds) and calls it from end() and end_from_js() in the Done/Pending/Wrote completion paths to trim the file to the final written byte count.
Blob writer/file-sink wiring
src/runtime/webcore/Blob.rs
When creating file sinks/writers in do_write and get_writer, set truncate_on_end to true for path-opened destinations and false for fd-backed destinations (Windows write paths adjusted accordingly).
Truncation behavior tests
test/js/bun/util/filesink.test.ts, test/js/bun/s3/s3-download-truncate.test.ts
Adds tests that verify a shorter write truncates an existing larger file when opened by path, and that S3 downloads into an existing larger file produce the exact expected final size and contents (stale tail bytes removed).

Possibly related PRs:

  • oven-sh/bun#31135: Changes to FileSink.rs around flush/terminal handling; related to sink end behavior that this PR also modifies.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Truncate existing destination files in path-opened FileSinks' directly and clearly describes the main change: implementing truncation for path-based FileSink destinations.
Description check ✅ Passed The description covers both required sections: it explains what the PR does (fixes truncation for path-opened FileSinks in two use cases) and how it was verified (two new test files and existing test suites).
Linked Issues check ✅ Passed Code changes meet all primary objectives: #31682 requires truncating destination files in Bun.write(Bun.file(path), s3file) and #25968 requires FileSink.writer() to truncate larger files; both are addressed via the deferred truncation mechanism in FileSink.rs and Blob.rs.
Out of Scope Changes check ✅ Passed All code changes (FileSink.rs, Blob.rs, and two test files) are directly related to implementing the truncate-on-end feature and verifying it, with no unrelated modifications.

✏️ 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.

@robobun

robobun commented Jun 2, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 4:51 AM PT - Jun 2nd, 2026

❌ @robobun, your commit f9d9123 has 8 failures in Build #59857 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 31686

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

bun-31686 --bun

@github-actions

github-actions Bot commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. fix(FileSink): truncate existing file using O_TRUNC flag #25966 - Same fix (adds O_TRUNC to FileSink path opens) for the same truncation bug
  2. fix(FileSink): truncate existing file using ftruncate #25967 - Alternative fix (uses ftruncate) for the same FileSink truncation bug

🤖 Generated with Claude Code

@robobun

robobun commented Jun 2, 2026

Copy link
Copy Markdown
Collaborator Author

Re the duplicate flag: #25966 / #25967 are the same idea for the file.writer() half of this (#25968), but both patch src/bun.js/webcore/FileSink.zig, which no longer exists — that tree was restructured and the implementation now lives in src/runtime/webcore/FileSink.rs, so neither applies to main as-is.

This PR applies the O_TRUNC fix to the current code, keeps the existing truncate option intact instead of deleting it, and additionally covers + tests the Bun.write(Bun.file(path), s3file) path from #31682 (which streams through the same sink). If a maintainer prefers to credit the earlier PRs, the src/ change here is the same one-liner in spirit.

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

LGTM — minimal fix that makes the existing truncate: true default actually take effect; the inline nit about tmpdirSync is purely stylistic.

Extended reasoning...

Overview

The source change is 4 lines in src/runtime/webcore/FileSink.rs: Options::flags() now ORs in O_TRUNC when self.truncate is set, instead of ignoring the field with let _ = self;. The truncate field already defaulted to true and is never set to false anywhere in the codebase (verified by grep — every FileSinkOptions construction uses ..Default::default() without overriding it). The remaining diff is two regression tests: a new s3-download-truncate.test.ts exercising the original report via a fake S3 endpoint, and a 10-line case in filesink.test.ts for the direct Bun.file(path).writer() path.

Security risks

None. This adds O_TRUNC to an open that already uses O_WRONLY|O_CREAT — i.e., it only affects files the caller is already overwriting. No auth, crypto, permissions, or untrusted input handling is touched.

Level of scrutiny

Low-to-moderate. The fix is mechanically obvious once you see that truncate: true was the existing default but dead. I verified the PR's claim that fd-based sinks are unaffected: open_for_writing_result for the Fd arm calls dup_with_flags(*fd_, 0) and never consults input_flags, so Bun.stdout.writer(), Bun.file(fd).writer(), and subprocess stdin keep their current semantics. POSIX ignores O_TRUNC on FIFOs/terminals, and Windows already truncates via FILE_OVERWRITE_IF, so the blast radius is exactly the buggy case: path-opened regular files.

Other factors

The PR description is thorough, references two open issues (#31682, #25968) and a prior attempt (#25981) closed only for inactivity, and reports passing the relevant test suites. The new tests fail without the fix and pass with it. The only finding from the bug hunt is a style nit (tmpdirSync vs tempDir in the new S3 test file) which doesn't affect correctness and can be taken or left.

Comment thread test/js/bun/s3/s3-download-truncate.test.ts Outdated

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

LGTM — thanks for switching to tempDir.

Extended reasoning...

Overview

The src change is a 4-line edit to Options::flags() in src/runtime/webcore/FileSink.rs: when self.truncate is set (it always is — Default for Options sets truncate: true and no caller overrides it), OR in O_TRUNC to the open flags. Previously the field was dead code and path-opened FileSinks never truncated, leaving stale tail bytes when writing over a larger existing file. Two regression tests are added: a direct Bun.file(path).writer() case in filesink.test.ts and an end-to-end S3→file case with a fake Bun.serve endpoint.

Security risks

None. The change adds O_TRUNC to an open that already had O_WRONLY|O_CREAT — standard write-mode semantics, no privilege/permission surface touched. The new S3 test spins up a localhost server and a child process with proxy env vars stripped; nothing reaches the network.

Level of scrutiny

Low-to-moderate. The src change is mechanically trivial and the truncate field's intent was already encoded in the struct. I verified the PR's blast-radius claim: open_for_writing_result for the Fd arm calls dup_with_flags(*fd_, 0) and ignores input_flags entirely, so Bun.stdout.writer(), Bun.file(fd).writer(), and subprocess stdin are unaffected. POSIX ignores O_TRUNC on FIFOs/ttys. Windows already truncates via FILE_OVERWRITE_IF. This is a behavior change for Bun.file(path).writer(), but it fixes two reported bugs (#25968, #31682), matches Node's createWriteStream default, matches every other Bun.write variant, and three prior PRs (#25966/#25967/#25981) attempted the same fix against the old Zig tree.

Other factors

The PR description reports the full filesink, bun-write, streams, blob-write, and createWriteStream suites pass with the change. My only prior feedback — a style nit about tmpdirSync vs tempDir in the new test file — was addressed in b39192c and the thread is resolved. No CODEOWNERS cover the touched paths. The bug-hunting system found nothing.

On Windows these call sites open via bun_sys::open, which routes through
libuv (uv_fs_open) with POSIX open semantics — not the NtCreateFile path
whose WRONLY disposition overwrites. Without UV_FS_O_TRUNC the destination
kept its stale tail on Windows, same as the POSIX bug fixed in the previous
commit; the s3-download-truncate test caught it on the windows-x64 lane.

@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 current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/js/bun/util/filesink.test.ts`:
- Around line 176-178: The test that creates target using tmpdirSync() should
use the harness-provided tempDir helper instead: import/use tempDir from
'harness' and replace tmpdirSync() with tempDir() when building the target path
in the "truncates an existing larger file when opened by path" test (the it(...)
block that defines target and writes the file); ensure the test follows the
established tempDir cleanup pattern rather than calling
tmpdirSync()/fs.mkdtempSync().
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 5f6cdbb4-0c8c-4986-bff6-57d5e00163b1

📥 Commits

Reviewing files that changed from the base of the PR and between 561eb8f and b3819f6.

📒 Files selected for processing (4)
  • src/runtime/webcore/Blob.rs
  • src/runtime/webcore/FileSink.rs
  • test/js/bun/s3/s3-download-truncate.test.ts
  • test/js/bun/util/filesink.test.ts

Comment thread test/js/bun/util/filesink.test.ts

@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 correctness issues and the fix looks right, but this changes default open semantics for Bun.file(path).writer() and the streaming Bun.write paths in core runtime code — worth a maintainer sign-off on the user-visible behavior change (and the late-added Windows Blob.rs opens).

Extended reasoning...

Overview

The PR adds O_TRUNC to three path-open sites so that path-backed FileSinks truncate a pre-existing larger destination file:

  • src/runtime/webcore/FileSink.rs: Options::flags() now honors the existing truncate: true default (previously dead) by ORing in O_TRUNC. This is the POSIX path for Bun.file(path).writer() and the S3→file streaming form of Bun.write.
  • src/runtime/webcore/Blob.rs (two sites, both #[cfg(windows)]): adds O_TRUNC to the direct bun_sys::open calls used by the Windows streaming-write paths that bypass Options::flags().
  • Two tests: a new S3 fake-endpoint test and a new filesink.test.ts case, both verifying stale tail bytes are gone.

The diff is small and the reasoning in the PR description is solid: the truncate field already defaulted to true and was simply never read; every other Bun.write form already truncates; fd-based sinks and FIFO/tty are unaffected. My earlier nit (use tempDir instead of tmpdirSync in the new test file) was addressed in b39192c.

Security risks

None identified. This adds a standard open flag to write-only file opens; it does not touch auth, crypto, permissions, or untrusted input parsing.

Level of scrutiny

Medium. The mechanical change is trivial, but it lives in src/runtime/webcore/ and changes the default semantics of a public API (Bun.file(path).writer()) from "overwrite-in-place without truncation" to "truncate on open." That is almost certainly the intended behavior (it fixes #31682 and #25968, matches Node's createWriteStream default, and matches every other Bun.write variant), but it is still a user-visible behavior change in a core I/O primitive. Two prior community PRs (#25966 / #25967) attempted the same fix against the old Zig tree and were closed without merge, so a maintainer should confirm there's no lingering reason this was held back.

Other factors

  • The Windows Blob.rs changes were added in the most recent commit (b3819f6) and aren't covered by the PR description's "Windows already truncates via FILE_OVERWRITE_IF" analysis — that analysis applies to the open_for_writing path in FileSink::setup, not these direct bun_sys::open calls. They look correct (and harmless if redundant), but a maintainer familiar with the Windows bun_sys::open → NtCreateFile mapping should glance at them.
  • Good test coverage: both new tests fail without the src/ change and pass with it; the author also reports the broader filesink/bun-write/streams suites pass.
  • No outstanding reviewer comments; CodeRabbit was rate-limited and did not review.

@Jarred-Sumner Jarred-Sumner left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Use ftruncate and do it at the end, O_TRUNC makes it slower when rewriting large files repeatedly.

…NC at open

O_TRUNC frees every block of the destination up front, so rewriting a large
file in place deallocates and reallocates the whole extent list each time.
Instead, leave the open flags alone and ftruncate once when the sink ends,
trimming the destination to exactly the bytes written.

The byte count is the fd's current offset: path opens start at offset zero
and the writer only writes sequentially (uv_fs_write uses offset -1 on
Windows). Bytes still buffered at end() flush sequentially afterwards and
re-extend the file, so the final size is unchanged by the ordering. lseek
failing (pipe/tty/socket) skips the truncate, matching how O_TRUNC is
ignored for those file types. The fd-provided sink paths never set the flag,
so user fds are never truncated.
@robobun

robobun commented Jun 2, 2026

Copy link
Copy Markdown
Collaborator Author

Reworked in c9238dd: the open flags are untouched again — path-opened sinks now ftruncate(fd, lseek(fd, 0, SEEK_CUR)) once when the sink ends. The offset is used as the byte count because the written counter misses synchronous writes (it's why Bun.write resolves with 0 on this path); bytes still buffered at end() flush sequentially afterwards and re-extend the file, so the final size stays exact. Fd-provided sinks never set the flag, and a failed lseek (pipe/tty/socket) skips the truncate.

@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 current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/runtime/webcore/Blob.rs`:
- Around line 1929-1933: The Windows writer path currently forces
sink_mut.truncate_on_end = true for all path-backed writers; update the logic in
Blob.rs so truncate_on_end respects FileSinkOptions.truncate (the option parsed
by Start::from_js_with_tag::<{ streams::StartTag::FileSink }>()), i.e., only set
truncate_on_end when the file sink options indicate truncation and the path is
not a file descriptor (keep the existing PathOrFileDescriptor::Fd check); ensure
the Windows branch reads the same arg0/file sink options used on non-Windows so
Bun.file(path).writer({ truncate: false }) does not truncate on Windows.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: d8c9b950-cde8-4c74-b8b1-0e6ec5630fc3

📥 Commits

Reviewing files that changed from the base of the PR and between b3819f6 and 0c5e408.

📒 Files selected for processing (2)
  • src/runtime/webcore/Blob.rs
  • src/runtime/webcore/FileSink.rs

Comment thread src/runtime/webcore/Blob.rs
Comment thread src/runtime/webcore/FileSink.rs Outdated
Comment thread src/runtime/webcore/FileSink.rs Outdated
robobun added 2 commits June 2, 2026 04:42
…he writer

Truncating in the Pending arm of end()/endFromJS raced the in-flight
uv_fs_write on the libuv thread pool on Windows: if the worker's WriteFile
landed between the lseek and the ftruncate, its bytes were cut and nothing
re-extended the file. Move the truncate for the pending case into the
on_write completion callback, which runs once the post-end() drain finishes
(done, no buffered data, Drained/EndOfFile) while the fd is still open; the
synchronous Done/Wrote flush arms keep truncating inline since no write is
outstanding there.

Also read the fd from the writer's current source on both platforms instead
of self.fd on Windows: setup() never updates self.fd, so a sink re-started
with start({path}) would have truncated a stale — possibly user-owned —
descriptor.
@robobun

robobun commented Jun 2, 2026 •

Copy link
Copy Markdown
Collaborator Author

CI status for merge consideration (build 59857 now complete): 71 checks pass, 3 fail, and none of the red touches this diff.

  • All Linux and Windows test lanes: ✅ — including windows-2019-x64-baseline, the lane that caught the original Windows gap and validates the ftruncate-at-end approach plus the drain-race and fd-source fixes.
  • darwin-26-aarch64-test-bun: ✅ on retry — a macOS lane running the full suite green, covering the POSIX path of this change.
  • darwin-14-x64-test-bun: ❌ container-provisioning errors on that runner only — sql.test.ts and regression/issue/21311.test.ts fail with PostgresError: role "bun_sql_test" does not exist; websocket-proxy.test.ts fails with "Failed to start service squid".
  • darwin-14-aarch64-test-bun: ❌ (after repeatedly expiring waiting for agents) — sole visible failure is node/test/parallel/test-dgram-cluster-close-during-bind.js ("Socket should not bind"), a dgram/cluster network flake unrelated to FileSink.

The FileSink/truncation tests pass everywhere they ran. Diff is green; the darwin red is runner provisioning + a known-flaky node-compat network test.

@robobun

robobun commented Jun 4, 2026

Copy link
Copy Markdown
Collaborator Author

Closing in favor of #31737 (per maintainer request), which fixes the same bug with O_TRUNC at open and now carries the regression tests from this branch, including the fake-S3-endpoint download test.

This branch's variant — ftruncate to the current offset when the sink ends, per the review feedback here, so rewriting a large file in place doesn't free and reallocate every block — remains on farm/bfeb7e5a/filesink-truncate if that tradeoff is preferred.

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.

FileSink does not truncate existing file Bun.write(Bun.file(path), s3file) does not truncate a larger existing destination file

2 participants