Skip to content

feat: page hole purging (Bun parity P7b) - #302

Merged
zackees merged 28 commits into
mainfrom
bun-parity/p7b-holes
Sep 3, 2026
Merged

feat: page hole purging (Bun parity P7b)#302
zackees merged 28 commits into
mainfrom
bun-parity/p7b-holes

Conversation

@zackees

@zackees zackees commented Sep 2, 2026

Copy link
Copy Markdown
Owner

Stacked on #299 (Phase 7a). Base branch is bun-parity/p7-scavenger, not main.
Closes the second clause of mi_on_thread_idle's contract.

imported from oven-sh/mimalloc @ 942b8342, MIT. Refs #272, #264, #10.

What this is

Upstream mimalloc returns a page's memory to the OS only when every block in it is
free, so one long-lived object pins a whole 64 KiB/512 KiB page. That is oven-sh/bun#39844
(heap peaks 1.4–2.6× Node). Hole purging discards the memory of the free blocks inside a
still-used page, in OS-page units, at each mi_on_thread_idle / park point.

The unit is an OS page because that is what madvise/MEM_RESET works in. page->purged
is a bitmap over the OS pages of the block area; an OS page is discarded only when every
block overlapping it is free, and a purged block is held off every free list (mimalloc
threads its free list through the free blocks themselves, so a discarded block cannot carry
a next pointer). _mi_os_discard never touches commit state — the arena tracks commit per
64 KiB slice and cannot represent a sub-slice hole.

Layout (CLAUDE.md rule 6)

Bun puts all of this in src/page.c (+1038) plus the sweep drivers in src/theap.c. Here
the whole engine is in a new src/page-holes.c and src/page.c carries exactly five
hook calls, each marked HOOK n/5 in the source:

# site call
1 mi_page_free_collect_ex _mi_page_unpurge_run when the free list is empty but holes exist
2 mi_page_free_collect_ex the _mi_page_free_collect_no_unpurge split (inspection must not mutate)
3 _mi_page_abandon _no_unpurge
4 mi_page_extend_free _mi_page_unpurge_unformed_upto before the first free-list pointer is written
5 _mi_page_init _mi_page_purged_reset

plus two debug-only assertion lines (the conservation invariant, and
_mi_page_holes_assert_valid). arena.c gets _mi_page_unpurge_all at the single choke
point where a page returns to the arena, and the abandoned-page sweep/report walkers (they
need the arena bitmaps and the claim protocol). scavenger.c gets the _mi_purge_holes_of
phase and the purge_holes_min_interval pacing _mi_theap_sweep_parked was already shaped
for. theap.c counts purged blocks as free in the block visitor.

Public API: mi_purge_holes_stats_t / mi_purge_holes_stats_get / mi_purge_holes_report,
a mi_stats_print block, and purge_holes_stats() in the Rust crate. Options appended after
scavenger (no slot renumbered): purge_holes=1, purge_holes_eager_zero=0,
purge_holes_min_interval=100 ms, purge_holes_full_every=64.

Numbers

Peak RSS, Bun's workload shape (400k × 512 B, survivors every 2 OS pages, mi_on_thread_idle
between 12 rounds; min of 5, Release, Linux x64):

peak RSS
MIMALLOC_PURGE_HOLES=1 105.0 MB
MIMALLOC_PURGE_HOLES=0 210.0 MB

Peak RSS, the gate + stress suite (min of 5). These never call mi_on_thread_idle, so
the engine is inert in them and the numbers only show it costs nothing. Same-binary
run-to-run spread on test-stress-heaps was 785.5 → 828.1 MB (5.4%) across two independent
min-of-5 rounds, which is larger than every delta below:

workload holes=1 holes=0 7a base this branch
test-memory-gate 27.1 MB 28.3 MB
test-stress 264.4 MB 259.5 MB 259.6 MB 253.8 MB
test-stress-heaps 785.5 MB 770.3 MB 794.3 MB 828.1 MB
test-process-rss 137.4 MB 137.4 MB

ci/memory_gate.py check against a fresh Release build: PASS, peak_rss 19.7 MB vs
baseline 22.3 MB (allowed 25.6) — no re-baselining needed, it went down.

Latency (#154 methodology: single-threaded alloc/free pairs over 8 small sizes,
N=4e6, best-of-5, three independent rounds, Release):

7a base this branch
plain alloc/free (ns) 25.91 / 27.18 / 25.34 25.44 / 23.86 / 25.99
against a heap that has holes (ns) 24.29 / 24.42 / 24.01 23.77 / 24.11 / 24.58

Within noise in both shapes, which is what the design predicts: the free path gains no
code at all
in a Release build. The only new free.c line is inside MI_CHECK_DOUBLE_FREE
(debug/secure), because a block being freed was live and therefore was never inside a
discarded OS page. Posted on #10.

The one real cost: sizeof(mi_page_t) 144 → 192 bytes, all of it appended at the tail,
behind Bun's static assert that the hot fields still fit the first cache line. With
MI_PAGE_META_IS_SEPARATED that is +768 KB of arena page-meta per 1 GiB arena.

