Skip to content

Fold Bun's remaining patches into the fork, and take two upstream bitmap fixes - #63

Merged
Jarred-Sumner merged 9 commits into
bun-dev3-v2from
claude/fold-in-bun-patches
Oct 29, 2026
Merged

Jarred-Sumner merged 9 commits into
bun-dev3-v2from
claude/fold-in-bun-patches

Conversation

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

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.

Commit Why
always restore chunkmap bit if bits were cleared temporarily Upstream 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.
republish failing chunk during huge-allocation rollback Upstream 0e853a07c. The same kind of race in the rollback path.
Report out of memory instead of returning NULL from a throwing operator new With -DMI_NEW_OOM=<fn>, a build without exceptions calls <fn> where it cannot throw std::bad_alloc. new (std::nothrow) still returns NULL. Upstream still returns NULL here.
A request to an exclusive arena looks in the chunks of every bin See below. Upstream has the same behavior.
Protect the guard pages of secure mode In a release build with MI_SECURE below 3, decommit is only an madvise, so the guard page at the end of the arena info stayed readable and writable. Upstream dev3 has _mi_os_guard() for this since 5c7353119: take that one at the next merge.
MI_GETENV_ALLOWED: read only the listed names from the environment A generic form of what Bun's patch hardcoded. MI_NO_GETENV reads 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:

Arena Small blocks served before After
64 MiB 31.8 MiB (50%) 63.2 MiB (99%)
128 MiB 95.6 MiB (75%) 127.1 MiB (99%)
256 MiB 223.2 MiB (87%) 254.8 MiB (100%)

test/test-exclusive-arena-bins.c is that experiment for 64 MiB.

Testing

  • The new test, compiled against static.c on 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 that MIMALLOC_PURGE_DELAY took effect while MIMALLOC_VERBOSE was ignored.
  • The tree compiles with Bun's release flags.
  • Not run: the full ctest suite, and any platform but macOS arm64.

daanx and others added 6 commits October 28, 2026 17:52
(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.
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
Jarred-Sumner merged commit ba224b9 into bun-dev3-v2 Oct 29, 2026
23 checks passed
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants