Skip to content
Open
Show file tree
Hide file tree
Changes from 14 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
187 changes: 100 additions & 87 deletions src/runtime/webcore/Blob.rs
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,8 @@
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 @@
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 @@
}

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 @@
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 @@
// 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 @@
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 @@
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,18 +2154,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 All @@ -2178,24 +2169,21 @@
}

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 +2249,55 @@
}

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(_)) && !self.size_is_explicit.get() {
return self.stat_file_size(store).unwrap_or(MAX_SIZE);
}
}
self.size.get()
}

Check warning on line 2282 in src/runtime/webcore/Blob.rs

View check run for this annotation

Claude / Claude Code Review

view_size() skips clamp for explicit file slices, so nested .slice(-N) diverges from .size

`view_size()` still skips `stat_file_size` for explicit file slices (`!self.size_is_explicit.get()` at line 2277), so a nested negative-index slice resolves against the raw bound rather than the clamped `.size`: for `s = Bun.file(threeByteFile).slice(0, 100)`, `s.size === 3` but `s.slice(-1)` uses 100 → offset 99 → `""` instead of `"c"`. This is the same `.size`-vs-size-consumer divergence already fixed for `get_size_for_bindings` in aaa9c8a9/72053b5e, at the remaining `view_size()` call site (`
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
robobun marked this conversation as resolved.

/// Re-stat `store` and return bytes available past `self.offset`, clamped
/// by an explicit slice bound. `None` when non-seekable or stat failed.
Comment thread
robobun marked this conversation as resolved.
Outdated
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 +3356,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 +4289,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 +6203,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
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