Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions src/runtime/webcore/Blob.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -700,6 +701,7 @@ impl BlobExt for Blob {
NewInternalReadFileHandler::<C, F>::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(
Expand Down Expand Up @@ -6268,6 +6270,38 @@ impl Drop for TemporaryBytes {
}
}

/// Off-thread counterpart of `to_string_with_bytes::<Temporary>`; keep in sync.
#[cfg(not(windows))]
pub(crate) fn decode_blob_text_owned(mut bytes: Vec<u8>) -> 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::<u8, u16>(buf) {
Ok(units) => BunString::clone_utf16(units),
Err(_) => {
let units: Vec<u16> = 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;
Expand All @@ -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<JSValue> {
// SAFETY: `by` upholds the `ReadFileToJs::call` contract — a leaked
// `Box<[u8]>` for `Temporary`, store-backed otherwise.
Expand Down
33 changes: 33 additions & 0 deletions src/runtime/webcore/blob/read_file.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<Temporary>` arm reclaims it);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -146,6 +159,8 @@ pub struct ReadFileRead {
/// `to_*_with_bytes::<Temporary>(*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<BunString>,
}

/// Result-or-error union for a completed read.
Expand Down Expand Up @@ -198,6 +213,9 @@ pub struct ReadFile {
#[cfg(not(windows))]
pub(crate) size: SizeType,
pub(crate) buffer: Vec<u8>,
#[cfg(not(windows))]
pub(crate) convert_to_text: bool,
pub(crate) decoded_text: Option<BunString>,
pub task: WorkPoolTask,
pub(crate) system_error: Option<SystemError>,
pub(crate) errno: Option<Error>,
Expand Down Expand Up @@ -347,6 +365,7 @@ impl ReadFile {
on_complete_callback: ReadFileOnReadFileCallback,
off: SizeType,
max_len: SizeType,
convert_to_text: bool,
) -> Result<Box<ReadFile>, Error> {
// store.ref() — `StoreRef` carries the +1; held in `self.store`.
let file_store = store.data.as_file().clone();
Expand All @@ -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,
Expand Down Expand Up @@ -390,6 +411,7 @@ impl ReadFile {
off: SizeType,
max_len: SizeType,
context: *mut C,
convert_to_text: bool,
) -> Result<Box<ReadFile>, Error> {
// `ReadFileCompletion`
// monomorphizes per `C`, so `handler_run::<C>` calls `C::run` directly
Expand All @@ -408,6 +430,7 @@ impl ReadFile {
handler_run::<C>,
off,
max_len,
convert_to_text,
)
}

Expand Down Expand Up @@ -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();
Expand All @@ -625,6 +649,7 @@ impl ReadFile {
cb_ctx,
ReadFileResultType::Result(ReadFileRead {
buf: bun_core::heap::into_raw(buf.into_boxed_slice()),
decoded_text,
}),
);
Ok(())
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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,
})
};

Expand Down
69 changes: 67 additions & 2 deletions test/js/bun/util/bun-file.test.ts
Original file line number Diff line number Diff line change
@@ -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 () => {
Expand Down Expand Up @@ -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);
Comment thread
robobun marked this conversation as resolved.
Resolved
});
});
Loading