Repository navigation
Conversation
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.
Collaborator
Author
Collaborator
Author
|
Status: draft, ready for a first look. PR: #46512 How I reproduced it, on release 1.4.3-canary (linux x64):
This PR needs oven-sh/WebKit#872. It stays a draft until that merges and |
This was referenced Oct 30, 2026
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Bun.XML.parse,Bun.TOML.parse,Bun.YAML.parseand the UTF-8 decoders read aBun.mmapview in place while another process writes the file. On 1.4.3:panic(main thread): Segmentation fault at address 0x7F272A5EDFFF,panic: assertion failed: self.input[pos.cast()] == unit, XML keys that do not look up. Found by audit, not by a user.SharedArrayBufferfirst (Add b.bytes and define thirty-five Bun.* functions with the bindgen #45590, Hardening: fix crashes across the runtime, bundler, dev server, shell and CLI #46021), but the test isisShared()(src/jsc/bindings/Bindgen/JSBytesView.cpp:59), false for a mapping.Fix
Bun.mmapmarks its buffer (ArrayBufferExternalWriters::Yes, [JSC] An ArrayBuffer over memory that something outside JavaScript writes says so WebKit#872). The mark is on theArrayBufferContents: a transfer keeps it, a copy does not.shared: "copied", 9 gates in the UTF-8 decoders andArrayBuffer::can_change_under_borrowaskcanBeWrittenConcurrently().Bun.gzipSyncsaysmapped: "borrowed": libdeflate's writes are bounded (Hardening: fix crashes across the runtime, bundler, dev server, shell and CLI #46021).test/internal/bindgen.test.ts,test/js/bun/util/mmap.test.js, a source lint. 9 of 10 new tests fail onmainwith the same WebKit.Background
ArrayBufferContentsowns anArrayBuffer's bytes, andtransfer()moves it.XML.parsestill aborts), aSharedArrayBufferfromBun.mmap(users see it).Downsides
Bun.mmapview copies it, writer or not: +124 instructions at 64 bytes, then +50k to +80k and +1 MiB peak RSS per MiB.ArrayBuffer, +0 otherwise.Bun.markdown.*(buffer): +31 per call. Binary: +768 bytes.fs.readin 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.mmapview 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?patches/libdeflate/bounded-emit.patch). The parsers then need every index into the input checked, and a racing result can still contradict itself.SharedArrayBufferfromBun.mmap. Every existing check then covers it, and Web APIs that refuse a shared buffer refuse the view.MMapOptionsthat 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 ownfs.writeFileSync. A writer that truncates the file ends in a fault on any read of the view, as the JSDoc ofBun.mmapsays. No user reported any of this: three issues mentionBun.mmap(#9857, #9858, #28792) and none is about a writer.The gates.
JSBytesView.cpp(the bindgen option behindBun.TOML.parse,Bun.YAML.parse,TextDecoder#decode, andBun.XML.parsethroughJsBytes::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), andArrayBuffer::can_change_under_borrowin Rust (Bun.markdown). The gates that refuse a shared buffer (Bun.Image,CompressionStream,b.bufferSource) still askisShared(), so a mapped view is accepted where it was.test/internal/source-lints/array-buffer-copy-gates.test.tscounts the uses ofisShared()that are left, so that a new copy gate has to choose.Two new schema options.
mapped: "borrowed"leaves a mapped view in place whereshared: "copied"copies a shared buffer.Bun.gzipSyncandBun.deflateSyncuse 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.longestnames the longest input a body reads:Bun.XML.parse,Bun.TOML.parseandBun.YAML.parserefuse a mapped file over 2 GiB at once, without a copy (24 ms and 31 MB of RSS, as onmain).The pin.
WEBKIT_VERSIONgoes fromd6375c72aftoautobuild-preview-pr-872-a1ba2bc3, which isd6375c72afplus the one commit of oven-sh/WebKit#872. The preview release exists only while that pull request is open.Other lenders.
JSBuffer__fromMmapandArrayBuffer__fromSharedMemfdpassArrayBufferExternalWriters::Noon purpose: whatever wrote those mappings is done when JavaScript gets them, and a mark would copy them on every decode. Memory frombun:ffiand N-API is not marked.Tests.
bindgen.test.tsis deterministic.bytesOfAreSameMemoryCopyingSharedreports a copy for a mapped view, itsbuffer, asubarray, aDataView, aUint16Array,Buffer.from(view.buffer), ashared: falsemapping, an offset mapping, and the buffer aftertransfer(),transferToFixedLength(), a structured clone that transfers and a transfer into a Worker. It reports no copy forslice(),new Uint8Array(view),structuredClone(view),transfer(4),Bun.file(path).bytes()andreadFile(path). Withmapped: "borrowed"a mapped view is not copied and a shared buffer is.mmap.test.jscallsBun.XML.parse,Bun.TOML.parse,Bun.YAML.parse,Bun.markdown.htmland 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 throughtransfer()and as a private mapping.mainsources with the patched WebKitmain(38afee6)Failure forms on the unfixed debug build:
ASSERTION FAILED: hash == ...inStringImpl::setHash(XML), ASANheap-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.jsandtest/js/node/string_decoder(273 pass), the SharedArrayBuffer decoder race inbuffer.test.js, the libdeflate race inzlib.test.js.Measurements. Release builds of
main(38afee6) and of this branch, same machine and flags.instructions:ufromperf_event_open, as(I(2N) - I(N)) / N, minimum of 5 runs, withBUN_JSC_useConcurrentJIT=0 BUN_JSC_useConcurrentGC=0. "A/A" is a second copy of themainbinary.mainTextDecoder#decode, 64-byteUint8ArrayTextDecoder#decode, 4 KiB view over anArrayBufferBuffer#toString, 64 bytesBun.XML.parse, 64 bytesBun.XML.parse, 4 KiB view over anArrayBufferBun.TOML.parse, 64 bytesBun.YAML.parse, 64 bytesBun.gzipSync, 64 bytesBun.markdown.html, 64 bytesBun.markdown.html, 4 KiB view over anArrayBufferTextDecoder#decode, mapped 64 bytes, no writerBuffer#toString, mapped 64 bytes, no writerBun.XML.parse, mapped 64 bytes, no writerBun.gzipSync, mapped 64 bytesnew ArrayBuffer(64)new ArrayBuffer(64).transfer()buffer.slice()of 64 bytesThe predicate itself, from the disassembly: a view with no
ArrayBuffertakes the same instructions. A view over anArrayBuffertakes 5 where it took 3 after the buffer is found (one more load, no branch).Bun.markdownpays a call into C++ for the same answer, because the RustArrayBufferstruct has no field for it: a field would cost everyas_array_buffercaller a load..textof the release binary: 82,022,297 to 82,023,065 bytes.sizeof(ArrayBufferContents)56 andsizeof(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.parseof 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#decodeof 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::copyBytesandArrayBuffer::try_copy_bytesin a debug build, 1,000 calls each ofBun.XML.parse,Bun.TOML.parse,Bun.YAML.parseandTextDecoder#decode: aUint8Array, aBuffer, anArrayBuffer, aDataView, a string and aBlob0, aSharedArrayBufferview 1 (as onmain), aBun.mmapview 1.perf,strace,valgrindandbloatyare not in the container. The numbers come from aperf_event_openandwait4wrapper and fromllvm-size.Self-review, the 4 concerns that are not addressed.
BunString__fromUTF8,to_utf16_allocand others). They would protect the decoders from every writer, also anfs.readin 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.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.ArrayBufferExternalWritersat everycreateFromBytescall. Here the argument is explicit at the three mapping constructors, and the default stays for memory that Bun allocated.Not fixed, with what I know of each.
fs.read(thread pool) is filling. A typed array over 1,000 bytes has noArrayBufferto mark, and this mark has no setter. On 1.4.3,Bun.XML.parseof a 20-byteUint8Arrayunder 256 reads in flight returns a key that an equal string does not find in 387,928 of 2,319,000 parses. Keep large typed arrays attached while native code still uses them #46315 changes how native code holds a large typed array.TypedArray.prototype.sortof a mapped array with a writer stops atstd::span<unsigned int>::operator[]: Assertion '__idx < size()' failed. A copy, as for a shared array, doubles the memory of the sort and cannot be made at 2 GiB or more. [JSC] An ArrayBuffer over memory that something outside JavaScript writes says so WebKit#872 has the details.shared: "copied"read a mapped view in place, as they read a shared buffer:Bun.JSON5.parse,Bun.JSONL.parse,new Bun.Image.Related. #41574 (a private copy of every buffer in the UTF-8 decoders) and #42310 (
Bun.TranspilerandBun.markdown) touch the same gates. This change does not depend on them.