Repository navigation
Fold Bun's remaining patches into the fork, and take two upstream bitmap fixes - #63
Merged
Merged
Conversation
(cherry picked from microsoft/mimalloc dc0bfd7; the file is src/arena/bitmap.c there)
(cherry picked from microsoft/mimalloc 0e853a0; the file is src/arena/bitmap.c there)
…or new mimalloc implements the C++ `operator new` family (alloc-override.c) and is Bun's global allocator on Linux. Bun compiles without exceptions and installs no `std::new_handler`, so the no-exceptions arm of mi_try_new_handler reaches `h == NULL`, cannot throw, and returns false. mi_theap_try_new then returns NULL to a caller the standard promised memory, and the caller writes through it: a refused `std::string` growth faults at address 0x0. With -DMI_NEW_OOM=<function> that arm calls the named noreturn function instead. `new (std::nothrow)` still returns NULL.
The arenas give each chunk the bin of the first thing that is placed in it, and a page of one slice is never placed in a chunk of another bin, to reduce fragmentation. A request that finds nothing in one arena goes on to the next, or has a new arena reserved. A heap with an exclusive arena (mi_heap_new_in_arena()) has nowhere else to go. In an arena of two chunks, once a page of eight slices was the first in the second chunk, requests for small blocks failed when the first chunk was full, with most of the arena free. Where the search in an exclusive arena has found nothing, it goes through the chunks of all bins. A search that finds room runs the same instructions as before, and so does every search in another arena.
A guard page was only decommitted. In a release build with MI_SECURE below 3, decommit is an madvise and leaves the protection as it is, so the guard page at the end of the arena info stayed readable and writable. Protect it as well, and unprotect it before it is committed again. Upstream dev3 has _mi_os_guard() for this since 5c73531: take that one when it is merged.
MI_NO_GETENV reads nothing from the environment. An embedder may want the options that set sizes and pace to stay settable, and none of those that print, write files or change what is checked. With -DMI_GETENV_ALLOWED="name,name" only the listed names are read. mi_is_read_from_environment() answers the same question to the embedder.
It places pages by their address in the arena, which guard pages change, like the two purge-holes tests. With MIMALLOC_GUARDED_SAMPLE_RATE=1024, which ctest sets in a debug build, it stopped at 3 of 64 MiB.
EFAULT is for corrupted meta data, and the default error handler aborts on it in a secure or debug build. These three sites only found no memory: with ENOMEM they return NULL, and the caller reports out of memory the way it does for every other allocation.
Merged
VirtualProtect fails on a page that is not committed, so every guard page was reported as an error and the tests of a secure build failed on Windows. A decommitted page is not accessible there, which is what the protection is for on unix.
Jarred-Sumner
added a commit
to oven-sh/bun
that referenced
this pull request
Oct 29, 2026
oven-sh/mimalloc#63 and oven-sh/mimalloc#64 are merged. This is the head of the fork's branch. Its tree is that of 916b51e524f9, which the fork's CI tested.
Jarred-Sumner
added a commit
to oven-sh/bun
that referenced
this pull request
Oct 29, 2026
…malloc to v3.6.0 and ICU to 79.1 (#47281) Three things that go together. All three dependencies' PRs are merged, and both pins are commits of their main branches. | | What | From | |---|---|---| | 1 | The address space ballast is removed, and `patches/mimalloc/` with it | oven-sh/WebKit#937, oven-sh/mimalloc#63 | | 2 | mimalloc goes from v3.5.3 to v3.6.0 | oven-sh/mimalloc#64 | | 3 | ICU goes from 78.3 to 79.1 (Unicode 18.0, CLDR 49) | oven-sh/WebKit#936 | **About 3:** oven-sh/WebKit#936 merged just before oven-sh/WebKit#937, so no commit of WebKit's `main` has one without the other. It changes some locale text (`fr` hours, `th` short time, `zh-Hant` short zone names) and makes `EST5EDT`, `CST6CDT`, `MST7MDT` and `PST8PDT` resolve to the zones the IANA database links them to. What people will see, all of it CLDR 49 (an ICU 79.1 that is not Bun's gives the same). Node has ICU 78.3, so Bun differs from Node in these until Node moves: | | ICU 78.3 | ICU 79.1 | |---|---|---| | `toLocaleDateString("ko")` | `1970. 1. 1.` | `1970/1/1` | | `en`, `{ month: "short", year: "2-digit" }` | `Jan 70` | `Jan ’70` | | `timeZoneName: "long"`, `Europe/London` and `Europe/Dublin` in winter, every locale tried | `Greenwich Mean Time` | `GMT+00:00` | | `timeZoneName: "long"`, `Pacific/Honolulu` | `Hawaii-Aleutian Standard Time` | `Hawaii Time` | #47314 is the same ICU upgrade by itself. The test changes here that overlap with it are the same lines. The `Intl` snapshots run only on the ICU version they were made with, so they are regenerated (one value differs, the Korean date). They exist to catch a fault in Bun's compressed ICU data, so each value was also compared with a Node that links an ICU 79.1 whose data is not compressed: 385 of 389 are the same, and the other 4 differ in the case of the first letter of a display name, as they did with ICU 78.3. ## 1. The ballast and the patches ### What **The address space ballast is removed.** Under a limit on address space (`ulimit -v`), an executable compiled with `--aot` kept 8 MB of address space unused. When a `malloc` that may not fail found no memory, it released 1 MB and tried again, so that the thread could reach a point where it throws a `RangeError`. mimalloc is not built for that, and it took ten patches, each fixing what the one before exposed. It was still best effort: a request larger than what was left of the reserve crashed anyway. Such a `malloc` crashes on purpose again, as it does in plain Bun and in upstream JavaScriptCore. Allocations that can fail (strings, arrays, typed arrays, `ArrayBuffer`) still throw a `RangeError`. **`patches/mimalloc/` is gone.** We maintain a fork, so what is worth keeping is a commit there. | Patches | Count | Outcome | |---|---|---| | The ballast | 10 | Deleted | | `hand-out-last-freed`, `hand-out-resident-slices` | 2 | Deleted, see below | | `new-oom-hook`, `look-in-every-bin-of-an-exclusive-arena`, `secure-guard-pages-are-protected`, `out-of-memory-is-no-corruption` | 4 | Commits in the fork | | `environment-sets-sizes-and-pace-only` | 1 | A generic `MI_GETENV_ALLOWED` in the fork. The same 16 names now live in `scripts/build/deps/mimalloc.ts`. No change in behavior. | ### What stays The cage reserved in segments under a limit, the `RangeError` at the object limit, the reserve of the structure heap, and `new Worker()` throwing a `TypeError` when there is no room for another thread. ### The two `hand-out` patches They made the allocator hand out the memory that was freed last while an `--aot` build runs. Because `aot` is on by default, every thread-local `mi_free` in every Bun process paid for the test: 23 → 26 instructions on arm64, one of them a load of a global. `bun build --compile --aot` of `typescript/lib/_tsc.js`, macOS arm64, canary `8d5672c55`, six runs each in turn, switched off with `MIMALLOC_HAND_OUT_LAST_FREED=0`. Min / median / max: | | On | Off | |---|---|---| | Peak footprint (MB) | 553 / 585 / 676 | 523 / 530 / 542 | | Instructions (G) | 382.2 / 382.8 / 384.3 | 381.7 / 382.6 / 383.1 | | Cycles (G) | 175.8 / 176.8 / 182.2 | 177.7 / 179.2 / 179.9 | | System CPU (ms) | 1171 / 1418 / 1760 | 1213 / 1457 / 1484 | **Not measured on Linux, or on a large program.** If they show a real gain there, they can come back as fork commits behind a define. ### Tests removed All of them assert survival once address space is gone, which is no longer promised. | File | Removed | |---|---| | `test/aot/heap-limit-cases.ts` | 5 tests ("... where no address space is left", "... gets back only what malloc keeps, goes on", "... shapes of objects and of address space in one stretch ...") | | `test/aot/workers-within-address-space-limit-cases.ts` | 5 groups, 15 cases ("... find no room at once ...", "... has taken all the room, and the program goes on", "wave after wave ..."), and their 4 fixtures | | `test/aot/two-builds-give-the-same-bytes.test.ts` | 1 test that used the deleted option as its switch | | `test/internal/build-hardened-flags.test.ts` | 1 test about the `hand-out` patch list. The others now check the `MI_GETENV_ALLOWED` define. | ## 2. mimalloc v3.6.0 | Change | Why | |---|---| | `MIMALLOC_COMMIT` | The merge of upstream `v3.6.0` into the fork | | `purge_holes = 56` in `src/mimalloc_sys/mimalloc.rs` | The fork's options come after upstream's, and upstream added six. 50 is now `profile_time_interval` | Every other option number in that enum was compared with the merged header and is unchanged. To go back to v3.5.3 with the same fork commits, pin `6da53c355e1332db3161e0749111926ed35bc986` and set `purge_holes = 50`. What differs from upstream's defaults, and why, is in oven-sh/mimalloc#64: cache offsets only for blocks above 512 KiB, no purge on the thread that frees, `page_reclaim_on_free` at 0. ### Memory against the previous pin Linux x64, 64 cores, CI builds of this branch with v3.5.3 (`6da53c35`) and with v3.6.0 at an earlier commit (`eab19c23`, before the two changes to the defaults), 5 interleaved rounds, medians in MB. The two builds differ in mimalloc alone. | Workload | Peak, v3.5.3 | Peak, v3.6.0 | After going idle, v3.5.3 | After going idle, v3.6.0 | |---|---|---|---|---| | Startup | 16.7 | 16.7 | 14.4 | 14.6 | | 3M objects, 1 in 2000 kept | 583.4 | 578.9 | 96.6 | 91.4 | | Buffers of mixed sizes | 3170.5 | 3168.9 | 33.3 | 33.0 | | 48 MiB buffers in a loop | 66.5 | 66.3 | 18.8 | 18.7 | | 4 waves of 16 Workers | 582.1 | 587.9 | 36.9 | 36.1 | | `Bun.serve` under load | 54.4 | 52.6 | 32.7 | 32.4 | | Bundling a 17 MB file | 825.7 | 810.2 | | | The ranges of the five runs do not overlap for the bundle peak and for the objects after going idle. The Workers peak is noise (568 to 620 against 560 to 660). ### Costs | | v3.5.3 | v3.6.0 | |---|---|---| | `mi_malloc`, arm64 instructions | 22 | 23 | | `mi_free`, arm64 instructions | 35 | 35 | | `sizeof(mi_theap_t)` | 8240 | 8920 | | Binary size | | +24 KB Linux x64, +48 KB macOS arm64, +114 KB Windows x64 | ### Not measured Speed (the machine was busy, so the timings are rough and show no difference), memory on macOS, Windows or arm64, and a real application. ## Testing - macOS arm64, release build against the WebKit branch: it links, none of the removed symbols are in the binary, allocation-heavy JS and a Worker run, a huge `ArrayBuffer` is still a catchable `RangeError`, and a program built with `--compile --aot` runs. That build had the ballast removed but predates the `hand-out` removal and the move to the fork commit. - `test/internal/build-hardened-flags.test.ts` passes (11 of 11), and the build scripts typecheck. - CI on the final commit (build 134939): 180 jobs passed and none failed. That is every build lane, every Linux test job (Debian, Ubuntu and Alpine, x64 and arm64), the 20 ASAN jobs, the hardened lane, `cargo-test`, and Windows x64 and arm64. - **Not run:** the macOS test jobs. No machine picked them up in any build of this PR. The macOS build lanes pass, and the fork's own CI passes on macOS. - Five tests needed a retry (`testFFI`, `no-leak-records-at-exit`, `native-contract`, `decorators`). Each needed one as often in build 134852, which is `main` with a one-line fix. - `test/js/bun/wasm/compile-rss.test.ts` failed once and passed on retry in an earlier build with v3.6.0 on Alpine arm64: 10.8 MiB kept against a limit of 10. It did not in the final build. Its fixture, 24 runs per version on Linux x64, shows no difference (idle median -0.7 against -0.8 MiB, none over the limit). - The engine's own tests passed on Linux x64 and arm64 in oven-sh/WebKit#937, the hardened AOT lanes among them.
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.
What
oven-sh/bun applied 17 patch files on top of this fork at build time. 13 of them are being deleted there. The 4 worth keeping move here as commits, where this repo's CI runs against them, so that
patches/mimalloc/in Bun can go away.dc0bfd763. A racing search could leave a chunk's bit in the chunkmap cleared, after which its free slices were not found again. The fork still had the bug.0e853a07c. The same kind of race in the rollback path.-DMI_NEW_OOM=<fn>, a build without exceptions calls<fn>where it cannot throwstd::bad_alloc.new (std::nothrow)still returns NULL. Upstream still returns NULL here.MI_SECUREbelow 3, decommit is only anmadvise, so the guard page at the end of the arena info stayed readable and writable. Upstream dev3 has_mi_os_guard()for this since5c7353119: take that one at the next merge.MI_GETENV_ALLOWED: read only the listed names from the environmentMI_NO_GETENVreads nothing; this reads only the names in a comma-separated string. Off unless defined.The exclusive arena fix
A chunk takes the size bin of the first page placed in it, and a chunk whose first page has several slices takes no small pages. A heap with an exclusive arena has no other arena to go to. Filling the first chunk with 1 KiB blocks, placing one 48 KiB block (which lands at the start of the second chunk), then allocating 1 KiB blocks until refused:
test/test-exclusive-arena-bins.cis that experiment for 64 MiB.Testing
static.con macOS arm64: without the fix it gets 30 MiB (release) and 24 MiB (debug) and fails; with it, 61 MiB and 49 MiB. Those runs used a threshold of 48 MiB, which the debug build cleared by only 1 MiB, so the threshold is now 40 MiB. I have not re-run it since that change.MI_GETENV_ALLOWED: a small driver confirmed that listed names are read, that near misses (a longer name, a shorter name, the bare prefix, the empty string, two names joined by a comma) are not, and thatMIMALLOC_PURGE_DELAYtook effect whileMIMALLOC_VERBOSEwas ignored.ctestsuite, and any platform but macOS arm64.