Skip to content

Blob: count the copy-on-write copies of a memfd store, so two reads of one large body do not share memory - #45947

Open
robobun wants to merge 6 commits into
mainfrom
robobun/427e332d/memfd-store-view-private
Open

robobun wants to merge 6 commits into
mainfrom
robobun/427e332d/memfd-store-view-private

Conversation

@robobun

@robobun robobun commented Oct 18, 2026 •

Copy link
Copy Markdown
Collaborator

Related to #22885

Problem

  • On Linux, two copies of one Blob body of 8 MiB or more share memory. After kept = await res.clone().bytes() and mine = await res.bytes(), mine.fill(90) changes all of kept.
  • The bytes are in a memfd. The store's last owner gets its MAP_SHARED mapping (has_one_ref(), src/runtime/webcore/Blob.rs:2837). A copy is a MAP_PRIVATE mapping that this test does not count.

Fix

  • Each copy holds a reference on the store's LinuxMemFdAllocator until JS frees it. The last owner gets the shared mapping only when that count is 1.
  • Correct because one remaining owner means that nothing can make a new copy.
  • The mapping moves to Rust, and ArrayBuffer__fromSharedMemfd is removed.
  • Verified: test/js/web/fetch/blob-cow.test.ts (20 of 32 cases fail on main), blob.test.ts, body.test.ts and 12 more. Self-reviewed: 12 concerns raised, 10 addressed, 2 handed off.

