Skip to content

Copy a Bun.mmap view where a shared buffer is copied (file mappings only) - #46512

Draft
robobun wants to merge 1 commit into
mainfrom
robobun/5167e3ae/file-mapped-buffers-copied-like-shared
Draft

robobun wants to merge 1 commit into
mainfrom
robobun/5167e3ae/file-mapped-buffers-copied-like-shared

Conversation

@robobun

@robobun robobun commented Oct 22, 2026

Copy link
Copy Markdown
Collaborator

Problem

Fix

Background

  • ArrayBufferContents owns an ArrayBuffer's bytes, and transfer() moves it.
  • Considered a table of mappings (a lock at every gate), bounds in each reader (XML.parse still aborts), a SharedArrayBuffer from Bun.mmap (users see it).

Downsides

  • A parse or decode of a Bun.mmap view copies it, writer or not: +124 instructions at 64 bytes, then +50k to +80k and +1 MiB peak RSS per MiB.
  • Other inputs: +2 instructions at a gate for a view over an ArrayBuffer, +0 otherwise. Bun.markdown.*(buffer): +31 per call. Binary: +768 bytes.
  • Not fixed: a buffer that an async fs.read in flight fills, sort() of a mapped array, a writer that truncates the file. Draft until WebKit#872 merges.
Notes

Question for a maintainer. A Bun.mmap view that nothing writes was parsed in place. The process cannot know whether a writer exists, so the copy is now made for every mapped view that reaches a function that reads a byte twice. Which do you want?

  1. This change: a copy for each such call, through the mark.
  2. No copy, and bounds in each reader (the shape of patches/libdeflate/bounded-emit.patch). The parsers then need every index into the input checked, and a racing result can still contradict itself.
  3. A SharedArrayBuffer from Bun.mmap. Every existing check then covers it, and Web APIs that refuse a shared buffer refuse the view.
  4. This change plus an option in MMapOptions that selects the unmarked buffer, for a caller who knows that nothing else writes the file.

Scope. The writer writes the file in place: another mapping, pwrite, Bun's own fs.writeFileSync. A writer that truncates the file ends in a fault on any read of the view, as the JSDoc of Bun.mmap says. No user reported any of this: three issues mention Bun.mmap (#9857, #9858, #28792) and none is about a writer.

