Skip to content

Bun.write: borrow TypedArray sources instead of snapshotting on the JS thread - #36535

Closed
robobun wants to merge 8 commits into
mainfrom
farm/f1cb9653/bun-write-borrow-typed-array
Closed

robobun wants to merge 8 commits into
mainfrom
farm/f1cb9653/bun-write-borrow-typed-array

Conversation

@robobun

@robobun robobun commented Jul 31, 2026 •

Copy link
Copy Markdown
Collaborator

Status: closed unmerged by the 90-day stale sweep. Body corrected after the close. Read Downsides before any revival.

Problem

  • Bun.write(path, typedArray) copies the whole source on the JS thread before the pooled write starts: 50 to 57 ms for 128 MB. fs.promises.writeFile returns in about 2.5 ms.
  • The cause is write_file_internal -> Blob::get -> Blob::try_create (src/runtime/webcore/Blob.rs): a memfd copy on Linux for 8 MB or more, bytes.to_vec() otherwise.

Fix

  • If the destination is not S3, borrow_array_buffer_for_write pins and GC-protects the source and wraps its bytes in a Bytes store. The allocator free of the store releases both.
  • The pool thread writes from the caller's buffer. WriteFile::then and WriteFileWindows::run_from_js_thread drop the store on the JS thread.
  • Verified: test/js/bun/io/bun-write-typed-array-nonblocking.test.ts.

Background

  • A pin (JSC::ArrayBuffer::pin) stops JS from detaching a buffer. protect() is a GC root.
  • No design review ran before this change was written. No other design was weighed.

Downsides

  • Behavior change. Today const p = Bun.write(out, buf); buf.fill(x); await p writes the old bytes (checked on 1.4.3). With this change the pool thread reads buf after the call returns, so the new bytes can reach the file.
  • Below 256 KB on POSIX the write normally completes before the call returns. So the result of a late mutation depends on the size.
  • A pin does not protect a view of a non-shared WebAssembly.Memory: grow() detaches it. This branch has no guard for that. Main has one since Hardening: fix crashes across the runtime, bundler, dev server, shell and CLI #46021 (PinnedArrayBuffer::root_read_only).
Notes

What was measured, and on which build. All on one linux x64 container.

Synchronous part of the call (time until the promise is returned), 128 MB source:

before after
release 50.6 to 57.5 ms (1.4.0-canary.1+1498d7b77), 45.3 to 45.6 ms (1.3.14) 0.017 to 0.021 ms (release build of 72ab7d0)
debug + ASAN 48.8 to 50.9 ms (build of the base) 0.47 to 0.50 ms (test script), 1.8 to 1.95 ms (first probe)
release, BUN_FEATURE_FLAG_DISABLE_MEMFD=1 71.8 to 73.7 ms not measured

Largest gap of a 1 ms interval during await Bun.write(out, 256 MB):

before (release 1.4.0-canary) after (debug + ASAN build of the branch)
Bun.write 99 to 118 ms 21 ms
fs.promises.writeFile 4 to 6 ms 27 ms

There is no release "after" number for the second table.

Measured after the close, on release 1.4.3 (rev 367d939), which does not have this change: Bun.write call time 31 to 51 ms at 128 MB and 486 ms at 512 MB. fs.promises.writeFile call time 2.4 to 3.3 ms at 32, 128 and 512 MB.

Corrections to the first version of this body:

  • It had a table "256 MB: ~110 ms to ~2 ms" for release, debug + ASAN and memfd disabled. Those cells were not measured as labelled. They mixed the 256 MB interval probe with the 128 MB call-time probe.
  • It showed strace lines as console output and said "a 500 MB write stalled a 1 ms heartbeat for ~378 ms". Both are from the report that started this work. strace is not installed in the container that built this change.
  • It had no Downsides section.

Also: the gate is "destination is not S3", not "destination is a file". The change adds an unsafe block and an allocator free that must run on the JS thread.

