From aa15f80ade483cd81ab3792232cc6f5ee73a7396 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 24 Sep 2026 15:44:39 +0000 Subject: [PATCH 1/2] Bun.file reads: stop at the reader's own fstat size once the store is statted A Bun.file() read that knows nothing about the file reads until read() returns 0. That last call is an EOF probe: it returns no bytes. A read after `.size` or `exists()` skips the probe, because the blob carries the cached stat size as its limit. A store can be statted while the size of the blob stays unknown: `.lastModified`, `Response.clone()` and `.body` on another wrapper all do that. Those reads paid the probe although the size of the file was known. ReadFile, ReadFileUV and FileReader now bound such a read by the size from the fstat that they already run at open. The bound applies only when the store was statted before the read, and the fstat reports a regular file with a size above 0. A size of 0 stays unbounded, because a procfs file reports 0 and has content. Read syscalls per operation on a 64 KiB file (/proc/self/io, debug build): .lastModified then text(): 2.04 -> 1.04 .lastModified then stream(): 2.10 -> 1.09 fresh text() and stream(): 2.03 and 2.10, unchanged .size then text() and stream(): 1.03 and 1.09, unchanged --- src/runtime/webcore/FileReader.rs | 25 +++++++++- src/runtime/webcore/blob/read_file.rs | 15 ++++++ test/js/bun/util/bun-file-read.test.ts | 67 +++++++++++++++++++++++++- 3 files changed, 105 insertions(+), 2 deletions(-) diff --git a/src/runtime/webcore/FileReader.rs b/src/runtime/webcore/FileReader.rs index c22703418fec..d18c44135a3d 100644 --- a/src/runtime/webcore/FileReader.rs +++ b/src/runtime/webcore/FileReader.rs @@ -107,6 +107,11 @@ pub(crate) struct OpenedFileBlob { pub(crate) nonblocking: bool, #[cfg(not(windows))] pub(crate) file_type: FileType, + /// Size from this open's `fstat`, for a regular file whose store was + /// statted before the open. The reader stops there, so it needs no extra + /// `read()` to find EOF. + #[cfg(not(windows))] + pub(crate) known_size: Option, } impl Default for OpenedFileBlob { @@ -117,6 +122,8 @@ impl Default for OpenedFileBlob { nonblocking: true, #[cfg(not(windows))] file_type: FileType::File, + #[cfg(not(windows))] + known_size: None, } } } @@ -218,6 +225,9 @@ impl Lazy { if sys::S::ISREG(mode) { is_nonblocking = false; + if stat.st_size > 0 && file.seekable.is_some() { + this.known_size = usize::try_from(stat.st_size).ok(); + } } // pollable: `S.ISFIFO(mode) or S.ISSOCK(mode)` @@ -312,6 +322,8 @@ impl FileReader { let mut pollable = false; #[cfg(unix)] let mut file_type = FileType::File; + #[cfg(unix)] + let mut known_size: Option = 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` // is owned locally and the cell borrow is released immediately. @@ -337,6 +349,7 @@ impl FileReader { #[cfg(unix)] { file_type = opened.file_type; + known_size = opened.known_size; } #[cfg(unix)] { @@ -396,7 +409,17 @@ impl FileReader { unsafe { (*self.parent()).increment_count() }; self.waiting_for_on_reader_done.set(true); } - self.reader().set_limit(self.max_size); + #[cfg(unix)] + 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, + }; + #[cfg(windows)] + let limit = 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) diff --git a/src/runtime/webcore/blob/read_file.rs b/src/runtime/webcore/blob/read_file.rs index 1209524a46d4..ffc36fefbe35 100644 --- a/src/runtime/webcore/blob/read_file.rs +++ b/src/runtime/webcore/blob/read_file.rs @@ -726,6 +726,14 @@ 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 { + // A store that was statted before this read holds a file of known + // size. Stop at this `fstat`'s size, so the read needs no extra + // `read()` to find EOF. + if self.file_store.seekable.is_some() { + self.max_length = self + .max_length + .min(self.total_size.saturating_sub(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 @@ -1252,6 +1260,13 @@ impl<'a> ReadFileUV<'a> { log!("is_regular_file: {}", this.is_regular_file); if stat.size() > 0 && this.is_regular_file { + // keep in sync with resolveSizeAndLastModified: a store that was + // statted before this read stops at this `fstat`'s size. + if this.file_store.seekable.is_some() { + this.max_length = this + .max_length + .min(this.total_size.saturating_sub(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 diff --git a/test/js/bun/util/bun-file-read.test.ts b/test/js/bun/util/bun-file-read.test.ts index 40c6309a0c13..7452a1a7b480 100644 --- a/test/js/bun/util/bun-file-read.test.ts +++ b/test/js/bun/util/bun-file-read.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { tempDir } from "harness"; +import { bunEnv, bunExe, isLinux, tempDir } from "harness"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -54,3 +54,68 @@ describe("Bun.file read-loop target selection", () => { expect(Bun.hash(buf)).toBe(Bun.hash(bytes.subarray(start, end))); }); }); + +// A Bun.file() whose store was already statted is a file of known size. Its +// readers stop at the size from their own fstat. They do not issue one more +// read() to find EOF. /proc/self/io counts the read syscalls of the subprocess. +describe.skipIf(!isLinux)("Bun.file read syscalls for a file of known size", () => { + const fixture = /* js */ ` + import { readFileSync } from "node:fs"; + const file = process.argv.at(-1); + const io = () => /^syscr: (\\d+)$/m.exec(readFileSync("/proc/self/io", "utf8")); + if (!io()) { + console.log(JSON.stringify({ supported: false })); + process.exit(0); + } + const syscr = () => Number(io()[1]); + const expected = readFileSync(file, "utf8"); + const drain = async stream => { + const decoder = new TextDecoder(); + let out = ""; + for await (const chunk of stream) out += decoder.decode(chunk, { stream: true }); + return out + decoder.decode(); + }; + const N = 100; + async function perOp(op) { + for (let i = 0; i < 5; i++) await op(); + const before = syscr(); + let ok = true; + for (let i = 0; i < N; i++) ok = (await op()) === expected && ok; + return { ok, readsPerOp: Math.round((syscr() - before) / N) }; + } + const open = touch => { const f = Bun.file(file); touch(f); return f; }; + console.log(JSON.stringify({ + supported: true, + lastModifiedThenText: await perOp(() => open(f => f.lastModified).text()), + lastModifiedThenStream: await perOp(() => drain(open(f => f.lastModified).stream())), + sizeThenText: await perOp(() => open(f => f.size).text()), + sizeThenStream: await perOp(() => drain(open(f => f.size).stream())), + })); + `; + + it("one read() per operation, no EOF probe", async () => { + using dir = tempDir("bun-file-read-count", { "data.txt": Buffer.alloc(64 * 1024, "a").toString() }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", fixture, path.join(String(dir), "data.txt")], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + const { supported, ...counts } = JSON.parse(stdout); + if (!supported) { + console.warn("/proc/self/io has no syscr on this kernel, nothing to count"); + return; + } + // One read() returns the whole 64 KiB. A second one per operation is the EOF probe. + expect(counts).toEqual({ + lastModifiedThenText: { ok: true, readsPerOp: 1 }, + lastModifiedThenStream: { ok: true, readsPerOp: 1 }, + sizeThenText: { ok: true, readsPerOp: 1 }, + sizeThenStream: { ok: true, readsPerOp: 1 }, + }); + expect(exitCode).toBe(0); + }); +}); From 22bb92a1da79c803c882ec30640b16e70a861a7e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 24 Sep 2026 17:37:27 +0000 Subject: [PATCH 2/2] FileReader: take the known size from the open handle on Windows too No fstat runs when FileReader opens a file on Windows, so the stream reader had no size of its own there and could only stop at EOF. For a store that was already statted as a regular file, ask the open handle for its size (GetFileSizeEx: one call, no threadpool hop). The reader then stops at that size, as it does on POSIX. --- src/runtime/webcore/FileReader.rs | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/src/runtime/webcore/FileReader.rs b/src/runtime/webcore/FileReader.rs index d18c44135a3d..d2ca98a17403 100644 --- a/src/runtime/webcore/FileReader.rs +++ b/src/runtime/webcore/FileReader.rs @@ -107,10 +107,9 @@ pub(crate) struct OpenedFileBlob { pub(crate) nonblocking: bool, #[cfg(not(windows))] pub(crate) file_type: FileType, - /// Size from this open's `fstat`, for a regular file whose store was - /// statted before the open. The reader stops there, so it needs no extra - /// `read()` to find EOF. - #[cfg(not(windows))] + /// Size taken from the open fd, for a regular file whose store was statted + /// before the open. The reader stops there, so it needs no extra read to + /// find EOF. pub(crate) known_size: Option, } @@ -122,7 +121,6 @@ impl Default for OpenedFileBlob { nonblocking: true, #[cfg(not(windows))] file_type: FileType::File, - #[cfg(not(windows))] known_size: None, } } @@ -257,6 +255,14 @@ impl Lazy { } } + // No `fstat` runs at open on Windows, so ask for the size alone. + #[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) @@ -322,7 +328,6 @@ impl FileReader { let mut pollable = false; #[cfg(unix)] let mut file_type = FileType::File; - #[cfg(unix)] let mut known_size: Option = 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` @@ -346,10 +351,10 @@ 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; - known_size = opened.known_size; } #[cfg(unix)] { @@ -409,7 +414,6 @@ impl FileReader { unsafe { (*self.parent()).increment_count() }; self.waiting_for_on_reader_done.set(true); } - #[cfg(unix)] let limit = match known_size { Some(size) => { let available = size.saturating_sub(self.start_offset.unwrap_or(0)); @@ -417,8 +421,6 @@ impl FileReader { } None => self.max_size, }; - #[cfg(windows)] - let limit = self.max_size; self.reader().set_limit(limit); let start_result = if let Some(offset) = self.start_offset { self.reader()