From 99412e9be04022b40bd82190801215429d0bb2ff Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 31 Jul 2026 05:28:30 +0000 Subject: [PATCH 1/4] Bun.file().text(): decode UTF-8 on the work pool, not the JS thread The ReadFile work-pool task read the file bytes off-thread but handed the raw buffer back to the JS-thread completion handler, which then ran the full UTF-8 -> UTF-16 decode (strings::to_utf16_alloc) inline. On a large file of invalid UTF-8 this froze the event loop for the entire decode; a 500 MB random file held the loop for ~11 s even though fs.promises.readFile(path, 'utf8') on the same runtime offloaded the identical decode and kept the max loop gap at ~30 ms. Give ReadFileToJs a WANTS_TEXT associated const and, when set, run decode_blob_text_owned (BOM handling + to_bun_string_from_owned_slice, the same helper readFile('utf8') uses) at the tail of do_read_loop, still on the work pool. ReadFileRead now carries an optional pre-built BunString; NewReadFileHandler wraps it with transfer_to_js instead of calling to_string_with_bytes. POSIX only for now: ReadFileUV on Windows delivers bytes on the uv loop thread. --- src/runtime/webcore/Blob.rs | 38 +++++++++++++++ src/runtime/webcore/blob/read_file.rs | 43 +++++++++++++++++ test/js/bun/util/bun-file.test.ts | 66 ++++++++++++++++++++++++++- 3 files changed, 145 insertions(+), 2 deletions(-) diff --git a/src/runtime/webcore/Blob.rs b/src/runtime/webcore/Blob.rs index 921bc6cb7dbf..4c9eb4f2763d 100644 --- a/src/runtime/webcore/Blob.rs +++ b/src/runtime/webcore/Blob.rs @@ -483,6 +483,7 @@ impl BlobExt for Blob { self.offset.get(), self.size.get(), handler, + F::WANTS_TEXT, ) .unwrap_or_else(|e| bun_core::handle_oom(Err(e))); let read_file_task = read_file::ReadFileTask::create_on_js_thread( @@ -700,6 +701,7 @@ impl BlobExt for Blob { NewInternalReadFileHandler::::run, self.offset.get(), self.size.get(), + false, ) .unwrap_or_else(|e| bun_core::handle_oom(Err(e))); let read_file_task = read_file::ReadFileTask::create_on_js_thread( @@ -6268,6 +6270,41 @@ impl Drop for TemporaryBytes { } } +/// Off-thread counterpart of `to_string_with_bytes::`: same BOM +/// handling and UTF-8 decode, but produces a thread-safe `BunString` +/// (`WTF::ExternalStringImpl`) instead of a `JSValue`. Keep in sync with +/// `to_string_with_bytes`. +#[cfg_attr(windows, allow(dead_code))] +pub(crate) fn decode_blob_text_owned(mut bytes: Vec) -> BunString { + let (bom, bom_len) = { + let (bom, rest) = strings::BOM::detect_and_split(&bytes); + (bom, bytes.len() - rest.len()) + }; + if bytes.len() == bom_len { + return BunString::empty(); + } + if bom == Some(strings::BOM::Utf16Le) { + let buf = &bytes[bom_len..]; + let buf = &buf[..buf.len() & !1]; + return match bytemuck::try_cast_slice::(buf) { + Ok(units) => BunString::clone_utf16(units), + Err(_) => { + let units: Vec = buf + .as_chunks::<2>() + .0 + .iter() + .map(|pair| u16::from_le_bytes(*pair)) + .collect(); + BunString::clone_utf16(&units) + } + }; + } + if bom_len > 0 { + bytes.drain(..bom_len); + } + webcore::encoding::to_bun_string_from_owned_slice(bytes, node::types::Encoding::Utf8) +} + // Marker types for static fn dispatch through do_read_file/do_read_from_s3. // Each implements `ReadFileToJs` so a plain fn-pointer monomorphizes per `*WithBytes` body. pub(crate) struct ToStringWithBytesFn; @@ -6280,6 +6317,7 @@ pub(crate) struct ToFormDataWithBytesFn; // it to the matching unsafe `*_with_bytes` body, so the deref is documented there. #[allow(clippy::not_unsafe_ptr_arg_deref)] impl read_file::ReadFileToJs for ToStringWithBytesFn { + const WANTS_TEXT: bool = true; fn call(b: &Blob, g: &JSGlobalObject, by: *mut [u8], l: Lifetime) -> JsResult { // SAFETY: `by` upholds the `ReadFileToJs::call` contract — a leaked // `Box<[u8]>` for `Temporary`, store-backed otherwise. diff --git a/src/runtime/webcore/blob/read_file.rs b/src/runtime/webcore/blob/read_file.rs index 248d17bcf14f..bab9cd29465c 100644 --- a/src/runtime/webcore/blob/read_file.rs +++ b/src/runtime/webcore/blob/read_file.rs @@ -53,6 +53,11 @@ macro_rules! log { /// `F` provides the callback that converts the read bytes to a JSValue. /// Modelled as a trait so each instantiation monomorphizes. pub trait ReadFileToJs { + /// When `true`, the read task decodes the bytes to a `BunString` on the + /// work pool (same path `fs.promises.readFile(..., "utf8")` uses) so the + /// JS-thread completion is just a `transfer_to_js` instead of a full + /// UTF-8→UTF-16 decode. + const WANTS_TEXT: bool = false; /// `by` carries the caller's allocation provenance unchanged: /// `Lifetime::Temporary` ⇒ a `Box::<[u8]>::into_raw` the callee MUST take /// ownership of (every `to_*_with_bytes::` arm reclaims it); @@ -105,6 +110,20 @@ impl<'a, F: ReadFileToJs> ReadFileCompletion for NewReadFileHandler<'a, F> { drop(handler); match maybe_bytes { ReadFileResultType::Result(result) => { + if let Some(mut decoded) = result.decoded_text { + // The work-pool thread already produced the WTF string; just + // hand it to JS. `result.buf` is an empty Box when this arm + // is taken. + // SAFETY: `buf` is `Box::<[u8]>::into_raw` from the producer. + unsafe { drop(bun_core::heap::take(result.buf)) }; + AnyPromise::Normal(promise).wrap(global_this, move |g| { + if decoded.is_dead() { + return Err(g.throw_out_of_memory()); + } + bun_jsc::StringJsc::transfer_to_js(&mut decoded, g) + })?; + return Ok(()); + } let bytes = result.buf; if blob.size.get() > 0 { blob.size @@ -146,6 +165,10 @@ pub struct ReadFileRead { /// `to_*_with_bytes::(*mut [u8])`, which itself decides whether /// the bytes are freed locally or transferred to a JSC external string. pub(crate) buf: *mut [u8], + /// Populated when `ReadFile::convert_to_text` was set: the work-pool thread + /// already ran the UTF-8 decode, so the JS-thread completion just wraps the + /// string. `buf` is an empty Box when this is `Some`. + pub(crate) decoded_text: Option, } /// Result-or-error union for a completed read. @@ -198,6 +221,9 @@ pub struct ReadFile { #[cfg(not(windows))] pub(crate) size: SizeType, pub(crate) buffer: Vec, + #[cfg(not(windows))] + pub(crate) convert_to_text: bool, + pub(crate) decoded_text: Option, pub task: WorkPoolTask, pub(crate) system_error: Option, pub(crate) errno: Option, @@ -347,6 +373,7 @@ impl ReadFile { on_complete_callback: ReadFileOnReadFileCallback, off: SizeType, max_len: SizeType, + convert_to_text: bool, ) -> Result, Error> { // store.ref() — `StoreRef` carries the +1; held in `self.store`. let file_store = store.data.as_file().clone(); @@ -362,6 +389,8 @@ impl ReadFile { read_eof: false, size: 0, buffer: Vec::new(), + convert_to_text, + decoded_text: None, task: WorkPoolTask { node: Default::default(), callback: Self::do_read_loop_task, @@ -390,6 +419,7 @@ impl ReadFile { off: SizeType, max_len: SizeType, context: *mut C, + convert_to_text: bool, ) -> Result, Error> { // `ReadFileCompletion` // monomorphizes per `C`, so `handler_run::` calls `C::run` directly @@ -408,6 +438,7 @@ impl ReadFile { handler_run::, off, max_len, + convert_to_text, ) } @@ -609,6 +640,7 @@ impl ReadFile { let _store = this.store.take().unwrap(); // reshaped for borrowck — take buffer out so it survives `drop(this)`. let buf = core::mem::take(&mut this.buffer); + let decoded_text = this.decoded_text.take(); // `_store` is dropped at end of scope (= store.deref()). let system_error = this.system_error.take(); @@ -625,6 +657,7 @@ impl ReadFile { cb_ctx, ReadFileResultType::Result(ReadFileRead { buf: bun_core::heap::into_raw(buf.into_boxed_slice()), + decoded_text, }), ); Ok(()) @@ -920,6 +953,15 @@ impl ReadFile { if self.buffer.len() + 16_000 < self.buffer.capacity() { self.buffer.shrink_to_fit(); } + + // `.text()`: decode here (on the work pool) so the JS thread only + // has to wrap the resulting WTF string. This is the same split + // `fs.promises.readFile(path, "utf8")` uses. + if self.convert_to_text && self.system_error.is_none() { + let bytes = core::mem::take(&mut self.buffer); + self.decoded_text = Some(crate::webcore::blob::decode_blob_text_owned(bytes)); + } + // `Bytes` is owning, and `then()` delivers `self.buffer` directly, // so do not also stash it in `byte_store` — that would double-free. self.on_finish(); @@ -1146,6 +1188,7 @@ impl<'a> ReadFileUV<'a> { let boxed = core::mem::take(&mut this_box.byte_store).into_boxed_slice(); ReadFileResultType::Result(ReadFileRead { buf: bun_core::heap::into_raw(boxed), + decoded_text: None, }) }; diff --git a/test/js/bun/util/bun-file.test.ts b/test/js/bun/util/bun-file.test.ts index 6a422f38589f..f5ac9497a29b 100644 --- a/test/js/bun/util/bun-file.test.ts +++ b/test/js/bun/util/bun-file.test.ts @@ -1,6 +1,7 @@ -import { expect, test } from "bun:test"; +import { expect, test, describe } from "bun:test"; +import { randomBytes } from "crypto"; import fsPromises from "fs/promises"; -import { bunEnv, bunExe, tempDir } from "harness"; +import { bunEnv, bunExe, isWindows, tempDir } from "harness"; import { join } from "path"; test("delete() and stat() should work with unicode paths", async () => { @@ -155,3 +156,64 @@ test("Bun.file().json() with UTF-8 BOM does not free an interior pointer", async }); expect(exitCode).toBe(0); }); + +describe("Bun.file().text()", () => { + test.each([ + ["ascii", Buffer.from(Buffer.alloc(1000, "hello world\n").toString())], + ["utf8-multibyte", Buffer.from(Buffer.alloc(1000, "héllo wörld 🎉\n").toString())], + ["utf8-bom", Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), Buffer.from("hello bom\n")])], + ["utf16le-bom", Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from("hello utf16\n", "utf16le")])], + ["invalid-utf8", Buffer.from([0x80, 0x81, 0x82, 0xff, 0xfd, 0xc0, 0xc1])], + ["bom-only", Buffer.from([0xef, 0xbb, 0xbf])], + ["empty", Buffer.from([])], + ])("decodes %s identically to in-memory Blob", async (name, bytes) => { + using dir = tempDir("bun-file-text-decode", { "data.bin": bytes }); + const viaFile = await Bun.file(join(dir, "data.bin")).text(); + const viaBlob = await new Blob([bytes]).text(); + expect(viaFile).toBe(viaBlob); + }); + + // The POSIX ReadFile task runs the UTF-8 decode on the work pool; on + // Windows ReadFileUV delivers bytes on the uv loop thread, so the decode + // still happens inline there. + test.skipIf(isWindows)("does not block the event loop while decoding", async () => { + using dir = tempDir("bun-file-text-loop", {}); + // Random bytes force the invalid-UTF-8 slow path, which is where the + // on-thread decode was most visible. + const big = join(dir, "big.bin"); + await Bun.write(big, randomBytes(8 * 1024 * 1024)); + + const probe = ` + let last = performance.now(), maxGap = 0; + const iv = setInterval(() => { + const n = performance.now(); + if (n - last > maxGap) maxGap = n - last; + last = n; + }, 1); + await new Promise(r => setTimeout(r, 20)); + maxGap = 0; + last = performance.now(); + const t0 = performance.now(); + const s = await Bun.file(process.env.BIG_FILE).text(); + const n = performance.now(); + if (n - last > maxGap) maxGap = n - last; + clearInterval(iv); + console.log(JSON.stringify({ dur: n - t0, maxGap, len: s.length })); + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", probe], + env: { ...bunEnv, BIG_FILE: big }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + const { dur, maxGap, len } = JSON.parse(stdout); + // When the decode ran on the JS thread the heartbeat stalled for the + // whole decode, so maxGap was essentially equal to dur. With the decode + // on the work pool the loop keeps ticking throughout. + expect(len).toBeGreaterThan(0); + expect(maxGap).toBeLessThan(dur / 2); + expect(exitCode).toBe(0); + }); +}); From b2099182398b48359c43fa64aba854b306454cb4 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 31 Jul 2026 05:30:44 +0000 Subject: [PATCH 2/4] [autofix.ci] apply automated fixes --- test/js/bun/util/bun-file.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/js/bun/util/bun-file.test.ts b/test/js/bun/util/bun-file.test.ts index f5ac9497a29b..d069b045a9f7 100644 --- a/test/js/bun/util/bun-file.test.ts +++ b/test/js/bun/util/bun-file.test.ts @@ -1,4 +1,4 @@ -import { expect, test, describe } from "bun:test"; +import { describe, expect, test } from "bun:test"; import { randomBytes } from "crypto"; import fsPromises from "fs/promises"; import { bunEnv, bunExe, isWindows, tempDir } from "harness"; From d72fdf1ca5c37485182ca38f9b88fc6a75247c6d Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 31 Jul 2026 05:34:03 +0000 Subject: [PATCH 3/4] trim comments; cfg-gate decode_blob_text_owned instead of allow(dead_code) --- src/runtime/webcore/Blob.rs | 7 ++----- src/runtime/webcore/blob/read_file.rs | 16 +++------------- 2 files changed, 5 insertions(+), 18 deletions(-) diff --git a/src/runtime/webcore/Blob.rs b/src/runtime/webcore/Blob.rs index 4c9eb4f2763d..0029cb0dcf02 100644 --- a/src/runtime/webcore/Blob.rs +++ b/src/runtime/webcore/Blob.rs @@ -6270,11 +6270,8 @@ impl Drop for TemporaryBytes { } } -/// Off-thread counterpart of `to_string_with_bytes::`: same BOM -/// handling and UTF-8 decode, but produces a thread-safe `BunString` -/// (`WTF::ExternalStringImpl`) instead of a `JSValue`. Keep in sync with -/// `to_string_with_bytes`. -#[cfg_attr(windows, allow(dead_code))] +/// Off-thread counterpart of `to_string_with_bytes::`; keep in sync. +#[cfg(not(windows))] pub(crate) fn decode_blob_text_owned(mut bytes: Vec) -> BunString { let (bom, bom_len) = { let (bom, rest) = strings::BOM::detect_and_split(&bytes); diff --git a/src/runtime/webcore/blob/read_file.rs b/src/runtime/webcore/blob/read_file.rs index bab9cd29465c..6ce64fe45d0c 100644 --- a/src/runtime/webcore/blob/read_file.rs +++ b/src/runtime/webcore/blob/read_file.rs @@ -53,10 +53,7 @@ macro_rules! log { /// `F` provides the callback that converts the read bytes to a JSValue. /// Modelled as a trait so each instantiation monomorphizes. pub trait ReadFileToJs { - /// When `true`, the read task decodes the bytes to a `BunString` on the - /// work pool (same path `fs.promises.readFile(..., "utf8")` uses) so the - /// JS-thread completion is just a `transfer_to_js` instead of a full - /// UTF-8→UTF-16 decode. + /// Decode to a `BunString` on the work pool instead of on the JS thread. const WANTS_TEXT: bool = false; /// `by` carries the caller's allocation provenance unchanged: /// `Lifetime::Temporary` ⇒ a `Box::<[u8]>::into_raw` the callee MUST take @@ -111,9 +108,6 @@ impl<'a, F: ReadFileToJs> ReadFileCompletion for NewReadFileHandler<'a, F> { match maybe_bytes { ReadFileResultType::Result(result) => { if let Some(mut decoded) = result.decoded_text { - // The work-pool thread already produced the WTF string; just - // hand it to JS. `result.buf` is an empty Box when this arm - // is taken. // SAFETY: `buf` is `Box::<[u8]>::into_raw` from the producer. unsafe { drop(bun_core::heap::take(result.buf)) }; AnyPromise::Normal(promise).wrap(global_this, move |g| { @@ -165,9 +159,7 @@ pub struct ReadFileRead { /// `to_*_with_bytes::(*mut [u8])`, which itself decides whether /// the bytes are freed locally or transferred to a JSC external string. pub(crate) buf: *mut [u8], - /// Populated when `ReadFile::convert_to_text` was set: the work-pool thread - /// already ran the UTF-8 decode, so the JS-thread completion just wraps the - /// string. `buf` is an empty Box when this is `Some`. + /// Pre-decoded on the work pool; `buf` is an empty Box when this is `Some`. pub(crate) decoded_text: Option, } @@ -954,9 +946,7 @@ impl ReadFile { self.buffer.shrink_to_fit(); } - // `.text()`: decode here (on the work pool) so the JS thread only - // has to wrap the resulting WTF string. This is the same split - // `fs.promises.readFile(path, "utf8")` uses. + // `.text()`: decode here (on the work pool) instead of the JS thread. if self.convert_to_text && self.system_error.is_none() { let bytes = core::mem::take(&mut self.buffer); self.decoded_text = Some(crate::webcore::blob::decode_blob_text_owned(bytes)); From 7fd4282410b8853c1dc180a06a7464e6bfe7eda2 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 31 Jul 2026 05:52:27 +0000 Subject: [PATCH 4/4] test: skip heartbeat ratio check when dur is below setInterval granularity --- test/js/bun/util/bun-file.test.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/test/js/bun/util/bun-file.test.ts b/test/js/bun/util/bun-file.test.ts index d069b045a9f7..8f59f0cbe8dd 100644 --- a/test/js/bun/util/bun-file.test.ts +++ b/test/js/bun/util/bun-file.test.ts @@ -209,11 +209,14 @@ describe("Bun.file().text()", () => { const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect(stderr).toBe(""); const { dur, maxGap, len } = JSON.parse(stdout); + expect(len).toBeGreaterThan(0); + expect(exitCode).toBe(0); // When the decode ran on the JS thread the heartbeat stalled for the // whole decode, so maxGap was essentially equal to dur. With the decode - // on the work pool the loop keeps ticking throughout. - expect(len).toBeGreaterThan(0); + // on the work pool the loop keeps ticking throughout. Skip the ratio + // check when the whole operation was faster than setInterval can + // meaningfully sample. + if (dur < 50) return; expect(maxGap).toBeLessThan(dur / 2); - expect(exitCode).toBe(0); }); });