diff --git a/src/runtime/webcore/Blob.rs b/src/runtime/webcore/Blob.rs index 921bc6cb7dbf..0029cb0dcf02 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,38 @@ impl Drop for TemporaryBytes { } } +/// 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); + (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 +6314,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..6ce64fe45d0c 100644 --- a/src/runtime/webcore/blob/read_file.rs +++ b/src/runtime/webcore/blob/read_file.rs @@ -53,6 +53,8 @@ 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 { + /// 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 /// ownership of (every `to_*_with_bytes::` arm reclaims it); @@ -105,6 +107,17 @@ 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 { + // 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 +159,8 @@ 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], + /// Pre-decoded on the work pool; `buf` is an empty Box when this is `Some`. + pub(crate) decoded_text: Option, } /// Result-or-error union for a completed read. @@ -198,6 +213,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 +365,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 +381,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 +411,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 +430,7 @@ impl ReadFile { handler_run::, off, max_len, + convert_to_text, ) } @@ -609,6 +632,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 +649,7 @@ impl ReadFile { cb_ctx, ReadFileResultType::Result(ReadFileRead { buf: bun_core::heap::into_raw(buf.into_boxed_slice()), + decoded_text, }), ); Ok(()) @@ -920,6 +945,13 @@ impl ReadFile { if self.buffer.len() + 16_000 < self.buffer.capacity() { self.buffer.shrink_to_fit(); } + + // `.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)); + } + // `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 +1178,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..8f59f0cbe8dd 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 { describe, expect, test } 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,67 @@ 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); + 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. Skip the ratio + // check when the whole operation was faster than setInterval can + // meaningfully sample. + if (dur < 50) return; + expect(maxGap).toBeLessThan(dur / 2); + }); +});