From 24d214a2e90e6611132755aee8c91decdc1dd7d6 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 24 Sep 2026 02:28:45 +0000 Subject: [PATCH 1/5] Read a regular file whose st_size is 0 instead of returning empty ReadFile skipped the read when its fstat said st_size 0 and the store's cached mode said regular file. That cache is only filled once something stats the shared store, which clone() does since #42053 through store_reads_repeatably. new Response(Bun.file('/proc/version')).clone() then made both copies and the Bun.file itself read as "": a procfs file is a regular file with st_size 0 and real content. Drop the shortcut and read to EOF. A truly empty file costs one read() that returns 0. --- src/runtime/webcore/blob/read_file.rs | 9 --------- test/js/web/fetch/body-clone.test.ts | 17 ++++++++++++++++- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/src/runtime/webcore/blob/read_file.rs b/src/runtime/webcore/blob/read_file.rs index 1209524a46d4..3cda48da08d5 100644 --- a/src/runtime/webcore/blob/read_file.rs +++ b/src/runtime/webcore/blob/read_file.rs @@ -752,15 +752,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); diff --git a/test/js/web/fetch/body-clone.test.ts b/test/js/web/fetch/body-clone.test.ts index 2b1a40941e22..863ab51976ce 100644 --- a/test/js/web/fetch/body-clone.test.ts +++ b/test/js/web/fetch/body-clone.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { bunEnv, bunExe, isASAN, isDebug, isWindows, tempDirWithFiles } from "harness"; +import { bunEnv, bunExe, isASAN, isDebug, isLinux, isWindows, tempDirWithFiles } from "harness"; import net from "node:net"; import { join } from "node:path"; @@ -1107,6 +1107,21 @@ describe("clone() of a body over an unread native stream keeps the Blob behind i expect(await typesAndText(response)).toEqual(expected); }); + // clone() stats the file to learn whether it can be read twice. A procfs file is a regular + // file whose st_size is 0, and that cached stat must not turn a read into "". + test.skipIf(!isLinux)("Response over a procfs Bun.file(): both copies and the Bun.file() read it", async () => { + const content = await Bun.file("/proc/version").text(); + expect(content).not.toBe(""); + const procfs = Bun.file("/proc/version"); + const original = new Response(procfs); + const clone = original.clone(); + expect({ + clone: await clone.text(), + original: await original.text(), + file: await procfs.text(), + }).toEqual({ clone: content, original: content, file: content }); + }); + test("the stream given to the constructor is locked after clone() and .body is a fresh stream", async () => { const stream = file().stream(); const response = new Response(stream); From ca8abd44ef4122598dfcebd6501f91c05f6197ef Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 24 Sep 2026 03:18:24 +0000 Subject: [PATCH 2/5] Test that text() still reads a procfs Bun.file() after the body getter stat'd it --- test/js/web/fetch/body.test.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/test/js/web/fetch/body.test.ts b/test/js/web/fetch/body.test.ts index 0a71d6f50ad0..2a43f91a6d34 100644 --- a/test/js/web/fetch/body.test.ts +++ b/test/js/web/fetch/body.test.ts @@ -1,6 +1,6 @@ import { file, spawn, version, type Socket } from "bun"; import { describe, expect, test } from "bun:test"; -import { bunEnv, bunExe, exampleSite, tempDir } from "harness"; +import { bunEnv, bunExe, exampleSite, isLinux, tempDir } from "harness"; import net from "net"; import { join } from "node:path"; import { isDisturbed, isErrored, isReadable, Readable } from "node:stream"; @@ -352,6 +352,20 @@ for (const { body, fn } of bodyTypes) { expect([Buffer.from(await subject.bytes()).toString(), subject.bodyUsed]).toEqual(["defgh", true]); }); }); + + // A procfs file is a regular file whose st_size is 0. The body getter stats the + // file, and that cached stat must not turn a later read of the Bun.file() into "". + describe.skipIf(!isLinux)("made from a procfs Bun.file()", () => { + const path = "/proc/version"; + + test("the body getter does not make a later text() on the same Bun.file() empty", async () => { + const expected = await Bun.file(path).text(); + expect(expected).not.toBe(""); + const file = Bun.file(path); + expect(fn(file).body).toBeInstanceOf(ReadableStream); + expect(await file.text()).toBe(expected); + }); + }); }); for (const { string, buffer } of utf8) { describe("arrayBuffer()", () => { From b2146809a62a3bf92ff9e9cbcc45aea13eb45fac Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 25 Sep 2026 10:46:43 +0000 Subject: [PATCH 3/5] Bun.file: do not use a cached stat size as the byte budget of a read or a copy `Blob::resolve_size` copied the cached `st_size` of a file onto the blob. Readers and the file copy of `Bun.write` used it as a byte budget. A procfs file reports 0 bytes and has content, and a file can grow. - `resolve_size` stores no size on a file view. A file view keeps `MAX_SIZE` ("until EOF") unless `slice()` set a window. `.size`, `expect()` and the HEAD `Content-Length` take the length from `resolved_size()`. - After a stat, `ReadFile`, `ReadFileUV` and `FileReader` stop at the size from their own `fstat`, so a read after `.size` needs no `read()` at EOF. On Windows the stream reader asks `GetFileSizeEx`. - `slice()` on a handle that nothing statted behaves as before and runs no stat. After a stat, an index from the end counts from the length of the stat, and `.size` of a window stops at that length. - `do_sendfile` clamps the window against its own `fstat` before it decides if the response is a range. --- src/jsc/webcore_types.rs | 6 + src/runtime/server/RequestContext.rs | 18 +- src/runtime/webcore/Blob.rs | 97 +++-- src/runtime/webcore/FileReader.rs | 25 +- src/runtime/webcore/blob/read_file.rs | 34 +- test/js/bun/http/bun-serve-file.test.ts | 41 ++- test/js/bun/http/serve.test.ts | 100 +++++ test/js/bun/io/bun-write.test.js | 93 +++++ test/js/bun/util/bun-file-fd-read.test.ts | 20 +- test/js/bun/util/bun-file-read.test.ts | 426 +++++++++++++++++++++- test/js/bun/util/bun-stdin-slice.test.ts | 24 +- test/js/web/fetch/blob.test.ts | 2 +- 12 files changed, 835 insertions(+), 51 deletions(-) diff --git a/src/jsc/webcore_types.rs b/src/jsc/webcore_types.rs index 56a871291ab1..1dd5a098bd19 100644 --- a/src/jsc/webcore_types.rs +++ b/src/jsc/webcore_types.rs @@ -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() + } } // ──────────────────────────────────────────────────────────────────── diff --git a/src/runtime/server/RequestContext.rs b/src/runtime/server/RequestContext.rs index 9aa66e885445..4f1553791230 100644 --- a/src/runtime/server/RequestContext.rs +++ b/src/runtime/server/RequestContext.rs @@ -1869,11 +1869,6 @@ 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 @@ -1881,6 +1876,10 @@ where .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); @@ -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 // 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() { @@ -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 { diff --git a/src/runtime/webcore/Blob.rs b/src/runtime/webcore/Blob.rs index fcac203eded1..b9b862bf0c5b 100644 --- a/src/runtime/webcore/Blob.rs +++ b/src/runtime/webcore/Blob.rs @@ -747,9 +747,8 @@ impl BlobExt for Blob { writer.write_int_le::(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. An unsliced blob writes + // `MAX_SIZE` (unknown), and the receiver stats the file, like v3. writer.write_int_le::(self.size.get())?; self.resolve_size(); store.serialize(writer)?; @@ -1840,6 +1839,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 = None; // Mutate the fixed-3 args array in place to shift the string arg into [2]. if args[0].is_string() { @@ -1856,9 +1857,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")); } @@ -1869,14 +1869,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() { @@ -2002,9 +2005,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. @@ -2016,11 +2024,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 { // TODO: make this async for files @@ -2074,30 +2082,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 @@ -3887,7 +3899,7 @@ fn on_structured_clone_deserialize>( // 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); } @@ -5752,6 +5764,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::data_mut` encapsulates the raw-pointer deref under the diff --git a/src/runtime/webcore/FileReader.rs b/src/runtime/webcore/FileReader.rs index c22703418fec..994bc5e45a73 100644 --- a/src/runtime/webcore/FileReader.rs +++ b/src/runtime/webcore/FileReader.rs @@ -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, } impl Default for OpenedFileBlob { @@ -117,6 +119,7 @@ impl Default for OpenedFileBlob { nonblocking: true, #[cfg(not(windows))] file_type: FileType::File, + known_size: None, } } } @@ -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)` @@ -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) @@ -312,6 +326,7 @@ impl FileReader { let mut pollable = false; #[cfg(unix)] let mut file_type = FileType::File; + 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. @@ -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; @@ -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) diff --git a/src/runtime/webcore/blob/read_file.rs b/src/runtime/webcore/blob/read_file.rs index 3cda48da08d5..8c31364529d1 100644 --- a/src/runtime/webcore/blob/read_file.rs +++ b/src/runtime/webcore/blob/read_file.rs @@ -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; +/// 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. @@ -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 @@ -847,12 +867,8 @@ 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, or the `fstat` size + // of this read (`stop_at_own_size`), or `MAX_SIZE`. if !self.read_eof && buffer.len() >= self.max_length as usize { break; } @@ -1243,6 +1259,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 diff --git a/test/js/bun/http/bun-serve-file.test.ts b/test/js/bun/http/bun-serve-file.test.ts index 9d5bc5b7bbd6..36d3495e637a 100644 --- a/test/js/bun/http/bun-serve-file.test.ts +++ b/test/js/bun/http/bun-serve-file.test.ts @@ -1,8 +1,8 @@ -import type { Server } from "bun"; +import type { BunFile, Server } from "bun"; import { afterAll, beforeAll, describe, expect, it, mock, test } from "bun:test"; import { bunEnv, bunExe, isASAN, isLinux, isWindows, rmScope, rss, tempDir, tempDirWithFiles } from "harness"; import { mkfifo } from "mkfifo"; -import { closeSync, openSync, unlinkSync, writeSync } from "node:fs"; +import { appendFileSync, closeSync, openSync, unlinkSync, writeFileSync, writeSync } from "node:fs"; import { join } from "node:path"; const LARGE_SIZE = 1024 * 1024 * 8; @@ -1304,6 +1304,43 @@ test("file routes frame a slice that reaches or starts past EOF by the bytes the }); }); +describe.concurrent("a reused Bun.file() handle after the file changes", () => { + const stats = [ + [".size", (handle: BunFile) => handle.size], + ["await exists()", (handle: BunFile) => handle.exists()], + [".lastModified", (handle: BunFile) => handle.lastModified], + ] as const; + const changes = [ + ["grows", "1234", (path: string) => appendFileSync(path, "5678"), "12345678"], + ["shrinks", "12345678", (path: string) => writeFileSync(path, "12"), "12"], + ] as const; + + describe.each(stats)("%s before the change", (_stat, stat) => { + it.each(changes)("the file %s", async (_change, before, change, after) => { + using dir = tempDir("serve-reused-handle", { "file.txt": before }); + const path = join(String(dir), "file.txt"); + const handle = Bun.file(path); + await stat(handle); + change(path); + await using server = Bun.serve({ port: 0, fetch: () => new Response(handle) }); + + const get = async (headers: Record = {}) => { + const response = await fetch(server.url, { headers }); + return { + status: response.status, + contentLength: response.headers.get("content-length"), + contentRange: response.headers.get("content-range"), + body: await response.text(), + }; + }; + expect({ whole: await get(), range: await get({ Range: "bytes=0-1" }) }).toEqual({ + whole: { status: 200, contentLength: String(after.length), contentRange: null, body: after }, + range: { status: 206, contentLength: "2", contentRange: `bytes 0-1/${after.length}`, body: after.slice(0, 2) }, + }); + }); + }); +}); + // A request that declares a body arms the request-body (onData) callback on // the uWS response before the fetch handler runs. uWS keeps a single shared // userdata slot per response, so when the handler returns a file response diff --git a/test/js/bun/http/serve.test.ts b/test/js/bun/http/serve.test.ts index e42c3d7a5974..e4ad3a173e29 100644 --- a/test/js/bun/http/serve.test.ts +++ b/test/js/bun/http/serve.test.ts @@ -2530,6 +2530,106 @@ describe("should support Content-Range with Bun.file()", () => { }); }); } + + describe("a slice() that ends after the end of the file", () => { + const content = "0123456789"; + const size = content.length; + + // fetch() waits for the bytes that a wrong Content-Length declares, so read the wire + function wire(port: number, method: string, pathname: string) { + const { promise, resolve, reject } = Promise.withResolvers<{ + status: number; + contentLength: string | undefined; + contentRange: string | undefined; + body: string; + }>(); + const chunks: Buffer[] = []; + const socket = net.connect(port, "127.0.0.1", () => { + socket.write(`${method} ${pathname} HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n`); + }); + socket.on("data", chunk => chunks.push(chunk)); + socket.on("error", reject); + socket.on("close", () => { + const raw = Buffer.concat(chunks).toString("latin1"); + const headEnd = raw.indexOf("\r\n\r\n"); + const [statusLine, ...lines] = raw.slice(0, headEnd).split("\r\n"); + const headers: Record = {}; + for (const line of lines) { + const colon = line.indexOf(":"); + headers[line.slice(0, colon).toLowerCase()] = line.slice(colon + 1).trim(); + } + resolve({ + status: Number(statusLine.split(" ")[1]), + contentLength: headers["content-length"], + contentRange: headers["content-range"], + body: raw.slice(headEnd + 4), + }); + }); + return promise; + } + + describe.each([ + ["a fresh Bun.file()", (path: string) => Bun.file(path)], + [ + "a Bun.file() after .size", + (path: string) => { + const handle = Bun.file(path); + void handle.size; + return handle; + }, + ], + ] as const)("%s", (_label, open) => { + it("fetch handler: slice(5, size + 5) is a 206 of the 5 bytes that exist", async () => { + using dir = tempDir("serve-slice-after-eof", { "ten.txt": content }); + const path = join(String(dir), "ten.txt"); + await using server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch: () => new Response(open(path).slice(5, size + 5)), + }); + const get = await wire(server.port, "GET", "/"); + const head = await wire(server.port, "HEAD", "/"); + expect({ get, head: { contentLength: head.contentLength, body: head.body } }).toEqual({ + get: { status: 206, contentLength: "5", contentRange: "bytes 5-9/*", body: "56789" }, + head: { contentLength: "5", body: "" }, + }); + }); + + it("fetch handler: slice(size, 2 * size) is an empty body with Content-Length: 0", async () => { + using dir = tempDir("serve-slice-after-eof", { "ten.txt": content }); + const path = join(String(dir), "ten.txt"); + await using server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch: () => new Response(open(path).slice(size, 2 * size)), + }); + const { contentLength, body } = await wire(server.port, "GET", "/"); + expect({ contentLength, body }).toEqual({ contentLength: "0", body: "" }); + }); + + it("static route: both slices declare the bytes they send", async () => { + using dir = tempDir("serve-slice-after-eof", { "ten.txt": content }); + const path = join(String(dir), "ten.txt"); + await using server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + routes: { + "/tail": new Response(open(path).slice(5, size + 5)), + "/empty": new Response(open(path).slice(size, 2 * size)), + }, + fetch: () => new Response("fallback", { status: 404 }), + }); + const framing = async (pathname: string) => { + const { contentLength, body } = await wire(server.port, "GET", pathname); + return { contentLength, body }; + }; + expect({ tail: await framing("/tail"), empty: await framing("/empty") }).toEqual({ + tail: { contentLength: "5", body: "56789" }, + empty: { contentLength: "0", body: "" }, + }); + }); + }); + }); }); it("formats error responses correctly", async () => { diff --git a/test/js/bun/io/bun-write.test.js b/test/js/bun/io/bun-write.test.js index ad575d0b98a6..60e36eb29c5e 100644 --- a/test/js/bun/io/bun-write.test.js +++ b/test/js/bun/io/bun-write.test.js @@ -1554,3 +1554,96 @@ int posix_fadvise(int fd, off_t offset, off_t len, int advice) { expect(exitCode).toBe(0); }); }); + +// `.size`, exists() and structuredClone() stat the destination. That stat is +// not a limit on the copy: only a slice() of the destination is (#4930). +(isWindows ? describe : describe.concurrent)("a stat of the destination does not limit a file-to-file copy", () => { + const content = "0123456789"; + const setup = (name, existing) => { + const dir = tempDir(name, { "src.txt": content, ...(existing === undefined ? {} : { "dest.txt": existing }) }); + return { dir, src: Bun.file(join(String(dir), "src.txt")), dest: join(String(dir), "dest.txt") }; + }; + const onDisk = p => fs.readFileSync(p, "utf8"); + const triggers = [ + [".size", f => void f.size], + ["exists()", f => f.exists()], + ["structuredClone()", f => void structuredClone(f)], + ]; + + it.each(triggers)("%s on a destination that does not exist yet", async (_name, touch) => { + const { dir, src, dest } = setup("bun-write-dest-missing", undefined); + using _ = dir; + const out = Bun.file(dest); + await touch(out); + expect([await Bun.write(out, src), onDisk(dest)]).toEqual([10, content]); + }); + + it.each(triggers)("%s on a shorter destination", async (_name, touch) => { + const { dir, src, dest } = setup("bun-write-dest-shorter", "abc"); + using _ = dir; + const out = Bun.file(dest); + await touch(out); + expect([await Bun.write(out, src), onDisk(dest)]).toEqual([10, content]); + }); + + it.skipIf(isWindows)(".size on a shorter destination that is a file descriptor", async () => { + const { dir, src, dest } = setup("bun-write-dest-fd", "abc"); + using _ = dir; + const fd = fs.openSync(dest, "r+"); + try { + const out = Bun.file(fd); + expect(out.size).toBe(3); + expect([await Bun.write(out, src), onDisk(dest)]).toEqual([10, content]); + } finally { + fs.closeSync(fd); + } + }); + + it("blob.write(file) after exists()", async () => { + const { dir, src, dest } = setup("bun-write-dest-method", undefined); + using _ = dir; + const out = Bun.file(dest); + expect(await out.exists()).toBe(false); + expect([await out.write(src), onDisk(dest)]).toEqual([10, content]); + }); + + it("a Response source after exists()", async () => { + const { dir, src, dest } = setup("bun-write-dest-response", undefined); + using _ = dir; + const out = Bun.file(dest); + expect(await out.exists()).toBe(false); + expect([await Bun.write(out, new Response(src)), onDisk(dest)]).toEqual([10, content]); + }); + + it("a slice() of the destination that is longer than the source", async () => { + const { dir, src, dest } = setup("bun-write-dest-slice-long", "abc"); + using _ = dir; + const out = Bun.file(dest); + expect(out.size).toBe(3); + expect([await Bun.write(out.slice(0, 100), src), onDisk(dest)]).toEqual([10, content]); + }); + + it("a slice() of the destination is still the limit", async () => { + const { dir, src, dest } = setup("bun-write-dest-slice", "abc"); + using _ = dir; + const out = Bun.file(dest); + expect(out.size).toBe(3); + expect([await Bun.write(out.slice(0, 4), src), onDisk(dest)]).toEqual([4, "0123"]); + }); + + it("text() returns what write() wrote, on one handle (#23902)", async () => { + const { dir, dest } = setup("bun-write-then-text", undefined); + using _ = dir; + const file = Bun.file(dest); + expect(await file.exists()).toBe(false); + const seen = []; + const written = []; + for (let i = 1; i <= 8; i++) { + const data = Buffer.alloc(i * 4, "asdf").toString(); + await file.write(data); + written.push(data); + seen.push(await file.text()); + } + expect(seen).toEqual(written); + }); +}); diff --git a/test/js/bun/util/bun-file-fd-read.test.ts b/test/js/bun/util/bun-file-fd-read.test.ts index fe1e53191e31..29bcaa3ab81d 100644 --- a/test/js/bun/util/bun-file-fd-read.test.ts +++ b/test/js/bun/util/bun-file-fd-read.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { closeSync, openSync } from "fs"; +import { appendFileSync, closeSync, openSync } from "fs"; import { isWindows, tempDir } from "harness"; import { join } from "path"; @@ -49,4 +49,22 @@ describe.skipIf(isWindows)("Bun.file(fd) read", () => { expect(await withFd(path, fd => Bun.file(fd).text())).toBe(""); expect((await withFd(path, fd => Bun.file(fd).arrayBuffer())).byteLength).toBe(0); }); + + // A stat of the handle does not limit a later read of the same handle. + test.each([ + [".size", (f: Bun.BunFile) => void f.size], + ["exists()", (f: Bun.BunFile) => f.exists()], + [".lastModified", (f: Bun.BunFile) => void f.lastModified], + ])("%s, then the file grows: text() returns all of it", async (_name, stat) => { + using dir = tempDir("bun-file-fd-read", { "fd-grow.txt": "0123456789" }); + const path = join(String(dir), "fd-grow.txt"); + + const text = await withFd(path, async fd => { + const file = Bun.file(fd); + await stat(file); + appendFileSync(path, "abcdefghij"); + return file.text(); + }); + expect(text).toBe("0123456789abcdefghij"); + }); }); diff --git a/test/js/bun/util/bun-file-read.test.ts b/test/js/bun/util/bun-file-read.test.ts index 40c6309a0c13..aa8bcfc831b2 100644 --- a/test/js/bun/util/bun-file-read.test.ts +++ b/test/js/bun/util/bun-file-read.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "bun:test"; -import { tempDir } from "harness"; +import { bunEnv, bunExe, isLinux, tempDir } from "harness"; +import { appendFileSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -54,3 +55,426 @@ describe("Bun.file read-loop target selection", () => { expect(Bun.hash(buf)).toBe(Bun.hash(bytes.subarray(start, end))); }); }); + +// `syscr` in /proc/self/io counts the read syscalls of a process. +const hasSyscr = + isLinux && + (() => { + try { + return /^syscr: \d+$/m.test(readFileSync("/proc/self/io", "utf8")); + } catch { + return false; + } + })(); + +// After a stat, a reader stops at the size from its own fstat and issues no read() to find EOF. +describe.skipIf(!hasSyscr)("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 syscr = () => Number(/^syscr: (\\d+)$/m.exec(readFileSync("/proc/self/io", "utf8"))[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; + const reads = (syscr() - before) / N; + return { ok, readsPerOp: Math.round(reads), reads }; + } + const open = touch => { const f = Bun.file(file); touch(f); return f; }; + console.log(JSON.stringify({ + 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())), + bodyStream: await perOp(() => drain(new Response(Bun.file(file)).body)), + })); + `; + + 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 measured: Record = JSON.parse(stdout); + const counts = Object.fromEntries( + Object.entries(measured).map(([k, v]) => [k, { ok: v.ok, readsPerOp: v.readsPerOp }]), + ); + const one = { ok: true, readsPerOp: 1 }; + const want = { + lastModifiedThenText: one, + lastModifiedThenStream: one, + sizeThenText: one, + sizeThenStream: one, + bodyStream: one, + }; + if (!Bun.deepEquals(counts, want)) console.error("read syscalls per operation, not rounded:", measured); + // One read() returns the whole 64 KiB. A second one per operation is the EOF probe. + expect(counts).toEqual(want); + expect(exitCode).toBe(0); + }); +}); + +// A stat size is a hint: a procfs file reports 0 bytes and has content, and a file can grow. +describe("a stat of a Bun.file() does not limit a later read", () => { + const triggers = { + ".size": (f: Bun.BunFile) => void f.size, + "exists()": (f: Bun.BunFile) => f.exists(), + ".lastModified": (f: Bun.BunFile) => void f.lastModified, + "structuredClone()": (f: Bun.BunFile) => void structuredClone(f), + }; + type Trigger = keyof typeof triggers; + const drain = async (stream: ReadableStream) => + Buffer.from(await new Response(stream).bytes()).toString(); + const echo = () => Bun.serve({ port: 0, fetch: async req => new Response(await req.text()) }); + + describe.skipIf(!isLinux)("procfs file (st_size is 0)", () => { + const procfs = "/proc/version"; + // Read inside the tests: a skipped describe still runs this callback on macOS and Windows. + const content = () => readFileSync(procfs, "utf8"); + + it.each(Object.keys(triggers) as Trigger[])("%s, then every reader returns the content", async name => { + const expected = content(); + const touched = async () => { + const f = Bun.file(procfs); + await triggers[name](f); + return f; + }; + await using server = echo(); + const form = new FormData(); + form.append("f", await touched()); + + expect(expected.length).toBeGreaterThan(0); + expect({ + size: (await touched()).size, + text: await (await touched()).text(), + bytes: Buffer.from(await (await touched()).bytes()).toString(), + arrayBuffer: Buffer.from(await (await touched()).arrayBuffer()).toString(), + stream: await drain((await touched()).stream()), + responseBody: await drain(new Response(await touched()).body!), + responseText: await new Response(await touched()).text(), + formData: await ((await new Response(form).formData()).get("f") as File).text(), + fetchUpload: await (await fetch(server.url, { method: "POST", body: await touched() })).text(), + }).toEqual({ + size: 0, + text: expected, + bytes: expected, + arrayBuffer: expected, + stream: expected, + responseBody: expected, + responseText: expected, + formData: expected, + fetchUpload: expected, + }); + }); + + it.each(Object.keys(triggers) as Trigger[])("%s, then a slice still reads its window", async name => { + const expected = content(); + const f = Bun.file(procfs); + await triggers[name](f); + expect({ + text: await f.slice(0, 10).text(), + responseBody: await drain(new Response(f.slice(0, 10)).body!), + tail: await f.slice(6).text(), + }).toEqual({ + text: expected.slice(0, 10), + responseBody: expected.slice(0, 10), + tail: expected.slice(6), + }); + }); + }); + + it.each(Object.keys(triggers) as Trigger[])("%s, then the file grows: a read returns all of it", async name => { + using dir = tempDir("bun-file-read-grew", { "data.txt": "1234" }); + const p = path.join(String(dir), "data.txt"); + const f = Bun.file(p); + await triggers[name](f); + writeFileSync(p, "12345678"); + expect({ text: await f.text(), stream: await drain(f.stream()) }).toEqual({ + text: "12345678", + stream: "12345678", + }); + }); + + it("a fetch upload after .size sends the file as it is now", async () => { + // 40 KiB and more takes the sendfile path where there is one. + const before = Buffer.alloc(40 * 1024, "a").toString(); + const after = before + Buffer.alloc(40 * 1024, "b").toString(); + using dir = tempDir("bun-file-read-upload", { "data.txt": before }); + const p = path.join(String(dir), "data.txt"); + await using server = echo(); + + const f = Bun.file(p); + expect(f.size).toBe(before.length); + writeFileSync(p, after); + const sent = await (await fetch(server.url, { method: "POST", body: f })).text(); + expect(Bun.hash(sent)).toBe(Bun.hash(after)); + expect(sent.length).toBe(after.length); + }); + + it("an S3 upload after .size sends the file as it is now", async () => { + const received: number[] = []; + await using server = Bun.serve({ + port: 0, + async fetch(req) { + if (req.method === "PUT") received.push((await req.arrayBuffer()).byteLength); + return new Response("", { headers: { etag: '"x"' } }); + }, + }); + const client = new Bun.S3Client({ + endpoint: server.url.href, + bucket: "b", + accessKeyId: "a", + secretAccessKey: "b", + }); + using dir = tempDir("bun-file-read-s3", { "data.txt": "0123456789" }); + const p = path.join(String(dir), "data.txt"); + + const f = Bun.file(p); + expect(f.size).toBe(10); + appendFileSync(p, "abcdefghij"); + expect({ written: await client.write("key", f), received }).toEqual({ written: 20, received: [20] }); + }); + + it("an empty file reads empty after a stat", async () => { + using dir = tempDir("bun-file-read-empty", { "empty.txt": "" }); + const f = Bun.file(path.join(String(dir), "empty.txt")); + expect(await f.exists()).toBe(true); + expect({ size: f.size, text: await f.text(), stream: await drain(f.stream()) }).toEqual({ + size: 0, + text: "", + stream: "", + }); + }); + + it("exists() sees a file that is created after the first call", async () => { + using dir = tempDir("bun-file-exists-later", {}); + const p = path.join(String(dir), "later.txt"); + const f = Bun.file(p); + const before = { exists: await f.exists(), size: f.size }; + writeFileSync(p, "hello"); + expect({ before, after: { exists: await f.exists(), size: f.size, text: await f.text() } }).toEqual({ + before: { exists: false, size: 0 }, + after: { exists: true, size: 5, text: "hello" }, + }); + }); +}); + +// Each reader returns what the file has at the time of the read, whatever stat ran before. +describe("a read of a Bun.file() returns the file as it is now", () => { + const stats: Record unknown> = { + "no stat": () => {}, + ".size": f => f.size, + "exists()": f => f.exists(), + ".lastModified": f => f.lastModified, + "structuredClone()": f => structuredClone(f), + }; + const changes: Record void> = { + "no change": () => {}, + "grows": p => appendFileSync(p, "abcdefghij"), + "shrinks": p => writeFileSync(p, "0123"), + "is rewritten": p => writeFileSync(p, "ABCDEFGHIJ"), + }; + const readers: Record Promise> = { + "text()": f => f.text(), + "bytes()": async f => Buffer.from(await f.bytes()).toString(), + "arrayBuffer()": async f => Buffer.from(await f.arrayBuffer()).toString(), + "stream()": async f => Buffer.from(await Bun.readableStreamToBytes(f.stream())).toString(), + "new Response(f).text()": f => new Response(f).text(), + "slice(2, 30).text()": f => f.slice(2, 30).text(), + }; + + it.each(Object.keys(stats))("after %s", async stat => { + using dir = tempDir("bun-file-read-now", {}); + const wrong: string[] = []; + let n = 0; + for (const [change, mutate] of Object.entries(changes)) { + for (const [reader, read] of Object.entries(readers)) { + const p = path.join(String(dir), `${n++}.txt`); + writeFileSync(p, "0123456789"); + const f = Bun.file(p); + await stats[stat](f); + mutate(p); + const now = readFileSync(p, "utf8"); + const expected = reader.startsWith("slice") ? now.slice(2, 30) : now; + const got = await read(f); + if (got !== expected) wrong.push(`the file ${change}, ${reader}: ${JSON.stringify(got)}`); + } + } + expect(wrong).toEqual([]); + }); +}); + +// After a stat a reader stops at the size from its own fstat at open. With no stat it reads to EOF. +describe("a file that grows while a stream reads it", () => { + const MiB = 1024 * 1024; + const appended = 64 * 1024; + + it.each([ + ["a file that nothing statted", MiB + appended, (_f: Bun.BunFile) => {}], + ["a file after .size", MiB, (f: Bun.BunFile) => void f.size], + ["a file after exists()", MiB, (f: Bun.BunFile) => f.exists()], + ["a file after .lastModified", MiB, (f: Bun.BunFile) => void f.lastModified], + ["a file after structuredClone()", MiB, (f: Bun.BunFile) => void structuredClone(f)], + ] as const)("%s: the stream returns %d bytes", async (_name, total, stat) => { + using dir = tempDir("bun-file-read-stream-grows", {}); + const p = path.join(String(dir), "data.bin"); + writeFileSync(p, Buffer.alloc(MiB, "a")); + const f = Bun.file(p); + await stat(f); + + const reader = f.stream().getReader(); + let chunk = await reader.read(); + appendFileSync(p, Buffer.alloc(appended, "b")); + let received = 0; + while (!chunk.done) { + received += chunk.value.length; + chunk = await reader.read(); + } + expect(received).toBe(total); + }); +}); + +// slice() runs no stat: only a file that something statted before has a length it can use. +describe("Bun.file().slice() after .size matches an in-memory Blob", () => { + const data = "0123456789"; + const cases: [number?, number?][] = [ + [], + [3], + [0, 4], + [2, 8], + [5, 5000], + [10], + [15], + [20, 30], + [-3], + [-50, 3], + [2, -2], + [-5, -2], + [-2, -5], + [-5, Infinity], + [0, 0], + ]; + const drain = async (stream: ReadableStream) => + Buffer.from(await new Response(stream).bytes()).toString(); + const statted = (p: string) => { + const f = Bun.file(p); + void f.size; + return f; + }; + + it("size, text() and stream() of each window", async () => { + using dir = tempDir("bun-file-slice-matrix", { "data.txt": data }); + const open = () => statted(path.join(String(dir), "data.txt")); + const blob = new Blob([data]); + + const got = []; + const want = []; + for (const args of cases) { + got.push({ + args, + size: open().slice(...args).size, + text: await open() + .slice(...args) + .text(), + stream: await drain( + open() + .slice(...args) + .stream(), + ), + }); + const expected = await blob.slice(...args).text(); + want.push({ args, size: blob.slice(...args).size, text: expected, stream: expected }); + } + expect(got).toEqual(want); + }); + + it("slices of slices", async () => { + using dir = tempDir("bun-file-slice-nested", { "data.txt": data }); + const f = () => statted(path.join(String(dir), "data.txt")); + const blob = new Blob([data]); + expect({ + inner: await f().slice(2, 8).slice(-3).text(), + fromEndTwice: await f().slice(-5).slice(1, -1).text(), + pastEof: await f().slice(5, 5000).slice(-2).text(), + }).toEqual({ + inner: await blob.slice(2, 8).slice(-3).text(), + fromEndTwice: await blob.slice(-5).slice(1, -1).text(), + pastEof: await blob.slice(5, 5000).slice(-2).text(), + }); + }); + + it("expect(slice).toHaveLength() is the length of the window in the file", async () => { + using dir = tempDir("bun-file-slice-length", { "data.txt": data }); + expect(statted(path.join(String(dir), "data.txt")).slice(5, 5000)).toHaveLength(5); + }); + + it("an index from the end counts from the length that .size reports", async () => { + using dir = tempDir("bun-file-slice-stale", { "data.txt": data }); + const p = path.join(String(dir), "data.txt"); + const f = Bun.file(p); + expect(f.size).toBe(10); + writeFileSync(p, data + "abcdefghij"); + const tail = f.slice(-3); + expect({ fileSize: f.size, size: tail.size, text: await tail.text() }).toEqual({ + fileSize: 10, + size: 3, + text: "789", + }); + }); +}); + +// A stat pins `.size` and exists() of the handle, so slice() must leave the handle as it found it. +describe("slice() of a Bun.file() runs no stat", () => { + const data = "0123456789"; + + it.each([ + ["slice(-3)", (f: Bun.BunFile) => f.slice(-3)], + ["slice(0, 5).size", (f: Bun.BunFile) => f.slice(0, 5).size], + ["slice(0, 5).exists()", (f: Bun.BunFile) => f.slice(0, 5).exists()], + ] as const)("%s, then the file grows: .size of the file is the new length", async (_name, call) => { + using dir = tempDir("bun-file-slice-no-stat", { "data.txt": data }); + const p = path.join(String(dir), "data.txt"); + const f = Bun.file(p); + await call(f); + appendFileSync(p, "abcdefghij"); + expect(f.size).toBe(20); + }); + + it("slice(-2), then the file is removed: exists() of the file is false", async () => { + using dir = tempDir("bun-file-slice-no-stat", { "data.txt": data }); + const p = path.join(String(dir), "data.txt"); + const f = Bun.file(p); + f.slice(-2); + unlinkSync(p); + expect(await f.exists()).toBe(false); + }); + + it("slice(-5), then the file grows: the file still reads whole", async () => { + const before = "abcdefghijklmnopqrstuvwxyz"; + using dir = tempDir("bun-file-slice-no-stat", { "data.txt": before }); + const p = path.join(String(dir), "data.txt"); + const file = Bun.file(p); + file.slice(-5); + appendFileSync(p, data); + expect({ + body: (await Bun.readableStreamToBytes(new Response(file).body!)).length, + size: file.size, + text: await file.text(), + }).toEqual({ body: 36, size: 36, text: before + data }); + }); +}); diff --git a/test/js/bun/util/bun-stdin-slice.test.ts b/test/js/bun/util/bun-stdin-slice.test.ts index d0d66d69a1bc..9e38ee1fa007 100644 --- a/test/js/bun/util/bun-stdin-slice.test.ts +++ b/test/js/bun/util/bun-stdin-slice.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { bunEnv, bunExe, isWindows } from "harness"; +import { bunEnv, bunExe, isWindows, tempDir } from "harness"; +import { join } from "node:path"; // Reading a sliced non-regular file blob (like stdin from a pipe) with a size // close to Blob.max_size used to overflow when computing the initial read @@ -41,6 +42,27 @@ test.skipIf(isWindows)("Bun.stdin.slice(0, N).text() caps reads at N bytes", asy expect(exitCode).toBe(0); }); +// A stat of stdin is not where the input ends. +test.skipIf(isWindows)("Bun.stdin.text() returns a file on stdin as it is now, after Bun.stdin.size", async () => { + using dir = tempDir("bun-stdin-size", { "in.txt": "0123456789" }); + const input = join(String(dir), "in.txt"); + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `void Bun.stdin.size; + require("node:fs").appendFileSync(process.env.INPUT, "abcdefghij"); + process.stdout.write(await Bun.stdin.text());`, + ], + env: { ...bunEnv, INPUT: input }, + stdin: Bun.file(input), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr, exitCode }).toEqual({ stdout: "0123456789abcdefghij", stderr: "", exitCode: 0 }); +}); + // Streaming a slice of a pipe is the POSIX path where the chunk that ends the // slice is handed to a read that is already waiting (FileReader::on_read_chunk // with a parked read); a regular file is read straight into the pull buffer diff --git a/test/js/web/fetch/blob.test.ts b/test/js/web/fetch/blob.test.ts index 0c41948f887e..e35d085ad66c 100644 --- a/test/js/web/fetch/blob.test.ts +++ b/test/js/web/fetch/blob.test.ts @@ -695,7 +695,7 @@ describe("file-backed slice bounds are respected when streaming and serving", () expect(await new Response(s).text()).toBe("56789"); const clone = structuredClone(s); expect(await clone.text()).toBe("56789"); - // Serializing resolves the original's size, clamping the window to EOF. + // structuredClone() stats the file. After a stat, `.size` of a window stops at the end of the file. expect(s.size).toBe(5); }); From 88ed20316220aea3957e5ac952782a0d8f51a24d Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 25 Sep 2026 11:21:05 +0000 Subject: [PATCH 4/5] Cut two comments to one line --- src/runtime/webcore/Blob.rs | 3 +-- src/runtime/webcore/blob/read_file.rs | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/runtime/webcore/Blob.rs b/src/runtime/webcore/Blob.rs index b9b862bf0c5b..131841649da1 100644 --- a/src/runtime/webcore/Blob.rs +++ b/src/runtime/webcore/Blob.rs @@ -747,8 +747,7 @@ impl BlobExt for Blob { writer.write_int_le::(stored_name.len() as u32)?; writer.write_all(stored_name)?; } else { - // Version 4: the window end of a file-backed slice. An unsliced blob writes - // `MAX_SIZE` (unknown), and the receiver stats the file, like v3. + // Version 4: the window end of a file-backed slice, or `MAX_SIZE` (unknown) for an unsliced blob. writer.write_int_le::(self.size.get())?; self.resolve_size(); store.serialize(writer)?; diff --git a/src/runtime/webcore/blob/read_file.rs b/src/runtime/webcore/blob/read_file.rs index 8c31364529d1..32cb75a49e7e 100644 --- a/src/runtime/webcore/blob/read_file.rs +++ b/src/runtime/webcore/blob/read_file.rs @@ -867,8 +867,7 @@ impl ReadFile { // SAFETY: read() wrote `read_amount` initialized bytes into spare capacity. unsafe { bun_core::vec::commit_spare(&mut buffer, read_amount) }; } - // `max_length` is the end of a `slice()` window, or the `fstat` size - // of this read (`stop_at_own_size`), or `MAX_SIZE`. + // `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; } From 598cd9d8cdc7132d9bf828721e06fba97e49efb9 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 25 Sep 2026 13:30:26 +0000 Subject: [PATCH 5/5] test: Bun.serve sends the whole body of an open Bun.file(fd) that grew After .size or exists() on the handle, main sends Content-Length: 200000 and 100000 body bytes. --- test/js/bun/http/bun-serve-file.test.ts | 41 +++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/test/js/bun/http/bun-serve-file.test.ts b/test/js/bun/http/bun-serve-file.test.ts index 36d3495e637a..35cb733c81e6 100644 --- a/test/js/bun/http/bun-serve-file.test.ts +++ b/test/js/bun/http/bun-serve-file.test.ts @@ -3,6 +3,7 @@ import { afterAll, beforeAll, describe, expect, it, mock, test } from "bun:test" import { bunEnv, bunExe, isASAN, isLinux, isWindows, rmScope, rss, tempDir, tempDirWithFiles } from "harness"; import { mkfifo } from "mkfifo"; import { appendFileSync, closeSync, openSync, unlinkSync, writeFileSync, writeSync } from "node:fs"; +import { connect } from "node:net"; import { join } from "node:path"; const LARGE_SIZE = 1024 * 1024 * 8; @@ -1341,6 +1342,46 @@ describe.concurrent("a reused Bun.file() handle after the file changes", () => { }); }); +// A read through an fd moves its position, so each row serves one request from its own fd. +describe.skipIf(isWindows)("an open Bun.file(fd) after the file grows", () => { + it.concurrent.each([ + [".size", (handle: BunFile) => handle.size], + ["await exists()", (handle: BunFile) => handle.exists()], + [".lastModified", (handle: BunFile) => handle.lastModified], + ] as const)("%s before the growth: the body has the bytes that Content-Length declares", async (_stat, stat) => { + using dir = tempDir("serve-fd-grows", { "log.txt": Buffer.alloc(100_000, "a").toString() }); + const path = join(String(dir), "log.txt"); + const fd = openSync(path, "r"); + try { + const handle = Bun.file(fd); + await stat(handle); + appendFileSync(path, Buffer.alloc(100_000, "b")); + await using server = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: () => new Response(handle) }); + + // fetch() waits for the bytes that a wrong Content-Length declares, so read the wire. + const { promise, resolve, reject } = Promise.withResolvers(); + const chunks: Buffer[] = []; + const socket = connect(server.port, "127.0.0.1", () => { + socket.write("GET / HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n"); + }); + socket.on("data", chunk => chunks.push(chunk)); + socket.on("error", reject); + socket.on("close", () => resolve(Buffer.concat(chunks))); + const raw = await promise; + + const headEnd = raw.indexOf("\r\n\r\n"); + const head = raw.subarray(0, headEnd).toString("latin1"); + expect({ + status: head.split("\r\n")[0], + contentLength: /content-length: (\d+)/i.exec(head)?.[1], + body: raw.length - headEnd - 4, + }).toEqual({ status: "HTTP/1.1 200 OK", contentLength: "200000", body: 200_000 }); + } finally { + closeSync(fd); + } + }); +}); + // A request that declares a body arms the request-body (onData) callback on // the uWS response before the fetch handler runs. uWS keeps a single shared // userdata slot per response, so when the handler returns a file response