Background

  • A memfd is a file in memory. With one, blob.arrayBuffer() is one mmap, not a copy (Make file uploads use less memory on Linux #7938).
  • A MAP_PRIVATE page reads the file until its first write, which copies it. A MAP_SHARED write goes to the file.
  • Weighed: a store reference per copy. It keeps the store's fd and shared mapping open for each kept copy.

Downsides

  • A last-owner read runs 4 to 15 more instructions. A copy read makes 1 more allocation (24 bytes).
  • A last-owner read while a copy is alive costs +1 mmap and +1 munmap.
  • Not fixed: a write to the memfd by fd number still changes every copy of a live Blob.
Notes

Who hits it

#22885 reported this class for every body size: a write into the result of clone().arrayBuffer() changed the original. #33072 fixed it with the last-owner test. This is the case that the test does not see. A fuzz run found it.

Linux and Android only: macOS and Windows have no memfd path. The body must be a single store of 8 MiB or more (1 MiB with --smol). A copy of it must be alive when a body that is the store's last owner is read. Measured at 12 MiB on main:

When the last reader gets the shared mapping Body forms
Without a collection new Response([bytes]), new Response([blob]), new Request(url, { body: [bytes] }), a DataView body, server.fetch(url, { body: bytes })
After a collection a body made from a Blob, a File, or a file part out of formData(), once that object is collected. With the body made inside a function, that happens without a forced collection in 9 of 10 runs
Never Uint8Array, ArrayBuffer, Buffer and string bodies, a body whose Blob is alive, server.fetch(new Request(...)), a request that arrives over the network

One store can also be reached from two threads through a blob: URL. The copy and the last owner are then in different VMs.

Strings are safe. blob.text() returns a string that holds a reference on the store.

Why this layer

The rule that broke is the last-owner test. It asks whether this Blob is the only holder of the bytes. A copy-on-write mapping of the memfd is a holder that no count saw. main already took an allocator reference in the copy branch, for the length of the call. This PR keeps that reference for the life of the buffer, and the test reads it.

  • owns_bytes_alone reads the store count first and the allocator count second. With one store owner left, no other thread can reach copy_on_write_js, so the allocator count can only go down. The other order would race.
  • Only alloc and copy_on_write_js map the memfd. copy_on_write_js maps first and takes the reference only when the kernel accepts the mapping. The buffer's destructor is the single place that releases it.
  • The fd field is private, and nothing writes it after construction. allocator_interface::free closes the fd when it unmaps the store's view, and create closes it when the first mapping fails. main closed it in Drop, which ran at that same moment because nothing else held the allocator. A copy that outlives its Blob keeps its private mapping, a 24-byte record and the 16-byte allocator, and nothing else.
  • The other readers of Store::has_one_ref() are not edited. Any::to_internal_blob_if_possible and Store::to_any_blob copy memfd bytes to the heap before they hand them out, so they never expose the shared mapping.

The Lifetime::Share arm of the same function had no ArrayBuffer or Uint8Array caller, and it handed out the view of a shared store. It takes the copy arm now. alloc loses its unused flags and offset parameters and maps MAP_SHARED as before.

Designs weighed:

Design Result
An allocator reference per copy (this PR) main's fds and mappings at every point. +4 to +15 instructions per last-owner read, +1 allocation of 24 bytes per copy read
A store reference per copy (the second revision) +0 instructions on the last-owner read. A kept copy keeps the store's fd and shared mapping: 100 fds for 100 kept copies (main 0), and 17 kept 64 MiB copies under ulimit -v 4000000 (main 26)
Store view MAP_PRIVATE (the first revision) every live large Blob counts in VmData, every last-owner result is copy-on-write. Numbers below
Store view read-only, mprotect at the hand-out +1 syscall and a page copy per written page on every last-owner read, and a step that the kernel can refuse
Always a private copy at the hand-out +1 mmap, +1 munmap and a page copy per written page on every last-owner read. No count and no gate
A flag on the allocator that a copy was made a collected copy leaves the flag set
Private view plus a write seal +1 fcntl per large Blob, and the zero-copy hand-out is gone for good
No memfd store gives back #7938: a copy per blob.arrayBuffer()

The count exists to keep the zero-copy hand-out for a sole owner. That hand-out reaches only the body forms in the first two rows of the table above. It never reaches a Blob that is alive or an upload from the network. If that hand-out is not wanted, "always a private copy" is the smaller change. It costs 16384 page copies and +64 MiB of anonymous memory for each overwritten 64 MiB result of new Response([bytes]).

Self-review

The review of the second revision raised 7 concerns. This revision addresses 6 of them:

  • The count moves from the store to the allocator. That removes the fds and shared mappings that a kept copy held, and the lower capacity under ulimit -v.
  • The mapping moves to Rust. The C++ mapper had three early returns that each released the reference by hand, and no test ran them.
  • The limit matrix now has ulimit -v.
  • The mapping tests are gated on what they need (memfd_create and /proc), not on the behaviour under test. Three cases assert that the body is in a memfd.
  • The tests have a DataView body.
  • The reach of the zero-copy hand-out is in the design table.

One is handed off to a separate change: a read of a small slice maps the whole file.

The review of this revision raised 5 concerns. Four are addressed:

  • The comment on the fd close gave a wrong reason. The code no longer needs one: the field is not written after construction (see above).
  • The "Left for later" entry about DataView bodies named the wrong follow-up. Two concerns said so. It is corrected below.
  • The two macOS x64 test jobs of the first build of this revision expired without an agent. They ran and passed in the build of the next push.

One is handed off: a typed array over a buffer with an unaligned base aborts in console.log (below). The review also asked for an issue for the body init follow-up. I did not file one, because it is a design choice with a memory cost and not a defect. It is described below for the assignee.

Measurements

Release builds of main 6f58f6b and of this change on that commit, linux x64, 12 vCPU, kernel 6.1.

  • Earlier-copy pages changed: 0 of 2048 on the PR (main 2048 of 2048), over 11 variants, 4 runs each: Response and Request, arrayBuffer() and bytes(), both read orders, a DataView body, a collected Blob, a read from the Blob object first, a slice, a formData() file part, transfer(), structuredClone with transfer. With --smol at 1 MiB: 0 of 256 (main 256 of 256). Control at 8388607 bytes: 0 on both.
  • Never-copied 64 MiB last-owner result: 2 faults on a fill after a read pass (main 2), VmData +0 kB at construct and at the read (main +0), mappings: one rw-s (main: one rw-s). Fill 7.26 ms (main 7.37 and 7.39 ms in two interleaved series of 15).
  • Syscalls per construct / copy read / never-copied read (N=200 minus N=100): memfd_create 2, ftruncate 1, pwrite64 1, mmap 1, munmap 1 / mmap 1, munmap 1 / the same as a construct. main is identical in all three. Last-owner read with a live copy: +1 mmap, +1 munmap. Counted with a ptrace counter, because strace is not installed in the container.
  • Instructions on the last-owner read, from the disassembly of the two Transfer instances: bytes() +4 for a heap store and +8 for a memfd store, arrayBuffer() +11 and +15. The test is 2 compares, and for a memfd store 2 loads and 1 compare more. The arrayBuffer() instance also pushes and pops 3 more registers, because the compiler now inlines the copy arm into it. Counted with perf_event_open (user instructions, JIT off, one GC thread, slope between 10000 and 20000 reads of a 1 KiB body, median of 4 series): bytes() 7985 per read on main and 7989 on the PR, arrayBuffer() 8373 and 8384.
  • Instructions on a copy read: no difference found. Each 8 MiB result starts a collection, so a read costs 274000 to 292000 instructions on main and 277000 to 289000 on the PR. Atomic operations on the allocator count: 2 per copy read on both (the decrement moves from the end of the call to the free of the buffer).
  • Memfd fds held by 100 kept copies of 100 Blobs: 0 (main 0). Of one Blob: 0 (main 0). Shared mappings: 0 (main 0). Private mappings: 100 (main 100). After collection: 0.
  • ulimit -d 1 GiB, 64 MiB objects: live Blobs 48 of 48 (main 48), never-copied results 40 of 40 (main 40), after a collected copy 40 of 40 (main 40), with a live copy 4 pairs then RangeError: Out of memory, exit 0 (main 9 pairs then the same error, and each main pair is one memory).
  • ulimit -v 4000000, kept copies of dropped Blobs: 26 at 64 MiB and 49 at 32 MiB, then RangeError: Out of memory, exit 0. main is identical.
  • Read-overwrite-drop of a never-cloned 64 MiB body under memory.max 1 GiB: 30 of 30 iterations, peak 583 MiB (main 30 of 30, 583 MiB), 5 runs each.
  • Worker fixture crashes: 0 of 10 (main 10 of 10). blob: URL runs that change the earlier copy: 0 of 40 (main 40 of 40, with the collection forced).
  • Release .text: -256 bytes (81695461 to 81695205, size -A).

With every copy-on-write mmap refused through an LD_PRELOAD shim, the reads fall back to heap copies, the copies stay intact, and no memfd fd is left after the Blob is collected.

The last commit changes only where the fd is closed. A release build of it repeats these results for the alias, the fds, both limits, the refused mappings, the consumer script and the test file, and its .text has the same size. With the store's own mapping refused through a second shim, create fails 20 of 20 times, the Blobs fall back to the heap, and no memfd fd stays open.

A script with 63 checks reads a 12 MiB Blob through its consumers (reads, slices, streams, Bun.write, Bun.spawn stdin, fetch upload, FormData, Worker, object URL), with and without a live copy. Both release builds read the same bytes in all 63.

The earlier revisions

The first revision changed one flag: alloc mapped the store's view MAP_PRIVATE | MAP_NORESERVE. It removed the alias. Measured on a release build of it:

  • Under ulimit -d 1 GiB the 10th live 64 MiB Blob aborted the process (exit 134). main and this revision keep 48 of 48.
  • The read-overwrite-drop loop under memory.max 1 GiB was killed in 5 of 5 runs (exit 137, peak 1024 MiB). main and this revision finish 30 of 30 at 583 MiB.
  • A 64 MiB fill of a last-owner result after a read pass took 24.7 ms (main 7.3 ms).

The second revision gave each copy a store reference. It added nothing to a last-owner read. Measured on a release build of it:

  • 100 kept copies of 100 dropped Blobs held 100 memfd fds and 100 shared mappings. main and this revision hold 0.
  • Under ulimit -v 4000000 it kept 17 copies of 64 MiB and 31 of 32 MiB. main and this revision keep 26 and 49.
  • It gave the C++ mapper three early returns that each had to release the reference by hand.

The Worker crash

The alias gave another thread a way to write a plain ArrayBuffer during a native read. kept = await res.clone().arrayBuffer(), mine = await res.arrayBuffer(), worker.postMessage(mine, [mine]). The Worker rewrites its buffer in a loop, once with ASCII and once with 3-byte UTF-8 sequences. The main thread calls new TextDecoder().decode(kept.subarray(0, 1 << 20)) in a loop. main: 10 of 10 runs end in panic: Segmentation fault at address 0x0 (7 of 10 in an earlier round, where the other 3 read the Worker's bytes). PR: 0 of 10, and no foreign byte.

This PR removes that route, not the crash. The UTF-8 decoder reads its input twice (src/bun_core/string/immutable/unicode.rs), so any buffer that another thread writes can still crash it. #41574 changes the decoder and has the race fixture. The test file here does not carry this shape because it is a race.

Tests

blob-cow.test.ts gets 27 cases:

  • Twelve need no collection: 3 bodies (new Response([bytes]), new Request(url, { body: [bytes] }), new Response(new DataView(buffer))) x clone().arrayBuffer() / clone().bytes() x arrayBuffer() / bytes().
  • Two write after buffer.transfer() and after structuredClone with transfer.
  • Five need a collection: a body made from a Blob object. The test waits on a FinalizationRegistry, because the finalizer is what releases the reference on the bytes. The wait is bounded, so an object that is never collected fails the case and does not hang it.
  • Eight are Linux only and read /proc/self/fd and /proc/self/maps. They run when memfd_create works and /proc is readable, and they are skipped with BUN_FEATURE_FLAG_DISABLE_MEMFD. Three assert that each body form puts its bytes in a memfd, so a changed threshold or label fails them. The others: the only owner gets the rw-s mapping. The last owner gets a second rw-p mapping while a copy is alive, and the fd is closed. It gets the rw-s mapping again after the copy was collected. A copy that outlives its Blob keeps one rw-p mapping and no fd. Nothing maps the memfd after every copy was collected.

20 of the 32 cases in the file fail on a release build of main: the 19 byte cases and the second mapping case. The other 7 mapping cases hold on main too. They pin the zero-copy hand-out, the release of the reference and the close of the fd. The file passes 20 of 20 runs on the release build and 10 of 10 on the debug build.

One limit of the collection cases. With --rerun-each=100 the file fails 1 of 3200 executions, always in repetition 72 of one case: a tracked buffer is not collected within the 50 ticks. A heap snapshot at that point shows the buffer as a root of the JIT worklist. A DFG compile that is finished but not installed yet keeps the values that it was compiled against. The file passes 3200 of 3200 with BUN_JSC_useConcurrentJIT=0 and with BUN_JSC_useDFGJIT=0, and 640 of 640 with --rerun-each=20. CI runs each case once per process.

Suites on the debug build of this branch: memfd-disabled.test.ts, blob-oom.test.ts, body-clone.test.ts, blob-array-fast-path.test.ts, blob-write.test.ts, body-mixin-errors.test.ts, blob.test.ts and body.test.ts in test/js/web/fetch and in test/js/deno/fetch, FormData.test.ts, FormData-multipart-serialization.test.ts, structured-clone.test.ts, fs-oom.test.ts: 1531 pass, 1 fail. The failure is the 2 GiB case of fs-oom.test.ts, which takes more than the 5 s limit under the debug build on this machine. It does not use a Blob. In one run each, with the machine under load, a beforeAll of blob-oom.test.ts and two cases of body.test.ts that spawn a debug build also took more than 5 s. They passed in the runs after it. bun run rust:check-all passes for all 12 targets, and cargo clippy is clean for bun_runtime and bun_jsc.

Left for later

  • A write or ftruncate on the memfd by fd number (fs.writeSync(5, ...)) changes the Blob and every copy while the Blob is alive, and a truncate ends in a bus error. Same on main. A seal after create fills the file would close it.
  • The store's view stays writable for the kernel. A new hand-out or an in-place native write would need the same care.
  • Blob::try_create aborts when the heap refuses its fallback copy. Same on main.
  • A read of a small slice maps the whole file. Many kept slice reads of one large Blob reach vm.max_map_count. Same on main.
  • Body init puts a one-part buffer of 8 MiB or more in a memfd. That makes 12 measured body forms reach the shared mapping without a collection: a top-level DataView on Response and Request (2), an array of one buffer on Response and Request (5), and a buffer or an array of one buffer passed to server.fetch(url, { body }) (5). A DataView rule beside the typed array rule would remove 2 of them. A heap copy of a one-part buffer at body init would remove all 12 and leave new Blob([bytes]) on the memfd. It has a cost: with the memfd store off, the loop that keeps 40 of 40 results of 64 MiB under ulimit -d 1 GiB aborts on a 64 MiB heap allocation. After that change, the count would guard only bodies whose Blob was collected, and a plain copy could replace it. This is a choice for the assignee, not a defect.
  • Four more forms move the store out of a Blob and leave the caller's Blob empty: new Response([blob]), new Request(url, { body: [blob] }), new Response([file]) and server.fetch(url, { body: blob }). Blob: keep the caller's Blob intact after a slice read, an array body, and a rejected bytes() #38503 changes that.
  • A slice read at an odd offset gives JS a buffer with an unaligned base, as on main. console.log(new Uint16Array(await blob.slice(1, 17).arrayBuffer())) then aborts with cast_slice>TargetAlignmentGreaterAndInputNotAligned. Bun.mmap with an offset does the same with no Blob. Handed off as a separate fix in the consumers.
  • Blob: append onto the first part's store instead of copying it #38626, Bun.serve: borrow large ArrayBuffer Response bodies instead of cloning per-request #35138 and Blob, Bun.file: remove unsafe from Blob.rs, read_file.rs, copy_file.rs and Store.rs #40221 edit the same arms. Blob, Bun.file: remove unsafe from Blob.rs, read_file.rs, copy_file.rs and Store.rs #40221 moves the allocator file to src/jsc/.

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/web/fetch/blob-cow.test.ts

@coderabbitai

coderabbitai Bot commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository: oven-sh/bun/.coderabbit.yaml
  • Review profile: ASSERTIVE
  • Plan: Advanced
  • Run ID: 789428f4-8d17-44a4-8890-4228d487ae02
📥 Commits

Reviewing files that changed from the base of the PR and between bd26d5c and 0dbb770.

📒 Files selected for processing: 4
  • src/jsc/array_buffer.rs
  • src/jsc/bindings/ZigGlobalObject.cpp
  • src/runtime/allocators/LinuxMemFdAllocator.rs
  • src/runtime/webcore/Blob.rs

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 0 remain after this review.

Walkthrough

Blob Share and Clone ArrayBuffer views now use copied storage. Linux can use memfd-backed copy-on-write mappings. A deallocator releases the retained store reference. Tests cover isolation, collection, mappings, and memfd closure.

Changes

Blob copy-on-write buffers

Layer / File(s) Summary
Shared-memfd deallocator contract
src/jsc/array_buffer.rs, src/jsc/bindings/ZigGlobalObject.cpp
The shared-memfd ArrayBuffer API accepts a deallocator and context. C++ invokes the deallocator on mapping failures and after unmapping.
Memfd-backed JavaScript buffers
src/runtime/allocators/LinuxMemFdAllocator.rs
The allocator maps the memfd from offset zero. copy_on_write_js creates a JavaScript ArrayBuffer over a memfd range and retains a store reference for cleanup.
Blob copy lifetimes and validation
src/runtime/webcore/Blob.rs, test/js/web/fetch/blob-cow.test.ts
Share and Clone use copied buffers, with a Linux memfd copy-on-write path. Tests check read isolation, collection behavior, mapping permissions, and memfd closure.

Suggested reviewers: jarred-sumner, dylan-conway

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to 0dbb7

No confirmed issue currently prevents merging. The thread-safety question remains open for follow-up.

Pre-merge checks: Passed 4
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 identifies the main change: tracking copy-on-write memfd copies so separate reads of one large body do not share memory. It is specific and related to the changeset, although it is l…
Description check Passed The description is detailed and directly covers the problem, implementation, tradeoffs, and verification. It does not use the exact template headings, but it provides the required information in equiv…
Full details: Title check

Explanation

The title clearly identifies the main change: tracking copy-on-write memfd copies so separate reads of one large body do not share memory. It is specific and related to the changeset, although it is longer than necessary.

Full details: Description check

Explanation

The description is detailed and directly covers the problem, implementation, tradeoffs, and verification. It does not use the exact template headings, but it provides the required information in equivalent sections.

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@robobun

robobun commented Oct 18, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 8:36 PM PT - Oct 21st, 2026

✅ @robobun, your commit 9930e0ca012845e52e7ae4e51a23f6b232abd324 passed in Build #130383! 🎉


🧪   To try this PR locally:

bunx bun-pr 45947

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

bun-45947 --bun

@robobun

robobun commented Oct 18, 2026

Copy link
Copy Markdown
Collaborator Author

Reproduced on a release build of main (5f5799e), linux x64:

const size = 8 * 1024 * 1024;
const res = new Response([new Uint8Array(size).fill(97)]);
const kept = new Uint8Array(await res.clone().arrayBuffer());
const mine = new Uint8Array(await res.arrayBuffer());
mine.fill(90);
console.log(kept.indexOf(90)); // 0 on main, -1 with this PR

new Response(new Blob([bytes])) gives the same result after the Blob object is collected. The fix is in #45947.

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

Beyond the inline findings, I also checked the other consumers of the memfd store for the same aliasing: the Lifetime::Share arm in src/runtime/webcore/Blob.rs hands out the single store view with a store ref held, so its buffers share one mapping exactly as before; to_js_buffer_from_memfd in src/jsc/array_buffer.rs (subprocess stdio) is a separate memfd that never goes through LinuxMemFdAllocator, so its MAP_SHARED is unaffected; and the Bytes doc comment at src/jsc/webcore_types.rs:613 does not describe the old flags, so nothing stale remains there.

Extended reasoning...

The diff drops the offset/flags parameters of LinuxMemFdAllocator::alloc and maps the store's view MAP_PRIVATE | MAP_NORESERVE instead of MAP_SHARED, with create as the only caller updated, plus ~160 lines of new tests in blob-cow.test.ts. No security-sensitive surface is touched. The sibling memfd mapper (ArrayBuffer__fromSharedMemfd) and the Share/Transfer arms were read to confirm no other write path reaches the file.

Findings marked 🟡 are optional suggestions and need no follow-up push.

Comment thread src/runtime/allocators/LinuxMemFdAllocator.rs Outdated
Comment thread test/js/web/fetch/blob-cow.test.ts Outdated
Comment thread src/runtime/allocators/LinuxMemFdAllocator.rs Outdated
@robobun
robobun force-pushed the robobun/427e332d/memfd-store-view-private branch from d20af52 to bd26d5c Compare October 20, 2026 19:10
@robobun robobun changed the title Blob: map the memfd store's view MAP_PRIVATE so two reads of one large body do not share memory Blob: hold a store reference in each copy-on-write read, so two reads of one large body do not share memory Oct 20, 2026
Comment thread src/jsc/array_buffer.rs Outdated
Comment thread src/jsc/array_buffer.rs Outdated
Comment thread src/jsc/array_buffer.rs Outdated
Comment thread src/jsc/bindings/ZigGlobalObject.cpp Outdated
Comment thread src/runtime/allocators/LinuxMemFdAllocator.rs Outdated
Comment thread src/runtime/allocators/LinuxMemFdAllocator.rs Outdated
Comment thread src/runtime/webcore/Blob.rs Outdated

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

Code review completed

Nothing new to post: everything this review found is already covered by existing comments on this pull request or didn't merit a separate one.

@robobun
robobun force-pushed the robobun/427e332d/memfd-store-view-private branch from bd26d5c to 0dbb770 Compare October 20, 2026 20:08

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

I reviewed the new revision and did not find any bugs; because it moves ownership of a Store ref across the Rust/C++ boundary and changes what a live copy keeps open (one memfd fd and mapping per store until the copy is collected), a human look at the design tradeoff is still worthwhile.

What was reviewed:

  • Ref balance for the store.clone().into_raw() handed to ArrayBuffer__fromSharedMemfd: C++ releases it on the three early returns, in the munmap lambda, and when JSUint8Array::create / JSArrayBuffer::create fail (the moved Ref<ArrayBuffer> is destroyed, which runs the SharedTask). Store uses ThreadSafeRefCount, so running the deallocator off the JS thread is sound.
  • The Lifetime::Share fold into Clone: to_array_buffer/to_uint8_array have no Share caller (read_file passes Temporary, S3 passes Clone), so no behavior changes for existing callers; the Transfer arm correctly falls to the copy branch once a COW copy holds a ref.
  • The store view stays MAP_SHARED (the alloc signature change drops only unused parameters), and the /proc-based mapping tests are gated on a memfd probe rather than isLinux alone.
Extended reasoning...

The change touches the FFI declaration and definition of ArrayBuffer__fromSharedMemfd (src/jsc/array_buffer.rs, src/jsc/bindings/ZigGlobalObject.cpp), moves the memfd copy-on-write path into LinuxMemFdAllocator::copy_on_write_js (src/runtime/allocators/LinuxMemFdAllocator.rs), removes the Lifetime::Share ArrayBuffer arm in src/runtime/webcore/Blob.rs, and adds a large test block in test/js/web/fetch/blob-cow.test.ts. It touches no auth, injection, or data-exposure surface; the sensitive surface is unsafe Rust and raw-pointer ownership transfer across FFI plus mmap of a memfd. The parameter lists match on both sides, every C++ exit path releases the context exactly once, and the test probes memfd availability before reading /proc. It is still not a simple change: it alters when the store's fd is closed and relies on JSC ArrayBuffer destructor semantics, so a maintainer should weigh the fd-per-live-copy tradeoff, and no CODEOWNERS entry covers these files.

A Blob of 8 MiB or more keeps its bytes in a memfd on Linux. The store
mapped that file MAP_SHARED, and a read by the store's last owner handed
that mapping to JS. A write into it changed the file, and with it every
earlier copy-on-write copy of the same Blob.

Map the store's view MAP_PRIVATE | MAP_NORESERVE. No mapping of the file
is shared, so no write reaches it after create() fills it. Remove the
unused flags and offset parameters of alloc().
The byte cases also pass when no memfd store is in use. The new case
reads /proc/self/fd and /proc/self/smaps: the body's Blob is one memfd,
the store's view of it is private and exempt from the commit charge, and
a second read adds one more private mapping.

Reword the MAP_NORESERVE comment: the flag keeps the view out of
Committed_AS, and vm.overcommit_memory=2 ignores it.
The last owner of a memfd store gets the store's own MAP_SHARED mapping
(has_one_ref() in the Transfer arm). A copy-on-write mapping of the same
memfd held no reference on the store, so that test passed while a copy
was alive, and a write into the handed-out mapping changed the copy.

ArrayBuffer__fromSharedMemfd takes a deallocator and a context, and runs
the deallocator once: when its buffer is freed, or before it returns when
it makes no buffer. The one mapper, LinuxMemFdAllocator::copy_on_write_js,
passes a Store ref, so a store with a live copy is not solely owned. The
store's view is MAP_SHARED again, as on main: a body that was never
copied keeps the zero-copy hand-out.

The Share lifetime of the array-buffer conversion had no caller and
handed out the view of a shared store. It takes the copy arm now.
Each copy-on-write mapping of a memfd store now holds a reference on the
store's allocator until JS frees the buffer. The last owner of a store takes
the store's own shared mapping only when the allocator has no other reference.
A live copy no longer keeps the store, its file descriptor or its shared
mapping: the descriptor closes with the store's view.

The mapping is made in Rust and handed to JS through the existing no-copy
constructors, so ArrayBuffer__fromSharedMemfd is removed.
@robobun robobun changed the title Blob: hold a store reference in each copy-on-write read, so two reads of one large body do not share memory Blob: count the copy-on-write copies of a memfd store, so two reads of one large body do not share memory Oct 21, 2026
@robobun
robobun force-pushed the robobun/427e332d/memfd-store-view-private branch from 0dbb770 to d5d5a0d Compare October 21, 2026 08:58

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

I re-reviewed the latest revision (allocator-ref design, mapping moved into Rust) and found no bugs; because this is unsafe refcount/mmap/FFI-deallocator code on the Blob hot path, a human look is still worthwhile.

What was reviewed:

  • Ref balance of CopyOnWrite in src/runtime/allocators/LinuxMemFdAllocator.rs: the allocator ref is taken only after mmap succeeds, released exactly once via CopyOnWrite::release (JSC runs the deallocator on both GC and the Err return of to_js_with_context), and Drop closes the fd only when free has not already replaced it with Fd::INVALID, so no double-close.
  • The owns_bytes_alone ordering in src/runtime/webcore/Blob.rs: with one store owner, no other thread can enter copy_on_write_js, so a concurrent CopyOnWrite drop can only yield a false "has copies", which falls back to a private copy rather than the shared view.
  • The Share arm merge: no ArrayBuffer/Uint8Array caller passes Lifetime::Share (only to_json/to_string do), and the OOM-path detach() stays Clone-only, so the behavior change is confined to a dead path.
  • No remaining references to the removed ArrayBuffer__fromSharedMemfd; the new tests gate on memfd_create from bun:internal-for-testing and /proc readability, and the afterCollected helper is bounded (50 gc ticks) and fails rather than hangs.
Extended reasoning...

The range reworks how a Linux memfd-backed Blob store (>= 8 MiB) hands bytes to JS: the C++ ArrayBuffer__fromSharedMemfd and its Rust wrapper are deleted, replaced by LinuxMemFdAllocator::copy_on_write_js which mmaps MAP_PRIVATE, boxes a CopyOnWrite holding an allocator ref, and hands it to JSC with a release callback; the Transfer arm now gates the zero-copy MAP_SHARED hand-out on owns_bytes_alone (store has one ref and the allocator has no outstanding copy refs). It touches no auth, injection, or data-exposure surface, but it is unsafe Rust with raw-pointer refcounting, mmap/munmap ownership, an FFI deallocator, and cross-thread atomics. The bug hunt ran dry and the inline concerns from my earlier review of the first revision (MAP_PRIVATE store view, unguarded /proc reads) are no longer present in the final code, but the change is too complex and memory-safety-sensitive to approve without a human; no CODEOWNERS entry covers these paths and no third-party CHANGES_REQUESTED review is outstanding.

The allocator no longer writes its fd field after construction. free closes
the fd when it unmaps the store's view, and create closes it when the first
mapping fails. The Drop impl and the write that needed an exclusive-access
argument are removed.

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

I reviewed this revision and didn't find any bugs. Because it moves memfd mapping lifetime into unsafe Rust (an FFI finalizer plus a cross-thread refcount gate on the zero-copy hand-out), a human look is still worthwhile.

What was reviewed:

  • Allocator ref balance in copy_on_write_js: the +1 is taken only after mmap succeeds and is released exactly once by CopyOnWrite::release, which to_js_with_context runs on collection or before returning Err; create closes the fd on alloc failure without a double close now that Drop no longer closes it.
  • The owns_bytes_alone ordering: with the store at one ref, no other holder can reach the copy path, so the allocator count can only decrease between the two reads (both SeqCst); a stale "copies exist" read just falls back to a private mapping.
  • free closing the fd while private mappings survive: CopyOnWrite never reads fd, and the only other readers (alloc, copy_on_write_js) run while the store's Bytes is still live.
  • Lifetime::Share folded into the copy arm: the only Share callers in src/runtime/webcore/Blob.rs are the string/JSON paths, so no caller loses the shared view.
Extended reasoning...

The change deletes the C++ ArrayBuffer__fromSharedMemfd mapper and replaces it with a Rust LinuxMemFdAllocator::copy_on_write_js that owns a MAP_PRIVATE mapping plus an allocator ref, makes the allocator's fd private and closed from the vtable free path, adds has_copies gating the Lifetime::Transfer zero-copy hand-out in src/runtime/webcore/Blob.rs, and adds a test matrix in test/js/web/fetch/blob-cow.test.ts. It touches no auth, injection or data-exposure surface, but it is unsafe memory-lifetime code at an FFI boundary with a cross-thread refcount used as a correctness gate. The bug hunt ran dry with no findings and the refcount, fd-close and race-ordering paths traced out balanced, which is why this is a defer rather than a request for changes; the depth of unsafe code and the prior design churn across revisions is why it is not an approve. No CODEOWNERS entry covers the changed files.

@robobun

robobun commented Oct 25, 2026

Copy link
Copy Markdown
Collaborator Author

#46835 removes the self.detach() of the Lifetime::Clone arm on main. There, a blob.bytes() that rejects for its size empties the caller's Blob, and Bun.write(path, blob) then writes 0 bytes.

This PR keeps that detach as if LIFETIME == Lifetime::Clone { self.detach(); } (src/runtime/webcore/Blob.rs:2779), so the two PRs conflict on one hunk. The side of this PR puts the defect back, and 6 cases of blob-oom.test.ts in #46835 then fail.

One change here removes the conflict for both merge orders: drop only the if LIFETIME == Lifetime::Clone { } wrapper, and keep the self.detach(); line as main has it. I checked that with git merge-file against the head of #46835: 0 conflicts, and the merged arm does not detach. No caller passes Lifetime::Share to this function, on main or here, so the line without the wrapper changes nothing by itself.

This branch has not been deployed

No deployments
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