Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
6 changes: 6 additions & 0 deletions src/jsc/webcore_types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,10 @@ pub struct Blob {
pub charset: Cell<AsciiStatus>,
/// Was it created via the `File` constructor?
pub is_jsdom_file: Cell<bool>,
/// True when `size` is a bound the caller asked for (`Blob.slice`). For a
/// file store `size` is otherwise a `stat` hint that must be refreshed on
/// every access: the filesystem can change between calls.
Comment thread
robobun marked this conversation as resolved.
Outdated
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 +166,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 +379,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
148 changes: 84 additions & 64 deletions src/runtime/webcore/Blob.rs
Original file line number Diff line number Diff line change
Expand Up @@ -755,10 +755,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: a file-backed slice's window end. A stat-derived
// `size` is a hint (the receiver re-stats locally), so only a
// caller-supplied slice bound goes on the wire; an unbounded
// view stays MAX_SIZE (unknown), like v3.
Comment thread
robobun marked this conversation as resolved.
Outdated
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 +1271,24 @@ impl BlobExt for Blob {
}

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 => {
// Always re-stat: `exists()` must reflect the current
// filesystem state, not a cached snapshot.
Comment thread
robobun marked this conversation as resolved.
Outdated
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 +1974,9 @@ impl BlobExt for Blob {
let blob = self.dupe();
blob.offset.set(offset);
blob.size.set(len);
// `MAX_SIZE` is the "unbounded" sentinel, so an unbounded slice of an
// unresolved file blob is still unbounded.
Comment thread
robobun marked this conversation as resolved.
Outdated
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 +2159,11 @@ impl BlobExt for Blob {
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`.
// Always re-stat so `lastModified` reflects the current mtime
// instead of a cached snapshot. Do not hold a pattern-bound
// `&File` across `resolve_file_stat` (Stacked Borrows UB).
Comment thread
robobun marked this conversation as resolved.
Outdated
resolve_file_stat(store);
// Fresh borrow after mutation by `resolve_file_stat`.
return JSValue::js_number(JSValue::purify_nan(
store.data_mut().as_file().last_modified as f64,
));
Expand Down Expand Up @@ -2261,21 +2261,43 @@ impl BlobExt for Blob {
}

fn get_size(&self, _: &JSGlobalObject) -> JSValue {
if let Some(store) = self.store.get() {
if matches!(store.data, store::Data::File(_)) {
// Always re-stat so `size` reflects the current file size
// instead of a cached snapshot.
Comment thread
robobun marked this conversation as resolved.
Outdated
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;
// A caller-supplied `slice()` bound is authoritative, but
// still cannot report past EOF.
Comment thread
robobun marked this conversation as resolved.
Outdated
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);
}
// Non-seekable (pipe/FIFO/char device) or stat failed. A slice
// bound is the only size the caller has, so keep it.
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
self.size.set(0);
return JSValue::js_number(0.0);
Comment thread
robobun marked this conversation as resolved.
Outdated
}
}
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 +3361,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 @@ -4271,6 +4294,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 @@ -6184,37 +6208,33 @@ fn resolve_file_stat(store: &StoreRef) {
// `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);
}
// The file may not exist (or the fd is invalid). Clear the cached
// stat so the JS-facing getters reflect the current state instead
// of a past snapshot.
Comment thread
robobun marked this conversation as resolved.
Outdated
_ => {
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
89 changes: 87 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,87 @@ 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);
}
});
});
Loading