Skip to content
Draft
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
6 changes: 6 additions & 0 deletions src/jsc/webcore_types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -820,6 +820,12 @@ pub mod store {
..Default::default()
}
}

/// A `stat` of this store ran and succeeded: only `resolve_file_stat` sets `seekable`.
#[inline]
pub fn is_statted(&self) -> bool {
self.seekable.is_some()
}
}

// ────────────────────────────────────────────────────────────────────
Expand Down
18 changes: 8 additions & 10 deletions src/runtime/server/RequestContext.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1869,18 +1869,17 @@ where
offset: blob_offset,
total: 0,
};
if is_regular && auto_close {
self.flags.set_needs_content_range(
sendfile.remain.saturating_sub(sendfile.offset) != stat_size,
);
}
if is_regular {
sendfile.offset = sendfile.offset.min(stat_size);
sendfile.remain = sendfile
.remain
.max(sendfile.offset)
.min(stat_size)
.saturating_sub(sendfile.offset);
if auto_close {
self.flags
.set_needs_content_range(sendfile.remain != stat_size);
}
}
self.sendfile.set(sendfile);

Expand All @@ -1889,9 +1888,9 @@ where
// Content-Range arithmetic gets ambiguous; the slice path keeps
// its existing slice-as-range behavior. `offset == 0` alone is
// insufficient — `Bun.file(p).slice(0, n)` has offset 0 — so we
// also check the size: an unsliced blob has either the unset-size
// sentinel or, if JS already read `.size`, the stat'd size; a
// `.slice(0, n)` blob has `n < stat_size`. Skip if the user
// also check the size: an unsliced blob has the unset-size
// sentinel, and a slice over the whole file has the stat'd size. A
// shorter `.slice(0, n)` blob has `n < stat_size`. Skip if the user
Comment thread
robobun marked this conversation as resolved.
// already set Content-Range or a non-200 status — they're
// managing partial responses themselves.
let user_handles_range = if let Some(r) = self.response_mut() {
Expand Down Expand Up @@ -2633,8 +2632,7 @@ where
// Response from `response_weakref`, so no borrow of the Response
// (here, `blob`) may still be live across it. Nothing is written
// to the socket in between, so the wire output is unchanged.
blob.resolve_size();
let blob_size = blob.size.get();
let (_, blob_size) = blob.resolved_size();
this.render_metadata();

if blob_size == crate::webcore::blob::MAX_SIZE {
Expand Down
96 changes: 68 additions & 28 deletions src/runtime/webcore/Blob.rs
Original file line number Diff line number Diff line change
Expand Up @@ -747,9 +747,7 @@ 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.
// Version 4: the window end of a file-backed slice, or `MAX_SIZE` (unknown) for an unsliced blob.
writer.write_int_le::<u64>(self.size.get())?;
self.resolve_size();
store.serialize(writer)?;
Expand Down Expand Up @@ -1840,6 +1838,8 @@ impl BlobExt for Blob {
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");
// Set by an index from the end. The window then closes at this length.
let mut length: Option<i64> = None;

// Mutate the fixed-3 args array in place to shift the string arg into [2].
if args[0].is_string() {
Expand All @@ -1856,9 +1856,8 @@ 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);
let length = *length.get_or_insert_with(|| end_relative_length(self));
relative_start = start.wrapping_add(length).max(0);
} else {
relative_start = start.min(i64::try_from(self.size.get()).expect("int cast"));
}
Expand All @@ -1869,14 +1868,17 @@ 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);
let length = *length.get_or_insert_with(|| end_relative_length(self));
relative_end = end.wrapping_add(length).max(0);
} else {
relative_end = end.min(i64::try_from(self.size.get()).expect("int cast"));
}
}
}
if let Some(length) = length {
relative_start = relative_start.min(length);
relative_end = relative_end.min(length);
}

let mut content_type = BlobContentType::default();
if let Some(content_type_) = args_iter.next_eat() {
Expand Down Expand Up @@ -2002,9 +2004,14 @@ impl BlobExt for Blob {
}

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

// If the file doesn't exist or is not seekable
// signal that the size is unknown.
Expand All @@ -2016,11 +2023,11 @@ impl BlobExt for Blob {
}
}

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