The gates. JSBytesView.cpp (the bindgen option behind Bun.TOML.parse, Bun.YAML.parse, TextDecoder#decode, and Bun.XML.parse through JsBytes::get_or_copy_if_written_concurrently), JSBuffer.cpp (Buffer#toString("utf8")), JSStringDecoder.cpp (3), JSTextDecoderStream.cpp (2), ReadableStreamOperations.cpp (2, Response#textStream), NodeBufferModule.cpp (transcode), and ArrayBuffer::can_change_under_borrow in Rust (Bun.markdown). The gates that refuse a shared buffer (Bun.Image, CompressionStream, b.bufferSource) still ask isShared(), so a mapped view is accepted where it was. test/internal/source-lints/array-buffer-copy-gates.test.ts counts the uses of isShared() that are left, so that a new copy gate has to choose.

Two new schema options. mapped: "borrowed" leaves a mapped view in place where shared: "copied" copies a shared buffer. Bun.gzipSync and Bun.deflateSync use it: their writes are bounded since #46021, and a copy would double the memory it takes to compress a large mapped file. zlib.test.js ("libdeflate doesn't write past its output buffer when another thread rewrites a mapped input") therefore still reaches libdeflate with the mapping. longest names the longest input a body reads: Bun.XML.parse, Bun.TOML.parse and Bun.YAML.parse refuse a mapped file over 2 GiB at once, without a copy (24 ms and 31 MB of RSS, as on main).

The pin. WEBKIT_VERSION goes from d6375c72af to autobuild-preview-pr-872-a1ba2bc3, which is d6375c72af plus the one commit of oven-sh/WebKit#872. The preview release exists only while that pull request is open.

Other lenders. JSBuffer__fromMmap and ArrayBuffer__fromSharedMemfd pass ArrayBufferExternalWriters::No on purpose: whatever wrote those mappings is done when JavaScript gets them, and a mark would copy them on every decode. Memory from bun:ffi and N-API is not marked.

Tests.

  • bindgen.test.ts is deterministic. bytesOfAreSameMemoryCopyingShared reports a copy for a mapped view, its buffer, a subarray, a DataView, a Uint16Array, Buffer.from(view.buffer), a shared: false mapping, an offset mapping, and the buffer after transfer(), transferToFixedLength(), a structured clone that transfers and a transfer into a Worker. It reports no copy for slice(), new Uint8Array(view), structuredClone(view), transfer(4), Bun.file(path).bytes() and readFile(path). With mapped: "borrowed" a mapped view is not copied and a shared buffer is.
  • mmap.test.js calls Bun.XML.parse, Bun.TOML.parse, Bun.YAML.parse, Bun.markdown.html and the UTF-8 decoders (TextDecoder, Buffer#toString, StringDecoder, transcode, TextDecoderStream, Response#textStream) on a mapped view while a Worker with its own mapping rewrites the file. Two more cases hold the mapping through transfer() and as a private mapping.
Build the 7 racing cases
debug ASAN, main sources with the patched WebKit 7 fail, in 3 of 3 runs each at a quarter of the calls
debug ASAN, this branch 0 fail
release, main (38afee6) 6 fail
release, this branch 0 fail

Failure forms on the unfixed debug build: ASSERTION FAILED: hash == ... in StringImpl::setHash (XML), ASAN heap-buffer-overflow (TOML, the decoders), panic: assertion failed: self.input[pos.cast()] == unit (YAML), panic: attempt to subtract with overflow (markdown), panic: assertion failed: sequence[0] > 127 (the decoders).

Also on the debug build of this branch: test/js/web/encoding/text-decoder.test.js and test/js/node/string_decoder (273 pass), the SharedArrayBuffer decoder race in buffer.test.js, the libdeflate race in zlib.test.js.

Measurements. Release builds of main (38afee6) and of this branch, same machine and flags. instructions:u from perf_event_open, as (I(2N) - I(N)) / N, minimum of 5 runs, with BUN_JSC_useConcurrentJIT=0 BUN_JSC_useConcurrentGC=0. "A/A" is a second copy of the main binary.

Instructions per call main this branch A/A
TextDecoder#decode, 64-byte Uint8Array 550.7 549.3 550.5
TextDecoder#decode, 4 KiB view over an ArrayBuffer 1,837.6 1,835.7 1,848.5
Buffer#toString, 64 bytes 573.6 573.8 573.8
Bun.XML.parse, 64 bytes 6,412.4 6,412.8 6,412.0
Bun.XML.parse, 4 KiB view over an ArrayBuffer 15,988.1 15,933.9 15,999.3
Bun.TOML.parse, 64 bytes 6,880.5 6,860.4 6,953.5
Bun.YAML.parse, 64 bytes 12,402.2 12,402.6 12,402.2
Bun.gzipSync, 64 bytes 19,717.4 19,723.3 19,717.3
Bun.markdown.html, 64 bytes 6,145.6 6,177.1 6,146.9
Bun.markdown.html, 4 KiB view over an ArrayBuffer 51,105.1 51,142.0 51,117.9
TextDecoder#decode, mapped 64 bytes, no writer 561.7 686.0 560.2
Buffer#toString, mapped 64 bytes, no writer 586.5 719.2 589.6
Bun.XML.parse, mapped 64 bytes, no writer 5,675.4 6,001.0 5,745.1
Bun.gzipSync, mapped 64 bytes 19,714.9 19,720.6 19,715.2
new ArrayBuffer(64) 1,082.9 1,085.4 1,083.2
new ArrayBuffer(64).transfer() 2,074.4 2,085.5 2,076.1
buffer.slice() of 64 bytes 1,288.7 1,291.8 1,285.2

The predicate itself, from the disassembly: a view with no ArrayBuffer takes the same instructions. A view over an ArrayBuffer takes 5 where it took 3 after the buffer is found (one more load, no branch). Bun.markdown pays a call into C++ for the same answer, because the Rust ArrayBuffer struct has no field for it: a field would cost every as_array_buffer caller a load.

.text of the release binary: 82,022,297 to 82,023,065 bytes. sizeof(ArrayBufferContents) 56 and sizeof(ArrayBuffer) 96, before and after.

The cost of the copy by size, measured on the first revision of this change (9634520 against it, same method): Bun.XML.parse of a mapped 1 MiB, 16 MiB and 1 GiB file 2.149 M, 33.68 M and 2,167 M instructions before, 2.201 M, 34.76 M and 2,248 M after. TextDecoder#decode of the same 0.229 M, 3.11 M and 212 M before, 0.275 M, 3.99 M and 288 M after. Peak RSS for the 1 GiB file 3,179 MB before and 4,228 MB after.

Copies per call, from breakpoint hit counts on Bun::Bindgen::copyBytes and ArrayBuffer::try_copy_bytes in a debug build, 1,000 calls each of Bun.XML.parse, Bun.TOML.parse, Bun.YAML.parse and TextDecoder#decode: a Uint8Array, a Buffer, an ArrayBuffer, a DataView, a string and a Blob 0, a SharedArrayBuffer view 1 (as on main), a Bun.mmap view 1.

perf, strace, valgrind and bloaty are not in the container. The numbers come from a perf_event_open and wait4 wrapper and from llvm-size.

Self-review, the 4 concerns that are not addressed.

  • Bounds in the two-pass UTF-8 to UTF-16 routines (BunString__fromUTF8, to_utf16_alloc and others). They would protect the decoders from every writer, also an fs.read in flight, and would make the 9 decoder gates optional. That is another change. Decode a private copy of a JS buffer in the UTF-8 decoders #41574 works on the same decoders.
  • The decoder gates copy for every encoding, also where only UTF-8 reads twice (StringDecoder, transcode). Hardening: fix crashes across the runtime, bundler, dev server, shell and CLI #46021 chose that for a shared buffer, and this change keeps one rule for both.
  • An explicit ArrayBufferExternalWriters at every createFromBytes call. Here the argument is explicit at the three mapping constructors, and the default stays for memory that Bun allocated.
  • Cycle counts next to the instruction counts. They vary with the load of the machine.

Not fixed, with what I know of each.

Related. #41574 (a private copy of every buffer in the UTF-8 decoders) and #42310 (Bun.Transpiler and Bun.markdown) touch the same gates. This change does not depend on them.

A Bun.mmap view is a plain ArrayBuffer over a file mapping. Whatever else has
the file open writes its bytes at any time. Code that reads a byte twice and
counts on what it was copied a SharedArrayBuffer first, but it read a mapping
in place: Bun.XML.parse, Bun.TOML.parse, Bun.YAML.parse, TextDecoder,
Buffer#toString, StringDecoder, TextDecoderStream, Response#textStream,
buffer.transcode and Bun.markdown.

Bun.mmap now makes its buffer with ArrayBufferExternalWriters::Yes
(oven-sh/WebKit#872). The mark is on the ArrayBufferContents, so a transfer
keeps it and a copy of the bytes does not have it. The places that asked
isShared() before they borrowed the bytes ask canBeWrittenConcurrently().
ArrayBuffer::can_change_under_borrow asks it too.

The bindgen option shared: "copied" copies a mapped view too. mapped:
"borrowed" leaves it where it is: Bun.gzipSync and Bun.deflateSync say so,
because libdeflate's writes are bounded. longest names the longest input a
body reads, so that a longer one is refused without a copy.

A source lint counts the uses of isShared() that are left, so that a new copy
asks the right question.

WEBKIT_VERSION names the preview build of oven-sh/WebKit#872.
@robobun

robobun commented Oct 22, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 5:03 AM PT - Oct 22nd, 2026

@robobun, your commit 538ad1b is building: #130896

@robobun

robobun commented Oct 22, 2026

Copy link
Copy Markdown
Collaborator Author

Status: draft, ready for a first look. PR: #46512

How I reproduced it, on release 1.4.3-canary (linux x64):

  • One process holds Bun.mmap(path) of <root><item/></root> and loops Bun.XML.parse(view). A second process maps the same file and writes 0x80 and < over the first byte in turn. 3 of 3 runs end in panic(main thread): Segmentation fault at address ...FFF, the view pointer minus 1. Bun.TOML.parse does the same.
  • The same writer against Bun.YAML.parse: panic: assertion failed: self.input[pos.cast()] == unit, 2 of 2 runs.
  • test/js/bun/util/mmap-racing-writer-fixture.ts in this PR does it in one process with a Worker: 6 of 7 cases fail on a release build of main, 7 of 7 on a debug build.

This PR needs oven-sh/WebKit#872. It stays a draft until that merges and WEBKIT_VERSION can name a commit.

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.

1 participant