Skip to content

bmalloc, libpas: stop fragmenting the heap into VMAs, and bound the EAGAIN retry loop#275

Open
robobun wants to merge 2 commits into
mainfrom
farm/cc14b897/bmalloc-madvise-eagain
Open

bmalloc, libpas: stop fragmenting the heap into VMAs, and bound the EAGAIN retry loop#275
robobun wants to merge 2 commits into
mainfrom
farm/cc14b897/bmalloc-madvise-eagain

Conversation

@robobun

@robobun robobun commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator

bmalloc pairs every physical-page decommit with madvise(MADV_DONTDUMP) and every commit with madvise(MADV_DODUMP), on Linux only. Both toggle VM_DONTDUMP over a sub-range of the reservation, which splits the VMA. VMAs whose flags differ cannot merge back, so a heap that is repeatedly committed and decommitted at page granularity shreds its own reservation into one VMA per chunk.

Once the process reaches vm.max_map_count, every subsequent madvise() that needs a split fails. The kernel reports that failure as EAGAIN, not ENOMEM (mm/madvise.c, madvise_vma_behavior()):

out:
    /*
     * madvise() returns EAGAIN if kernel resources, such as
     * slab, are temporarily unavailable.
     */
    if (error == -ENOMEM)
        error = -EAGAIN;

And SYSCALL/PAS_SYSCALL retry EAGAIN forever, with no delay and no cap:

#define SYSCALL(x) do { \
    while ((x) == -1 && errno == EAGAIN) { } \
} while (0);

The EAGAIN never clears, so the loop never exits. A GC thread pins a core spinning on madvise while holding the allocator lock, and the main thread parks in futex_wait behind it. The process stays alive and stops doing anything.

Reproduction

The fragmentation, modelling bmalloc's commit/decommit over a single reservation (32MB, 64KB chunks, four scavenger rounds):

  with MADV_DONTDUMP/MADV_DODUMP (today):  512 VMAs
  without them (this patch):                 1 VMAs
vma_fragmentation.c
#define _GNU_SOURCE
#include <stdio.h>
#include <string.h>
#include <sys/mman.h>
#include <sys/prctl.h>

#define CHUNK (64 * 1024)
#define CHUNKS 512
#define RESERVATION ((size_t)CHUNK * CHUNKS)

static size_t count_named_vmas(const char *name) {
    FILE *f = fopen("/proc/self/maps", "r");
    char line[512];
    size_t n = 0;
    while (fgets(line, sizeof line, f))
        if (strstr(line, name)) n++;
    fclose(f);
    return n;
}

