Keep the file size when preallocating a write
Fixes #44387
Problem
Bun.write(Bun.stdout, data)over 1 KiB underbun x.js >> out.logputs NUL bytes before the data: 1996 NULs for a 2000 byte write.WriteFile::run_with_fd(src/runtime/webcore/blob/write_file.rs:470) callspreallocate_file:fallocatewith mode 0, which grows the file to the payload length.>>opens the log withO_APPEND, so the kernel writes at the new end.- A short copy keeps the grown size as a NUL tail:
Bun.write(path, Bun.file(fd))from a positioned fd, and shellmvacross file systems.
Fix
preallocate_file(src/sys/lib.rs) passesFALLOC_FL_KEEP_SIZE. The blocks are still reserved. The write that follows sets the size.- Two copy fallbacks in
src/sys/lib.rsthat calledfallocatedirectly now callpreallocate_file. - The four copy sites that preallocate (those two, the shell
mvfallback, theBun.writekernel-copy loop) release the reserved blocks after a short copy:bun_sys::release_preallocation(anfstat, thenftruncateto the current size) or anftruncateto the bytes copied. - Correct because no caller reads the size that
fallocateset. Each writes or copies its bytes right after (see Notes). - Verified: four new tests in
test/js/bun/io/bun-write.test.jsandtest/js/bun/shell/commands/mv.test.tsfail on1.4.4-canary.1+5c6d4e24cand pass here. Notes lists the other suites.
Background
fallocate(2)reserves disk blocks so a large write does not fragment. Mode 0 also sets the file size.FALLOC_FL_KEEP_SIZEdoes not.- Considered an
O_APPENDcheck inWriteFile(#41685) and a trim after each copy (#41487, #41677). Each covers one call site of seven. #33355 namedFALLOC_FL_KEEP_SIZEas the clean fix.
Downsides
- A write that fails midway on
Bun.write(fd, string)orfs.writeFileto a caller's fd leaves the reserved blocks past EOF allocated until a truncate or unlink. Measured on ext4: a 4 MiB reservation and a 1 MiB write leavest_blocksat 4 MiB. The copy sites trim them now, these two write sites do not. - One
fstatper cross-device copy fallback (bun_sysand the shellmv), on a path that already does anopenat, a copy and aclose. Nothing changes off Linux and Android.
Notes
What changed on 2026-10-23 (a3dbe73f61). The branch conflicted with main in src/install/TarballStream.rs, src/libarchive/lib.rs and test/cli/install/bun-install-streaming-extract.test.ts. main (5c6d4e24cd) is merged in, and the three files are resolved to main's side.
- Since #44968, both tar writers go through
EntryWriter(src/libarchive/lib.rs). It takes the length of an entry from the offset that libarchive returns withARCHIVE_EOF, it returns anftruncatefailure, it marks the file sparse on Windows, and it does not preallocate. - So the tar writers no longer read a size that
fallocateset. The tar-writer changes of the earlier revisions of this PR (d4167acebb and before) and its two tar tests are dropped. main'ssparse tar memberstests cover the entries that end in a hole. - The two earlier downsides about tar entries (the NTFS clusters for a trailing hole on Windows, and an extraction that fails when the resize fails) are no longer effects of this PR.
- Two tests for the copy sites are added (the cases from #41487 and #41677).
- The source diff against main is now
src/sys/lib.rs(three hunks) and one comment insrc/runtime/node/node_fs.rs. It is a subset of the source diff that was self-reviewed at d4167acebb (3 concerns raised, all 3 addressed). The merge added no source lines.
Repro on 1.4.4-canary.1+5c6d4e24c:
printf 'abc\n' > out.log
bun -e 'await Bun.write(Bun.stdout, "x".repeat(1999) + "\n")' >> out.log
wc -c < out.log # 4000, expected 2004
tr -cd '\0' < out.log | wc -c # 1996, expected 0
The same through Bun.file(fd) with fs.openSync(p, "a") needs a payload of 256 KiB or more: a shorter string takes the synchronous main-thread path, which does not preallocate.
The four new tests. On 1.4.4-canary.1+5c6d4e24c (the release build of the main commit that is merged in) all four fail. With a debug build of a3dbe73f61 all four pass.
appends to stdout redirected with >>(test/js/bun/io/bun-write.test.js): size 4000, expected 2004.appends to a file descriptor opened with 'a'(same file): size 600000, expected 300004.Bun.write(path, Bun.file(fd)) with a positioned fd does not leave preallocated zeros(same file): size 2162688, expected 65536.across devices, the destination is the bytes copied, not st_size(test/js/bun/shell/commands/mv.test.ts): the destination of a sysfs file is 4096 bytes, expected the 5 bytes of content. The test is skipped where/sys/devices/system/cpu/onlinedoes not exist.
A probe with 12 cells. A program prints n bytes, and sh redirects them into a log that holds 4 bytes. Bytes in the log afterwards, and how many are NUL:
| 1,000 | 2,000 | 70,000 | 1,000,000 | |
|---|---|---|---|---|
Bun.write(Bun.stdout), >>, canary 5c6d4e24c | 1,004, 0 NUL | 4,000, 1,996 NUL | 140,000, 69,996 NUL | 2,000,000, 999,996 NUL |
Bun.write(Bun.stdout), >>, this branch | 1,004, 0 NUL | 2,004, 0 NUL | 70,004, 0 NUL | 1,000,004, 0 NUL |
The controls (Bun.write(Bun.stdout) with >, and process.stdout.write with >>) are right on both builds in all four sizes.
Other suites run with the debug build of a3dbe73f61:
- All of
test/js/bun/shell/commands/mv.test.ts: 16 pass, 1 skip, 0 fail. writeFile with a preallocate-sized bufferintest/js/node/fs/fs.test.ts: 4 pass.- The
sparsetests intest/js/bun/archive.test.ts: 12 pass. sparse tar membersintest/cli/install/bun-install-streaming-extract.test.ts: 2 pass.- All of
test/js/bun/io/bun-write.test.js: 109 pass, 1 fail. The failure isBun.file(0) survives GC, which hit the 5 s limit in the concurrent run (the file isdescribe.concurrent, and the debug build has ASAN). It passes alone in 1.0 s. It does not reach a preallocation. cargo check -p bun_sysforaarch64-apple-darwin,x86_64-unknown-freebsd,x86_64-pc-windows-msvc,aarch64-linux-androidandx86_64-unknown-linux-musl, andcargo clippy -p bun_sys: all clean.
Syscalls per call, counted from the diff: unchanged. Each site makes one fallocate call, as before.
Per-caller review of preallocate_file with FALLOC_FL_KEEP_SIZE, against main 5c6d4e24cd (seven sites):
blob/write_file.rs(Bun.write(dest, string | bytes)): writes the whole payload from the fd position, or appends. The size follows the write. This is the bug of #44387.runtime/node/node_fs.rs(fs.writeFile): writes the buffer, thenftruncateto the written length for a path with a truncating flag. For a caller's fd there is noftruncate, so a short write no longer leaves a NUL tail either. TheO_APPENDskip stays: an append lands at the end of the file, so a reservation at the cursor is not used. Against main this file differs by one comment.blob/copy_file.rs(Bun.write(path, Bun.file(src))): the destination is openedO_TRUNC.copy_file_rangecopies from the source fd's position and stops at its EOF. The size is what was copied. This is the case from #41487, and thepositioned fdtest covers it.sys/lib.rsmove_file_z_with_handleandcopy_file_z_slow_with_handle(cross-device fallbacks, two sites):O_TRUNCdestination, thencopy_file. The size is what was copied. These two calledfallocatedirectly and now callpreallocate_file.shell/builtin/mv.rs(cross-device fallback):O_TRUNCdestination,bun_sys::copy_file. This is the case from #41677, and themvtest covers it.jsc/RuntimeTranspilerCache.rs:pwritevof the whole content from offset 0 into a fresh tmpfile.- The tar writers (
libarchive/lib.rs,install/TarballStream.rs) do not preallocate on main any more.
Reserved blocks after a short write, measured with raw syscalls on ext4 (measured again on 2026-10-23): fallocate(KEEP_SIZE, 0, 4 MiB), write 1 MiB, st_blocks 4 MiB. After ftruncate to 1 MiB: st_blocks 1 MiB. Close alone does not free them. With mode 0, the same short write left 3 MiB of visible NUL bytes in the file. preallocate_file does nothing off Linux and Android, so the change has no effect there.
Related PRs:
- #41685 (open) adds an
O_APPENDcheck inwrite_file.rs(superseded by this change) and afallback_read_writepath forcopy_file_rangeEBADFon anO_APPENDdestination (independent offallocate, not addressed here). - #41487 and #41677 (closed in favor of this PR) add an
ftruncateafter a short copy. Their NUL-tail symptom is fixed here, andf5fcab65bdcarries their trim: theBun.write(path, Bun.file(fd))test with a positioned fd assertsst_blocksunder 1 MiB after a 64 KiB copy into a 2 MiB reservation. - #36542 (closed): tar extraction with no disk allocation from the entry header and the file length from libarchive. main has that behaviour in
EntryWriter, which #44968 added.
no test proof · iteration 1 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/bun/shell/commands/mv.test.ts, test/js/bun/io/bun-write.test.js