Profiler interaction (#272 design points 1–4), designed and tested

  1. A discard never covers a sampled block. _mi_prof_debug_assert_no_records_in
    (src/profile.c, MI_DEBUG only) is called from src/page-holes.c before every
    _mi_os_discard and asserts that no record's block and no record struct lies in the
    range — records come from the profiler's raw-OS arena (rule 4), never from a page. It
    try-acquires prof_lock and skips rather than blocking: the sweep holds
    tld->theaps_lock and mi_prof_visit holds prof_lock across a user callback that may
    delete a heap, which would be an ABBA cycle.
  2. No inspection path hands out a discarded block. _mi_theap_area_visit_blocks marks
    purged blocks in its free map, and every inspection collect became _no_unpurge.
  3. A parked thread's mi_tld_t.profiler is untouched — nothing in the sweep reads or
    writes it; 7a's assertion around _mi_thread_idle_work still holds.
  4. See "Two things Bun parity P7: background scavenger, page hole purging, and mi_on_thread_idle #272 got wrong" below.

test-profile-race.c gains a fifth scenario for 1 and 2: two owner-sweeping threads, two
parked threads swept by the scavenger, and a walker running mi_prof_visit,
mi_prof_snapshot_visit and mi_theap_visit_blocks over pages it is itself having swept.
Every pointer either walk yields is checked with mi_page_block_is_purged and dereferenced.
It asserts it was not vacuous: ~38k discards / ~270 MB and ~94 live records per run. A one-off
negative control (mi_assert_internal(false) inside _mi_prof_debug_assert_no_records_in)
confirmed that assert does fire on this workload.

Two things #272 got wrong

Also, contrary to the issue's rule-6 line: there is no mi_page_can_purge_holes call on the
free fast path, and _mi_page_unpurge_run is not a mi_page_queue_find_free_ex hook — it
lives in _mi_page_free_collect, which that function calls.

Tests

  • test/test-purge-holes.c — Bun's own 1705-line, 27-case test, imported and passing
    unmodified apart from two adaptations noted in its header (the src/page.c file
    references become src/page-holes.c; the one open-coded sweep state calls the shared
    mi_page_sweep_state() inline). Registered twice: as itself, and as test-purge-holes-off
    with MIMALLOC_PURGE_HOLES=0, where every data-integrity check still runs and nothing may
    be discarded.
  • test/test-purge-zero.c — verbatim.
  • test/test-park-handoff.c — both Phase-7b caveats removed: the two
    purge_holes_min_interval cases are live (in-window park observed swept at 96 ms of a
    100 ms window, vs the 30 s safety net), a new test-park-handoff-eager variant runs the
    file with MIMALLOC_PURGE_HOLES_MIN_INTERVAL=0, and the run asserts hole memory was
    actually discarded (1902 discards / 19 MB here; exactly 0 with the option off).
  • test/test-profile-race.c — scenario 5 above.

Existing suite: every one of the 55 debug-full tests passes with purge_holes default-on and
purge_holes_eager_zero forced on by MI_DEBUG>1, which means a mis-scoped discard would
have corrupted visibly. _mi_page_holes_assert_valid and the conservation invariant in
mi_page_is_valid_init make each of those a test of the purge machinery.

Local verification (Linux x64, gcc)

config result
Debug + MI_DEBUG_FULL + MI_PPROF=ON 55/55, 10/10 consecutive runs of the five hole/park/race tests
Release + MI_PPROF=ON 46/46
Release + MI_PPROF=OFF 36/36
gcc ASan (MI_TRACK_ASAN=ON, debug-full) 54/54
g++ -x c++ syntax check of src/static.c (the native MSVC gate compiles as C++) clean
cargo test -p mimalloc-pprof green
ci/check_internal_state.py, ci/check_doc_snippets.py, ci/memory_gate.py check green

uv run ci/verify_local.py --keep-going results are in a follow-up comment.

Known / stated, not fixed here

  • _mi_os_discard's failure path calls _mi_warning_message, which can allocate; on the
    scavenger thread that would create a theap and trip 7a's "no theap on the scavenger"
    invariant. Bun accepts this and so does this PR — it is the ENOMEM path only.
  • macOS: compile-verified only for MADV_FREE_REUSABLE; arm64 via the cross toolchain,
    x64 pending the golden image. The macOS CI lanes are the real check.
  • _mi_deferred_free in this tree lacks Bun's owner-only guard
    (tld->thread_id != _mi_thread_id() early return), so the scavenger's mi_theap_collect
    for a parked thread can invoke a user deferred-free callback on the scavenger thread. That
    is pre-existing on 7a, not introduced here; reported on feat: background scavenger thread and mi_on_thread_idle (Bun parity P7a) #299 rather than fixed on a branch
    another agent owns.
  • cargo fmt --check flags rust/mimalloc-pprof/build.rs — verified pre-existing on
    origin/bun-parity/p7-scavenger, untouched here.

Do not merge before #299.

🤖 Generated with Claude Code

https://claude.ai/code/session_01YT4jVokb2gdT8ngFrH8i8S

zackees and others added 10 commits September 2, 2026 09:58
… helpers (P7b, WIP)

The first, purely additive slice of #272's Phase 7b. Nothing is wired up yet: no
option reads it, no call site invokes it, and `ctest` is unchanged (51/51 on
Debug-FULL + MI_PPROF=ON).

- `_mi_prim_discard` on every platform (unix: `MADV_FREE_REUSABLE` on macOS with a
  `MADV_DONTNEED` fallback, `MADV_DONTNEED` elsewhere; Windows: `MEM_RESET` +
  `VirtualUnlock` so the RSS drop is immediate; WASI/Emscripten: no-op with
  `MI_PRIM_HAS_DISCARD == 0`). Releases the physical pages while keeping the range
  committed and accessible -- NEVER `MEM_DECOMMIT`, because the arena tracks commit
  per 64 KiB slice and cannot represent a sub-slice hole.
- `_mi_os_discard` (`src/os.c`), including the `purge_holes_eager_zero` pre-memzero
  (see below) and the "count only what was actually discarded" rule.
- `mi_page_t`: the `purged[MI_PAGE_PURGE_WORDS]` OS-page bitmap, the unformed-tail
  `unformed_purged_lo/hi`, and `swept_state`. Appended at the very TAIL, after this
  fork's own `metadata`/`has_metadata` -- both because they are cold (only the idle
  sweep touches them) and because of the measured lesson from P7a: moving a field
  that the free path reads costs ~2 ns/alloc+free.
- `include/mimalloc/internal.h`: the hole-purging declarations and the shared
  inline helpers. Bun keeps these as `src/page.c` statics; here they are shared
  between the engine (`src/page-holes.c`, next commit) and the few hook calls that
  stay in `page.c` (CLAUDE.md rule 6).

NOTE on `purge_holes_eager_zero`, since #272's step 4 describes it wrongly: it is
not zero-tracking. It is a `_mi_memzero` performed BEFORE `_mi_prim_discard`, so
that a discard which wrongly overlaps a live block corrupts visibly instead of
silently on macOS, where `MADV_FREE_REUSABLE` reclaims lazily. It makes discarding
more expensive, not cheaper. It therefore has nothing to unify with this fork's
`mi_option_purge_zeroes` -- which is in any case currently a dead option, see
issue #67.

Refs #272, #264

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YT4jVokb2gdT8ngFrH8i8S
Appended after `scavenger`, so no existing slot is renumbered (#264 records that
slots 47+ diverged from Bun long ago, so their slot numbers 50-53 are not matched).
`MIMALLOC_PURGE_HOLES*` env names derive automatically.

  purge_holes              = 1    discard free blocks inside still-used pages on idle
  purge_holes_eager_zero   = 0    zero a range before discarding it, so a mis-scoped
                                  discard corrupts visibly instead of silently on
                                  macOS's lazy MADV_FREE_REUSABLE (debug builds force
                                  it on). This is a test-sharpening knob, NOT zero
                                  tracking -- see the note in the previous commit.
  purge_holes_min_interval = 100  min ms between two sweeps of one thread's heaps
  purge_holes_full_every   = 16   every N'th sweep ignores the per-page skip check

`purge_holes_eager_zero` is the one already consumed, by `_mi_os_discard`; the other
three are read by the engine in the next commit. Release + Debug-FULL ctest unchanged
(42/42 and 51/51).

Refs #272

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YT4jVokb2gdT8ngFrH8i8S
Rust-only, per CLAUDE.md rule 2.

Refs #272

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YT4jVokb2gdT8ngFrH8i8S
imported from oven-sh/mimalloc @ 942b8342, MIT (issue #272).

Upstream mimalloc returns a page's memory to the OS only when EVERY block in it
is free, so one long-lived object pins a whole 64KB/512KB page. At an idle point
(`mi_on_thread_idle`) the sweep now discards the memory of the free blocks
inside a still-used page, in OS-page units, through `_mi_os_discard` (which
never changes commit state).

The engine lives in the new `src/page-holes.c` (CLAUDE.md rule 6; Bun keeps it
as +1038 lines in `src/page.c` plus the sweep drivers in `src/theap.c`):
per-page `purged` bitmap, unformed-tail discard, un-purge run/all, the
`swept_state` skip, the sweep drivers, and the read-only hole report. `page.c`
carries exactly five hook calls, marked HOOK n/5:
  1 `_mi_page_unpurge_run` from `mi_page_free_collect_ex`
  2 the `_mi_page_free_collect_no_unpurge` split (inspection must not mutate)
  3 `_mi_page_abandon` -> `_no_unpurge`
  4 `_mi_page_unpurge_unformed_upto` in `mi_page_extend_free`
  5 `_mi_page_purged_reset` in `_mi_page_init`
plus two debug-only assertion lines. Nothing is added to the alloc/free fast
path: the free path's only new code is inside `MI_CHECK_DOUBLE_FREE` (a freed
block was live, hence never in a discarded OS page), and the new `mi_page_t`
fields are at the tail of the struct behind a static assert that the hot fields
still fit the first cache line.

`arena.c` gains `_mi_page_unpurge_all` at the single choke point where a page
goes back to the arena, plus the abandoned-page sweep and report walkers (they
need the arena bitmaps and the claim protocol). `scavenger.c` gains the
`_mi_purge_holes_of` phase of `_mi_thread_idle_work` and the
`purge_holes_min_interval` pacing that `_mi_theap_sweep_parked` was already
shaped for. `theap.c` counts purged blocks as free in the block visitor, so no
inspection path can hand a discarded pointer to a callback.

