Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
03a78e8
Bun.file: make exists()/size/lastModified reflect the current filesys…
robobun Jul 7, 2026
e95bb16
get_size: preserve slice() bound for non-seekable and missing files
robobun Jul 7, 2026
ca36103
structuredClone: propagate size_is_explicit for file-backed slices
robobun Jul 28, 2026
af1030d
trim explanatory comments to single lines
robobun Jul 28, 2026
f085a78
get_size: do not cache 0 when stat fails
robobun Jul 28, 2026
7d808ea
get_size: stop caching stat-derived size into self.size entirely
robobun Jul 28, 2026
0a4a557
serve.test.ts: negative slice start now resolves against the live fil…
robobun Jul 28, 2026
d005d65
get_slice: keep file store for empty regular files; drop resolve_size…
robobun Jul 28, 2026
eaa085a
get_slice: only stat for negative indices; keep default end unbounded
robobun Jul 28, 2026
50167f3
[autofix.ci] apply automated fixes
autofix-ci[bot] Jul 28, 2026
9a0becc
test: assert structuredClone result reads too, including a sliced file
robobun Jul 28, 2026
61f8990
get_size_for_bindings: route file stores through view_size()
robobun Jul 28, 2026
aaa9c8a
get_size_for_bindings: match get_size's clamp for explicit file slices
robobun Jul 28, 2026
72053b5
extract stat_file_size helper shared by get_size, get_size_for_bindin…
robobun Jul 28, 2026
a0a053d
trim stat_file_size doc comment to one line
robobun Jul 28, 2026
54db525
view_size: clamp explicit file slices via stat_file_size
robobun Jul 28, 2026
073d5f4
[autofix.ci] apply automated fixes
autofix-ci[bot] Jul 28, 2026
b69b413
test: assert concrete length for both .size and toHaveLength
robobun Jul 28, 2026
55325a1
Merge origin/main into farm/1db7f535/bunfile-stat-cache
robobun Jul 29, 2026
cdead2d
lastModified: return 0 for missing files and in-memory blobs instead …
robobun Jul 29, 2026
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
4 changes: 4 additions & 0 deletions src/jsc/webcore_types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,8 @@ pub struct Blob {
pub charset: Cell<AsciiStatus>,
/// Was it created via the `File` constructor?
pub is_jsdom_file: Cell<bool>,
/// `size` is a caller-supplied `slice()` bound, not a `stat` hint.
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 +164,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 @@ -374,6 +377,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
128 changes: 64 additions & 64 deletions src/runtime/webcore/Blob.rs
Original file line number Diff line number Diff line change
Expand Up @@ -755,10 +755,12 @@
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: slice window end (MAX_SIZE = unbounded; receiver re-stats).
writer.write_int_le::<u64>(if self.size_is_explicit.get() {
self.size.get()
} else {
MAX_SIZE
})?;
self.resolve_size();
Comment thread
robobun marked this conversation as resolved.
Outdated
store.serialize(writer)?;
}
Expand Down Expand Up @@ -1266,25 +1268,22 @@
}