static size_t run(int use_dontdump, const char *name) {
    char *res = mmap(NULL, RESERVATION, PROT_READ | PROT_WRITE,
                     MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
    prctl(PR_SET_VMA, PR_SET_VMA_ANON_NAME, res, RESERVATION, name);
    memset(res, 1, RESERVATION);

    for (int round = 0; round < 4; round++) {
        for (int i = round % 2; i < CHUNKS; i += 2) {          /* decommit */
            void *p = res + (size_t)i * CHUNK;
            madvise(p, CHUNK, MADV_DONTNEED);
            if (use_dontdump) madvise(p, CHUNK, MADV_DONTDUMP);
        }
        for (int i = (round + 1) % 2; i < CHUNKS; i += 2) {    /* commit */
            void *p = res + (size_t)i * CHUNK;
            madvise(p, CHUNK, MADV_NORMAL);
            if (use_dontdump) madvise(p, CHUNK, MADV_DODUMP);
            memset(p, 1, CHUNK);
        }
    }
    size_t vmas = count_named_vmas(name);
    munmap(res, RESERVATION);
    return vmas;
}

int main(void) {
    printf("  with MADV_DONTDUMP/MADV_DODUMP: %3zu VMAs\n", run(1, "WKFastMalloc-before"));
    printf("  without them:                   %3zu VMAs\n", run(0, "WKFastMalloc-after"));
    return 0;
}

And the livelock itself, on kernel 6.17:

vm.max_map_count = 1048576
before exhaustion: madvise(MADV_DONTDUMP) -> 0 (ok)
filled VMAs: stopped at i=1048550 (Cannot allocate memory), map_count=1048577
after exhaustion:  madvise(MADV_DONTDUMP) -> -1 errno=11 (Resource temporarily unavailable)
SYSCALL() loop: 2000000 retries in 0.67s (2967850 calls/sec), still EAGAIN: yes
madvise_eagain.c
#define _GNU_SOURCE
#include <stdio.h>
#include <string.h>
#include <errno.h>
#include <sys/mman.h>
#include <unistd.h>

int main(void) {
    long limit;
    FILE *f = fopen("/proc/sys/vm/max_map_count", "r");
    fscanf(f, "%ld", &limit);
    fclose(f);
    size_t page = (size_t)sysconf(_SC_PAGESIZE);

    /* bmalloc marks a sub-range of a larger mapping, once per decommit. */
    char *victim = mmap(NULL, 32 * page, PROT_READ | PROT_WRITE,
                        MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
    memset(victim, 1, 32 * page);
    printf("before: madvise(MADV_DONTDUMP) -> %d\n",
           madvise(victim + 4 * page, 4 * page, MADV_DONTDUMP));

    /* Exhaust the VMA budget: alternating protections prevent merging. */
    size_t want = (size_t)limit + 64;
    char *blk = mmap(NULL, want * page, PROT_READ | PROT_WRITE,
                     MAP_PRIVATE | MAP_ANONYMOUS | MAP_NORESERVE, -1, 0);
    for (size_t i = 0; i < want; i += 2)
        if (mprotect(blk + i * page, page, PROT_READ) != 0) break;

    errno = 0;
    int r = madvise(victim + 16 * page, 4 * page, MADV_DONTDUMP);
    printf("after:  madvise(MADV_DONTDUMP) -> %d errno=%d (%s)\n",
           r, errno, strerror(errno));  /* -1, EAGAIN, forever */
    return 0;
}

The fix

Stop toggling VM_DONTDUMP. This is the cause, both of the fragmentation and of the mmap_write_lock contention (MADV_DONTDUMP needs the write lock; MADV_DONTNEED only needs the read lock). It is also nearly free to remove: the pages it marks were just released by MADV_DONTNEED, so they are not resident, and a core dump already skips them. dump_user_range() writes a hole for any page get_dump_page() cannot fault in, and dump_skip() advances cprm->pos without charging cprm->written, so RLIMIT_CORE is not consumed either.

$ ./mincore_check
resident before MADV_DONTNEED: 256 / 256 pages
resident after  MADV_DONTNEED: 0 / 256 pages

MADV_NORMAL stays. It clears VM_RAND_READ/VM_SEQ_READ, which bmalloc never sets, so madvise_update_vma() returns early without touching the VMA. The measurement above confirms it: the run without MADV_DONTDUMP still calls MADV_NORMAL and still ends with one VMA.

Cap the EAGAIN retry. 100 retries, 1ms apart. These syscalls are advisory, or validated elsewhere, so giving up after 100ms is recoverable where an unbounded loop is not. nanosleep() rather than usleep() because it is not marked obsolescent, and bmalloc already builds with -D_GNU_SOURCE (Source/bmalloc/CMakeLists.txt) so <time.h> declares it. The Windows branches of both headers never expand these macros.

Verification

Both macros exercised against the real headers, compiled with the flags bmalloc uses (-std=c++20 and -std=gnu11 -D_GNU_SOURCE):

permanent EAGAIN: 101 calls, 0.108s   (previously: does not return)
transient EAGAIN: 3 calls
success path:     1 calls, 0.000000s  (loop body never runs)
adjacent expansions in one scope: ok

Source/bmalloc/libpas/src/libpas/{pas_page_malloc,pas_committed_pages_vector,pas_thread_local_cache}.c and a TU including VMAllocate.h all compile with no new warnings against origin/main.

Not covered here: a full JSC run proving the [anon:WKFastMalloc] mapping count stays flat under a real workload. CI build + the JSC stress suite is the check I can offer.

Credit

#169 by @coleleavitt diagnosed the spin loop and proposed both halves of this fix in February. This PR rebases that work onto current main, adds the vm.max_map_count case (where the EAGAIN is permanent, so the retry cap rather than the backoff is what ends the hang) and the VMA-fragmentation measurement that explains how a process gets there in the first place.

Related: oven-sh/bun#17723, oven-sh/bun#27490.

robobun added 2 commits July 5, 2026 22:53
vmDeallocatePhysicalPages() followed MADV_DONTNEED with MADV_DONTDUMP, and
vmAllocatePhysicalPages() followed MADV_NORMAL with MADV_DODUMP. libpas did
the same in decommit_impl()/commit_impl(). Both toggle VM_DONTDUMP over a
sub-range of the reservation, which splits the VMA, and VMAs that differ in
their flags cannot merge back.

Over a fragmented heap the reservation shreds into one VMA per chunk. A 32MB
reservation cycled through four scavenger rounds at 64KB granularity ends up
with 512 VMAs instead of 1, and a multi-GB JS heap reaches tens of thousands.
Once the process hits vm.max_map_count every further madvise() that needs a
split fails. MADV_DONTDUMP also takes mmap_write_lock on Linux, which the
other GC threads then contend on; MADV_DONTNEED only needs the read lock.

The pages these calls mark were just released by MADV_DONTNEED, so they are
not resident and a core dump already skips them: dump_user_range() writes a
hole for any page get_dump_page() cannot fault in, and dump_skip() does not
charge RLIMIT_CORE. Dropping the pair costs nothing in dump size and removes
both the fragmentation and the write-lock traffic.

MADV_NORMAL stays: it clears VM_RAND_READ/VM_SEQ_READ, which bmalloc never
sets, so madvise_update_vma() returns early without touching the VMA.
Both macros retried forever with no delay:

    #define SYSCALL(x) do { \
        while ((x) == -1 && errno == EAGAIN) { } \
    } while (0);

madvise() does not only return EAGAIN for transient conditions. mm/madvise.c
reports a failed VMA operation as EAGAIN rather than ENOMEM:

    out:
        /* madvise() returns EAGAIN if kernel resources, such as
           slab, are temporarily unavailable. */
        if (error == -ENOMEM)
            error = -EAGAIN;

so a process sitting at vm.max_map_count gets EAGAIN on every call that needs
a split, permanently. The loop then spins on the syscall at roughly 3M
calls/sec per thread, pinning a core and holding the allocator lock, until
something kills the process. Reported as a multi-hour livelock with the main
thread parked in futex_wait behind the allocator.

Cap the retries at 100 and back off 1ms between attempts. The syscalls these
macros wrap are advisory or already validated elsewhere, so giving up after
100ms is recoverable where an unbounded loop is not. The fast path is
unchanged: on success the loop body never runs.

nanosleep() over usleep(): it is not marked obsolescent, and bmalloc already
builds with _GNU_SOURCE so <time.h> declares it. The Windows branches of both
headers never expand these macros.
@coderabbitai

coderabbitai Bot commented Jul 5, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: a923807f-625d-465a-9d92-372321e753de

📥 Commits

Reviewing files that changed from the base of the PR and between 2a51dee and 968cbdf.

📒 Files selected for processing (5)
  • Source/bmalloc/bmalloc/BSyscall.h
  • Source/bmalloc/bmalloc/VMAllocate.h
  • Source/bmalloc/libpas/src/libpas/pas_page_malloc.c
  • Source/bmalloc/libpas/src/libpas/pas_thread_local_cache.c
  • Source/bmalloc/libpas/src/libpas/pas_utils.h

Walkthrough

This PR modifies two independent areas: syscall retry macros in bmalloc and libpas gain bounded EAGAIN retry with nanosleep backoff instead of tight infinite loops, and madvise calls pairing MADV_DONTNEED/MADV_NORMAL with MADV_DONTDUMP/MADV_DODUMP are removed on Linux across VMAllocate.h, pas_page_malloc.c, with a related comment update in pas_thread_local_cache.c.

Changes

Bounded EAGAIN Retry Macros

Layer / File(s) Summary
bmalloc SYSCALL bounded retry
Source/bmalloc/bmalloc/BSyscall.h
Adds BPlatform.h/time.h includes and BSYSCALL_MAX_EAGAIN_RETRIES/BSYSCALL_EAGAIN_BACKOFF_NSEC constants; SYSCALL(x) now retries EAGAIN with nanosleep backoff up to a max count instead of looping indefinitely.
libpas PAS_SYSCALL bounded retry
Source/bmalloc/libpas/src/libpas/pas_utils.h
Adds <time.h> include and PAS_MAX_EAGAIN_SYSCALL_RETRIES/PAS_EAGAIN_SYSCALL_BACKOFF_NSEC constants; PAS_SYSCALL(x) retries EAGAIN with nanosleep backoff and stops after the retry cap.

MADV_DONTDUMP/MADV_DODUMP Removal

Layer / File(s) Summary
VMAllocate.h madvise cleanup
Source/bmalloc/bmalloc/VMAllocate.h
Removes Linux MADV_DONTDUMP call in vmDeallocatePhysicalPages and MADV_DODUMP call in vmAllocatePhysicalPages, adding comments on the deliberate non-pairing.
pas_page_malloc.c commit/decommit cleanup
Source/bmalloc/libpas/src/libpas/pas_page_malloc.c
Removes MADV_DODUMP from commit_impl and MADV_DONTDUMP from decommit_impl on Linux, adding explanatory comments while preserving !is_symmetric assertions.
pas_thread_local_cache.c comment update
Source/bmalloc/libpas/src/libpas/pas_thread_local_cache.c
Updates a comment describing asymmetric commit behavior as a no-op on Linux rather than referencing MADV_DODUMP.

Related Issues: None provided.

Related PRs: None provided.

Suggested Labels: bmalloc, libpas, memory-management, performance

Suggested Reviewers: None provided.

Poem:
A rabbit hops through syscall code,
No more spinning down that road,
Backoff sleeps, then tries again,
No dump-and-dontdump pairing plan,
Clean madvise calls, memory light—
This burrow's tidy, retries polite. 🐇

🚥 Pre-merge checks | ✅ 3 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description is detailed, but it does not follow the repo template: it lacks the bug title/Bugzilla link, Reviewed by line, and the required file list. Rewrite it in the template format with a bug title, Bugzilla URL, Reviewed by line, explanation, and per-file bullets, or include the commit message verbatim.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly captures the two main changes: removing VMA fragmentation from bmalloc/libpas and capping EAGAIN retries.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Comment @coderabbitai help to get the list of available commands.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't find any bugs, but this changes core allocator behavior (syscall retry semantics and Linux commit/decommit paths in bmalloc + libpas), so it warrants a human sign-off.

Extended reasoning...

Overview

This PR touches five files in bmalloc and libpas: BSyscall.h, VMAllocate.h, pas_page_malloc.c, pas_thread_local_cache.c, and pas_utils.h. It makes two related changes: (1) removes the MADV_DONTDUMP/MADV_DODUMP toggling paired with every Linux decommit/commit, and (2) rewrites the SYSCALL/PAS_SYSCALL macros to cap EAGAIN retries at 100 with a 1ms nanosleep backoff instead of spinning forever. A comment in pas_thread_local_cache.c is updated to reflect that the Linux asymmetric commit is now a no-op.

Security risks

None identified. The change removes advisory madvise calls and bounds a retry loop; it does not touch permissions, auth, or data handling. The one behavioral edge is that SYSCALL also wraps mprotect in commit_impl/decommit_impl, so a persistent-EAGAIN mprotect would now silently give up rather than spin — but that is strictly less bad than the current livelock, and mprotect doesn't split VMAs the way madvise(MADV_DONTDUMP) does.

Level of scrutiny

High. bmalloc/libpas is the process-wide allocator; every allocation and the GC scavenger go through these paths. The changes are small and very well-argued (kernel source cited, reproduction cases included, links to two open Bun hangs), but they encode design decisions a human should confirm: the 100×1ms retry budget, silently proceeding after the cap is hit, and dropping MADV_DONTDUMP (which slightly changes core-dump semantics for decommitted-but-later-touched pages, even if the practical effect is argued to be nil).

Other factors

The author explicitly notes they have not verified under a real JSC workload that the VMA count stays flat, deferring to CI + stress suite. The macro change introduces block-scoped locals inside do { }, so adjacent expansions in one scope are safe (and the PR description says this was tested). No prior reviews or comments exist on the PR, and the automated bug hunt found nothing. Given the criticality of the code path, I'm deferring rather than approving.

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