Skip to content

page-map: free(NULL) before init must not fault (glibc 2.44 startup segfault) - #30

Merged
Jarred-Sumner merged 1 commit into
bun-dev3-v2from
jarred/free-null-before-init
Aug 25, 2026
Merged

page-map: free(NULL) before init must not fault (glibc 2.44 startup segfault)#30
Jarred-Sumner merged 1 commit into
bun-dev3-v2from
jarred/free-null-before-init

Conversation

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

Fixes the Claude Code 2.1.243 linux-x64 startup segfault (anthropics/claude-code#89389 and ~12 duplicates): claude --version dies with SIGSEGV in free → __newlocale → pthread_once, before main(), on glibc 2.44 (Arch, CachyOS).

What happens

glibc 2.44's __newlocale calls free(NULL) from the dynamic loader, before any constructor has run. Bun overrides malloc on Linux, so that lands in mi_free, whose release-mode lookup (_mi_unchecked_ptr_page) reads submaps[0][0] without checking the submap:

free+0x28:  movq (%rax,%rcx), %rdi     ; rax = pmap->submaps[0] = NULL

The initial static page map (mi_page_map_empty, the upstream microsoft#1341 fix) has carried submaps[0] = NULL since the 2-level page-map restructure (upstream dev3 d63979ae, Aug 9). The Aug 13 fork sync picked that up, bun-internal 1021 shipped it in CC 2.1.242/243, and 1038 (a178e44a) has byte-identical free code — so the pending CC bump carries the crash forward.

Fix

Point submaps[0] at a static zero-initialized submap (64 KiB of .bss, never touched unless something frees before init). Every lookup through the empty map yields no page, and mi_free returns.

Test

test-free-before-init makes the call from .preinit_array, which the loader runs before every .init_array entry — the same point in startup as the glibc call. On Linux (Ubuntu 24.04 arm64, gcc, -DMI_OVERRIDE=ON):

  • without the fix: Segmentation fault, exit 139
  • with the fix: passes; full ctest 23/23 green in Release, and the test + test-api pass under MI_DEBUG_FULL

Upstream dev3 has the same bug; I'll send it there separately.

…ree(NULL) works before init

glibc 2.44's __newlocale calls free(NULL) from the loader, before any
constructor of the executable has run. With malloc overridden that lands in
mi_free, whose release-mode lookup (_mi_unchecked_ptr_page) reads
submaps[0][0] without checking the submap. The initial static page map
(mi_page_map_empty, the microsoft#1341 fix) carried submaps[0] = NULL since the
2-level page-map restructure (upstream dev3 d63979a), so the process
faulted at address 0 before main().

Point submaps[0] at a static zero-initialized submap instead. Every lookup
through the empty map now yields no page, and mi_free returns.

Adds test-free-before-init, which makes the call from .preinit_array (the
same point in startup as the glibc call) and segfaulted without the fix.
@Jarred-Sumner
Jarred-Sumner merged commit 942b834 into bun-dev3-v2 Aug 25, 2026
11 of 15 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the jarred/free-null-before-init branch August 25, 2026 03:10
Jarred-Sumner added a commit to oven-sh/bun that referenced this pull request Aug 25, 2026
…s (glibc 2.44 startup crash) (#40409)

### What does this PR do?

Bumps mimalloc to `942b8342` on oven-sh/mimalloc `bun-dev3-v2`, which
merges oven-sh/mimalloc#30.

`mi_free(NULL)` before mimalloc has initialized faults at address 0. The
static initial page map (`mi_page_map_empty`, upstream's fix for
microsoft/mimalloc#1341) has carried `submaps[0] = NULL` since upstream
dev3's 2-level page-map restructure (`d63979ae`), and the release-mode
lookup (`_mi_unchecked_ptr_page`) reads `submaps[0][0]` without checking
the submap:

```
free+0x28:  movq (%rax,%rcx), %rdi     ; rax = pmap->submaps[0] = NULL
```

The Aug 13 fork sync (#37367) picked that up; upstream dev3 still has
the bug. The fix points `submaps[0]` at a static zero-initialized submap
(64 KiB of `.bss`, untouched unless something frees before init), so the
lookup yields no page and `mi_free` returns. Only the pin moves here.

**How this gets hit:** glibc 2.44 refactored `__newlocale` into a
wrapper that ends with an unconditional `free(tmp_buffer)` (a leak fix
for the `LOCPATH` buffer), so every `newlocale(LC_ALL, "C", NULL)` is
now one `free(NULL)`. libstdc++'s iostream initializer is
`init_priority(90)` and calls exactly that through `std::locale()`, and
90 sorts before mimalloc's `constructor(101)` — so on glibc 2.44 (Arch,
CachyOS, Fedora rawhide) any binary that exports the override crashes in
`.init_array`, before `main()`. That's also the `nix`-on-Arch crash in
microsoft/mimalloc#1341.

**Does this affect oven-sh/bun binaries?** Not today: `linker.lds` keeps
the Linux `malloc`/`free` override private to the executable, so libc's
internal `free` still goes to glibc's allocator. It bites any build that
exports the override; taking it here so the fork doesn't carry a pin
divergence and so the next person to export it doesn't rediscover this.

### How did you verify your code works?

In oven-sh/mimalloc#30: a new `test-free-before-init` calls
`free(NULL)`/`mi_free(NULL)` from `.preinit_array` — before any
constructor. Linux (Ubuntu 24.04 arm64, gcc, `MI_OVERRIDE=ON`):
segfaults (exit 139) without the fix, passes with it; Release `ctest`
23/23 and `MI_DEBUG_FULL` green.

Also checked on a Linux build that exports the override, with an
`LD_PRELOAD` library whose constructor calls `free(NULL)` (volatile
pointer so gcc can't elide it; `dladdr` confirms it binds to the
executable's `free`): old pin → SIGSEGV, this pin → `bun --version`
prints normally.

Not built end-to-end here — CI is the build; nothing else in the pin
delta.
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.

1 participant