Public API: `mi_purge_holes_stats_t` / `mi_purge_holes_stats_get` /
`mi_purge_holes_report`, and a `mi_stats_print` line. `purge_holes_full_every`
default is 64, matching Bun (was 16 in the groundwork commit, with no reason
given for the deviation). `mi_option_purge_zeroes` is documented as dead since
the #80 pin bump (issue #67): the slot stays, it is unrelated to
`purge_holes_eager_zero`, which zeroes MORE (a test-sharpening knob).

Profiler interaction (#272 design points 1-3): a discard covers only free-block
ranges, and `_mi_prof_debug_assert_no_records_in` asserts under MI_DEBUG that no
sampled record -- neither its block nor the record struct, which lives in the
profiler's raw-OS arena (rule 4) -- falls inside any range about to be
discarded; inspection paths report purged blocks as free; nothing in the sweep
reads or writes `mi_tld_t.profiler`.

Refs #272, #264

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YT4jVokb2gdT8ngFrH8i8S
…andoff (P7b)

imported from oven-sh/mimalloc @ 942b8342, MIT (issue #272 / Bun parity P7b).

`test-purge-holes.c` (1705 lines, 27 cases) is Bun's own test for the hole
engine and it passes against this port unchanged apart from two adaptations,
both noted in its header: the `src/page.c` file references become
`src/page-holes.c` (CLAUDE.md rule 6), and the one place that open-codes the
sweep state calls `mi_page_sweep_state()` -- a shared inline in `internal.h`
here, a `page.c` static in Bun. Registered twice: as itself, and as
`test-purge-holes-off` with `MIMALLOC_PURGE_HOLES=0`, where every data-integrity
check still runs and the test additionally asserts nothing is ever discarded.

`test-purge-zero.c` is verbatim. Its header records that despite the name it is
not a test of `mi_option_purge_zeroes` (dead here since #80, see #67 and
`test-zero-tracking`) but of the unconditional property that a recycled,
previously purged slice never leaks one allocation's bytes into the next.

`test-park-handoff.c` loses both of its Phase-7b caveats: the two
`purge_holes_min_interval` pacing cases are enabled (the in-window park is now
observed to be swept ~96ms into a 100ms window, rather than at the scavenger's
30s safety net), and a new `-eager` ctest variant runs the whole file with
`MIMALLOC_PURGE_HOLES_MIN_INTERVAL=0` so every park is due when claimed. The
"did a sweep run" observable stays `_mi_test_idle_work_count()` rather than
Bun's `discard_calls`: `min_interval` skips a tld before any work runs, so the
pass counter is silent inside the window exactly as `discard_calls` would be,
while staying deterministic when a pass legitimately finds nothing discardable.
The 7b half of the contract is asserted separately at the end of the run --
1902 discards / 19 MB of hole memory actually given back on this machine, and
exactly zero with `purge_holes` off.

Refs #272, #264

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YT4jVokb2gdT8ngFrH8i8S
Issue #272 profiler-interaction points 1 and 2, tested rather than argued.
Scenario 4 (7a) covers whole arena slices going away under a *table* walk; this
one covers individual blocks losing their memory under a *block* walk.

Six roles run at once with the profiler sampling at 1/1024 bytes: two owner
threads that churn 512-byte blocks with scattered survivors and sweep their own
heaps with `mi_on_thread_idle()`, two parkers whose heaps the scavenger sweeps
via `mi_on_thread_idle_start/_end`, and a walker that loops `mi_prof_visit`,
`mi_prof_snapshot_new/_visit` and `mi_theap_visit_blocks` over pages it is
itself having swept. Every pointer either walk produces is checked with
`mi_page_block_is_purged` and then dereferenced -- a discarded OS page reads
back zero and is poisoned under ASan, so a purged block escaping into a visitor
fails or faults instead of passing silently. Survivors are checked byte-wise
after each sweep on all four workers.

The run asserts it was not vacuous: hole discards must have happened
(~38k discards / ~270 MB here) and live profiler records must still exist at the
end (~94 here). A one-off negative control -- `mi_assert_internal(false)` inside
`_mi_prof_debug_assert_no_records_in` -- confirmed that assert does fire on this
workload, i.e. the discards really do land on pages carrying records, which is
what makes invariant (1) a test and not a comment.

Runs in ~11s; also covered by the existing `test-profile-race-scavenger` ctest
variant (`MIMALLOC_PURGE_DELAY=1`).

Refs #272, #264

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YT4jVokb2gdT8ngFrH8i8S
Picks up the 7a branch's Windows process-exit hang fix and exit-path hardening
under this stacked branch.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YT4jVokb2gdT8ngFrH8i8S

# Conflicts:
#	rust/mimalloc-pprof/vendor/mimalloc-pprof-amalgamated.c
#	rust/mimalloc-pprof/vendor/mimalloc-pprof-amalgamated.h
…P7b)

`cargo run -p xtask -- amalgamate-c` / `amalgamate-h` after the P7b C work, so
`amalgamation-drift.yml` stays green: the vendored single-TU build picks up
`src/page-holes.c` and the `mi_purge_holes_stats_t` / `mi_purge_holes_stats_get`
/ `mi_purge_holes_report` declarations.

Also exposes the counters to the crate: `sys::MiPurgeHolesStats` (a `#[repr(C)]`
mirror of `mi_purge_holes_stats_t`) and a safe `purge_holes_stats()`, documented
with which fields are monotonic and which are gauges, and with a doctest.
Without it a Rust embedder calling `on_thread_idle()` / `park_while_idle()` has
no way to see whether hole purging is getting anything back.

rust/ only (CLAUDE.md rule 2). `cargo test -p mimalloc-pprof` green.

Refs #272, #264

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YT4jVokb2gdT8ngFrH8i8S
…C_FORKS.md

The four `purge_holes*` options get their doxygen entries next to `scavenger`,
with the correction the issue needed: `purge_holes_eager_zero` is a *test* knob
that zeroes a range before discarding it (so a mis-scoped discard corrupts
visibly on an OS that reclaims lazily), i.e. it makes discarding more expensive,
not cheaper -- it is not zero-tracking and has nothing to do with the (dead
since #80, see #67) `purge_zeroes`.

The README's "Returning memory when a thread goes idle" section gains the other
half of what that idle point now does, with the stats/report entry points; the
snippet compiles under `ci/check_doc_snippets.py`.

`MIMALLOC_FORKS.md`'s pass-3 row for hole purging goes from "open, scheduled" to
IMPORTED, with the design deviations, the measured numbers, and the two things
issue #272 got wrong.

Refs #272, #264

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YT4jVokb2gdT8ngFrH8i8S
Refs #272, #264

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YT4jVokb2gdT8ngFrH8i8S
zackees and others added 4 commits September 2, 2026 11:06
`c-unit.yml`'s `ctest-shared` job configures `MI_BUILD_STATIC=OFF`, and a shared
build hides the internal symbols (`-fvisibility=hidden`). Two consequences,
both caught by `ci/verify_local.py`'s `shared` config:

  * `test-purge-holes.c` walks `mi_page_t` and calls `_mi_ptr_page`,
    `_mi_page_purged_count` and `_mi_page_purge_os_page_blocks` throughout, so
    it can only ever link statically. Its whole `add_executable` is now inside
    the `MI_BUILD_STATIC` branch, so the shared lane skips it rather than
    failing to link.
  * `test-profile-race.c`'s new scenario 5 used `_mi_ptr_page` /
    `mi_page_block_is_purged` for its purged-block assertions and
    `_mi_os_page_size` for its churn shape. That test must keep running in the
    shared lane, so those two assertions are now behind `MI_TEST_LINKS_STATIC`
    (defined by CMake only on the static branch) and the page size comes from
    `sysconf(_SC_PAGESIZE)` / `GetSystemInfo` when it is not set. The scenario
    is a concurrency test first and still runs in full either way; only the two
    internal-state assertions drop out.

Verified locally with the exact `ctest-shared` configure line
(`-DMI_BUILD_SHARED=ON -DMI_BUILD_STATIC=OFF -DMI_BUILD_OBJECT=OFF`): links
clean, 41/41.

Refs #272, #264

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YT4jVokb2gdT8ngFrH8i8S
…ut-dependent

      purge-holes cases when every allocation is guarded

Two CI lanes, both caught by `ci/verify_local.py` and the first PR run.

**MSVC / any Windows lane (LNK2019 on `_mi_os_page_size` and `__mi_page_map`).**
`mimalloc-static` is compiled with `MI_STATIC_LIB`; a consumer that is not sees
the internal declarations through `__declspec(dllimport)` and cannot link them.
`mimalloc-test-tls-controls` already had to do this. Both
`mimalloc-test-purge-holes` and `mimalloc-test-profile-race` now carry
`MI_STATIC_LIB` on the static branch.

**`ctest-guarded`'s second pass (`MIMALLOC_GUARDED_SAMPLE_RATE=1`).** Guarding
every allocation makes each one oversized, guard-page-backed and returned as an
INTERIOR pointer, so a test can neither derive a block's index in its page from
the pointer it holds nor keep a page to itself (its own `calloc`s land in the
same bins). Six of `test-purge-holes.c`'s 25 cases -- the two report cases, the
read-only-on-abandoned case, both unformed-tail cases, and the sweep-skip case
-- assume both, and failed with `page ... is not exclusively ours: used=8 but we
hold 7`. They are now skipped, loudly, when the guarded sample rate is 1;
`layout_is_predictable()` in that file says why. Nothing about the ENGINE is
guard-sensitive (a guarded block is an ordinary free-listed block to the sweep),
and the lane's FIRST pass still runs all 25 cases, as does every other config.

Verified locally with each lane's exact configure line: shared
(`MI_BUILD_SHARED=ON MI_BUILD_STATIC=OFF MI_BUILD_OBJECT=OFF`) 41/41; guarded
(`MI_GUARDED=ON`) green in both passes, the second with
`MIMALLOC_GUARDED_SAMPLE_RATE=1`.

Refs #272, #264

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YT4jVokb2gdT8ngFrH8i8S
…y is

`windows-msvc` (and any `MI_USE_CXX` lane) failed with

    lld-link: error: undefined symbol: _mi_os_page_size
    lld-link: error: undefined symbol: __mi_page_map

The library there is built as C++ ("Building CXX object
CMakeFiles/mimalloc-static.dir/src/os.c.obj"), and `mimalloc/internal.h` -- unlike
`mimalloc.h` -- is not wrapped in `extern "C"`. So the library's internal symbols
are C++-mangled while a C test looks for the unmangled names.

CMakeLists already has the list of tests that must follow the library's language
for exactly this reason (`test-api.c`, `test-tls-controls.c`, ...);
`test-purge-holes.c` and `test-profile-race.c` join it. Both compile clean as
C++ (`g++ -x c++ -std=c++17 -fsyntax-only`), and a full local
`-DMI_USE_CXX=ON` build links and passes 5/5 on the purge and profile-race
tests.

Refs #272, #264

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YT4jVokb2gdT8ngFrH8i8S
The shared-lane fallback for the OS page size sits above the file's threading
shim, which is where <windows.h> was being pulled in -- so `ctest-shared-win-gnu`
failed with `unknown type name 'SYSTEM_INFO'`. Include it where it is used;
the later include is idempotent.

Refs #272, #264

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YT4jVokb2gdT8ngFrH8i8S
@zackees

zackees commented Sep 2, 2026

Copy link
Copy Markdown
Owner Author

Update after the first CI round

Four lane-only problems, all fixed and each verified locally against the lane's exact configure line. None of them touched the engine.

lane symptom cause fix
ctest-shared (linux + windows), *-x64-shared bundles undefined reference to _mi_os_page_size / __mi_page_map MI_BUILD_STATIC=OFF hides the internal symbols; test-purge-holes.c needs them throughout, test-profile-race.c's new scenario 5 for two assertions test-purge-holes is now static-lane-only (its whole add_executable is inside the MI_BUILD_STATIC branch); scenario 5's assertions are behind MI_TEST_LINKS_STATIC and its page size falls back to sysconf/GetSystemInfo, so the scenario still runs everywhere as the concurrency test it also is
build-windows-msvc (all), ctest* (windows-latest) lld-link: undefined symbol: _mi_os_page_size even against the static library those lanes build the library as C++ (MI_USE_CXX), and mimalloc/internal.h — unlike mimalloc.h — is not extern "C", so the internal symbols are mangled both tests joined CMakeLists' existing "compile as C++ when the library is" list, next to test-api.c which is there for the same reason; also MI_STATIC_LIB on the static branch, as mimalloc-test-tls-controls already needs
ctest-shared-win-gnu unknown type name 'SYSTEM_INFO' the shared-lane page-size fallback sits above the file's threading shim, which is where <windows.h> came in include it where it is used
ctest-guarded pass 2 (MIMALLOC_GUARDED_SAMPLE_RATE=1) page ... is not exclusively ours: used=8 but we hold 7 in 6 of 25 cases guarding every allocation makes each one oversized, guard-page-backed and returned as an interior pointer, so a test can neither derive a block's index in its page nor keep a page to itself those six cases (report-*, both unformed-tail*, sweep-skips-unchanged-pages) skip loudly when the sample rate is 1; layout_is_predictable() in the test says why. The engine is not guard-sensitive — a guarded block is an ordinary free-listed block to the sweep — and the lane's first pass still runs all 25

Locally verified with each lane's own configure line: shared 41/41 · MI_USE_CXX=ON links and 5/5 on the purge + race tests · MI_GUARDED=ON green in both passes.

One open decision: the memory gate

memory-gate (ubuntu-latest) is red and I have not re-baselined it. Full evidence and the three options are on #272 (#272 (comment)). Summary:

  • 7a base on CI: 25.3 MB, allowed 25.6 (passes with 1.2% headroom). 7b: 26.5 MB.
  • Locally, interleaved, 4-CPU-pinned, min of 10 alternating runs: 7a 21.9 MB, 7b 24.0 MB with purge_holes=1, 24.1 MB with purge_holes=0.
  • The option makes no difference, so this is not the sweep — it is sizeof(mi_page_t) 144 → 192 B, which mi_arena_info_slices_needed multiplies by the arena's slice count and commits up front: +0.75 MiB of page-meta per 1 GiB arena. Both builds reserve exactly one 1 GiB arena (MIMALLOC_VERBOSE=1). The remaining ~1 MB of the min-of-N delta is unattributed; the medians are 25.1 vs 25.3 MB and the gate's own output warns its spread (38.6% locally, 21.3% on 7a's CI run) exceeds its 15% tolerance.
  • MI_PAGE_PURGE_BITS cannot drop to 128 (a 512 KiB page on a 4 KiB OS page needs 129 bits, so every medium page would become ineligible — most of the benefit), and shrinking swept_state to 32 bits saves nothing after alignment. Bun pays the same 48 bytes.

Re-baselining is the option I'd pick, from artifacts of a green run on all three platforms, but it is a reviewed act and the alternative (lazy pages_meta commit) is an arena change that does not belong in a parity import. Your call.

Two 7a findings, reported not fixed

Both on #299, neither touched on either branch:

zackees and others added 5 commits September 2, 2026 11:30
7a's exit-path hardening (PR #299, `10566118`) added `_mi_scavenger_shutdown`
and moved `_mi_park_leave` ahead of `_mi_heap_detach_theaps`. Audited the sweep
against both; no code change was needed, and the reasons belong in the file
rather than in a review thread:

  * after `_mi_scavenger_stop` sets the shutdown flag, no sweep can start on the
    scavenger -- its run loop exits on `_mi_scavenger_running == 0`, the stop
    joins it, `_mi_scavenger_start_lazy` refuses to restart, and so
    `mi_on_thread_idle_start` hands nothing off. A direct `mi_on_thread_idle()`
    after that sweeps on the CALLING thread over its own theaps, which is safe
    at any point in the process's life.
  * `mi_heap_delete`/`_destroy` takes every parked owner back with
    `_mi_park_leave` BEFORE detaching, and that call does not return until
    MI_PARK_SWEEPING has cleared -- so no theap is detached under a running
    sweep. It terminates against this sweep because every phase re-reads
    `tld->park_reclaim`: between heaps (`_mi_purge_holes_of`), between pages
    (`mi_theap_page_purge_holes`) and between abandoned pages
    (`mi_arena_page_purge_holes_at`). The wait is therefore bounded by one
    page's walk, and `tld->theaps_lock` -- held across the sweep's passes, and
    try-acquired by `_mi_heap_detach_theaps` -- is always released before the
    deleter needs it.

Comment only. `test-heap-teardown`, `test-heap-delete-race`, `test-heap-churn`,
`test-heap-aba`, the three `test-park-handoff` variants, the fork tests and all
the purge tests: 18/18 on the merged tree, and 55/55 for the full debug-full
suite.

Refs #272, #264

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YT4jVokb2gdT8ngFrH8i8S
…-semantics note

rust/ only (CLAUDE.md rule 2).

Refs #272, #264

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YT4jVokb2gdT8ngFrH8i8S
…tld fields

7a is widening `park_state`/`park_reclaim`/`park_swept` from `_Atomic(uint32_t)`
to `_Atomic(size_t)` because MSVC's plain-C atomic wrapper only implements
uintptr_t/int64_t-width primitives, so `mi_atomic_load_*` on a 32-bit field
issues an 8-byte load and pulls the next field into the high half (the C4133 I
reported on #299).

This phase introduces no new atomics at all -- all six `mi_tld_t` hole-sweep
fields and all four `mi_page_t` hole fields are plain, because only the single
thread performing a sweep touches them, and the process-wide counters in
`src/page-holes.c` are `int64_t` reached through `mi_atomic_addi64_relaxed`
(the 64-bit primitive), exactly as `mi_stat_counter_t` does. Writing the rule
down where a future field would be added so the class does not come back.

Comment only.

Refs #272, #264

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YT4jVokb2gdT8ngFrH8i8S
rust/ only (CLAUDE.md rule 2).

Refs #272, #264

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YT4jVokb2gdT8ngFrH8i8S
@zackees

zackees commented Sep 2, 2026

Copy link
Copy Markdown
Owner Author

Final state

Head is a1c96855, which includes a merge of bun-parity/p7-scavenger at 8885a8af (the Windows exit-hang fix and the merge of main).

The memory gate passes; my earlier escalation on #272 is withdrawn. On this head, memory-gate (ubuntu-latest) reports 25.2 MB against 7a's own 25.3 MB, allowed 25.6 — 7b is marginally below the base branch. The 26.5 MB that prompted the escalation was one unlucky min-of-8 on a shared runner whose spread that same run reported as 24.2%, above the gate's own 15% tolerance. memory-gate (windows-latest) passes too. No re-baseline needed; ci/memory-baselines/*.json is untouched.

Two 7a interactions audited, no code change needed (da43fabc records the reasoning in src/page-holes.c):

  • After _mi_scavenger_stop sets _mi_scavenger_shutdown, no sweep can start on the scavenger — the run loop exits on _mi_scavenger_running == 0, the stop joins it, _mi_scavenger_start_lazy refuses to restart, so mi_on_thread_idle_start hands nothing off. A direct mi_on_thread_idle() after that sweeps on the calling thread over its own theaps, safe at any point.
  • mi_heap_delete/_destroy calls _mi_park_leave on every parked owner before _mi_heap_detach_theaps, and that call does not return until MI_PARK_SWEEPING clears — so no theap is detached under a running sweep. It terminates against this sweep because every phase re-reads tld->park_reclaim: between heaps (_mi_purge_holes_of), between pages (mi_theap_page_purge_holes), between abandoned pages (mi_arena_page_purge_holes_at). The wait is bounded by one page's walk, and tld->theaps_lock is always released before the deleter needs it.

No new atomics of the C4133 class (a1c96855 writes the rule into types.h next to the fields). This branch adds zero _Atomic fields: all six mi_tld_t hole-sweep fields and all four mi_page_t hole fields are plain, because only the single thread performing a sweep touches them. The process-wide counters in src/page-holes.c are int64_t reached through mi_atomic_addi64_relaxed — the 64-bit primitive on a 64-bit field, as mi_stat_counter_t does. My reads of 7a's park_state/park_reclaim go through mi_atomic_load_* and become correct automatically when those fields widen to _Atomic(size_t); I will re-verify at the next merge.

uv run ci/verify_local.py --keep-going on this head

config result
release, off, debug-full, guarded, shared, bundle, memory-gate, diag, rust, lint PASS (10/10)
asan FAIL — environmental only: MI_TRACK_ASAN did not survive configure (no clang on this box). Run by hand with gcc instead: MI_TRACK_ASAN=ON debug-full, 54/54.

guarded covers both passes, the second with MIMALLOC_GUARDED_SAMPLE_RATE=1. Separately, 10/10 consecutive runs of test-purge-holes, test-purge-holes-off, test-purge-zero, the three test-park-handoff variants and test-profile-race on the debug-full tree.

CI

Everything green except two, neither of them this branch's:

  • run-macos-x64 (dockurr/macos on Linux)No golden macOS disk under cache key 'macos-x64-golden-ventura-v1'. Fails identically on the base branch feat: background scavenger thread and mi_on_thread_idle (Bun parity P7a) #299; macOS x64 needs the golden image published before any PR can exercise it. macOS arm64 is green (build-macos (macos-arm64-{release,debug-full,leak})), so MADV_FREE_REUSABLE is run-verified there.
  • test (ubuntu-latest) (rust-native) — error: could not compile 'proc-macro2' (build script) ... exit status: 255 under the soldr rustc shim, with zccache ... errors=1. A toolchain/cache failure with no relation to the diff; the same job passed on the previous head, cargo test is green locally, and the other five rust-native targets (linux-musl, both darwin, both windows) pass on this head. Needs a re-run.

All Windows lanes are green, including the ones that matter for MEM_RESET/VirtualUnlock: ctest-debug-full (windows-latest) (MSVC Debug, where MI_DEBUG>1 forces purge_holes_eager_zero so every discard memzeroes its range first), ctest (windows-latest), ctest-shared (windows-latest), all four windows-msvc bundles and all four windows-gnu bundles.

@zackees

zackees commented Sep 2, 2026

Copy link
Copy Markdown
Owner Author

CI settled: 60 pass / 2 fail, and neither failure is this branch's

check verdict
ctest-debug-full (ubuntu-latest) pre-existing race on the base branch. test-park-handoff trips mi_theap_visit_pages's count == total assertion inside test_park_stress, on the scavenger, walking a parked thread's theap. Reproduced on bun-parity/p7-scavenger @ 2fcac37b at 3/120 runs versus 2/120 on this branch, with the same assertion and the same stack. Full evidence, instrumented-probe results and reproduction recipe on #299.
run-macos-x64 (dockurr/macos on Linux) No golden macOS disk under cache key 'macos-x64-golden-ventura-v1'. Infrastructure; fails identically on #299.

On the first one, the numbers that matter:

branch config failures / 120 runs
bun-parity/p7-scavenger @ 2fcac37b default 3
this branch @ a1c96855 default 2
this branch @ a1c96855 MIMALLOC_PURGE_HOLES_MIN_INTERVAL=0 2

Pinned to 4 CPUs (taskset -c 0-3), debug-full; unpinned it does not reproduce. The hole sweep gives the parked-thread walk more to do per park, so it is a marginally better amplifier of the race — but the base branch's rate is higher, and forcing a sweep on every park (min_interval=0) does not change it, so sweep frequency is not the mechanism. At ~2.5% per run this lane is green most of the time on both branches; #299's own ctest-debug-full being green is luck rather than evidence.

I have not worked around it here — no assertion relaxed, no test skipped, no ctest variant dropped. It is #299's to fix, and 7b will inherit the fix.

Everything else is green, including every Windows lane (ctest-debug-full (windows-latest) is the MSVC Debug one where MI_DEBUG>1 forces purge_holes_eager_zero, so every discard memzeroes its range before MEM_RESET + VirtualUnlock), macOS arm64, both memory gates, bundle-roundtrip, address sanitizer, fuzz, amalgamation-drift and all six rust-native targets.

@zackees

zackees commented Sep 2, 2026

Copy link
Copy Markdown
Owner Author

Merge order, to be explicit: ctest-debug-full (ubuntu-latest) will stay red on this PR until #299 fixes the parked-sweep race. That is not a 7b defect — it reproduces at a higher rate on the base branch (3/120 vs 2/120), including on the head that already carries the park-field atomic-width fix — but it does mean this PR should not be merged before #299 lands the fix, and it will go green here on its own once it does. Do not merge.

zackees and others added 3 commits September 2, 2026 13:15
Rebases 7b onto 7a's final head: `_Atomic(size_t)` park fields, the
`mi_scav_word_t` wake word, the `tlds_lock`-held detach, the owner-only
`_mi_deferred_free` guard, `_mi_park_leave_if_parked` on the allocator slow
paths, and `test_exit_while_swept_stress`.

Four conflicts, all resolved keeping both sides:

* `src/scavenger.c` -- 7b's `purge_holes_min_interval` pacing hunk sits on top of
  7a's widened `park_state`, so the CAS out-param becomes `size_t expected`
  (a `uint32_t` there would be written a full word wide by the MSVC-C atomics
  wrapper). The file's own width static-assert covers the FIELDS, not the
  out-param, so this one is a manual invariant; noted at the line.
* `README.md` -- both sides append to the idle section; kept 7a's
  scavenger-shutdown paragraph followed by 7b's hole-purging paragraph.
* both vendored amalgamation files -- taken from this branch and regenerated
  from the merged sources in a separate rust-only commit (CLAUDE.md rule 2).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YT4jVokb2gdT8ngFrH8i8S
… width

Review of PR #302, the medium and three of the lows.

**`purge_holes_min_interval` now paces `mi_on_thread_idle()` too.** The check and
the stamp both move into `_mi_purge_holes_of` -- the one path the owner and the
scavenger share -- so a thread that calls the "do the idle work here, on this
thread" entry point in a tight loop no longer walks every page of every one of
its theaps on every call. The option is documented as "do not sweep one thread's
heaps more often than every N milli-seconds" with no clause about which thread
sweeps. Three consequences, all deliberate:

* `_mi_theap_sweep_parked` must NOT stamp as well (it did): with the check now
  inside, its own pre-call stamp would make every scavenger-driven sweep see
  `now - last == 0` and skip. It keeps only its pre-claim copy of the check,
  which it needs before it can claim a park at all.
* that loop now reads `park_state` BEFORE `holes_sweep_last`. The plain
  `holes_sweep_last` gained a second writer -- the owner, while RUNNING -- and
  `tlds_lock` does not order that write. The acquire-load of MI_PARK_PARKED does:
  it pairs with the owner's release-store in `mi_on_thread_idle_start`, and a tld
  that is not parked is skipped without the field being read at all.
* the stamp sits AHEAD of the `purge_holes` enabled check, so an `-off` build
  paces the scavenger's claims exactly like an `-on` one.

`test-purge-holes.c` gains `owner-sweeps-are-paced` (sweeps inside the window do
no work at all; the deadline is waited out, not switched off, so expiry is what
is tested) and sets the option to 0 in `main` -- every other case drives
`mi_on_thread_idle()` back to back and asserts what one sweep did.

**`mi_park_state_t`** pins a `park_state` CAS out-param to the field's width.
7a's existing static assert covers the FIELDS only; a `uint32_t expected` is
rejected by gcc/clang through the C11 generic but merely warned about by the
MSVC-C wrapper (C4133), which then writes 8 bytes into the 4-byte local.

**Lows.** `mi_page_unpurge_range` resolves its block range before clearing any
bitmap bit, so the lookup's early return can no longer leave free-but-unlisted
blocks marked not-purged. `MI_PURGE_HOLES_MAX_HEAPS` is documented as a cap on
the abandoned-page pass only (the theaps' own pages are never capped) with why a
resume cursor is not worth it. The `MI_PAGE_PURGE_BITS` geometry comments are
corrected: v3 keeps `mi_page_t` out of line in the arena meta slices, so the
block area is slice-aligned and there is no header bit -- a 4 MiB page needs
1024 bits at a 4 KiB OS page (not 1025) and exactly 256 at 16 KiB, i.e. it fits
from 16 KiB up.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YT4jVokb2gdT8ngFrH8i8S
…nario 5's negative control repeatable

Review of PR #302, the teardown audit and the last low.

**The TEARDOWN section of `src/page-holes.c` is re-done against the post-merge
code**, with current line numbers and one claim it did not make before. The new
part is (d): 7a's `_mi_park_leave_if_parked` on the allocator slow paths means a
parked thread can un-park itself from a `thread_local` destructor while this
sweep is walking its pages. Nothing extra is needed -- `_mi_park_leave`
publishes `park_reclaim` and then spins until MI_PARK_SWEEPING clears, and the
sweeper only stores MI_PARK_PARKED after `_mi_thread_idle_work` has fully
returned, so both sweeps inside it get the same guarantee from the same
protocol. (b) is the lock-order half the review asked for: this sweep is a LEAF
(it holds `tld->theaps_lock` and takes nothing else of that family -- the
abandoned pass reads `heap->arena_pages[]` atomically and goes through the arena
bitmaps), and the two paths that could close a cycle with it hold the other lock
and TRY-acquire this one.

**`test_exit_while_hole_swept_stress`** is the empirical half: 12 threads x 60
rounds exiting inside a scavenger sweep, `purge_holes_min_interval` at 0 so
every park is a full hole sweep, and a destructor that both frees AND allocates
-- only the slow paths carry the park-leave, so a free-only destructor can miss
it. Every fourth thread stays parked ~8ms: at the tight spacing alone the sweep
bails on `park_reclaim` before discarding anything, and the case measured 0
discards over 720 parks, i.e. it raced a sweep that never did any work. It now
asserts the discards happened (468 locally). Bounded under `MI_GUARDED` for the
`vm.max_map_count` reason `LIVE` already is.

**Scenario 5's negative control is repeatable.** What used to be a one-off local
`mi_assert_internal(false)` patch is now two exported observables:
`_mi_prof_debug_records_in` (the predicate the assert is over -- aimed at a
range that deliberately contains a live sampled block it must report it) and
`_mi_prof_debug_records_compared` (the assert really did run on record-bearing
pages, not only record-free ones). 94 blocks reported and 26413 records compared
locally. Not fork-and-expect-SIGABRT: there is no such harness here, this test
also runs on Windows, and an aborting child would need an engine knob whose only
purpose is to be wrong.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YT4jVokb2gdT8ngFrH8i8S
zackees and others added 2 commits September 2, 2026 13:43
`test-park-handoff-no-scavenger` runs with `MIMALLOC_SCAVENGER=0`, so
`mi_on_thread_idle_start` reports false everywhere, nothing is ever swept and
the new case's "the parks it raced really did discard holes" check has no
subject. Count the successful handoffs and gate the check on there having been
one; the threads still exit through the same allocating destructor in that
variant, which is worth running on its own.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YT4jVokb2gdT8ngFrH8i8S
Regenerated from the merged and reviewed C sources: 7a's merge (widened park
fields, `mi_scav_word_t`, the park-leave on the allocator slow paths) plus this
round's `mi_park_state_t` guard, the owner-side hole-sweep pacing, the split of
`_mi_prof_debug_assert_no_records_in` into a predicate, and the comment fixes.
`cargo run -p xtask -- check` reports the vendored amalgamation matches
src/include.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YT4jVokb2gdT8ngFrH8i8S
@zackees
zackees changed the base branch from bun-parity/p7-scavenger to main September 2, 2026 22:15
zackees and others added 3 commits September 2, 2026 15:19
P7a landed on main as PR #299, so this branch reparents onto main. The only
real overlap is test/test-park-handoff.c, where 7a bounded three stress cases
under a forced guarded sample rate and 7b added a fourth (the hole-swept exit
stress) with its own bound; git kept both hunks. The vendored amalgamation is
resolved to this branch's copy and re-run in a following rust-only commit.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YT4jVokb2gdT8ngFrH8i8S
P7a (c53496b, now on main) established that `#if defined(MI_GUARDED)` is the
wrong signal for "every allocation gets a guard page": MI_GUARDED is compiled
into every MI_DEBUG build, but only ctest-guarded's second pass forces
`MIMALLOC_GUARDED_SAMPLE_RATE=1`. Gating on the compile flag therefore also
de-powers ctest-debug-full, the one job whose MI_DEBUG_FULL assertions can
catch the #272 race this corpus hunts.

`test_exit_while_hole_swept_stress` was written on this branch with the old
compile-time gate, so after the merge the file argued one thing at the top and
did the other 650 lines down. Move it onto the same `guard_every_alloc()`
runtime gate as the other three stress cases, with the same FULL/BOUND define
pair: 12/60/DTOR_BLOCKS normally, 4/10/64 only when the sample rate is forced
near 1-in-1. The block count is latched into a file static before the first
`pthread_create` because both the thread body and its destructor read it and
the `calloc` length must match the loop that walks it; `pthread_t t[]` is sized
at THREADS_MAX, as in the other three.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YT4jVokb2gdT8ngFrH8i8S
Picks up main's `_mi_park_leave_if_parked` comment rewording and the new
`mi_free_block_local` MI_PARK_SWEEPING assertion (c53496b) into the vendored
single-TU copy the sys crate compiles. `xtask check` is green.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YT4jVokb2gdT8ngFrH8i8S
zackees added a commit that referenced this pull request Sep 2, 2026
mi_on_thread_idle landed on main in 1dbbb8d; ci/check_bun_surface.py now
reports zero missing symbols on both glibc and musl (alpine:3.20), so
remove continue-on-error from the bun-surface job and drop the dated
TODO. Clean up test-bun-surface.cpp's now-redundant local extern "C"
redeclaration of mi_on_thread_idle (the real prototype ships in
include/mimalloc.h now) and update docs/ci-gates.md and the 09-02 gap
analysis's status table: scavenger/mi_on_thread_idle rows -> DONE
(#299), hole purging -> PENDING (#302, still open).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YT4jVokb2gdT8ngFrH8i8S
zackees added a commit that referenced this pull request Sep 2, 2026
Issue #274 (Bun parity P9a). ci/check_bun_surface.py reproduces Bun's
scripts/build/deps/mimalloc.ts DirectBuild outside CMake: compiles
src/static.c as C++ with Bun's exact define set, links
test/test-bun-surface.cpp against it, runs the result, and on a link
failure parses the linker output into a clear MISSING_SYMBOLS list rather
than dumping raw ld errors. Verified locally on ubuntu (glibc) and inside
alpine:3.20 (--musl): both report exactly one missing symbol,
mi_on_thread_idle.

Registered in .github/workflows/c-unit.yml as job `bun-surface`, matrix
glibc + musl, continue-on-error: true with a dated TODO -- mi_on_thread_idle
lands with Bun parity P7a (#299, open), so this job is expected to be red
until then. Flip continue-on-error off once #299 (and the stacked #302)
merge and the script reports zero missing symbols on both rows.

ci/tests/test_check_bun_surface.py unit-tests parse_missing_symbols against
GNU ld, lld, and ld64/macOS linker-output shapes (the macOS/Windows shapes
are forward-compatible coverage; this job itself only runs the glibc/musl
rows today).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YT4jVokb2gdT8ngFrH8i8S
zackees added a commit that referenced this pull request Sep 2, 2026
Issue #274 (Bun parity P9b). docs/bun-gap-analysis-2026-09-02.md re-runs
the 2026-09-01 analysis's four checks against Bun's current pin: the
scripts/build/deps/mimalloc.ts commit (942b8342) has not moved, and all
five consumer files (mimalloc_sys.rs, MimallocArena.rs,
MimallocWTFMalloc.h, BunJSCModule.h, heapStats-mimalloc.test.ts) are
byte-identical to what was cached in that session -- no new gap exists at
Bun's current pin, so no new sub-issue is filed. The rest of the doc is a
status table for every item and gap ID (B1-B20) from the 2026-09-01
analysis against what has actually merged since (#276, #281, #284, #286,
#289, #291, #297) versus what's still open (#299, #302, and the items tied
to them): 7/9 required-before-pitch items done, 11/20 B-numbered gaps done,
1 partial, 0 new.

docs/ci-gates.md gains a `bun-surface` row and a short section explaining
why that CI job is temporarily continue-on-error (mi_on_thread_idle isn't
on main yet) with a dated TODO for when to make it a hard gate.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YT4jVokb2gdT8ngFrH8i8S
zackees added a commit that referenced this pull request Sep 2, 2026
The 2026-09-02 gap analysis said "no MIMALLOC_FORKS.md changes are needed"
on the strength of PR #275's title alone. Actually grepped and read the
file this time: every item merged since 09-01 already has a correctly
marked IMPORTED row, and the two still-open rows (scavenger/hole-purging/
mi_on_thread_idle, purge_delay 1000->100) are correctly marked open --
citing the parent issue #272 rather than its split child PRs #299/#302,
which is accurate but one level less specific. No edit needed; the doc now
says why, backed by the actual grep/read instead of an inference.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YT4jVokb2gdT8ngFrH8i8S
zackees added a commit that referenced this pull request Sep 2, 2026
Not for merge. PR #302's `ctest-win-gnu` failed `test-profile:1109`
(`after_workers_only2.accum_samples > before_workers_only2.accum_samples`)
on attempt 1 of run 33690738299 and passed on attempt 2, so this is a
~1-in-5 flake on that lane, not a deterministic failure. Locally the same
window produces ~4000-5000 samples, so a delta of 0 is an all-or-nothing
blackout, not timing marginality -- these prints say which of the three
possible blackouts it is:

  * workers descheduled            -> per-worker batch counts stay flat
  * workers ran on the fast path   -> hook_enabled/countdown stay flat
  * sampled but not credited       -> over_thr grows while accum does not

`ctest --repeat until-fail:40` on the lane so one push is a real sample,
and every print goes to stderr with an explicit fflush: msvcrt block-
buffers stdout, which is why the original failure showed the assert with
no test output at all.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YT4jVokb2gdT8ngFrH8i8S
zackees added a commit that referenced this pull request Sep 2, 2026
Not for merge. PR #302's `ctest-win-gnu` failed `test-profile:1109`
(`after_workers_only2.accum_samples > before_workers_only2.accum_samples`)
on attempt 1 of run 33690738299 and passed on attempt 2, so this is a
~1-in-5 flake on that lane, not a deterministic failure. Locally the same
window produces ~4000-5000 samples, so a delta of 0 is an all-or-nothing
blackout, not timing marginality -- these prints say which of the three
possible blackouts it is:

  * workers descheduled            -> per-worker batch counts stay flat
  * workers ran on the fast path   -> hook_enabled/countdown stay flat
  * sampled but not credited       -> over_thr grows while accum does not

`ctest --repeat until-fail:40` on the lane so one push is a real sample,
and every print goes to stderr with an explicit fflush: msvcrt block-
buffers stdout, which is why the original failure showed the assert with
no test output at all.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YT4jVokb2gdT8ngFrH8i8S
@zackees
zackees force-pushed the bun-parity/p7b-holes branch from 06743d5 to 017cf3f Compare September 2, 2026 23:10
zackees added a commit that referenced this pull request Sep 2, 2026
Not for merge. PR #302's `ctest-win-gnu` failed `test-profile:1109`
(`after_workers_only2.accum_samples > before_workers_only2.accum_samples`)
on attempt 1 of run 33690738299 and passed on attempt 2, so this is a
~1-in-5 flake on that lane, not a deterministic failure. Locally the same
window produces ~4000-5000 samples, so a delta of 0 is an all-or-nothing
blackout, not timing marginality -- these prints say which of the three
possible blackouts it is:

  * workers descheduled            -> per-worker batch counts stay flat
  * workers ran on the fast path   -> hook_enabled/countdown stay flat
  * sampled but not credited       -> over_thr grows while accum does not

`ctest --repeat until-fail:40` on the lane so one push is a real sample,
and every print goes to stderr with an explicit fflush: msvcrt block-
buffers stdout, which is why the original failure showed the assert with
no test output at all.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YT4jVokb2gdT8ngFrH8i8S
@zackees
zackees force-pushed the bun-parity/p7b-holes branch from 017cf3f to 14f2d89 Compare September 2, 2026 23:17
zackees added a commit that referenced this pull request Sep 2, 2026
…w notes

Opus review (PR #305):
- B1: TLS-slot zeroing was attributed to d078ad06 ("macOS VM tag default"),
  not the actual fix. Verified against oven-sh/mimalloc directly
  (`git show --stat afb41757 d078ad06`): afb41757 ("threadlocal: zero new
  slots explicitly when expanding the slot array", src/threadlocal.c) is
  the real commit. Fixed in README.md (text + href) and
  MIMALLOC_FORKS.md:325, where the error originated (PR #148's body).
- B2: "upstream is 10" -> 1000ms. Verified `git show 6def7be9:src/options.c`
  at the pin: `purge_delay` upstream default is 1000ms, not 10.
- B4: dropped the "(open, stacked on #299)" parenthetical from the
  purge_holes row now that #299 is merged; PR stays draft until #302 merges.
- M1: the scavenger row's deviation (c) had it backwards -- the park
  protocol IS Bun's, imported as part of this PR. Replaced with the real
  deviation: mi_subproc_t's new fields are appended at the struct tail
  rather than mid-struct, which is where Bun's placement would have
  shifted `stats` (the free path touches it, ~2 ns/alloc+free).
- Minor: "Our PR" -> "Landed in"; added the regen command
  (`uv run ci/bench_hole_purging.py --build-dir ... --include-dir include
  --out-dir .github/assets [--table]`) after the source sentence; added
  a sentence to the v3-only note that upstream dev3 is itself still
  pre-release, linking docs/fork-divergence.md#how-v3-was-validated;
  added "The scavenger is on in both runs; the chart isolates hole
  purging" to the prose; re-rendered both RSS SVGs for the new title.

`check_doc_snippets.py` and `pytest ci/tests` (235 tests) both pass.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YT4jVokb2gdT8ngFrH8i8S
@zackees
zackees force-pushed the bun-parity/p7b-holes branch from 14f2d89 to 392092c Compare September 2, 2026 23:30
`ctest-win-gnu` failed `test-profile:1109`
(`after_workers_only2.accum_samples > before_workers_only2.accum_samples`)
on attempt 1 of run 33690738299 and passed on attempt 2, so it is a rare
flake on that lane rather than a P7b regression -- no hole sweep runs in
this test at all (nothing calls `mi_on_thread_idle`, so every 7b hook in
`mi_page_free_collect_ex` is a no-op here).

What it actually is, from 60 instrumented runs on that runner: step 3's
worker-only window is a FIXED 20M-iteration spin, which measures ~12 ms
wide there (step 1's is ~10x longer only because sampling at a 128-byte
interval slows every thread down). Inside those 12 ms, 23 of the 60 runs
had at least one of the six workers get no cpu at all, and 7 had two --
Windows' long, non-varying server quantum lets a freshly spawned thread
at the back of a saturated ready queue wait out the whole window. The
step-3 assertion needs only one sample, but at an 8192-byte interval one
sample needs ~8 KB of worker allocation: when all six workers lose the
window, zero is the honest answer and the assertion is asserting the
scheduler, not the profiler.

So end the window when the workers have collectively allocated 256x the
sample interval instead of after a fixed spin. The sample assertions are
unchanged (`> before + 5` at rate 128, `> before` at 8192) and now rest
on ~256 expected samples at either rate; the main thread still allocates
nothing inside the window, so growth is still unambiguously worker-driven.
The window also now asserts that the workers really did allocate, and a
60s deadline keeps six genuinely stuck workers a failed assertion rather
than a hung test.

Worker progress is credited per BLOCK, not per batch. Crediting a whole
batch at a time made the first credit after `mi_prof_start_ex` a batch
that had mostly been allocated -- unsampled -- before the profiler
started, and a 224-byte batch is 57 KB, more than a whole window: one
straddling batch could close the window with almost no sampled
allocation inside it. That is what failed `test-profile-accum` at step 1
in `bundle-roundtrip` on the first version of this commit; per block, the
straddle is at most one block.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YT4jVokb2gdT8ngFrH8i8S
@zackees
zackees force-pushed the bun-parity/p7b-holes branch from 392092c to 6606336 Compare September 2, 2026 23:53
@zackees

zackees commented Sep 3, 2026

Copy link
Copy Markdown
Owner Author

Opus review (two rounds): APPROVE. Hole engine in src/page-holes.c with 5 hooks in page.c; free list rebuilt before discard; zero contract preserved; profiler records off-page with a per-discard MI_DEBUG assert; owner-driven sweeps paced; MinGW test-profile flake root-caused (fixed 12 ms spin window lost the workers' quantum on Windows; now sized by allocation, 6606336e). Memory gate: runner read 30.5 MB with 21.6% spread (#298); pinned local A/B on identical hardware, min-of-8: main 22.7 MB vs this head 24.1 MB (+1.4 MB, within tolerance; the per-page bitmap). Churn RSS after idle 230.7→73.2 MB (README bench), latency neutral. Merging; closes #272.

@zackees
zackees merged commit c227c3a into main Sep 3, 2026
57 of 58 checks passed
@zackees
zackees deleted the bun-parity/p7b-holes branch September 3, 2026 00:19
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