Repository navigation
Bun.file().text(): decode in the read job on the work pool (POSIX) #47509
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| // Bun.file(path).text() against fs.promises.readFile(path, "utf8"), one read | ||
| // at a time and eight at once. Node has no Bun.file: (await fs.openAsBlob(path)).text() stands in. | ||
| import { mkdtempSync, openAsBlob, rmSync, writeFileSync } from "node:fs"; | ||
| import { readFile } from "node:fs/promises"; | ||
| import { tmpdir } from "node:os"; | ||
| import { join } from "node:path"; | ||
| import { bench, group, run } from "../runner.mjs"; | ||
|
|
||
| const KiB = 1024; | ||
| const text = typeof Bun !== "undefined" ? path => Bun.file(path).text() : async path => (await openAsBlob(path)).text(); | ||
| const eight = read => Promise.all(Array.from({ length: 8 }, read)); | ||
|
|
||
| const units = { | ||
| "ascii": Buffer.from("The quick brown fox jumps over the lazy dog.\n"), | ||
| "utf8": Buffer.from("héllo wörld, 世界 💕\n"), | ||
| // Every byte is an invalid sequence: the decoder replaces each one. | ||
| "invalid utf8": Buffer.from([0xff]), | ||
| }; | ||
|
|
||
| const dir = mkdtempSync(join(tmpdir(), "bun-file-text-bench-")); | ||
|
|
||
| function fixture(name, bytes) { | ||
| const unit = units[name]; | ||
| const path = join(dir, `${name.replaceAll(" ", "-")}-${bytes}.txt`); | ||
| // Whole units only, so the utf8 fixtures stay valid. | ||
| writeFileSync(path, Buffer.alloc(bytes - (bytes % unit.length), unit)); | ||
| return path; | ||
| } | ||
|
|
||
| try { | ||
| for (const bytes of [64, 4 * KiB, 256 * KiB, 8 * KiB * KiB]) { | ||
| for (const name of Object.keys(units)) { | ||
| const path = fixture(name, bytes); | ||
| group(`${bytes} bytes, ${name}`, () => { | ||
| bench("text()", async () => (await text(path)).length); | ||
| bench('readFile(path, "utf8")', async () => (await readFile(path, "utf8")).length); | ||
| bench("8 x text()", async () => (await eight(() => text(path))).length); | ||
| bench('8 x readFile(path, "utf8")', async () => (await eight(() => readFile(path, "utf8"))).length); | ||
| }); | ||
| } | ||
| } | ||
|
|
||
| await run(); | ||
| } finally { | ||
| rmSync(dir, { recursive: true, force: true }); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,12 +8,12 @@ use core::sync::atomic::Ordering; | |
|
|
||
| use crate::Error; | ||
| use crate::webcore::Lifetime; | ||
| #[cfg(not(windows))] | ||
| use crate::webcore::blob::ClosingState; | ||
| #[cfg(windows)] | ||
| use crate::webcore::blob::store::Bytes as ByteStore; | ||
| use crate::webcore::blob::store::{Data, File as FileStore}; | ||
| use crate::webcore::blob::{Blob, FileCloser, FileOpener, MAX_SIZE, SizeType, Store}; | ||
| #[cfg(not(windows))] | ||
| use crate::webcore::blob::{ClosingState, text_from_owned_bytes}; | ||
| use crate::webcore::node_types::PathOrFileDescriptor; | ||
| #[cfg(windows)] | ||
| use bun_collections::ByteVecExt as _; | ||
|
|
@@ -25,7 +25,8 @@ use bun_io::FileAction; | |
| // `bun_jsc::EventLoop` is the *module*; the struct is one level deeper. | ||
| use bun_jsc::event_loop::EventLoop; | ||
| use bun_jsc::{ | ||
| self as jsc, AnyPromise, JSGlobalObject, JSPromiseStrong, JSValue, JsResult, SystemError, | ||
| self as jsc, AnyPromise, JSGlobalObject, JSPromiseStrong, JSValue, JsResult, StringJsc as _, | ||
| SystemError, | ||
| }; | ||
| use bun_ptr::RefPtr; | ||
| #[cfg(windows)] | ||
|
|
@@ -52,9 +53,21 @@ macro_rules! log { | |
| // NewReadFileHandler | ||
| // ────────────────────────────────────────────────────────────────────────── | ||
|
|
||
| /// What a [`ReadFile`] job hands its completion. | ||
| #[cfg(not(windows))] | ||
| #[derive(Clone, Copy, PartialEq, Eq)] | ||
| pub(crate) enum ReadAs { | ||
| Bytes, | ||
| /// Blob text, decoded on the work pool: [`ReadFileResultType::Text`]. | ||
| Text, | ||
| } | ||
|
|
||
| /// `F` provides the callback that converts the read bytes to a JSValue. | ||
| /// Modelled as a trait so each instantiation monomorphizes. | ||
| pub(crate) trait ReadFileToJs { | ||
| /// What the job hands `then`. Windows has no pool stage until `ReadFileUV` goes (#42819). | ||
| #[cfg(not(windows))] | ||
| const READ_AS: ReadAs; | ||
| /// `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); | ||
|
|
@@ -131,6 +144,9 @@ impl<'a, F: ReadFileToJs> ReadFileCompletion for NewReadFileHandler<'a, F> { | |
| ) | ||
| })?; | ||
| } | ||
| ReadFileResultType::Text(text) => { | ||
| AnyPromise::Normal(promise).wrap(global_this, move |g| text.into_js(g))?; | ||
| } | ||
| ReadFileResultType::Err(err) => { | ||
| // SAFETY: `promise` was just swapped out of `handler.promise`, | ||
| // the `JSPromiseStrong` that rooted it across the async read; | ||
|
|
@@ -215,15 +231,18 @@ pub(crate) struct ReadFileRead { | |
| #[allow(clippy::large_enum_variant)] | ||
| pub(crate) enum ReadFileResultType { | ||
| Result(ReadFileRead), | ||
| /// Blob text that the job decoded on the work pool. | ||
| Text(BunString), | ||
| Err(SystemError), | ||
| } | ||
|
|
||
| /// The completion token a `ReadFile` keeps across its async I/O. | ||
| pub(crate) type ReadFileTask = bun_jsc::Completion<ReadFile>; | ||
|
|
||
| // 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. | ||
| // SAFETY: file store / byte store / blob store ref (atomic), the read buffer, the text decoded | ||
| // from it (a string this job built and no other thread has seen) and io-loop registration | ||
| // state: nothing thread-affine. What the bytes are delivered to lives in the job's JS side | ||
| // (`ReadFileCompletionFns`), never here. | ||
| unsafe impl Send for ReadFile {} | ||
|
|
||
| impl bun_jsc::JobContext for ReadFile { | ||
|
|
@@ -293,6 +312,10 @@ pub(crate) struct ReadFile { | |
| #[cfg(not(windows))] | ||
| pub(crate) size: SizeType, | ||
| pub(crate) buffer: Vec<u8>, | ||
| #[cfg(not(windows))] | ||
| pub(crate) read_as: ReadAs, | ||
| /// `buffer`, decoded where the job finishes. Only a `ReadAs::Text` job sets it. | ||
| pub(crate) text: Option<BunString>, | ||
| pub task: WorkPoolTask, | ||
| pub(crate) system_error: Option<SystemError>, | ||
| pub(crate) errno: Option<Error>, | ||
|
|
@@ -370,6 +393,7 @@ impl ReadFile { | |
| store: RefPtr<Store>, | ||
| off: SizeType, | ||
| max_len: SizeType, | ||
| read_as: ReadAs, | ||
| ) -> Result<ReadFile, Error> { | ||
| let file_store = store.data.as_file().clone(); | ||
| let read_file = ReadFile { | ||
|
|
@@ -383,6 +407,8 @@ impl ReadFile { | |
| read_eof: false, | ||
| size: 0, | ||
| buffer: Vec::new(), | ||
| read_as, | ||
| text: None, | ||
| task: WorkPoolTask { | ||
| node: Default::default(), | ||
| callback: Self::do_read_loop_task, | ||
|
|
@@ -635,6 +661,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 text = this.text.take(); | ||
|
|
||
| // `_store` is dropped at end of scope (= store.deref()). | ||
| let system_error = this.system_error.take(); | ||
|
|
@@ -643,6 +670,9 @@ impl ReadFile { | |
| if let Some(err) = system_error { | ||
| return completion.complete(ReadFileResultType::Err(err)); | ||
| } | ||
| if let Some(text) = text { | ||
| return completion.complete(ReadFileResultType::Text(text)); | ||
| } | ||
|
|
||
| // The receiver takes ownership. Normalize to `Box<[u8]>` so every | ||
| // consumer can reclaim via `heap::take` with a matching layout. | ||
|
|
@@ -694,6 +724,16 @@ impl ReadFile { | |
| if !close_after_io { | ||
| if let Some(io_task) = self.io_task.take() { | ||
| bloblog!("ReadFile.onFinish() = immediately"); | ||
| // A stopping VM releases this job unrun, and waits for this thread first. | ||
| if self.read_as == ReadAs::Text | ||
| && self.system_error.is_none() | ||
| && io_task.ticket().script_allowed() | ||
| { | ||
| let bytes = core::mem::take(&mut self.buffer).into_boxed_slice(); | ||
| let text = text_from_owned_bytes(bytes); | ||
|
Comment on lines
+728
to
+733
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 Servers that await several large Why this was flaggedTrigger: k overlapping Verification: src/runtime/webcore/blob/read_file.rs:728-735
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is the cost in the first Downsides bullet, and the numbers agree. I did not add a cap in this PR. The PR body asks a maintainer to decide it. What the measurements in the notes show:
What a cap costs:
If a maintainer wants the bound, this block in |
||
| debug_assert!(text.is_thread_isolated()); | ||
| self.text = Some(text); | ||
| } | ||
| io_task.finish(); | ||
|
robobun marked this conversation as resolved.
Resolved
|
||
| } | ||
| } | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.