From 5f4d4c8332ac3c04ac83298dcc0cc743e07204d9 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 12 Aug 2026 16:13:35 +0000 Subject: [PATCH] Bun.write: don't cap a file-to-file copy at the destination's cached size Bun.write(Bun.file(dest), Bun.file(src)) passed the destination blob's size to CopyFile / CopyFileWindows as the number of bytes to copy. For an unsliced Bun.file() that field is only the stat size cached by .size, exists(), toHaveLength() or structuredClone(), so once any of those had run the copy was cut to the destination's previous length, and the exists() guard pattern produced an empty file. Record on the blob whether size is a window the caller asked for with slice(); the copy only takes the window in that case and otherwise copies the whole source. Structured clones carry the window only for sliced blobs, so a clone of a primed whole-file blob stays a whole-file blob and a clone of a slice stays a slice. Fixes #4930 --- src/jsc/webcore_types.rs | 7 ++ src/runtime/webcore/Blob.rs | 31 +++++-- test/js/bun/io/bun-write.test.js | 135 +++++++++++++++++++++++++++++++ 3 files changed, 167 insertions(+), 6 deletions(-) diff --git a/src/jsc/webcore_types.rs b/src/jsc/webcore_types.rs index b3a6a52daae4..18e5de0680be 100644 --- a/src/jsc/webcore_types.rs +++ b/src/jsc/webcore_types.rs @@ -129,6 +129,11 @@ pub struct Blob { pub charset: Cell, /// Was it created via the `File` constructor? pub is_jsdom_file: Cell, + /// Set when `size` is a window the caller asked for with `slice()`. A + /// file-backed blob otherwise holds `MAX_SIZE` there until something + /// (`.size`, `exists()`, ...) caches the file's stat size into it, and a + /// cached stat size must not be mistaken for a window. + pub size_is_explicit: Cell, /// `bun.ptr.RawRefCount(u32, .single_threaded)` — counts in-flight `*Blob` /// borrows handed to async readers; not the JS GC retain count. Zero while /// the JS cell is the sole owner. @@ -162,6 +167,7 @@ impl Default for Blob { content_type_was_set: Cell::new(false), charset: Cell::new(AsciiStatus::Unknown), is_jsdom_file: Cell::new(false), + size_is_explicit: Cell::new(false), ref_count: bun_ptr::RawRefCount::init(0), global_this: Cell::new(core::ptr::null()), last_modified: Cell::new(0.0), @@ -375,6 +381,7 @@ impl Blob { content_type_was_set: Cell::new(self.content_type_was_set.get()), charset: Cell::new(self.charset.get()), is_jsdom_file: Cell::new(self.is_jsdom_file.get()), + size_is_explicit: Cell::new(self.size_is_explicit.get()), ref_count: bun_ptr::RawRefCount::init(0), // setNotHeapAllocated global_this: Cell::new(self.global_this.get()), last_modified: Cell::new(self.last_modified.get()), diff --git a/src/runtime/webcore/Blob.rs b/src/runtime/webcore/Blob.rs index 006cf841e312..98d38c10610f 100644 --- a/src/runtime/webcore/Blob.rs +++ b/src/runtime/webcore/Blob.rs @@ -780,10 +780,15 @@ impl BlobExt for Blob { writer.write_int_le::(stored_name.len() as u32)?; writer.write_all(stored_name)?; } else { - // Version 4: a file-backed slice's window end. Written before - // resolve_size() so an unresolved blob stays MAX_SIZE (unknown) - // on the wire and the receiver stats it locally, like v3. - writer.write_int_le::(self.size.get())?; + // Version 4: the window end of a sliced file-backed blob. An + // unsliced blob puts MAX_SIZE on the wire even once it has + // cached a stat size, so the receiver stats the file itself + // (like v3) instead of turning that stale size into a window. + writer.write_int_le::(if self.size_is_explicit.get() { + self.size.get() + } else { + MAX_SIZE + })?; self.resolve_size(); store.serialize(writer)?; } @@ -1948,6 +1953,9 @@ impl BlobExt for Blob { let blob = self.dupe(); blob.offset.set(offset); blob.size.set(len); + // `slice()` of a still-unresolved file blob yields `MAX_SIZE`, which + // is not a window. + blob.size_is_explicit.set(len != MAX_SIZE); let content_type_was_allocated = content_type.is_owned() && !content_type.is_empty(); // infer the content type if it was not specified @@ -3305,6 +3313,7 @@ impl BlobExt for Blob { ), charset: Cell::new(blob.charset.get()), is_jsdom_file: Cell::new(blob.is_jsdom_file.get()), + size_is_explicit: Cell::new(blob.size_is_explicit.get()), ref_count: bun_ptr::RawRefCount::init(0), // setNotHeapAllocated global_this: Cell::new(blob.global_this.get()), last_modified: Cell::new(blob.last_modified.get()), @@ -4210,6 +4219,7 @@ fn on_structured_clone_deserialize>( // resolve_size() clamps this to the actual file size on first use. if size != MAX_SIZE { blob.size.set(size as SizeType); + blob.size_is_explicit.set(true); } } if let Some(store) = blob.store.get() { @@ -4685,6 +4695,15 @@ pub(crate) fn write_file_with_source_destination( } // If this is file <> file, we can just copy the file else if destination_type == store::DataTag::File && source_type == store::DataTag::File { + // Only a slice() window on the destination bounds the copy. Otherwise + // `size` is at most the stat size that `.size` / `exists()` cached, and + // passing it would cut the copy to the destination's old length (0 when + // it did not exist yet) instead of replacing the file. + let max_length = if destination_blob.size_is_explicit.get() { + destination_blob.size.get() + } else { + MAX_SIZE + }; #[cfg(windows)] { return Ok(copy_file::CopyFileWindows::init( @@ -4692,7 +4711,7 @@ pub(crate) fn write_file_with_source_destination( source_store, ctx.bun_vm().event_loop_shared(), options.mkdirp_if_not_exists.unwrap_or(true), - destination_blob.size.get(), + max_length, options.mode, )); } @@ -4702,7 +4721,7 @@ pub(crate) fn write_file_with_source_destination( destination_store, source_store, destination_blob.offset.get(), - destination_blob.size.get(), + max_length, ctx, options.mkdirp_if_not_exists.unwrap_or(true), options.mode, diff --git a/test/js/bun/io/bun-write.test.js b/test/js/bun/io/bun-write.test.js index f02bdf4f8e17..1ee914ecabda 100644 --- a/test/js/bun/io/bun-write.test.js +++ b/test/js/bun/io/bun-write.test.js @@ -7,6 +7,7 @@ import { exampleSite, gcTick, isASAN, + isLinux, isWindows, tempDir, withoutAggressiveGC, @@ -275,6 +276,140 @@ const IS_UV_FS_COPYFILE_DISABLED = } }); + // The file -> file copy took the destination blob's `size` as the number of + // bytes to copy. On an unsliced Bun.file() that field only holds the stat + // size cached by `.size` / `exists()`, so the copy was cut to the + // destination's previous length: 0 bytes for the `exists()` guard in #4930. + describe("Bun.file -> Bun.file is not capped by the destination's cached size", () => { + const source = Buffer.alloc(100_000, "S").toString(); + + // uv_fs_copyfile (Windows) and fcopyfile over an existing file (macOS) + // resolve with 0 regardless of this fix, so only Linux checks the count. + function expectCopiedWhole(destPath, written) { + expect(fs.statSync(destPath).size).toBe(source.length); + expect(fs.readFileSync(destPath, "utf8")).toBe(source); + if (isLinux) expect(written).toBe(source.length); + } + + // Each primer caches the destination's current size onto a blob and + // returns the blob to write to. The cached size lives on the blob, so the + // clone and the whole-file slice have to be the destination themselves. + const primers = [ + [ + "f.size", + (f, size) => { + expect(f.size).toBe(size); + return f; + }, + ], + [ + "await f.exists()", + async f => { + expect(await f.exists()).toBe(true); + return f; + }, + ], + [ + "expect(f).toHaveLength()", + (f, size) => { + expect(f).toHaveLength(size); + return f; + }, + ], + [ + "structuredClone(f), which stats f while serializing it", + f => { + structuredClone(f); + return f; + }, + ], + [ + "f.size, writing to structuredClone(f)", + (f, size) => { + expect(f.size).toBe(size); + return structuredClone(f); + }, + ], + [ + "f.slice().size, writing to the whole-file slice", + (f, size) => { + const whole = f.slice(); + expect(whole.size).toBe(size); + return whole; + }, + ], + ]; + + describe.each([ + ["shorter", "short"], + ["empty", ""], + ])("%s existing destination", (_, existing) => { + it.each(primers)("primed by %s", async (_, prime) => { + using dir = tempDir("bun-write-dest-size-cached", { "src.bin": source, "dest.bin": existing }); + const destPath = join(String(dir), "dest.bin"); + const dest = await prime(Bun.file(destPath), existing.length); + + const written = await Bun.write(dest, Bun.file(join(String(dir), "src.bin"))); + expectCopiedWhole(destPath, written); + }); + }); + + it("primed by exists() on a destination that does not exist yet (#4930)", async () => { + using dir = tempDir("bun-write-dest-exists-guard", { "in.txt": source }); + const outPath = join(String(dir), "out.txt"); + const out = Bun.file(outPath); + expect(await out.exists()).toBe(false); + + const written = await Bun.write(out, Bun.file(join(String(dir), "in.txt"))); + expectCopiedWhole(outPath, written); + }); + + it("primed by .size on an fd destination", async () => { + using dir = tempDir("bun-write-fd-dest-size-cached", { "src.bin": source, "dest.bin": "short" }); + const destPath = join(String(dir), "dest.bin"); + const fd = fs.openSync(destPath, "r+"); + let written; + try { + const dest = Bun.file(fd); + expect(dest.size).toBe(5); + written = await Bun.write(dest, Bun.file(join(String(dir), "src.bin"))); + } finally { + fs.closeSync(fd); + } + expectCopiedWhole(destPath, written); + }); + + // The cached size is also wrong in the other direction: the Windows copy + // padded the shorter copy back out to it. + it("primed by .size, a shorter source replaces a longer destination", async () => { + using dir = tempDir("bun-write-dest-shrinks", { + "src.bin": "tiny", + "dest.bin": Buffer.alloc(1000, "D").toString(), + }); + const destPath = join(String(dir), "dest.bin"); + const dest = Bun.file(destPath); + expect(dest.size).toBe(1000); + + const written = await Bun.write(dest, Bun.file(join(String(dir), "src.bin"))); + expect(fs.readFileSync(destPath, "utf8")).toBe("tiny"); + if (isLinux) expect(written).toBe(4); + }); + + // A window the caller asked for with slice() still bounds the copy, and + // survives structuredClone. + it.each([ + ["f.slice(0, 4)", f => f.slice(0, 4)], + ["structuredClone(f.slice(0, 4))", f => structuredClone(f.slice(0, 4))], + ])("a destination sliced with %s is still bounded by the slice", async (_, slice) => { + using dir = tempDir("bun-write-dest-window", { "src.bin": source, "dest.bin": "0123456789" }); + const destPath = join(String(dir), "dest.bin"); + + const written = await Bun.write(slice(Bun.file(destPath)), Bun.file(join(String(dir), "src.bin"))); + expect(fs.readFileSync(destPath, "utf8")).toBe("SSSS"); + if (isLinux) expect(written).toBe(4); + }); + }); + it("Bun.file", async () => { const file = path.join(import.meta.dir, "fetch.js.txt"); await gcTick();