Not checked:

  • The mutate-after-call probe was not run on a build of this branch. The statement follows from the mechanism. fs.promises.writeFile, which reads in place in the same way, wrote the later bytes in that probe.
  • Whether a destination backed by bytes can reach the new branch.
  • The WebAssembly.Memory case on a build of this branch. On 1.4.3 (rev 367d939, before Hardening: fix crashes across the runtime, bundler, dev server, shell and CLI #46021) fs.promises.writeFile(out, viewOfWasmMemory) followed by memory.grow(1) wrote a file with zero pages at its end in 2 of 24 runs with 16 or more other live memories, and in 0 of 12 runs with 8 or fewer.

CI: build #86173 (head 6163626) finished with 195 jobs passed and 1 failed (debian 13 aarch64 - test-bun). The test of this change is in neither the failed nor the flaky list of that build.


[review] gate passed · iteration 2 · 2 files touched

fails on main (without fix)
ASAN without fix: BUILD FAILED (no junit output)
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/io/bun-write-typed-array-nonblocking.test.ts
ninja: Entering directory `/workspace/bun/build/debug'
[1/171] gen ErrorCode+*.h
[2/171] gen JSEvent.lut.h
Generating /workspace/bun/build/debug/codegen/JSEvent.lut.h from /workspace/bun/src/jsc/bindings/webcore/JSEvent.cpp
[3/171] gen bake.{client,server,error}.js
-> bake.client.js, bake.server.js, bake.error.js
[4/171] gen JSBuffer.lut.h
Generating /workspace/bun/build/debug/codegen/JSBuffer.lut.h from /workspace/bun/src/jsc/bindings/JSBuffer.cpp
[5/171] gen cpp.rs (cppbind)
[6/171] gen JSSink.{cpp,h,lut.h,rs}
generated_jssink.rs: 6 sinks, 72 exported symbols
Generating /workspace/bun/build/debug/codegen/JSSink.lut.h from /workspace/bun/build/debug/codegen/JSSink.lut.txt
[7/171] gen generated_host_exports.rs
generated_host_exports.rs: 94 exports (host=3, lazy=10, generic=81, rust=0); 239 extern-C blocks audited
[8/171] gen JS modules (bundle-modules)
Preprocess modules (11467ms)
Bundle modules (58ms)
Postprocesss modules (162ms)
Bundle Functions (787ms)
Generate Code (17ms)

[1
... (truncated)

release without fix: all passed
bun test v1.4.0-canary.1 (fed3e454b)

test/js/bun/io/bun-write-typed-array-nonblocking.test.ts:
(pass) Bun.write(path, largeTypedArray) does not block the JS thread copying the payload [517.01ms]
(pass) Bun.write(path, typedArray) writes the correct bytes for borrowed ArrayBuffer sources [39.30ms]
(pass) Bun.write(path, typedArray) releases its pin+protect on the source ArrayBuffer [64.24ms]

 3 pass
 0 fail
 11 expect() calls
Ran 3 tests across 1 file. [834.00ms]
__F:0:S:0
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/io/bun-write-typed-array-nonblocking.test.ts
bun test v1.4.0 (6163626b1)

test/js/bun/io/bun-write-typed-array-nonblocking.test.ts:
(pass) Bun.write(path, largeTypedArray) does not block the JS thread copying the payload [1339.77ms]
(pass) Bun.write(path, typedArray) writes the correct bytes for borrowed ArrayBuffer sources [1236.55ms]
(pass) Bun.write(path, typedArray) releases its pin+protect on the source ArrayBuffer [2928.79ms]

 3 pass
 0 fail
 11 expect() calls
Ran 3 tests across 1 file. [6.49s]
__F:0:S:0

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 851ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/128] gen ErrorCode+*.h
[2/128] gen bake.{client,server,error}.js
-> bake.client.js, bake.server.js, bake.error.js
[3/128] gen JSBuffer.lut.h
Generating /workspace/bun/build/release/codegen/JSBuffer.lut.h from /workspace/bun/src/jsc/bindings/JSBuffer.cpp
[4/128] gen JSEvent.lut.h
Generating /workspace/bun/build/release/codegen/JSEvent.lut.h from /workspace/bun/src/jsc/bindings/webcore/JSEvent.cpp
[5/128] gen JSSink.{cpp,h,lut.h,rs}
generated_jssink.rs: 6 sinks, 72 exported symbols
Generating /workspace/bun/build/release/codegen/JSSink.lut.h from /workspace/bun/build/release/codegen/JSSink.lut.txt
[6/128] gen cpp.rs (cppbind)
[7/128] gen generated_host_exports.rs
generated_host_exports.rs: 94 exports (host=3, lazy=10, generic=81, rust=0); 239 extern-C blocks audited
[8/128] gen JS modules (bundle-modules)
Preprocess modules (9935ms)
Bundle modules (83ms)
Postprocesss modules (184ms)
Bundle Functions (728ms)
Generate Code (39ms)

[10.99s] Bundled "src/js" for production
  2569 kb
  193 internal modules
  1
... (truncated)
diff hotspot
src/runtime/webcore/Blob.rs                        |  46 ++++++
 .../io/bun-write-typed-array-nonblocking.test.ts   | 156 +++++++++++++++++++++
 2 files changed, 202 insertions(+)

gate history · 6 passed · 0 rejected · iteration 2

evidence per changed file
file                                                      reads  edits  tests
src/runtime/webcore/Blob.rs                                   8     11      0
test/js/bun/io/bun-write-typed-array-nonblocking.test.ts      5      9      0

…S thread

For Bun.write(path, typedArray) with a non-S3 destination, the source
buffer was converted to a Blob via Blob::get -> try_create, which
copied the entire payload on the JS thread: memfd_create + ftruncate +
pwrite64(len) on Linux for buffers >= 8MB, or a full memcpy into a Vec
otherwise. A 500MB write stalled the event loop for ~380ms before the
pool thread ever saw the data.

Pin and GC-protect the ArrayBuffer and wrap its bytes in a Bytes store
whose allocator free releases the pin+protect. WriteFile::then /
WriteFileWindows::run_from_js_thread drop that store on the JS thread,
so the borrow is safely released there. The pool-thread write reads the
user's buffer directly, matching fs.promises.writeFile. S3 destinations
keep the copying path since their store may outlive the JS-thread drop.

Resizable buffers fall back to the copy path (a concurrent shrink would
invalidate the borrowed slice).
@coderabbitai

coderabbitai Bot commented Jul 31, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

Bun.write uses zero-copy handling for eligible ArrayBuffers on non-S3 destinations. Tests cover timing, output correctness, supported views, rejected writes, and buffer cleanup.

Zero-copy ArrayBuffer writes

Layer / File(s) Summary
Borrow and integrate ArrayBuffers
src/runtime/webcore/Blob.rs
Eligible non-empty, non-resizable ArrayBuffers are pinned and protected, exposed through a zero-copy store, and released when the store is freed. Non-S3 writes use this path.
Validate write behavior
test/js/bun/io/bun-write-typed-array-nonblocking.test.ts
Tests cover synchronous timing, byte counts, file lengths, and exact output for supported typed-array views.
Validate cleanup and rejection paths
test/js/bun/io/bun-write-typed-array-nonblocking.test.ts
GC-stress tests check bounded protection counts after successful and rejected writes. Directory-targeted writes are verified to reject.

Possibly related PRs

  • oven-sh/bun#36001: Modifies related Blob.rs and Bun.write handling through rope flattening rather than zero-copy ArrayBuffer borrowing.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the main change: borrowing TypedArray sources in Bun.write instead of copying them on the JavaScript thread.
Description check ✅ Passed The description explains the change, motivation, implementation, measurements, and verification results, despite using headings that differ from the template.

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

Comment thread test/js/bun/io/bun-write-typed-array-nonblocking.test.ts Outdated
The 4096-byte offset view was handled by write_bytes_to_file_fast on
non-Windows and never reached borrow_array_buffer_for_write. Use a 512KB
window so every case in the content test routes through the borrowed
path.
Comment thread src/runtime/webcore/Blob.rs Outdated
Comment thread src/runtime/webcore/Blob.rs Outdated
Comment thread src/runtime/webcore/Blob.rs Outdated
Comment thread src/runtime/webcore/Blob.rs Outdated
@robobun

robobun commented Jul 31, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 4:30 AM PT - Jul 31st, 2026

❌ @robobun, your commit 6163626 has 1 failures in Build #86173 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 36535

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

bun-36535 --bun

Comment thread test/js/bun/io/bun-write-typed-array-nonblocking.test.ts Outdated
Fresh 300KB buffer per iteration with Bun.gc(true), asserting
protectedObjectTypeCounts stays flat across both the success path and a
batch of rejected writes (EISDIR), so a missed unprotect/unpin on either
path would surface here.
Comment thread test/js/bun/io/bun-write-typed-array-nonblocking.test.ts Outdated
Both entry points route through write_file_internal but via different
PathOrBlob arms; the content test now runs each input through both.
@robobun

robobun commented Jul 31, 2026 •

Copy link
Copy Markdown
Collaborator Author

CI status for 6163626 (build #86173): the build finished with 195 jobs passed and 1 failed (debian 13 aarch64 - test-bun). test/js/bun/io/bun-write-typed-array-nonblocking.test.ts is in neither the failed nor the flaky list of that build. The failures in the list are in code this diff does not touch:

  • test/js/node/test/parallel/test-fs-read-stream-pos.js timeout (node fs read stream, reported to main-break triage)
  • the rest are tagged [flaky] (passed alone or on a retry): spawn-streaming-stdout, fetch.stream, 08965, abort, and others

Correction, made after the close: an earlier version of this comment said the test "passed on every lane in all three runs" and that the PR was "ready for review". Builds #86135 and #86156 were canceled by later pushes with 6 jobs not run, so that claim holds only for #86173. The PR was not ready for review: it changes what Bun.write writes when the caller mutates the buffer after the call, and the body did not say so. The body has a Downsides section now.

Comment thread test/js/bun/io/bun-write-typed-array-nonblocking.test.ts Outdated
The 1ms floor in Math.max(small_ms, 1) * 8 pinned the threshold at 8ms
on release hardware where the 1MB sample completes in microseconds; on a
fast runner the unfixed 64MB memfd snapshot can finish under that and
pass. Bump the large sample to 128MB, drop the floor to 0.05ms, and
widen the multiplier to 16x so the unfixed O(n) cost (128x the 1MB
sample) is clearly above threshold while the fixed O(1) cost stays well
below on both release and debug builds.

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/runtime/webcore/Blob.rs`:
- Around line 4996-5034: Update borrow_array_buffer_for_write to replace the
manual data.protect/value.unprotect pair with a boxed Strong handle stored in
the allocator’s opaque pointer field. In the free callback, recover and drop
that Strong on the JS thread instead of reconstructing JSValue and calling
unprotect, while preserving the existing pin/unpin cleanup and StoreRef drop
behavior.

In `@test/js/bun/io/bun-write-typed-array-nonblocking.test.ts`:
- Around line 50-98: Change the independent subprocess tests in the affected
range, including the test beginning “Bun.write(path, typedArray) writes…”, from
test to test.concurrent. Preserve their existing bodies, assertions, and setup;
only update the test declaration so both independent subprocess tests can run
concurrently.
- Around line 59-64: Add resizable ArrayBuffer and zero-length buffer entries to
the cases in the Bun.write/BunFile.write test, ensuring both trigger the
borrow_array_buffer_for_write fallback through Blob::get(). For each new case,
assert the expected written bytes and byte counts for both APIs while preserving
the existing non-resizable view coverage.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: f5eacf96-2b3a-4879-b263-617f70d1a350

📥 Commits

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

📒 Files selected for processing (2)
  • src/runtime/webcore/Blob.rs
  • test/js/bun/io/bun-write-typed-array-nonblocking.test.ts

Comment thread src/runtime/webcore/Blob.rs
Comment thread test/js/bun/io/bun-write-typed-array-nonblocking.test.ts Outdated
Comment thread test/js/bun/io/bun-write-typed-array-nonblocking.test.ts
… fallback

The content and GC-stress tests spawn independent subprocesses with no
shared state; run them concurrently. Add resizable-ArrayBuffer and empty
Uint8Array cases to the content round-trip to exercise the
borrow_array_buffer_for_write None branches.

@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 issues found in this revision — all three earlier review threads (GC-stress coverage, sibling Bun.file().write entry point, release-build timing gate) are now addressed, and the coderabbit nits are applied or reasonably declined. Deferring rather than auto-approving because borrow_array_buffer_for_write is new cross-thread GC-lifetime machinery (pin + protect stashed in a custom allocator whose free must run on the JS thread), which is worth a maintainer's eyes even though it mirrors the existing StringOrBuffer::Buffer pattern.

Checked this pass:

  • StoreRef ownership chain: on POSIX the last ref drops in WriteFile::then (JS thread via heap::take); on Windows in WriteFileWindows::deinit via run_from_js_thread/on_finish (uv-loop thread = JS thread), including the throw and WriteFileWindowsDeinitialized error arms.
  • Bytes::drop → StdAllocator::free skips empty slices, so the byte_len == 0 early-out is load-bearing (otherwise unpin/unprotect would never fire) — correctly handled.
  • JSValue(usize) ↔ *mut c_void round-trip in the allocator ptr is bit-identical; SizeType = u64 so byte_len as SizeType doesn't truncate.
  • Resizable guard matches the NodeHTTPResponse precedent (pin doesn't prevent resize()'s PROT_NONE); non-growable SAB is fine to borrow.
Extended reasoning...

Overview

Adds borrow_array_buffer_for_write in src/runtime/webcore/Blob.rs (~40 lines) plus a 6-line call site inside write_file_internal's source-blob construction, gated on !destination_blob.is_s3(). When data is a non-empty, non-resizable ArrayBuffer/view, it pins the backing JSC::ArrayBuffer, GC-protects the JSValue, and builds a store::Bytes whose StdAllocator::free releases both — so the pool-thread do_write_loop reads the user's buffer directly instead of the old Blob::get snapshot (memfd pwrite64 or .to_vec() on the JS thread). New test file covers timing scaling, content round-trip across Uint8Array/ArrayBuffer/DataView/offset-view/resizable/empty × Bun.write/Bun.file().write, and a 64-success + 16-EISDIR GC-stress loop asserting protectedObjectTypeCounts stays at baseline.

Security risks

None. No parsing of untrusted input, no auth/crypto/permissions surface. The change is purely a lifetime/ownership optimization on an existing write path.

Level of scrutiny

High. This is new unsafe code that (a) hands a JS-heap-backed slice to a pool thread, (b) relies on a custom allocator free callback firing on the JS thread as the last-ref release point, and (c) encodes a JSValue into a *mut c_void ptr slot. REVIEW.md calls memory safety "the most-blocked category" and specifically asks reviewers to be able to answer "who frees this, when, on which paths" and to prove GC-safety with a stress test. I traced the StoreRef graph on both platforms and both success/error arms and it checks out — the JS-thread refs (source_blob scopeguard, source_store local) drop first, leaving WriteFile.bytes_blob / WriteFileWindows.bytes_blob as the sole owner, which is dropped in then / deinit on the JS thread. The pool thread only calls shared_view() (a &[u8] borrow, no ref-count change). But this is exactly the class of change where a maintainer familiar with the WriteFile task lifecycle (VM shutdown mid-task, worker termination, the io-request path when close_after_io is set) should confirm the invariant holds on every terminal path.

Other factors

  • The implementation deliberately mirrors the existing fs.promises.writeFile pattern (MarkedArrayBuffer::from_js_pinned + value.protect() in src/runtime/node/types.rs, released by Unprotect::unprotect), which is a strong precedent.
  • The one open design point is coderabbit's Strong-vs-protect() suggestion, which the author declined with a reasonable rationale (matches sibling path, avoids a box, pin still needs separate carriage) and left open to maintainer preference.
  • All three of my earlier inline comments are addressed in fed3e45 / 11c0e6b / 72ab7d0, and CI on the latest push (#86173) is building; #86156 was green on this test file across all lanes.
  • The timing test's 0.8 ms release threshold (0.05 ms floor × 16) has ~40× headroom vs. the observed 0.02 ms fixed-build cost per the author's local measurements; best-of-3 in an isolated subprocess should keep it stable, but it's the one assertion worth watching on noisy shared runners.

@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 90-day stale sweep closed this PR. I corrected the PR body after the close. The changes:

  • The body has a Downsides section now. This change makes Bun.write(path, buf) read buf after the call returns. Today Bun.write writes the bytes as they are at the call. The first body did not say this.
  • The measurement table is replaced by the numbers that were measured, each with its build.
  • The strace lines came from the report that started this work, not from a run here. The body says so now.
  • The status comment above named three builds. Two of them were canceled before all lanes ran. It names the one finished build now.

Do not revive this branch as it is. The stall still reproduces on 1.4.3 (486 ms for a 512 MB source). A review of where the fix belongs is in progress, and any new PR follows from it.

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