feat: page hole purging (Bun parity P7b) - #302
Conversation
… 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
`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
Update after the first CI roundFour lane-only problems, all fixed and each verified locally against the lane's exact configure line. None of them touched the engine.
Locally verified with each lane's own configure line: One open decision: the memory gate
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 Two 7a findings, reported not fixedBoth on #299, neither touched on either branch:
|
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YT4jVokb2gdT8ngFrH8i8S
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
Final stateHead is The memory gate passes; my earlier escalation on #272 is withdrawn. On this head, Two 7a interactions audited, no code change needed (
No new atomics of the C4133 class (
|
| 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})), soMADV_FREE_REUSABLEis run-verified there.test (ubuntu-latest)(rust-native) —error: could not compile 'proc-macro2' (build script) ... exit status: 255under the soldr rustc shim, withzccache ... errors=1. A toolchain/cache failure with no relation to the diff; the same job passed on the previous head,cargo testis green locally, and the other fiverust-nativetargets (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.
CI settled: 60 pass / 2 fail, and neither failure is this branch's
On the first one, the numbers that matter:
Pinned to 4 CPUs ( 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 ( |
|
Merge order, to be explicit: |
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
`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
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
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
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
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
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
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
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
06743d5 to
017cf3f
Compare
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
017cf3f to
14f2d89
Compare
…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
14f2d89 to
392092c
Compare
`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
392092c to
6606336
Compare
|
Opus review (two rounds): APPROVE. Hole engine in |
Stacked on #299 (Phase 7a). Base branch is
bun-parity/p7-scavenger, notmain.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_RESETworks in.page->purgedis 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
nextpointer)._mi_os_discardnever touches commit state — the arena tracks commit per64 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 insrc/theap.c. Herethe whole engine is in a new
src/page-holes.candsrc/page.ccarries exactly fivehook calls, each marked
HOOK n/5in the source:mi_page_free_collect_ex_mi_page_unpurge_runwhen the free list is empty but holes existmi_page_free_collect_ex_mi_page_free_collect_no_unpurgesplit (inspection must not mutate)_mi_page_abandon_no_unpurgemi_page_extend_free_mi_page_unpurge_unformed_uptobefore the first free-list pointer is written_mi_page_init_mi_page_purged_resetplus two debug-only assertion lines (the conservation invariant, and
_mi_page_holes_assert_valid).arena.cgets_mi_page_unpurge_allat the single chokepoint where a page returns to the arena, and the abandoned-page sweep/report walkers (they
need the arena bitmaps and the claim protocol).
scavenger.cgets the_mi_purge_holes_ofphase and the
purge_holes_min_intervalpacing_mi_theap_sweep_parkedwas already shapedfor.
theap.ccounts 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_printblock, andpurge_holes_stats()in the Rust crate. Options appended afterscavenger(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_idlebetween 12 rounds; min of 5, Release, Linux x64):
MIMALLOC_PURGE_HOLES=1MIMALLOC_PURGE_HOLES=0Peak RSS, the gate + stress suite (min of 5). These never call
mi_on_thread_idle, sothe engine is inert in them and the numbers only show it costs nothing. Same-binary
run-to-run spread on
test-stress-heapswas 785.5 → 828.1 MB (5.4%) across two independentmin-of-5 rounds, which is larger than every delta below:
test-memory-gatetest-stresstest-stress-heapstest-process-rssci/memory_gate.py checkagainst a fresh Release build: PASS, peak_rss 19.7 MB vsbaseline 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):
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.cline is insideMI_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_SEPARATEDthat is +768 KB of arena page-meta per 1 GiB arena.Profiler interaction (#272 design points 1–4), designed and tested
_mi_prof_debug_assert_no_records_in(
src/profile.c,MI_DEBUGonly) is called fromsrc/page-holes.cbefore every_mi_os_discardand asserts that no record's block and no record struct lies in therange — records come from the profiler's raw-OS arena (rule 4), never from a page. It
try-acquiresprof_lockand skips rather than blocking: the sweep holdstld->theaps_lockandmi_prof_visitholdsprof_lockacross a user callback that maydelete a heap, which would be an ABBA cycle.
_mi_theap_area_visit_blocksmarkspurged blocks in its free map, and every inspection collect became
_no_unpurge.mi_tld_t.profileris untouched — nothing in the sweep reads orwrites it; 7a's assertion around
_mi_thread_idle_workstill holds.test-profile-race.cgains a fifth scenario for 1 and 2: two owner-sweeping threads, twoparked threads swept by the scavenger, and a walker running
mi_prof_visit,mi_prof_snapshot_visitandmi_theap_visit_blocksover pages it is itself having swept.Every pointer either walk yields is checked with
mi_page_block_is_purgedand 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
purge_holes_eager_zerois not zero-tracking. It is a_mi_memzeroperformed before_mi_prim_discard, so a mis-scoped discard corrupts visibly on macOS's lazyMADV_FREE_REUSABLEinstead of silently. It makes discarding more expensive. (Firstspotted by the 7a agent on Bun parity P7: background scavenger, page hole purging, and mi_on_thread_idle #272; confirmed against
src/os.c:734-745at942b8342.)mi_option_purge_zeroeshas no implementation to unify with. It is dead in this treesince the Bump the upstream pin from 579f8c0e to the dev3 tip #80 pin bump (Phase D: zero-tracking (zalloc skips memset after zero-purge) #67). The slot is kept and never renumbered,
MIMALLOC_PURGE_ZEROESstill parses, and
mimalloc.hnow says so explicitly rather than the option being silentlydropped.
test-purge-zero.c's header records that despite its name it does not test thatoption either.
Also, contrary to the issue's rule-6 line: there is no
mi_page_can_purge_holescall on thefree fast path, and
_mi_page_unpurge_runis not ami_page_queue_find_free_exhook — itlives 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 passingunmodified apart from two adaptations noted in its header (the
src/page.cfilereferences become
src/page-holes.c; the one open-coded sweep state calls the sharedmi_page_sweep_state()inline). Registered twice: as itself, and astest-purge-holes-offwith
MIMALLOC_PURGE_HOLES=0, where every data-integrity check still runs and nothing maybe discarded.
test/test-purge-zero.c— verbatim.test/test-park-handoff.c— both Phase-7b caveats removed: the twopurge_holes_min_intervalcases are live (in-window park observed swept at 96 ms of a100 ms window, vs the 30 s safety net), a new
test-park-handoff-eagervariant runs thefile with
MIMALLOC_PURGE_HOLES_MIN_INTERVAL=0, and the run asserts hole memory wasactually 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_holesdefault-on andpurge_holes_eager_zeroforced on byMI_DEBUG>1, which means a mis-scoped discard wouldhave corrupted visibly.
_mi_page_holes_assert_validand the conservation invariant inmi_page_is_valid_initmake each of those a test of the purge machinery.Local verification (Linux x64, gcc)
MI_DEBUG_FULL+MI_PPROF=ONMI_PPROF=ONMI_PPROF=OFFMI_TRACK_ASAN=ON, debug-full)g++ -x c++syntax check ofsrc/static.c(the native MSVC gate compiles as C++)cargo test -p mimalloc-pprofci/check_internal_state.py,ci/check_doc_snippets.py,ci/memory_gate.py checkuv run ci/verify_local.py --keep-goingresults 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 thescavenger 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.
MADV_FREE_REUSABLE; arm64 via the cross toolchain,x64 pending the golden image. The macOS CI lanes are the real check.
_mi_deferred_freein this tree lacks Bun's owner-only guard(
tld->thread_id != _mi_thread_id()early return), so the scavenger'smi_theap_collectfor 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 --checkflagsrust/mimalloc-pprof/build.rs— verified pre-existing onorigin/bun-parity/p7-scavenger, untouched here.Do not merge before #299.
🤖 Generated with Claude Code
https://claude.ai/code/session_01YT4jVokb2gdT8ngFrH8i8S