fn get_exists_sync(&self) -> JSValue {
if self.size.get() == MAX_SIZE {
self.resolve_size();
}

// If there's no store that means it's empty and we just return true
let Some(store) = self.store.get() else {
return JSValue::TRUE;
};

if matches!(store.data, store::Data::Bytes(_)) {
match store.data_mut().tag() {
// Bytes will never error
return JSValue::TRUE;
store::DataTag::Bytes => JSValue::TRUE,
store::DataTag::File => {
resolve_file_stat(store);
let file = store.data_mut().as_file();
// We say regular files and pipes exist.
JSValue::from(bun_sys::S::ISREG(file.mode) || bun_sys::S::ISFIFO(file.mode))
}
store::DataTag::S3 => JSValue::FALSE,
}

// We say regular files and pipes exist.
let store::Data::File(file) = &store.data else {
return JSValue::FALSE;
};
JSValue::from(bun_sys::S::ISREG(file.mode) || bun_sys::S::ISFIFO(file.mode))
}
fn do_write(&self, global_this: &JSGlobalObject, callframe: &CallFrame) -> JsResult<JSValue> {
// SAFETY: bun_vm() never returns null for a Bun-owned global.
Expand Down Expand Up @@ -1970,6 +1969,7 @@
let blob = self.dupe();
blob.offset.set(offset);
blob.size.set(len);
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 @@ -2152,18 +2152,7 @@
fn get_last_modified(&self, _: &JSGlobalObject) -> JSValue {
if let Some(store) = self.store.get() {
if matches!(store.data, store::Data::File(_)) {
// do not hold a pattern-bound `&File` across
// `resolve_file_stat` — it materializes `&mut File` on the same
// memory (Stacked Borrows UB; the optimizer may legally cache the
// pre-call `last_modified` and return the stale `INIT_TIMESTAMP`).
// Re-read via `StoreRef::data_mut` (raw-ptr-backed accessor) after
// the mutating call.
let last_modified = store.data_mut().as_file().last_modified;
// last_modified can be already set during read.
if last_modified == jsc::INIT_TIMESTAMP && !self.is_s3() {
resolve_file_stat(store);
}
// Fresh borrow after possible mutation by `resolve_file_stat`.
resolve_file_stat(store);
return JSValue::js_number(JSValue::purify_nan(
store.data_mut().as_file().last_modified as f64,
));
Expand Down Expand Up @@ -2261,21 +2250,36 @@
}

fn get_size(&self, _: &JSGlobalObject) -> JSValue {
if let Some(store) = self.store.get() {
if matches!(store.data, store::Data::File(_)) {
resolve_file_stat(store);
let file = store.data_mut().as_file();
if file.seekable.is_some() && file.max_size != MAX_SIZE {
let offset = file.max_size.min(self.offset.get());
let available = file.max_size - offset;
if self.size_is_explicit.get() {
return JSValue::js_number(available.min(self.size.get()) as f64);
}
// Cache for `get_slice` (negative-index math reads it).
self.size.set(available);
return JSValue::js_number(available as f64);

Check failure on line 2265 in src/runtime/webcore/Blob.rs

View check run for this annotation

Claude / Claude Code Review

get_size seekable arm's self.size.set(available) still poisons later reads (shrink→grow)

The seekable arm's `self.size.set(available)` at line 2264 is the sibling of the stat-failed `set(0)` you dropped in f085a78 — same read-poisoning, but via *truncate* instead of *unlink*: **exists → `.size` → `truncateSync(p, 0)` → `.size` → recreate → `.text()`** now returns `""` (pre-PR it returned the recreated content). `resolve_file_stat` on a truncated regular file yields `seekable=Some(true), max_size=0`, so this arm caches `self.size = 0`, and `do_read_file` gets `max_length = 0`. The ne
Comment thread
robobun marked this conversation as resolved.
Outdated
}
if self.size_is_explicit.get() {
return JSValue::js_number(self.size.get() as f64);
}
if file.seekable == Some(false) {
return JSValue::js_number(f64::INFINITY);
}
Comment thread
robobun marked this conversation as resolved.
Outdated
return JSValue::js_number(0.0);
}
}
if self.size.get() == MAX_SIZE {
if self.is_s3() {
return JSValue::js_number(f64::NAN);
}
self.resolve_size();
if self.size.get() == MAX_SIZE && self.store.get().is_some() {
return JSValue::js_number(f64::INFINITY);
} else if self.size.get() == 0 && self.store.get().is_some() {
if let store::Data::File(file) =
&self.store().expect("infallible: store present").data
{
if !file.seekable.unwrap_or(true) && file.max_size == MAX_SIZE {
return JSValue::js_number(f64::INFINITY);
}
}
}
}
JSValue::js_number(self.size.get() as f64)
Expand Down Expand Up @@ -3339,6 +3343,7 @@
),
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 @@ -4271,6 +4276,7 @@
// 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 @@ -6184,37 +6190,31 @@
// `StoreRef` liveness invariant; the caller holds the only ref across
// this call, so an exclusive borrow is sound.
let file = store.data_mut().as_file_mut();
match &file.pathlike {
let stat = match &file.pathlike {
PathOrFileDescriptor::Path(path) => {
let mut buffer = bun_paths::PathBuffer::uninit();
match bun_sys::stat(path.slice_z(&mut buffer)) {
bun_sys::Result::Ok(stat) => {
file.max_size = if bun_sys::S::ISREG(stat.st_mode as _) || stat.st_size > 0 {
((stat.st_size.max(0)) as u64) as SizeType
} else {
MAX_SIZE
};
file.mode = stat.st_mode as bun_sys::Mode;
file.seekable = Some(bun_sys::S::ISREG(stat.st_mode as _));
file.last_modified = stat_to_js_mtime(&stat);
}
// the file may not exist yet. That's okay.
_ => {}
}
bun_sys::stat(path.slice_z(&mut buffer))
}
PathOrFileDescriptor::Fd(fd) => bun_sys::fstat(*fd),
};
match stat {
bun_sys::Result::Ok(stat) => {
file.max_size = if bun_sys::S::ISREG(stat.st_mode as _) || stat.st_size > 0 {
((stat.st_size.max(0)) as u64) as SizeType
} else {
MAX_SIZE
};
file.mode = stat.st_mode as bun_sys::Mode;
file.seekable = Some(bun_sys::S::ISREG(stat.st_mode as _));
file.last_modified = stat_to_js_mtime(&stat);
}
// stat failed: clear the cached snapshot.
_ => {
file.max_size = MAX_SIZE;
file.mode = 0;
file.seekable = None;
file.last_modified = jsc::INIT_TIMESTAMP;
}
PathOrFileDescriptor::Fd(fd) => match bun_sys::fstat(*fd) {
bun_sys::Result::Ok(stat) => {
file.max_size = if bun_sys::S::ISREG(stat.st_mode as _) || stat.st_size > 0 {
((stat.st_size.max(0)) as u64) as SizeType
} else {
MAX_SIZE
};
file.mode = stat.st_mode as bun_sys::Mode;
file.seekable = Some(bun_sys::S::ISREG(stat.st_mode as _));
file.last_modified = stat_to_js_mtime(&stat);
}
_ => {}
},
}
}

Expand Down
101 changes: 99 additions & 2 deletions test/js/bun/util/bun-file.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { expect, test } from "bun:test";
import { describe, expect, test } from "bun:test";
import fs from "fs";
import fsPromises from "fs/promises";
import { bunEnv, bunExe, tempDirWithFiles } from "harness";
import { bunEnv, bunExe, isPosix, tempDir, tempDirWithFiles } from "harness";
import { join } from "path";

test("delete() and stat() should work with unicode paths", async () => {
Expand Down Expand Up @@ -155,3 +156,99 @@ test("Bun.file().json() with UTF-8 BOM does not free an interior pointer", async
});
expect(exitCode).toBe(0);
});

describe("BunFile exists()/size/lastModified reflect the current filesystem state", () => {
test("exists() sees a file deleted after the first call", async () => {
using dir = tempDir("bunfile-stat-deleted", {});
const p = join(String(dir), "a");
fs.writeFileSync(p, "abc");
const f = Bun.file(p);
expect(await f.exists()).toBe(true);
expect(f.size).toBe(3);
fs.unlinkSync(p);
expect({ exists: await f.exists(), size: f.size, truth: fs.existsSync(p) }).toEqual({
exists: false,
size: 0,
truth: false,
});
});

test("exists() sees a file created after the first call, and reads its contents", async () => {
using dir = tempDir("bunfile-stat-created", {});
const p = join(String(dir), "b");
const f = Bun.file(p);
expect(await f.exists()).toBe(false);
fs.writeFileSync(p, "content");
expect({ exists: await f.exists(), size: f.size, text: await f.text() }).toEqual({
exists: true,
size: 7,
text: "content",
});
});

test("size and lastModified track changes to the underlying file", async () => {
using dir = tempDir("bunfile-stat-changed", {});
const p = join(String(dir), "c");
fs.writeFileSync(p, "0123456789");
const f = Bun.file(p);
expect(f.size).toBe(10);
const firstMtime = f.lastModified;
fs.appendFileSync(p, "0123456789");
fs.utimesSync(p, 1000, 2000);
expect({ size: f.size, lastModified: f.lastModified }).toEqual({
size: fs.statSync(p).size,
lastModified: fs.statSync(p).mtimeMs,
});
expect(f.lastModified).not.toBe(firstMtime);
});

test("polling exists() observes create and delete", async () => {
using dir = tempDir("bunfile-stat-poll", {});
const p = join(String(dir), "d");
const f = Bun.file(p);
const seen: boolean[] = [];
seen.push(await f.exists());
fs.writeFileSync(p, "x");
seen.push(await f.exists());
fs.unlinkSync(p);
seen.push(await f.exists());
fs.writeFileSync(p, "y");
seen.push(await f.exists());
expect(seen).toEqual([false, true, false, true]);
expect(await f.text()).toBe("y");
});

test("slice() size is preserved across re-stat", async () => {
using dir = tempDir("bunfile-stat-slice", {});
const p = join(String(dir), "e");
fs.writeFileSync(p, "0123456789");
const f = Bun.file(p);
const s = f.slice(0, 5);
expect(s.size).toBe(5);
expect(await s.exists()).toBe(true);
fs.appendFileSync(p, "0123456789");
expect({ whole: f.size, slice: s.size }).toEqual({ whole: 20, slice: 5 });
});

test("slice() size is preserved for non-seekable and missing files", () => {
using dir = tempDir("bunfile-stat-slice-edge", {});
// A slice bound must survive a re-stat that cannot produce a regular-file
// size: a missing file has no stat, and a char device has no st_size.
expect(Bun.file(join(String(dir), "missing")).slice(0, 5).size).toBe(5);
if (isPosix) {
expect(Bun.file("/dev/null").slice(0, 5).size).toBe(5);
}
});

test("size on a deleted file does not poison a later read", async () => {
using dir = tempDir("bunfile-stat-poison", {});
const p = join(String(dir), "f");
fs.writeFileSync(p, "0123456789");
const f = Bun.file(p);
expect(f.size).toBe(10);
fs.unlinkSync(p);
expect(f.size).toBe(0);
fs.writeFileSync(p, "content");
expect(await f.text()).toBe("content");
});
});
Loading