Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
195 changes: 106 additions & 89 deletions src/runtime/webcore/Blob.rs
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,8 @@ pub trait BlobExt {
fn get_size_for_bindings(&self) -> u64;
fn get_stat(&self, global_this: &JSGlobalObject, callback: &CallFrame) -> JsResult<JSValue>;
fn get_size(&self, _: &JSGlobalObject) -> JSValue;
fn view_size(&self) -> SizeType;
fn stat_file_size(&self, store: &StoreRef) -> Option<SizeType>;
fn resolve_size(&self);
fn resolved_size(&self) -> (SizeType, SizeType);
fn constructor(global_this: &JSGlobalObject, callframe: &CallFrame) -> JsResult<*mut Blob>
Expand Down Expand Up @@ -755,11 +757,12 @@ 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())?;
self.resolve_size();
// 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
})?;
store.serialize(writer)?;
}
}
Expand Down Expand Up @@ -1266,25 +1269,22 @@ 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 => {
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 +1970,7 @@ impl BlobExt for Blob {
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 @@ -1997,17 +1998,22 @@ impl BlobExt for Blob {
// index the full fixed-3 array (args[2] is written below regardless of len).
let args = &mut arguments_[..];

if self.size.get() == 0 {
if self.size.get() == 0 && !self.is_bun_file() {
let ptr = Blob::new(Blob::init_empty(global_this));
// SAFETY: `ptr` just came from `heap::alloc` in `Blob::new`; force
// the inherent `Blob::to_js(&mut self)` over `JsClass::to_js`.
return Ok(unsafe { BlobExt::to_js(&*ptr, global_this) });
}

let this_size_i64 = i64::try_from(self.size.get()).expect("int cast");
let mut neg_base: Option<i64> = None;
let mut resolve_neg =
|| *neg_base.get_or_insert_with(|| i64::try_from(self.view_size()).expect("int cast"));

// If the optional start parameter is not used as a parameter, let relativeStart be 0.
let mut relative_start: i64 = 0;
// If the optional end parameter is not used, let relativeEnd be size.
let mut relative_end: i64 = i64::try_from(self.size.get()).expect("int cast");
let mut relative_end: i64 = this_size_i64;

// Mutate the fixed-3 args array in place to shift the string arg into [2].
if args[0].is_string() {
Expand All @@ -2024,11 +2030,9 @@ impl BlobExt for Blob {
if start_.is_number() {
let start = start_.to_int64();
if start < 0 {
relative_start = (start
.wrapping_add(i64::try_from(self.size.get()).expect("int cast")))
.max(0);
relative_start = (start.wrapping_add(resolve_neg())).max(0);
} else {
relative_start = start.min(i64::try_from(self.size.get()).expect("int cast"));
relative_start = start.min(this_size_i64);
}
}
}
Expand All @@ -2037,11 +2041,9 @@ impl BlobExt for Blob {
if end_.is_number() {
let end = end_.to_int64();
if end < 0 {
relative_end = (end
.wrapping_add(i64::try_from(self.size.get()).expect("int cast")))
.max(0);
relative_end = (end.wrapping_add(resolve_neg())).max(0);
} else {
relative_end = end.min(i64::try_from(self.size.get()).expect("int cast"));
relative_end = end.min(this_size_i64);
}
}
}
Expand Down Expand Up @@ -2152,50 +2154,39 @@ 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.
resolve_file_stat(store);
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);
// stat failed (missing file): expose 0, not the internal sentinel.
if last_modified == jsc::INIT_TIMESTAMP {
return JSValue::js_number(0.0);
}
// Fresh borrow after possible mutation by `resolve_file_stat`.
return JSValue::js_number(JSValue::purify_nan(
store.data_mut().as_file().last_modified as f64,
));
return JSValue::js_number(JSValue::purify_nan(last_modified as f64));
}
}

if self.is_jsdom_file.get() {
return JSValue::js_number(JSValue::purify_nan(self.last_modified.get()));
}

JSValue::js_number(jsc::INIT_TIMESTAMP as f64)
JSValue::js_number(0.0)
}

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

// If the file doesn't exist or is not seekable
// signal that the size is unknown.
if let Some(store) = self.store.get() {
if let store::Data::File(file) = &store.data {
if !file.seekable.unwrap_or(false) {
return u64::MAX;
}
if matches!(store.data, store::Data::File(_)) {
return match self.stat_file_size(store) {
Some(s) => s,
None if self.size_is_explicit.get() => self.size.get(),
None => u64::MAX,
};
}
Comment thread
claude[bot] marked this conversation as resolved.
}
Comment thread
robobun marked this conversation as resolved.

if self.size.get() == MAX_SIZE {
self.resolve_size();
}
if self.size.get() == MAX_SIZE {
return u64::MAX;
}

self.size.get()
}
fn get_stat(&self, global_this: &JSGlobalObject, callback: &CallFrame) -> JsResult<JSValue> {
Expand Down Expand Up @@ -2261,26 +2252,56 @@ impl BlobExt for Blob {
}

fn get_size(&self, _: &JSGlobalObject) -> JSValue {
if let Some(store) = self.store.get() {
if matches!(store.data, store::Data::File(_)) {
return JSValue::js_number(match self.stat_file_size(store) {
Some(s) => s as f64,
None if self.size_is_explicit.get() => self.size.get() as f64,
None if store.data_mut().as_file().seekable == Some(false) => f64::INFINITY,
None => 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)
}

/// Live size for the W3C slice algorithm; does not write `self.size`.
fn view_size(&self) -> SizeType {
if let Some(store) = self.store.get() {
if matches!(store.data, store::Data::File(_)) {
return self
.stat_file_size(store)
.unwrap_or_else(|| self.size.get());
}
}
self.size.get()
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
robobun marked this conversation as resolved.

/// Re-stat: bytes past `self.offset` (clamped by an explicit bound), or `None`.
fn stat_file_size(&self, store: &StoreRef) -> Option<SizeType> {
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;
return Some(if self.size_is_explicit.get() {
available.min(self.size.get())
} else {
available
});
}
None
}

fn resolve_size(&self) {
let Some(store) = self.store.get() else {
self.size.set(0);
Expand Down Expand Up @@ -3339,6 +3360,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 +4293,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 +6207,31 @@ 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);
}
// 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
13 changes: 12 additions & 1 deletion test/js/bun/http/serve.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1942,7 +1942,6 @@ describe("should support Content-Range with Bun.file()", () => {
const badRanges = [
[10, NaN],
[10, -Infinity],
[-(full.byteLength / 2) | 0, Infinity],
[-(full.byteLength / 2) | 0, -Infinity],
[full.byteLength + 100, full.byteLength],
[full.byteLength + 100, full.byteLength + 100],
Expand All @@ -1960,6 +1959,18 @@ describe("should support Content-Range with Bun.file()", () => {
});
});
}

it("negative start resolves against the live file size", async () => {
// Blob.slice(-N, Infinity) is the last N bytes per the W3C slice algorithm.
// This used to be treated as a bad range because slice() saw the unresolved
// MAX_SIZE sentinel and produced a past-EOF offset.
const start = -(full.byteLength / 2) | 0;
await getServer(async server => {
const response = await fetch(`${server.url.origin}/?start=${start}&end=Infinity`);
expect(await response.arrayBuffer()).toEqual(full.buffer.slice(start));
expect(response.status).toBe(206);
});
});
});

it("formats error responses correctly", async () => {
Expand Down
Loading
Loading