jemalloc: NULL arena deref when per-thread arena creation fails under mlockall(MCL_FUTURE) + RLIMIT_MEMLOCK
Chanda Mendon <[email protected]> Wed, 15 Jul 2026 20:19:05 +0000
| Newsgroups | gmane.os.freebsd.devel.hackers |
|---|---|
| Message-ID | <DS0PR05MB100164E90138A1F355310DCD1B4F82@DS0PR05MB10016.namprd05.prod.outlook.com> |
Hi,
We root-caused a deterministic ntpd crash on a FreeBSD 15-based system
to a NULL-pointer dereference inside jemalloc that occurs whenever
creation of a per-thread arena fails. We have two candidate fixes and
would appreciate review. (Thanks to Justin Hibbits for suggesting this
list.)
THE BUG
Environment that exposes it:
- the process sets RLIMIT_MEMLOCK to 32MB, then calls
mlockall(MCL_CURRENT|MCL_FUTURE). ntpd does this when built with
DFLT_RLIMIT_MEMLOCK=32; FreeBSD base builds with -1 so stock ntpd
is unaffected, our vendor build used 32.
- wired memory sits close to the limit (~30MB of 32MB here)
- a new thread performs its first malloc
Sequence, all in contrib/jemalloc:
1. First malloc in a thread with no bound arena ->
arena_choose_impl() -> arena_choose_hard() [src/jemalloc.c]
2. All initialized arenas are busy and a free slot exists, so
arena_choose_hard() calls arena_init_locked(). The new arena's
base mmap must be wired immediately (MCL_FUTURE) and would exceed
RLIMIT_MEMLOCK, so mmap fails -> arena_init_locked() returns NULL
-> arena_choose_hard() returns NULL.
3. In arena_choose_impl()
[include/jemalloc/internal/jemalloc_internal_inlines_b.h]:
ret = arena_choose_hard(tsd, internal);
assert(ret); <- compiled out in production
if (tcache_available(tsd)) {
...
tcache_arena_associate(tsdn, tcache, ret); <- ret == NULL
4. tcache_arena_associate() does
malloc_mutex_lock(tsdn, &arena->tcache_ql_mtx);
With arena == NULL this computes offsetof(arena_t, tcache_ql_mtx)
plus the lock offset (0x2818 in our build) and faults.
FIX CANDIDATE 1 - fail the allocation (arena_choose_impl):
ret = arena_choose_hard(tsd, internal);
if (unlikely(ret == NULL)) {
return NULL; /* malloc fails with ENOMEM */
}
Smallest diff. However, the allocation fails despite free space in
the already-initialized arenas, and the thread retries arena creation
on every subsequent malloc.
FIX CANDIDATE 2 - fall back to the least-loaded existing arena
(arena_choose_hard):
The selection scan leaves in choose[j] the index of the initialized
arena with the fewest bound threads. The creation path overwrites it
with the free-slot index, so save it first and fall back on failure:
unsigned saved_choose = choose[j];
/* Initialize a new arena. */
choose[j] = first_null;
arena = arena_init_locked(...);
if (arena == NULL) {
/*
* Creation failed (e.g. mmap ENOMEM under
* mlockall(MCL_FUTURE) + RLIMIT_MEMLOCK). Fall back to
* the least-loaded existing arena rather than returning
* NULL, which the caller dereferences.
*/
choose[j] = saved_choose;
if (!!j == internal)
ret = arena_get(tsd_tsdn(tsd), choose[j], false);
} else {
is_new_arena[j] = true;
if (!!j == internal)
ret = arena;
}
...
arena_bind(tsd, choose[j], !!j);
Arena 0 is guaranteed initialized (asserted just above the scan), so
the fallback cannot itself be NULL. The thread permanently shares an
arena, but allocation keeps working; if the shared arena can't grow
later, the normal extent-allocation failure path makes malloc return
NULL gracefully.
QUESTIONS
1. Any correctness issues with either fix, particularly fix 2's
binding of the thread to the fallback arena?
2. Which fix is preferable for FreeBSD?
3. Should this go to upstream jemalloc (github.com/jemalloc/jemalloc<http://github.com/jemalloc/jemalloc>)
as well? The assert-only NULL guard in arena_choose_impl() is
upstream code, not a FreeBSD-local change.
Thanks,
Chanda
Juniper Business Use Only