Skip to content

Bun.file().text(): decode UTF-8 on the work pool, not the JS thread - #36536

Closed
robobun wants to merge 4 commits into
mainfrom
farm/d8bb6ac8/bunfile-text-offthread-decode
Closed

robobun wants to merge 4 commits into
mainfrom
farm/d8bb6ac8/bunfile-text-offthread-decode

Conversation

@robobun

@robobun robobun commented Jul 31, 2026 •

Copy link
Copy Markdown
Collaborator

What

await Bun.file(path).text() ran the UTF-8→UTF-16 decode synchronously on the JS thread after the off-thread read finished, so a large non-ASCII file froze the event loop for the entire decode. fs.promises.readFile(path, 'utf8') in the same runtime already offloads the identical decode.

Repro

// dd if=/dev/urandom of=big.bin bs=1M count=512
let last = performance.now(), maxgap = 0;
setInterval(() => { const n = performance.now(); maxgap = Math.max(maxgap, n - last); last = n; }, 1);
const t0 = performance.now();
const s = await Bun.file('big.bin').text();
console.log({ dur_ms: Math.round(performance.now() - t0), max_loop_gap_ms: Math.round(maxgap), chars: s.length });

On main, 100 MB of random bytes: dur_ms: 1456, max_loop_gap_ms: 1411. The same file via fs.promises.readFile(p, 'utf8'): dur_ms: 1468, max_loop_gap_ms: 6. Scales linearly; 500 MB holds the loop for ~11 s.

Cause

ReadFile::run() fills self.buffer: Vec<u8> on the work pool. ReadFile::then() (JS thread) hands the raw bytes to NewReadFileHandler, which calls Blob::to_string_with_bytes → strings::to_utf16_alloc(buf, false, false) inline. NodeFS::read_file for readFile(p, 'utf8') instead runs webcore::encoding::to_bun_string_from_owned_slice inside AsyncFSTask::work_pool_callback, so the JS-thread step is just BunString::transfer_to_js.

Fix

Add ReadFileToJs::WANTS_TEXT (true for ToStringWithBytesFn). When set, do_read_loop runs decode_blob_text_owned (the same BOM handling as to_string_with_bytes::<Temporary>, then to_bun_string_from_owned_slice(.., Utf8)) at its tail, still on the work pool, and stashes the resulting BunString. ReadFileRead carries it through; the JS-thread handler just transfer_to_js's it.

POSIX only. ReadFileUV on Windows delivers bytes on the uv loop thread, so the decode still happens inline there; routing that through the work pool is a larger change.

Verification

100 MB random bytes, debug+ASAN: max_loop_gap_ms drops from the full decode time to ~40 ms. bun-file.test.ts adds a heartbeat probe (max gap must be < half the total duration; before the fix it was ~97%) plus correctness checks that Bun.file(p).text() matches new Blob([bytes]).text() across ASCII, multibyte UTF-8, UTF-8 BOM, UTF-16LE BOM, invalid UTF-8, BOM-only, and empty inputs.


no test proof · iteration 0 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/bun/util/bun-file.test.ts

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.
@coderabbitai

coderabbitai Bot commented Jul 31, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 30 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 6fc178ba-7a49-4f49-b5fc-70f1617093c3

📥 Commits

Reviewing files that changed from the base of the PR and between 21932c3 and 7fd4282.

📒 Files selected for processing (3)
  • src/runtime/webcore/Blob.rs
  • src/runtime/webcore/blob/read_file.rs
  • test/js/bun/util/bun-file.test.ts

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Jul 31, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 2:47 AM PT - Jul 31st, 2026

✅ @robobun, your commit 7fd4282410b8853c1dc180a06a7464e6bfe7eda2 passed in Build #86118! 🎉


🧪   To try this PR locally:

bunx bun-pr 36536

That installs a local version of the PR into your bun-36536 executable, so you can run:

bun-36536 --bun

Comment thread src/runtime/webcore/Blob.rs Resolved
Comment thread src/runtime/webcore/blob/read_file.rs Resolved
Comment thread src/runtime/webcore/blob/read_file.rs Resolved
Comment thread src/runtime/webcore/blob/read_file.rs Resolved
Comment thread test/js/bun/util/bun-file.test.ts Resolved

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No bugs found; the previous test-flakiness concern was addressed in 7fd4282. Deferring to a human for the native cross-thread BunString handoff and the duplicated BOM/decode logic in decode_blob_text_owned (marked "keep in sync" with to_string_with_bytes).

