Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/jsc/webcore_types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,11 @@ pub struct Blob {
pub charset: Cell<AsciiStatus>,
/// Was it created via the `File` constructor?
pub is_jsdom_file: Cell<bool>,
/// 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<bool>,
/// `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.
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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()),
Expand Down
31 changes: 25 additions & 6 deletions src/runtime/webcore/Blob.rs
Original file line number Diff line number Diff line change
Expand Up @@ -780,10 +780,15 @@ impl BlobExt for Blob {
writer.write_int_le::<u32>(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::<u64>(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::<u64>(if self.size_is_explicit.get() {
self.size.get()
} else {
MAX_SIZE
})?;
self.resolve_size();
store.serialize(writer)?;
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()),
Expand Down Expand Up @@ -4210,6 +4219,7 @@ fn on_structured_clone_deserialize<B: AsRef<[u8]>>(
// 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() {
Expand Down Expand Up @@ -4685,14 +4695,23 @@ 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(
destination_store,
source_store,
ctx.bun_vm().event_loop_shared(),
options.mkdirp_if_not_exists.unwrap_or(true),
destination_blob.size.get(),
max_length,
options.mode,
));
}
Expand All @@ -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,
Expand Down
135 changes: 135 additions & 0 deletions test/js/bun/io/bun-write.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
exampleSite,
gcTick,
isASAN,
isLinux,
isWindows,
tempDir,
withoutAggressiveGC,
Expand Down Expand Up @@ -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();
Expand Down