self.size.get()
size
}
fn get_stat(&self, global_this: &JSGlobalObject, callback: &CallFrame) -> JsResult<JSValue> {
// TODO: make this async for files
Expand Down Expand Up @@ -2074,30 +2081,34 @@ impl BlobExt for Blob {
}

fn get_size(&self, _: &JSGlobalObject) -> JSValue {
if self.size.get() == MAX_SIZE {
let mut size = self.size.get();
if self.needs_to_read_file() {
size = file_view_size(self);
// A pipe or FIFO keeps `MAX_SIZE`: its length is not known.
if size == MAX_SIZE {
return JSValue::js_number(f64::INFINITY);
}
} else if size == 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() {
size = self.size.get();
if size == 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)
JSValue::js_number(size as f64)
}

/// A file view keeps its window: the `st_size` of a file is a hint, never a byte budget (#4930).
fn resolve_size(&self) {
// Still stats a file store: readers and `exists()` use what it caches.
let (offset, size) = self.resolved_size();
self.offset.set(offset);
self.size.set(size);
if !self.needs_to_read_file() {
self.offset.set(offset);
self.size.set(size);
}
}

/// The `(offset, size)` of this view against its store, without touching
Expand Down Expand Up @@ -3887,7 +3898,7 @@ fn on_structured_clone_deserialize<B: AsRef<[u8]>>(
// make shared_view() slice past the end of the backing store (OOB heap read).
blob.offset.set(offset as SizeType); // intentional truncate
if let Some(size) = file_size {
// resolve_size() clamps this to the actual file size on first use.
// A window past EOF is fine: readers stop at EOF.
if size != MAX_SIZE {
blob.size.set(size as SizeType);
}
Expand Down Expand Up @@ -5752,6 +5763,35 @@ fn window_size(current: SizeType, available: SizeType) -> SizeType {
}
}

fn is_statted_file(blob: &Blob) -> bool {
blob.store
.get()
.as_ref()
.is_some_and(|store| match Store::data_mut(store).tag() {
store::DataTag::File => Store::data_mut(store).as_file().is_statted(),
_ => false,
})
}

/// The size of a file view, which caches no `stat` size. A window asks its store only after a `stat`, so `slice()` runs none.
fn file_view_size(blob: &Blob) -> SizeType {
if blob.size.get() == MAX_SIZE || is_statted_file(blob) {
blob.resolved_size().1
} else {
blob.size.get()
}
}

/// The length that a negative `slice()` index counts back from: the file's after a `stat`, else the view's own.
fn end_relative_length(blob: &Blob) -> i64 {
let length = if is_statted_file(blob) {
blob.resolved_size().1
} else {
blob.size.get()
};
i64::try_from(length).expect("int cast")
}

/// resolve file stat like size, last_modified
fn resolve_file_stat(store: &RefPtr<Store>) {
// `Store::data_mut` encapsulates the raw-pointer deref under the
Expand Down
25 changes: 24 additions & 1 deletion src/runtime/webcore/FileReader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,8 @@ pub(crate) struct OpenedFileBlob {
pub(crate) nonblocking: bool,
#[cfg(not(windows))]
pub(crate) file_type: FileType,
/// The size from this open's `fstat`, for a regular file whose store was statted before: the reader stops there.
pub(crate) known_size: Option<usize>,
}

impl Default for OpenedFileBlob {
Expand All @@ -117,6 +119,7 @@ impl Default for OpenedFileBlob {
nonblocking: true,
#[cfg(not(windows))]
file_type: FileType::File,
known_size: None,
}
}
}
Expand Down Expand Up @@ -218,6 +221,9 @@ impl Lazy {

if sys::S::ISREG(mode) {
is_nonblocking = false;
if stat.st_size > 0 && file.is_statted() {
this.known_size = usize::try_from(stat.st_size).ok();
}
}

// pollable: `S.ISFIFO(mode) or S.ISSOCK(mode)`
Expand Down Expand Up @@ -247,6 +253,14 @@ impl Lazy {
}
}

// Windows runs no `fstat` at open: one size query, only when the earlier stat found a regular file.
#[cfg(windows)]
if file.seekable == Some(true) {
if let Ok(size @ 1..) = sys::get_file_size(fd) {
this.known_size = usize::try_from(size).ok();
}
}

this.fd = fd;

Ok(this)
Expand Down Expand Up @@ -312,6 +326,7 @@ impl FileReader {
let mut pollable = false;
#[cfg(unix)]
let mut file_type = FileType::File;
let mut known_size: Option<usize> = None;
// R-2: move the `Lazy` out of the cell up-front (it's reset to `None`
// on every path through the original `if let` body) so the `RefPtr<Store>`
// is owned locally and the cell borrow is released immediately.
Expand All @@ -334,6 +349,7 @@ impl FileReader {
debug_assert!(opened.fd.is_valid());
self.fd.set(opened.fd);
pollable = opened.pollable;
known_size = opened.known_size;
#[cfg(unix)]
{
file_type = opened.file_type;
Expand Down Expand Up @@ -396,7 +412,14 @@ impl FileReader {
unsafe { (*self.parent()).increment_count() };
self.waiting_for_on_reader_done.set(true);
}
self.reader().set_limit(self.max_size);
let limit = match known_size {
Some(size) => {
let available = size.saturating_sub(self.start_offset.unwrap_or(0));
Some(self.max_size.map_or(available, |max| max.min(available)))
}
None => self.max_size,
};
self.reader().set_limit(limit);
let start_result = if let Some(offset) = self.start_offset {
self.reader()
.start_file_offset(self.fd.get(), pollable, offset)
Expand Down
42 changes: 27 additions & 15 deletions src/runtime/webcore/blob/read_file.rs
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,20 @@ pub(crate) enum ReadFileResultType {
/// The completion token a `ReadFile` keeps across its async I/O.
pub(crate) type ReadFileTask = bun_jsc::Completion<ReadFile>;

/// The read of a store that was statted before stops at the size from its own `fstat`: no `read()` to find EOF.
fn stop_at_own_size(
file_store: &FileStore,
max_length: SizeType,
size: SizeType,
offset: SizeType,
) -> SizeType {
if file_store.is_statted() {
max_length.min(size.saturating_sub(offset))
} else {
max_length
}
}

// SAFETY: file store / byte store / blob store ref (atomic), the read buffer and io-loop
// registration state — nothing thread-affine. What the bytes are delivered to lives in the job's
// JS side (`ReadFileCompletionFns`), never here.
Expand Down Expand Up @@ -726,6 +740,12 @@ impl ReadFile {
SizeType::try_from((stat.st_size as i64).max(0).min(MAX_SIZE as i64)).unwrap();

if stat.st_size > 0 && !self.could_block {
self.max_length = stop_at_own_size(
&self.file_store,
self.max_length,
self.total_size,
self.offset,
);
self.size = self.total_size.min(self.max_length);
// read up to 4k at a time if
// they didn't explicitly set a size and we're reading from something that's not a regular file
Expand All @@ -752,15 +772,6 @@ impl ReadFile {
return self.on_finish();
}

// Special files might report a size of > 0, and be wrong.
// so we should check specifically that its a regular file before trusting the size.
if self.size == 0 && bun_sys::is_regular_file(self.file_store.mode) {
self.buffer = Vec::new();

self.on_finish();
return;
}

// add an extra 16 bytes to the buffer to avoid having to resize it for trailing extra data
if !self.could_block || (self.size > 0 && self.size != MAX_SIZE) {
let want = (self.size as usize).saturating_add(16);
Expand Down Expand Up @@ -856,12 +867,7 @@ impl ReadFile {
// SAFETY: read() wrote `read_amount` initialized bytes into spare capacity.
unsafe { bun_core::vec::commit_spare(&mut buffer, read_amount) };
}
// - If they DID set a max length, we should stop
// reading after that.
//
// - If they DID NOT set a max_length, then it will
// be Blob.max_size which is an impossibly large
// amount to read.
// `max_length` is the end of a `slice()` window, this read's `fstat` size, or `MAX_SIZE`.
if !self.read_eof && buffer.len() >= self.max_length as usize {
break;
}
Expand Down Expand Up @@ -1252,6 +1258,12 @@ impl<'a> ReadFileUV<'a> {
log!("is_regular_file: {}", this.is_regular_file);

if stat.size() > 0 && this.is_regular_file {
this.max_length = stop_at_own_size(
&this.file_store,
this.max_length,
this.total_size,
this.offset,
);
this.size = this.total_size.min(this.max_length);
} else if stat.size() == 0 && !this.is_regular_file {
// read up to 4k at a time if they didn't explicitly set a size and
Expand Down
Loading
Loading