What was reviewed:

  • decode_blob_text_owned mirrors to_string_with_bytes::<Temporary> for empty/BOM/UTF-16LE/UTF-8 — validated against the 7-variant correctness test that compares to in-memory Blob#text().
  • Off-thread to_bun_string_from_owned_slice matches the established fs.promises.readFile pattern (node_fs.rs:7115 inside work_pool_callback); the resulting WTFStringImpl has an atomic refcount so the work-pool→JS-thread handoff is sound.
  • Ownership on the decoded_text path: self.buffer is take()'n before decode, so ReadFileRead::buf is an empty box that the handler reclaims via heap::take; is_dead() is checked and throws OOM before transfer_to_js.
Extended reasoning...

Overview

Moves the UTF-8→UTF-16 decode for Bun.file(path).text() from the JS thread to the work pool (POSIX only). Adds a WANTS_TEXT const to ReadFileToJs, threads a convert_to_text: bool through ReadFile::create[_with_ctx], runs a new decode_blob_text_owned() at the tail of do_read_loop(), and carries the resulting BunString through ReadFileRead::decoded_text for NewReadFileHandler::run to transfer_to_js on the JS thread. ReadFileUV (Windows) is unchanged and always sets decoded_text: None. ~80 lines of native change across Blob.rs and blob/read_file.rs, plus a 7-variant correctness matrix and a heartbeat regression test.

Security risks

None identified. No new user-controlled input paths; the decode is the same strings::to_utf16_alloc / BunString::clone_utf16 machinery already used by both the on-thread path and fs.promises.readFile.

Level of scrutiny

High. This is native memory-safety code in the most-blocked review category: cross-thread BunString lifetime, raw *mut [u8] ownership contracts in ReadFileRead, and a new struct field (decoded_text) that must be correctly take()'n on every terminal path (then() success, then() error, drop). The pattern is sound and follows NodeFS::read_file's precedent, but a maintainer should confirm the design choice to duplicate the BOM/UTF-16LE handling in decode_blob_text_owned (with a "keep in sync" comment) rather than refactor to_string_with_bytes to share a helper — REVIEW.md's "one source of truth" guidance applies directly here.

Other factors

  • CI build #86118 was still running at review time; the PR description itself notes "no test proof · Deferring to CI".
  • The blob.size update is skipped on the decoded_text branch in NewReadFileHandler::run, but blob there is a local moved from handler.context and to_string_with_bytes never reads self.size, so this is not a behavior change.
  • Windows keeps the on-thread decode; the heartbeat test is correctly skipIf(isWindows).
  • My earlier flakiness note (unbounded-below dur in the ratio assertion) was addressed with a dur < 50 floor guard in 7fd4282.

@github-actions

Copy link
Copy Markdown
Contributor

Closing this PR because it has been inactive for more than 90 days.

@github-actions github-actions Bot closed this Oct 30, 2026
@robobun

robobun commented Oct 30, 2026

Copy link
Copy Markdown
Collaborator Author

The stale bot closed this PR after 90 days. No maintainer reviewed it. CI was green on 7fd4282 (build 86118).

The bug is still present:

  • On bun 1.4.3-canary.1+367d939d9, await Bun.file(p).text() on a 100 MB file of random bytes holds the event loop for 1309 ms. fs.promises.readFile(p, "utf8") on the same file holds it for 7 ms.
  • On main at f7607a3, to_string_with_bytes still calls strings::to_utf16_alloc in the completion that runs on the JS thread (src/runtime/webcore/Blob.rs:2385).

This branch cannot be revived as it is:

  • It is 1927 commits behind main and conflicts with it. The read path changed (ReadFile is now a bun_jsc::Job), so the change needs a rewrite on current main.
  • It covers POSIX only. On Windows, ReadFileUV still decodes on the JS thread.
  • The first version did not weigh other places where the fix can live.

I am checking the design against current main, Windows included. I will post the result here.

@robobun

robobun commented Oct 31, 2026

Copy link
Copy Markdown
Collaborator Author

The result of that check: #47509 replaces this PR.

  • It keeps the design of this PR: the POSIX read job decodes .text() on the work pool, at the end of the job. It is written against the current read path (ReadFile as a bun_jsc::Job).
  • It has one decoder, text_from_owned_bytes, in place of the second decoder that this PR added.
  • Windows is not covered. Windows reads through ReadFileUV, which has no pool stage, and Remove libuv on Windows #42819 removes that path.
  • The new PR lists the measured costs (peak memory and CPU for overlapping reads, work-pool contention) and asks a maintainer to accept them.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants