Skip to main content

mimalloc_pprof/
lib.rs

1//! Rust global allocator support for the in-tree mimalloc build.
2//!
3//! ```no_run
4//! use mimalloc_pprof::{prof, MiMalloc};
5//! #[global_allocator] static ALLOCATOR: MiMalloc = MiMalloc;
6//! # fn main() -> std::io::Result<()> {
7//! prof::start(512 * 1024);
8//! prof::dump_file(std::path::Path::new("heap.prof"))?;
9//! # Ok(()) }
10//! ```
11//!
12//! Since 0.12.0 **every observability subsystem is opt-in** and the default build is a
13//! plain fast allocator whose `malloc`/`free` fast path is byte-identical to upstream
14//! mimalloc. Enable what you need with cargo features -- `pprof` (the example above),
15//! `memory-events`, `diagnostics`, `dhat` (implies `memory-events`), `owner-gate`, or
16//! `full` for all of them:
17//!
18//! ```toml
19//! mimalloc-pprof = { version = "1", features = ["pprof"] }
20//! ```
21//!
22//! The whole API compiles and links in every configuration: a subsystem that was not
23//! built in simply reports itself off (`prof::start` returns `false`, `heap_dump_json`
24//! returns `None`, and so on), so no `#[cfg]` is needed at the call site. Cargo features
25//! are additive and unified across the dependency graph, so a dependency that enables one
26//! enables it for your build too -- `cargo tree -e features` shows who.
27//!
28//! See the README's Rust integration guide for frame-pointer and line-table
29//! build flags. Open the resulting profile with `pprof -http=: app.exe heap.prof`.
30
31use core::alloc::{GlobalAlloc, Layout};
32use core::ffi::c_void;
33use std::ffi::CString;
34use std::path::PathBuf;
35
36pub mod sys;
37
38/// A `#[global_allocator]` implementation backed by mimalloc.
39pub struct MiMalloc;
40
41unsafe impl GlobalAlloc for MiMalloc {
42    unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
43        sys::mi_malloc_aligned(layout.size(), layout.align()).cast()
44    }
45
46    unsafe fn dealloc(&self, ptr: *mut u8, _layout: Layout) {
47        sys::mi_free(ptr.cast::<c_void>());
48    }
49
50    unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 {
51        sys::mi_realloc_aligned(ptr.cast::<c_void>(), new_size, layout.align()).cast()
52    }
53
54    unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 {
55        sys::mi_zalloc_aligned(layout.size(), layout.align()).cast()
56    }
57}
58
59/// Allocate `size` bytes from mimalloc's raw-OS-layer "unwrapped" path.
60///
61/// Thin wrapper around `mi_unwrapped_malloc` (include/mimalloc/memory-events.h):
62/// backed directly by `_mi_os_alloc_aligned`, never by the hooked `mi_malloc`
63/// family. Page granular, so this is not meant for hot-path/small allocations
64/// — it exists for low-level instrumentation and recursion avoidance (e.g.
65/// scratch storage for a memory-change callback that must not recursively
66/// enter mimalloc). Excluded from normal mimalloc allocation stats and from
67/// the memory-change accounting.
68///
69/// Returns a null pointer on failure (including invalid `alignment`; see
70/// `# Safety` below).
71///
72/// # Safety
73///
74/// - `alignment` must be `0` (treated as `align_of::<*const ()>()`, i.e.
75///   pointer size) or a power of two. A non-power-of-two, non-zero alignment
76///   is a validated input on the C side: `mi_unwrapped_malloc` returns a null
77///   pointer rather than invoking undefined behavior, but callers should not
78///   rely on that as anything other than a defined-failure contract — treat
79///   the alignment argument as a precondition to get right, not a value to
80///   probe.
81/// - The returned pointer, if non-null, must be passed only to
82///   [`unwrapped_free`] or [`unwrapped_realloc`] — never to `mi_free`, this
83///   crate's [`MiMalloc`] allocator, or Rust's global allocator, and vice
84///   versa (a pointer from `mi_malloc`/the Rust global allocator must never
85///   be passed to [`unwrapped_free`]/[`unwrapped_realloc`]). Mixing these
86///   families corrupts allocator-internal bookkeeping.
87/// - The memory is uninitialized; reading it before writing is undefined
88///   behavior, as with any raw allocation.
89pub unsafe fn unwrapped_malloc(size: usize, alignment: usize) -> *mut u8 {
90    unsafe { sys::mi_unwrapped_malloc(size, alignment).cast() }
91}
92
93/// Free a pointer returned by [`unwrapped_malloc`] or [`unwrapped_realloc`].
94///
95/// Thin wrapper around `mi_unwrapped_free` (include/mimalloc/memory-events.h).
96///
97/// # Safety
98///
99/// - `p` must be either a null pointer (a documented, safe no-op on the C
100///   side) or a pointer previously returned by [`unwrapped_malloc`] or
101///   [`unwrapped_realloc`] that has not already been freed.
102/// - `p` must never have come from `mi_malloc`, this crate's [`MiMalloc`]
103///   allocator, or Rust's global allocator — passing such a pointer here is
104///   undefined behavior (the "unwrapped" and normal allocation families use
105///   incompatible header layouts and are validated by a magic-number check
106///   that a foreign pointer will not satisfy).
107pub unsafe fn unwrapped_free(p: *mut u8) {
108    unsafe { sys::mi_unwrapped_free(p.cast()) }
109}
110
111/// Resize a pointer returned by [`unwrapped_malloc`] or [`unwrapped_realloc`].
112///
113/// Thin wrapper around `mi_unwrapped_realloc` (include/mimalloc/memory-events.h).
114/// If `p` is null, this behaves like [`unwrapped_malloc`]. If `new_size` is
115/// `0`, this frees `p` (like [`unwrapped_free`]) and returns a null pointer.
116/// Otherwise the existing contents are copied into a freshly allocated
117/// unwrapped block (up to `min(old payload size, new_size)` bytes) and `p` is
118/// freed; `p` must not be used again after this call, whether or not it
119/// returns null.
120///
121/// Returns a null pointer on failure (including invalid `alignment`; see
122/// [`unwrapped_malloc`]'s `# Safety` section), in which case `p` is left
123/// valid and unfreed.
124///
125/// # Safety
126///
127/// - `p` must be either a null pointer or a pointer previously returned by
128///   [`unwrapped_malloc`] or [`unwrapped_realloc`] that has not already been
129///   freed, per the same family-isolation rule as [`unwrapped_free`].
130/// - `alignment` has the same power-of-two-or-zero contract as
131///   [`unwrapped_malloc`].
132/// - After this call, `p` must not be read, written, or freed again — treat
133///   it as consumed regardless of whether the return value is null.
134pub unsafe fn unwrapped_realloc(p: *mut u8, new_size: usize, alignment: usize) -> *mut u8 {
135    unsafe { sys::mi_unwrapped_realloc(p.cast(), new_size, alignment).cast() }
136}
137
138/// Grow or shrink an allocation, zeroing any newly-exposed tail.
139///
140/// Thin wrapper around `mi_rezalloc`. This is the operation Rust's [`GlobalAlloc`]
141/// cannot express — that trait has no `grow_zeroed` — so without it a caller has to
142/// grow and then `memset` by hand, repeating work the allocator has already done, and
143/// (with zero-tracking) work it may be able to skip entirely.
144///
145/// # What is actually zeroed
146///
147/// **Not** `[old_requested_size, new_size)`. mimalloc measures from the block's old
148/// *usable* size, so the slack between what you asked for and what the block actually
149/// holds is left untouched:
150///
151/// ```text
152/// requested 64  ->  usable 80  ->  rezalloc to 70
153/// bytes [64,70) are NOT zeroed: the grow was served in place, within the old block
154/// ```
155///
156/// The guarantee is: everything past [`usable_size`] of the *original* block is zero.
157/// If you need a specific range zeroed, capture [`usable_size`] before the call and
158/// zero the remainder yourself.
159///
160/// (This is documented so precisely because a fuzz harness asserted the intuitive
161/// version and was falsified within seconds — see issue #87.)
162///
163/// # Safety
164///
165/// - `p` must be null, or a pointer from the **plain** allocation family — the global
166///   allocator, [`sys::mi_malloc`], or a previous [`rezalloc`]/[`recalloc`] — that has
167///   not been freed.
168/// - **Not interchangeable with [`unwrapped_malloc`]/[`unwrapped_realloc`].** Those
169///   place a header before the pointer, so passing one here fails the pointer check
170///   (`mi_usable_size: invalid pointer`) rather than working by accident.
171/// - After this call `p` is consumed: do not read, write, or free it again, whether or
172///   not the return value is null.
173/// - On failure a null pointer is returned and `p` is left valid and unfreed.
174pub unsafe fn rezalloc(p: *mut u8, new_size: usize) -> *mut u8 {
175    unsafe { sys::mi_rezalloc(p.cast(), new_size).cast() }
176}
177
178/// Grow or shrink an allocation to `count * size` bytes, zeroing any newly-exposed tail.
179///
180/// The [`rezalloc`] contract applies, including what is and is not zeroed. Thin wrapper
181/// around `mi_recalloc`; the element-count form exists to mirror `calloc`.
182///
183/// # Safety
184///
185/// Same contract as [`rezalloc`].
186pub unsafe fn recalloc(p: *mut u8, count: usize, size: usize) -> *mut u8 {
187    unsafe { sys::mi_recalloc(p.cast(), count, size).cast() }
188}
189
190/// Try to grow an allocation **in place**, without moving it.
191///
192/// Returns a null pointer if the block cannot be extended where it is — in which case
193/// `p` remains valid and unchanged, unlike [`rezalloc`]. Useful when moving would be
194/// more expensive than falling back to a different strategy.
195///
196/// # Safety
197///
198/// - `p` must be a pointer from this allocator that has not been freed.
199/// - Unlike [`rezalloc`], `p` is **not** consumed: on failure it is still live and must
200///   still be freed.
201pub unsafe fn expand(p: *mut u8, new_size: usize) -> *mut u8 {
202    unsafe { sys::mi_expand(p.cast(), new_size).cast() }
203}
204
205/// Bytes actually available in an allocation, which may exceed what was requested.
206///
207/// # Safety
208///
209/// `p` must be a live pointer from this allocator.
210pub unsafe fn usable_size(p: *const u8) -> usize {
211    unsafe { sys::mi_usable_size(p.cast()) }
212}
213
214/// Live per-heap -> per-page -> (optional) per-block JSON snapshot of the current
215/// subprocess (issue #269, Bun parity P4). Backs Bun's shipped `bun:jsc`
216/// `heapStats({dump:true|"blocks"}).mimallocDump`; see `src/heap-dump.c` for the JSON
217/// shape (`{"heaps":[{"seq":N,"pages":[{"id","block_size","used","reserved","thread_id"}],
218/// "blocks":[[id,size],...]}]}`, `blocks` present only when `include_blocks`).
219///
220/// Set `hash_addresses` to mix every reported address through a per-process key so a
221/// dump can be shared or diffed without exposing raw ASLR-derived pointers.
222///
223/// Safe, best-effort capture under concurrent frees (#374), using
224/// [`HEAP_DUMP_JSON_DEFAULT_WAIT_MS`] as the owner-acquisition deadline. See
225/// [`heap_dump_json_ex`] for the complete waiting and coverage contract.
226///
227/// Requires the `diagnostics` feature (#414): without it the dump is compiled out of the
228/// C library and this returns `None`.
229///
230/// Otherwise returns `None` only on allocation failure (out of memory building the JSON
231/// buffer), not for an empty subprocess.
232pub fn heap_dump_json(include_blocks: bool, hash_addresses: bool) -> Option<String> {
233    let ptr = unsafe { sys::mi_heap_dump_json(include_blocks, hash_addresses) };
234    heap_dump_json_from_ptr(ptr)
235}
236
237/// Live heap JSON capture with an explicit owner-acquisition deadline.
238///
239/// Mutable page state is copied only under ownership. With the `owner-gate`
240/// feature, an incomplete attempt is discarded and retried from a clean
241/// boundary until `wait_ms` expires. No caller gate, page pin, owner claim, or
242/// heap traversal lock is retained between attempts. A call nested inside an
243/// existing owner-gated allocator operation is one-shot because it cannot
244/// release its caller's outer gate. Without `owner-gate`, waiting cannot make
245/// an ordinary foreign owner claimable, so this always performs one attempt
246/// regardless of `wait_ms`.
247///
248/// Top-level `complete`, `skipped_pages`, and `busy_theaps` describe the final
249/// attempt. `complete: true` is not a process-wide atomic snapshot: pages are
250/// captured independently, and threads initialized concurrently need not share
251/// one global cutoff. The deadline bounds retries, not a capture already in
252/// progress, serialization, or the final result allocation.
253pub fn heap_dump_json_ex(
254    include_blocks: bool,
255    hash_addresses: bool,
256    wait_ms: usize,
257) -> Option<String> {
258    let ptr = unsafe { sys::mi_heap_dump_json_ex(include_blocks, hash_addresses, wait_ms) };
259    heap_dump_json_from_ptr(ptr)
260}
261
262fn heap_dump_json_from_ptr(ptr: *mut std::ffi::c_char) -> Option<String> {
263    use std::ffi::CStr;
264    if ptr.is_null() {
265        return None;
266    }
267    let json = unsafe { CStr::from_ptr(ptr) }
268        .to_string_lossy()
269        .into_owned();
270    unsafe { sys::mi_free(ptr.cast()) };
271    Some(json)
272}
273
274/// Default owner-acquisition deadline used by [`heap_dump_json`].
275pub const HEAP_DUMP_JSON_DEFAULT_WAIT_MS: usize = 100;
276
277/// Write a binary heap snapshot to `path` (issue #338, Bun parity).
278///
279/// A compact description of every arena and page -- and, with `blocks`, per-block free
280/// maps for the pages this thread owns -- in a format byte-identical to oven-sh/mimalloc's
281/// (version 1). Read it with `mi-heapview` (built with the C library) or the Python
282/// reference reader in `examples/heap-snapshot/`. Point-in-time and best-effort: other
283/// threads keep allocating while it is written, so their pages' counts may be slightly
284/// stale. Allocation-free on the writer's side, so it is safe to call from anywhere.
285///
286/// Requires the `diagnostics` feature (#414): without it the writer is compiled out of the
287/// C library and this always returns `Err`.
288///
289/// Errors: the file could not be created or written. The same snapshot can be produced
290/// without code by setting `MIMALLOC_SNAPSHOT_ON_EXIT=1|2` (and `MIMALLOC_SNAPSHOT_PATH`)
291/// -- also only in a `diagnostics` build.
292pub fn heap_snapshot_to_file(
293    path: impl AsRef<std::path::Path>,
294    blocks: bool,
295) -> std::io::Result<()> {
296    use std::ffi::CString;
297    let path = path.as_ref();
298    let c_path = CString::new(path.as_os_str().as_encoded_bytes()).map_err(|_| {
299        std::io::Error::new(
300            std::io::ErrorKind::InvalidInput,
301            "path contains an interior NUL byte",
302        )
303    })?;
304    let flags = if blocks { sys::MI_SNAPSHOT_BLOCKS } else { 0 };
305    let rc = unsafe { sys::mi_heap_snapshot_to_file(c_path.as_ptr(), flags) };
306    if rc == 0 {
307        Ok(())
308    } else {
309        Err(std::io::Error::other(format!(
310            "mi_heap_snapshot_to_file({}) failed",
311            path.display()
312        )))
313    }
314}
315
316/// Tell mimalloc this thread is idle (issue #272, Bun parity P7a).
317///
318/// Collects this thread's pending frees, discards the free blocks inside its still-used
319/// pages, and hands the arena purge to the background scavenger thread so freed memory
320/// returns to the OS now instead of at the next allocation that happens to run a purge --
321/// which, on a genuinely idle process, is never.
322///
323/// Safe on any thread; a no-op on a thread that never allocated. Call it when the thread
324/// has nothing to do (an event loop about to block, a worker pool waiting on its queue),
325/// not on a hot path: it costs a few `madvise`/`DiscardVirtualMemory` calls.
326pub fn on_thread_idle() {
327    unsafe { sys::mi_on_thread_idle() }
328}
329
330/// Guard form of [`on_thread_idle`] for a thread that is about to BLOCK: hands this
331/// thread's heaps to the background scavenger, which does the idle work above while this
332/// thread sits in the kernel, and takes them back on drop.
333///
334/// Returns `None` when nothing was handed off (no scavenger running, this thread never
335/// allocated, or it is already parked). That case is deliberately NOT an inline sweep: a
336/// caller blocks far more often than it is truly idle. If this park is idle enough to
337/// afford the work, call [`on_thread_idle`] instead.
338///
339/// The thread must not allocate or free between the call and the drop -- that is the
340/// precondition that lets another thread rewrite its free lists -- which is why the guard
341/// is `!Send` and holds no data.
342#[must_use = "the park ends when the guard is dropped"]
343pub fn park_while_idle() -> Option<IdlePark> {
344    if unsafe { sys::mi_on_thread_idle_start() } {
345        Some(IdlePark {
346            _not_send: core::marker::PhantomData,
347        })
348    } else {
349        None
350    }
351}
352
353/// Returned by [`park_while_idle`]; ends the park when dropped.
354pub struct IdlePark {
355    // the park is per-thread state: `mi_on_thread_idle_end` must run on the parking thread
356    _not_send: core::marker::PhantomData<*const ()>,
357}
358
359impl Drop for IdlePark {
360    fn drop(&mut self) {
361        unsafe { sys::mi_on_thread_idle_end() }
362    }
363}
364
365/// Stop the background scavenger thread (issue #272).
366///
367/// It restarts on demand (the next [`park_while_idle`], or the next thread that
368/// initializes), so this is a way to quiesce it -- e.g. before a `fork`/`exec` that counts
369/// threads, or in a test -- not a way to disable it permanently. For that, set the
370/// `scavenger` option to 0 (`MIMALLOC_SCAVENGER=0`) before the first allocation.
371pub fn scavenger_stop() {
372    unsafe { sys::mi_scavenger_stop() }
373}
374
375/// What page hole purging has reclaimed, process wide (issue #272, Bun parity P7b).
376///
377/// Hole purging discards the memory of the free blocks sitting inside pages that are still
378/// in use, at each [`on_thread_idle`] / [`park_while_idle`] point -- without it a page stays
379/// fully resident until every block in it is free, so one long-lived object pins a whole
380/// 64 KiB/512 KiB page. These counters are the only way to see how much that gets back;
381/// they are deliberately not part of `mi_stats_t`, because the sweep also covers pages that
382/// no heap owns.
383///
384/// Most fields are monotonic. `purged_bytes`, `purged_blocks` and `unformed_bytes` are
385/// gauges ("right now"), and the three `ineligible_*` fields are a gauge over the LAST sweep
386/// only. Everything is zero when the `purge_holes` option is off (`MIMALLOC_PURGE_HOLES=0`).
387///
388/// ```
389/// # use mimalloc_pprof as mi;
390/// let before = mi::purge_holes_stats().purged_bytes_total;
391/// mi::on_thread_idle();
392/// let after = mi::purge_holes_stats().purged_bytes_total;
393/// assert!(after >= before);
394/// ```
395#[must_use]
396pub fn purge_holes_stats() -> sys::MiPurgeHolesStats {
397    let mut stats = sys::MiPurgeHolesStats::default();
398    unsafe { sys::mi_purge_holes_stats_get(&raw mut stats) };
399    stats
400}
401
402/// Flags for [`purge_all_ex`] (issue #366).
403#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
404pub struct PurgeFlags {
405    /// Ignore `purge_delay` and hole-purge pacing, and let a claimed sweep run to
406    /// completion (`MI_PURGE_FORCE`). [`purge_all`]`(true)` is this flag set.
407    pub force: bool,
408}
409
410impl PurgeFlags {
411    /// Every flag clear: honour the purge pacing options.
412    pub const NONE: PurgeFlags = PurgeFlags { force: false };
413    /// `MI_PURGE_FORCE`.
414    pub const FORCE: PurgeFlags = PurgeFlags { force: true };
415
416    fn to_c(self) -> sys::mi_purge_flags_t {
417        if self.force {
418            sys::MI_PURGE_FORCE
419        } else {
420            0
421        }
422    }
423}
424
425/// The outcome of a [`purge_all`] / [`purge_all_ex`] call (issue #366).
426///
427/// `Partial` is a **normal outcome**, not an error: in a default build only threads
428/// parked in [`park_while_idle`] can be swept from another thread, so every running
429/// thread is reported as pending. Only `Busy` means nothing happened at all.
430#[derive(Clone, Copy, Debug, PartialEq, Eq)]
431pub enum PurgeStatus {
432    /// Every registered thread was reached (`MI_PURGE_OK`).
433    Ok,
434    /// Some owners were still pending when `wait_ms` ran out; everything reachable was
435    /// purged (`MI_PURGE_PARTIAL`). See [`PurgeAllReport::theaps_pending`].
436    Partial,
437    /// Another purge is in flight, or this is a re-entrant call: nothing was done
438    /// (`MI_PURGE_BUSY`).
439    Busy,
440}
441
442impl PurgeStatus {
443    fn from_c(rc: core::ffi::c_int) -> PurgeStatus {
444        match rc {
445            sys::MI_PURGE_OK => PurgeStatus::Ok,
446            sys::MI_PURGE_PARTIAL => PurgeStatus::Partial,
447            sys::MI_PURGE_BUSY => PurgeStatus::Busy,
448            other => unreachable!("mi_purge_all_ex returned an unknown status {other}"),
449        }
450    }
451}
452
453/// What a [`purge_all`] / [`purge_all_ex`] call returned to the OS and which threads it
454/// could not reach (issue #366). Mirrors `mi_purge_all_report_t`.
455#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
456pub struct PurgeAllReport {
457    /// Bytes returned to the OS by the arena passes.
458    pub arena_bytes: usize,
459    /// Bytes returned by hole purging (every swept thread plus abandoned pages).
460    pub hole_bytes: usize,
461    /// Threads claimed and swept by this call, the caller included.
462    pub theaps_swept: usize,
463    /// Registered threads not reached within `wait_ms`. Non-zero is the expected shape in
464    /// a default (ungated) build with running threads.
465    pub theaps_pending: usize,
466    /// Pre-fork threads of vanished threads, never touched.
467    pub theaps_orphaned: usize,
468    /// Whether the allocator was built with the owner gate (the crate's `owner-gate`
469    /// feature, C's `MI_OWNER_GATE=1`). Configuration, not completion.
470    pub gated: bool,
471    /// `theaps_pending == 0 && theaps_orphaned == 0`.
472    pub complete: bool,
473}
474
475impl From<sys::mi_purge_all_report_t> for PurgeAllReport {
476    fn from(r: sys::mi_purge_all_report_t) -> PurgeAllReport {
477        PurgeAllReport {
478            arena_bytes: r.arena_bytes,
479            hole_bytes: r.hole_bytes,
480            theaps_swept: r.theaps_swept,
481            theaps_pending: r.theaps_pending,
482            theaps_orphaned: r.theaps_orphaned,
483            gated: r.gated,
484            complete: r.complete,
485        }
486    }
487}
488
489/// Process-wide eager purge from any thread (issue #366): the full form of [`purge_all`].
490///
491/// Returns as much memory as the allocator's invariants allow across ALL threads' heaps --
492/// arenas, abandoned pages, the caller's own pages and holes, and every other registered
493/// thread's pages and holes that can be claimed: any thread parked in [`park_while_idle`],
494/// and, with the `owner-gate` feature (C `MI_OWNER_GATE=1`), any thread that is outside an
495/// allocator call for long enough to be caught. The report says exactly what was returned
496/// and what could not be reached.
497///
498/// `wait_ms` bounds **owner-acquisition waiting only** -- how long this call keeps trying
499/// to claim other threads' state. It does not bound a claimed thread's sweep, nor the
500/// `madvise`/`DiscardVirtualMemory` syscalls that sweep makes, so the call can take longer
501/// than `wait_ms` once it has something to purge.
502///
503/// [`PurgeStatus::Partial`] is a normal outcome, not a failure: everything reachable was
504/// purged and `theaps_pending` counts the threads that were not. In a default build with
505/// other threads running it is the *usual* outcome. Only [`PurgeStatus::Busy`] means
506/// nothing was done (another purge is in flight, or this call re-entered one), and the
507/// report is then all zeros apart from `gated`.
508///
509/// ```
510/// # use mimalloc_pprof as mi;
511/// let (status, report) = mi::purge_all_ex(mi::PurgeFlags::FORCE, 100);
512/// assert_ne!(status, mi::PurgeStatus::Busy);
513/// assert_eq!(report.complete, report.theaps_pending == 0 && report.theaps_orphaned == 0);
514/// ```
515pub fn purge_all_ex(flags: PurgeFlags, wait_ms: usize) -> (PurgeStatus, PurgeAllReport) {
516    let mut report = sys::mi_purge_all_report_t::default();
517    let rc = unsafe { sys::mi_purge_all_ex(flags.to_c(), wait_ms, &raw mut report) };
518    (PurgeStatus::from_c(rc), PurgeAllReport::from(report))
519}
520
521/// Process-wide eager purge from any thread (issue #366), with the C default of a 100 ms
522/// owner-acquisition wait: `mi_purge_all(force)`, but with the report kept.
523///
524/// `force` ignores the `purge_delay` / hole-purge pacing options and lets each claimed
525/// sweep run to completion. See [`purge_all_ex`] for what the wait bounds (owner
526/// acquisition only, never a sweep or its syscalls) and why a partial result -- some
527/// threads pending -- is the normal outcome in a build without the `owner-gate` feature.
528/// The status is [`PurgeAllReport::complete`]; a busy (nothing-done) call reports zero
529/// `theaps_swept`. Use [`purge_all_ex`] when the status itself is needed.
530///
531/// Goes through `mi_purge_all_ex` rather than C's `mi_purge_all`, which is the same call
532/// with the report thrown away.
533pub fn purge_all(force: bool) -> PurgeAllReport {
534    let flags = if force {
535        PurgeFlags::FORCE
536    } else {
537        PurgeFlags::NONE
538    };
539    purge_all_ex(flags, PURGE_ALL_DEFAULT_WAIT_MS).1
540}
541
542/// The `wait_ms` C's `mi_purge_all` passes to `mi_purge_all_ex`, and what [`purge_all`]
543/// uses.
544pub const PURGE_ALL_DEFAULT_WAIT_MS: usize = 100;
545
546/// Exact DHAT v2 heap/lifetime profiling controls.
547///
548/// DHAT records every non-internal allocation from the moment [`start`](dhat::start)
549/// succeeds.
550/// It is intended for short diagnostic runs and tests rather than continuous production
551/// telemetry. The generated JSON opens in the standard Valgrind `dh_view.html` viewer.
552/// It is independent of sampled [`prof`] profiling and of `mi_memory_set_callbacks`.
553///
554/// Requires the opt-in `dhat` cargo feature (C `MI_DHAT=1`; off by default, enable it with
555/// `features = ["dhat"]`). Without it the observer
556/// is compiled out of the allocator, the API stays present for source compatibility, and
557/// [`start`](dhat::start) returns `false`, [`is_enabled`](dhat::is_enabled) and
558/// `Stats::enabled` are `false`, and [`dump_file`](dhat::dump_file) returns an error.
559pub mod dhat {
560    use std::ffi::CString;
561    use std::io;
562    use std::path::Path;
563
564    use crate::sys;
565
566    /// Snapshot of exact DHAT collector state.
567    #[derive(Debug, Clone, Default, PartialEq, Eq)]
568    pub struct Stats {
569        pub enabled: bool,
570        /// True when raw-OS collector storage hit its configured budget or an internal
571        /// allocation failed. The application allocation still completed, but the
572        /// resulting profile is intentionally marked partial.
573        pub incomplete: bool,
574        pub total_bytes: u64,
575        pub total_blocks: u64,
576        pub live_bytes: u64,
577        pub live_blocks: u64,
578        pub peak_bytes: u64,
579        pub peak_blocks: u64,
580        pub dropped: u64,
581        pub internal_bytes: u64,
582    }
583
584    /// Start exact allocation/lifetime tracking. Returns `false` if it is already active,
585    /// or if the crate was built without the `dhat` feature.
586    pub fn start() -> bool {
587        unsafe { sys::mi_dhat_start() }
588    }
589
590    /// Stop observing allocation events. Retained records remain available to [`dump_file`]
591    /// so a caller can stop a measurement window before serializing its report.
592    pub fn stop() {
593        unsafe { sys::mi_dhat_stop() }
594    }
595
596    /// Whether exact DHAT tracking is currently active.
597    pub fn is_enabled() -> bool {
598        unsafe { sys::mi_dhat_is_enabled() }
599    }
600
601    /// Read the collector's exact counters. Returns a zero/default snapshot only if the
602    /// linked C library rejected the versioned ABI structure, or if the crate was built
603    /// without the `dhat` feature.
604    pub fn stats() -> Stats {
605        let mut raw: sys::mi_dhat_stats_t = unsafe { core::mem::zeroed() };
606        raw.size = core::mem::size_of::<sys::mi_dhat_stats_t>();
607        raw.version = sys::MI_DHAT_STATS_VERSION;
608        if unsafe { sys::mi_dhat_stats_get(&mut raw) } {
609            Stats {
610                enabled: raw.enabled,
611                incomplete: raw.incomplete,
612                total_bytes: raw.total_bytes,
613                total_blocks: raw.total_blocks,
614                live_bytes: raw.live_bytes,
615                live_blocks: raw.live_blocks,
616                peak_bytes: raw.peak_bytes,
617                peak_blocks: raw.peak_blocks,
618                dropped: raw.dropped,
619                internal_bytes: raw.internal_bytes,
620            }
621        } else {
622            Stats::default()
623        }
624    }
625
626    /// Serialize the current or stopped measurement window as a DHAT v2 JSON file.
627    pub fn dump_file(path: &Path) -> io::Result<()> {
628        let path = path
629            .to_str()
630            .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "DHAT path is not UTF-8"))?;
631        let path = CString::new(path)
632            .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "DHAT path contains NUL"))?;
633        if unsafe { sys::mi_dhat_dump(path.as_ptr()) } {
634            Ok(())
635        } else {
636            Err(io::Error::last_os_error())
637        }
638    }
639}
640
641/// Turn on sampled heap profiling at the default sample rate.
642///
643/// Convenience entry point for wiring profiling to a command-line flag:
644///
645/// ```no_run
646/// # let args_profile_heap = true;
647/// if args_profile_heap {
648///     mimalloc_pprof::enable_heap_profiling();
649/// }
650/// ```
651///
652/// Uses the built-in default rate (one sample per ~512 KiB allocated;
653/// `MIMALLOC_PROF_SAMPLE_RATE` still overrides it). Call [`prof::start`]
654/// instead to pick a rate programmatically. Allocations made before this
655/// call — including process-startup and static initialization — are not
656/// tracked; profiles reflect steady-state behavior from this point on,
657/// which is the usual intent for an opt-in CLI switch. To capture startup
658/// as well, set `MIMALLOC_PROF=1` in the environment instead.
659///
660/// Returns `false` if profiling was already enabled (the earlier session,
661/// and its sample rate, stay active), or if the crate was built with
662/// `default-features = false`.
663pub fn enable_heap_profiling() -> bool {
664    prof::start(0)
665}
666
667/// How [`ProfConfig`] fields interact with the profiler's environment
668/// variables and `mi_option_*` settings.
669///
670/// Mirrors `mi_prof_config_mode_t` (include/mimalloc/profile.h); see that
671/// header for the full FALLBACK/OVERRIDE semantics, including the caveat
672/// that in `Override` mode `accum == false`, `dump_format == Text`, and
673/// `max_profiler_bytes == None` cannot be distinguished from "unset" and so
674/// always fall back to env-then-default rather than forcing the off/default
675/// value.
676#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
677pub enum ProfConfigMode {
678    /// Struct fields are used only where the corresponding env var / option is absent.
679    #[default]
680    Fallback,
681    /// Non-default struct fields win over env vars / options (see the caveat above).
682    Override,
683}
684
685/// Output format for [`ProfConfig::dump_at_exit`].
686///
687/// Mirrors `MI_PROF_FORMAT_TEXT` / `MI_PROF_FORMAT_PROTO` (include/mimalloc/profile.h).
688#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
689pub enum DumpFormat {
690    /// Legacy "heap profile:" text format (see [`prof::dump_to_vec`]).
691    #[default]
692    Text,
693    /// Binary pprof `profile.proto` format (see [`prof::dump_proto_to_vec`]).
694    Proto,
695}
696
697/// Ergonomic, Rust-facing sibling of `mi_prof_config_t`
698/// (include/mimalloc/profile.h) for [`enable_heap_profiling_with`].
699///
700/// Fields mirror the C struct one-for-one, but trade its 0/NULL-means-unset
701/// raw-integer conventions for `Option<T>` and enums where that reads
702/// better. `#[non_exhaustive]` + `Default` keeps future fields additive:
703/// build from `Default::default()` and set the fields you need, e.g.
704///
705/// ```
706/// use mimalloc_pprof::ProfConfig;
707/// let mut config = ProfConfig::default();
708/// config.sample_interval = Some(4096);
709/// ```
710///
711/// (Within this crate, struct-update syntax like
712/// `ProfConfig { sample_interval: Some(4096), ..Default::default() }` also
713/// works; `#[non_exhaustive]` only blocks struct-literal construction from
714/// *other* crates, so new fields stay non-breaking for them.)
715#[non_exhaustive]
716#[derive(Debug, Clone, Default)]
717pub struct ProfConfig {
718    /// See [`ProfConfigMode`].
719    pub mode: ProfConfigMode,
720    /// Average bytes between samples. `None` = env/default (512 KiB).
721    pub sample_interval: Option<usize>,
722    /// Budget (bytes) for profiler-internal persistent sampling state
723    /// (sample records, the stack intern table, interned stack entries).
724    /// `None` = unbudgeted (cap-bounded only).
725    pub max_profiler_bytes: Option<usize>,
726    /// `None` = nondeterministic.
727    pub seed: Option<u64>,
728    pub accum: bool,
729    /// `None` = default (32); compile cap 128.
730    pub max_stack_depth: Option<usize>,
731    /// Path to dump the profile to at process exit. `None` = no exit dump.
732    pub dump_at_exit: Option<PathBuf>,
733    /// Format used for the exit dump. Ignored if `dump_at_exit` is `None`.
734    pub dump_format: DumpFormat,
735}
736
737/// Turn on sampled heap profiling using a struct-based configuration.
738///
739/// Sibling of [`enable_heap_profiling`] for callers that need more than a
740/// single sample rate -- e.g. seeding the sampler, capping profiler-arena
741/// memory, or registering an exit-time dump path/format. See [`ProfConfig`]
742/// and, for the full FALLBACK/OVERRIDE semantics, `mi_prof_config_mode_t` in
743/// `include/mimalloc/profile.h`.
744///
745/// Returns `false` if profiling was already enabled (the earlier session
746/// stays active), if the crate was built with `default-features = false`, or
747/// if `config.dump_at_exit` is set but is not
748/// representable as a NUL-free C string (non-UTF-8 or an embedded NUL byte)
749/// -- in that case `mi_prof_start_ex` is never called.
750pub fn enable_heap_profiling_with(config: &ProfConfig) -> bool {
751    // `dump_at_exit_c` must outlive the `mi_prof_start_ex` call below since
752    // `raw.dump_at_exit` borrows its bytes; it does, as both live to the end
753    // of this function.
754    let dump_at_exit_c: Option<CString> = match &config.dump_at_exit {
755        Some(path) => match path.to_str().and_then(|s| CString::new(s).ok()) {
756            Some(c) => Some(c),
757            None => return false,
758        },
759        None => None,
760    };
761
762    let mut raw: sys::mi_prof_config_t = unsafe { core::mem::zeroed() };
763    raw.size = core::mem::size_of::<sys::mi_prof_config_t>();
764    raw.version = sys::MI_PROF_CONFIG_VERSION;
765    raw.mode = match config.mode {
766        ProfConfigMode::Fallback => sys::MI_PROF_CONFIG_FALLBACK,
767        ProfConfigMode::Override => sys::MI_PROF_CONFIG_OVERRIDE,
768    };
769    raw.sample_interval = config.sample_interval.unwrap_or(0);
770    raw.max_profiler_bytes = config.max_profiler_bytes.unwrap_or(0);
771    raw.seed = config.seed.unwrap_or(0);
772    raw.accum = config.accum;
773    raw.max_stack_depth = config.max_stack_depth.unwrap_or(0);
774    raw.dump_at_exit = dump_at_exit_c
775        .as_ref()
776        .map_or(core::ptr::null(), |c| c.as_ptr());
777    raw.dump_format = match config.dump_format {
778        DumpFormat::Text => sys::MI_PROF_FORMAT_TEXT,
779        DumpFormat::Proto => sys::MI_PROF_FORMAT_PROTO,
780    };
781
782    unsafe { sys::mi_prof_start_ex(&raw) }
783}
784
785/// Safe controls for mimalloc's sampled heap profiler.
786pub mod prof {
787    use core::ffi::{c_char, c_void};
788    use std::ffi::{CStr, CString};
789    use std::io;
790    use std::panic::{catch_unwind, AssertUnwindSafe};
791    use std::path::Path;
792
793    use crate::sys;
794
795    pub fn start(sample_rate: usize) -> bool {
796        unsafe { sys::mi_prof_start(sample_rate) }
797    }
798    #[doc(hidden)]
799    pub fn start_seeded(sample_rate: usize, seed: u64) -> bool {
800        unsafe { sys::mi_prof_start_seeded(sample_rate, seed) }
801    }
802    pub fn stop() {
803        unsafe { sys::mi_prof_stop() }
804    }
805    pub fn is_enabled() -> bool {
806        unsafe { sys::mi_prof_is_enabled() }
807    }
808    pub fn reset() {
809        unsafe { sys::mi_prof_reset() }
810    }
811
812    pub fn dump_file(path: &Path) -> io::Result<()> {
813        let path = path.to_str().ok_or_else(|| {
814            io::Error::new(io::ErrorKind::InvalidInput, "profile path is not UTF-8")
815        })?;
816        let path = CString::new(path).map_err(|_| {
817            io::Error::new(io::ErrorKind::InvalidInput, "profile path contains NUL")
818        })?;
819        if unsafe { sys::mi_prof_dump(path.as_ptr()) } {
820            Ok(())
821        } else {
822            Err(io::Error::last_os_error())
823        }
824    }
825
826    unsafe extern "C" fn write_cb(arg: *mut c_void, buf: *const c_char, len: usize) {
827        let out = &mut *(arg as *mut Vec<u8>);
828        out.extend_from_slice(core::slice::from_raw_parts(buf.cast::<u8>(), len));
829    }
830
831    /// Serialize the current heap profile without holding the profiler lock.
832    pub fn dump_to_vec() -> Vec<u8> {
833        let mut out = Vec::new();
834        let ok =
835            unsafe { sys::mi_prof_dump_writer(Some(write_cb), (&mut out as *mut Vec<u8>).cast()) };
836        if ok {
837            out
838        } else {
839            Vec::new()
840        }
841    }
842
843    /// Serialize the current heap profile as a binary pprof `profile.proto`
844    /// `Profile` message (see [google/pprof's `profile.proto`][proto]),
845    /// without holding the profiler lock.
846    ///
847    /// Sample values are pre-scaled the same way Go's `runtime/pprof` scales
848    /// legacy heap samples (the `protomem.go` convention: `alloc_objects`,
849    /// `alloc_space`, `inuse_objects`, `inuse_space`, each already corrected
850    /// for Poisson sampling bias rather than left for a downstream tool to
851    /// rescale). The `Mapping` table is included, so external symbolizers
852    /// need only the binary — no text parsing of a "heap profile:" header or
853    /// a `MAPPED_LIBRARIES:` section. This is the compact, machine-oriented
854    /// counterpart to [`dump_to_vec`]'s text format, intended for API and
855    /// transport use (issue #23) where a `pprof`-compatible tool consumes
856    /// the bytes directly.
857    ///
858    /// [proto]: https://github.com/google/pprof/blob/main/proto/profile.proto
859    pub fn dump_proto_to_vec() -> Vec<u8> {
860        let mut out = Vec::new();
861        let ok = unsafe {
862            sys::mi_prof_dump_proto_writer(Some(write_cb), (&mut out as *mut Vec<u8>).cast())
863        };
864        if ok {
865            out
866        } else {
867            Vec::new()
868        }
869    }
870
871    /// Write the current heap profile to `path` in `profile.proto` format.
872    ///
873    /// See [`dump_proto_to_vec`] for the format details.
874    pub fn dump_proto_file(path: &Path) -> io::Result<()> {
875        let path = path.to_str().ok_or_else(|| {
876            io::Error::new(io::ErrorKind::InvalidInput, "profile path is not UTF-8")
877        })?;
878        let path = CString::new(path).map_err(|_| {
879            io::Error::new(io::ErrorKind::InvalidInput, "profile path contains NUL")
880        })?;
881        if unsafe { sys::mi_prof_dump_proto(path.as_ptr()) } {
882            Ok(())
883        } else {
884            Err(io::Error::last_os_error())
885        }
886    }
887
888    /// Snapshot of `mi_prof_stats_get`'s counters, translated from the raw
889    /// sys struct into plain Rust types.
890    #[derive(Debug, Clone, Default)]
891    pub struct ProfStats {
892        pub enabled: bool,
893        pub accum: bool,
894        pub sample_rate: usize,
895        pub live_samples: usize,
896        pub live_bytes: usize,
897        pub accum_samples: usize,
898        pub accum_bytes: usize,
899        pub unique_stacks: usize,
900        pub arena_committed: usize,
901        pub stack_table_overflows: usize,
902        /// Count of ALL dropped samples (record-alloc failure, stack-intern
903        /// failure, including the stack-table cap); a superset of
904        /// `stack_table_overflows`, so `dropped_samples >=
905        /// stack_table_overflows` always.
906        pub dropped_samples: usize,
907        /// Allocator-level ("ground truth") counters, read from the mimalloc v3
908        /// engine's per-heap statistics at the time of the call. Every field
909        /// above is *sampled*; these are exact, so comparing them against
910        /// `live_bytes` measures the sampler's error directly -- which is what
911        /// makes an assertion on a sampled profile meaningful in a test.
912        pub heap: HeapStats,
913    }
914
915    /// Exact allocator counters accompanying a [`ProfStats`] reading.
916    ///
917    /// These come from mimalloc v3's per-heap statistics
918    /// (`mi_heap_stats_get`/`mi_subproc_stats_get`), which the v2 engine did not
919    /// expose. They are valid even when the profiler is stopped.
920    #[derive(Debug, Clone, Default)]
921    pub struct HeapStats {
922        /// Bytes currently committed from the OS.
923        pub committed: usize,
924        /// Bytes currently reserved from the OS (always `>= committed`).
925        pub reserved: usize,
926        /// Bytes the application actually requested and still holds.
927        ///
928        /// Only maintained when the C library was built with `MI_STAT >= 2`;
929        /// otherwise this is 0. Check [`HeapStats::detailed`] before using it.
930        pub malloc_requested: usize,
931        /// Live mimalloc pages.
932        pub pages: usize,
933        /// Pages abandoned by exited threads.
934        pub pages_abandoned: usize,
935        /// Live first-class heaps.
936        pub heaps: usize,
937        /// Live thread-local heaps. The main thread's statically-initialized
938        /// theap is not counted, so a single-threaded process reports 0.
939        pub theaps: usize,
940        /// Cumulative bytes purged back to the OS.
941        pub purged: usize,
942        /// Whether the C library was built with `MI_STAT >= 2` ("detailed"
943        /// statistics), which upstream enables by default only for debug
944        /// builds. [`HeapStats::malloc_requested`] is maintained only at that
945        /// level; every other field here is maintained at any level.
946        ///
947        /// Without this flag you cannot tell "the application allocated
948        /// nothing" from "this build does not track that counter".
949        pub detailed: bool,
950    }
951
952    /// Read the profiler's current counters via `mi_prof_stats_get`.
953    ///
954    /// Returns `ProfStats::default()` (all zero/false) if the call fails,
955    /// e.g. because the sys struct's `size`/`version` header does not match
956    /// what the linked mimalloc build expects.
957    pub fn stats() -> ProfStats {
958        let mut raw: sys::mi_prof_stats_t = unsafe { core::mem::zeroed() };
959        raw.size = core::mem::size_of::<sys::mi_prof_stats_t>();
960        raw.version = sys::MI_PROF_STAT_VERSION;
961        if unsafe { sys::mi_prof_stats_get(&mut raw) } {
962            ProfStats {
963                enabled: raw.enabled,
964                accum: raw.accum,
965                sample_rate: raw.sample_rate,
966                live_samples: raw.live_samples,
967                live_bytes: raw.live_bytes,
968                accum_samples: raw.accum_samples,
969                accum_bytes: raw.accum_bytes,
970                unique_stacks: raw.unique_stacks,
971                arena_committed: raw.arena_committed,
972                stack_table_overflows: raw.stack_table_overflows,
973                dropped_samples: raw.dropped_samples,
974                heap: HeapStats {
975                    committed: raw.heap_committed,
976                    reserved: raw.heap_reserved,
977                    malloc_requested: raw.heap_malloc_requested,
978                    pages: raw.heap_pages,
979                    pages_abandoned: raw.heap_pages_abandoned,
980                    heaps: raw.heap_count,
981                    theaps: raw.theap_count,
982                    purged: raw.heap_purged,
983                    detailed: raw.heap_stats_detailed,
984                },
985            }
986        } else {
987            ProfStats::default()
988        }
989    }
990
991    /// One sampled call stack, copied out of the profiler by [`samples`].
992    #[derive(Debug, Clone)]
993    pub struct Sample {
994        pub stack: Vec<usize>,
995        pub live_objects: usize,
996        pub live_bytes: usize,
997        pub accum_objects: usize,
998        pub accum_bytes: usize,
999    }
1000
1001    impl Sample {
1002        /// Estimate the un-sampled byte volume behind this sample.
1003        ///
1004        /// Mirrors pprof's legacy heap-sample scaling formula
1005        /// (`scaleHeapSample` in pprof's `profile/legacy_profile.go`),
1006        /// which corrects for the bias a Poisson sampling process with mean
1007        /// interval `sample_rate` introduces toward larger allocations.
1008        pub fn estimated_bytes(&self, sample_rate: usize) -> u64 {
1009            if self.live_objects == 0 || self.live_bytes == 0 {
1010                return 0;
1011            }
1012            if sample_rate <= 1 {
1013                return self.live_bytes as u64;
1014            }
1015            let avg = self.live_bytes as f64 / self.live_objects as f64;
1016            let scale = 1.0 / (1.0 - (-avg / sample_rate as f64).exp());
1017            (self.live_bytes as f64 * scale) as u64
1018        }
1019    }
1020
1021    /// Frees the snapshot handle on drop, including on unwind, so a panic
1022    /// partway through collection never leaks profiler-arena memory.
1023    struct SnapshotGuard(*mut sys::mi_prof_snapshot_t);
1024
1025    impl Drop for SnapshotGuard {
1026        fn drop(&mut self) {
1027            unsafe { sys::mi_prof_snapshot_free(self.0) }
1028        }
1029    }
1030
1031    unsafe extern "C" fn collect_visitor(
1032        info: *const sys::mi_prof_sample_info_t,
1033        arg: *mut c_void,
1034    ) -> bool {
1035        let result = catch_unwind(AssertUnwindSafe(|| unsafe {
1036            let out = &mut *(arg as *mut Vec<Sample>);
1037            let info = &*info;
1038            let stack = (0..info.depth)
1039                .map(|i| *info.stack.add(i) as usize)
1040                .collect();
1041            out.push(Sample {
1042                stack,
1043                live_objects: info.live_objects,
1044                live_bytes: info.live_bytes,
1045                accum_objects: info.accum_objects,
1046                accum_bytes: info.accum_bytes,
1047            });
1048        }));
1049        result.is_ok()
1050    }
1051
1052    /// Collect a point-in-time copy of every live sampled stack.
1053    ///
1054    /// This snapshots under the profiler lock via `mi_prof_snapshot_new`,
1055    /// then walks and frees the snapshot outside that lock. Using
1056    /// `mi_prof_visit` directly here would run the (allocating) collection
1057    /// below from inside the visitor while the profiler lock is held,
1058    /// risking reentrant profiler-hook allocation and deadlock — the
1059    /// reentrancy hazard the snapshot API exists to avoid (issue #2,
1060    /// decisions 11-13).
1061    pub fn samples() -> Vec<Sample> {
1062        let snap = unsafe { sys::mi_prof_snapshot_new() };
1063        if snap.is_null() {
1064            return Vec::new();
1065        }
1066        let guard = SnapshotGuard(snap);
1067        let mut out: Vec<Sample> = Vec::new();
1068        unsafe {
1069            sys::mi_prof_snapshot_visit(
1070                guard.0,
1071                collect_visitor,
1072                (&mut out as *mut Vec<Sample>).cast(),
1073            );
1074        }
1075        out
1076    }
1077
1078    /// One loaded module (shared library or the main executable), copied out
1079    /// of the OS module list by [`modules`].
1080    #[derive(Debug, Clone)]
1081    pub struct ModuleInfo {
1082        pub path: String,
1083        pub base: usize,
1084        pub size: usize,
1085    }
1086
1087    unsafe extern "C" fn modules_visitor(
1088        info: *const sys::mi_prof_module_info_t,
1089        arg: *mut c_void,
1090    ) -> bool {
1091        let result = catch_unwind(AssertUnwindSafe(|| unsafe {
1092            let out = &mut *(arg as *mut Vec<ModuleInfo>);
1093            let info = &*info;
1094            // `info.path` is only valid for the duration of this callback (it
1095            // points into OS-owned module-list storage), so it must be copied
1096            // into an owned `String` right here rather than stashed for later.
1097            let path = CStr::from_ptr(info.path).to_string_lossy().into_owned();
1098            out.push(ModuleInfo {
1099                path,
1100                base: info.base,
1101                size: info.size,
1102            });
1103        }));
1104        result.is_ok()
1105    }
1106
1107    /// Enumerate the process's loaded modules (shared libraries and the main
1108    /// executable), e.g. to build pprof `Mapping` entries yourself.
1109    ///
1110    /// Unlike [`samples`]'s `collect_visitor`, this callback is free to
1111    /// allocate: `mi_prof_modules_visit` never takes the profiler lock (the
1112    /// module list is OS-owned, not part of the sampled-allocation table), so
1113    /// there is no reentrant-allocation-under-the-lock hazard here.
1114    pub fn modules() -> Vec<ModuleInfo> {
1115        let mut out: Vec<ModuleInfo> = Vec::new();
1116        unsafe {
1117            sys::mi_prof_modules_visit(modules_visitor, (&mut out as *mut Vec<ModuleInfo>).cast());
1118        }
1119        out
1120    }
1121}
1122
1123/// Print, per size class, what hole purging leaves behind (issue #272, Bun parity P7b).
1124///
1125/// Read-only: it purges nothing and mutates no free list. The report goes to mimalloc's
1126/// own output sink (stderr by default), not to a returned `String` — building a `String`
1127/// here would allocate from inside a walk over the very free lists being reported.
1128/// `mi_purge_holes_report` takes no sink argument at all; to capture the text, install a
1129/// process-wide sink with C's `mi_register_output` (not bound by this crate).
1130///
1131/// Like the idle sweep it only covers what the calling thread may safely read: its own
1132/// theaps, plus the abandoned pages of the heaps behind them. Call it right after an
1133/// [`on_thread_idle`] sweep, when the numbers still describe that sweep.
1134pub fn purge_holes_report() {
1135    unsafe { sys::mi_purge_holes_report() }
1136}
1137
1138/// mimalloc's `mi_option_*` settings: the runtime knobs behind every `MIMALLOC_*`
1139/// environment variable.
1140///
1141/// Options are read once, lazily, the first time the allocator needs them, so setting one
1142/// after the allocation it governs has already happened has no effect. In particular
1143/// [`Opt::SCAVENGER`] and the profiler options must be set before the first allocation to
1144/// matter; [`Opt::PURGE_HOLES`] and its companions are re-read per sweep and can be
1145/// changed at any time.
1146///
1147/// ```
1148/// use mimalloc_pprof::options::{self, Opt};
1149/// let previous = options::get(Opt::PURGE_HOLES_MIN_INTERVAL);
1150/// options::set(Opt::PURGE_HOLES_MIN_INTERVAL, 0); // sweep on every idle call
1151/// mimalloc_pprof::on_thread_idle();
1152/// options::set(Opt::PURGE_HOLES_MIN_INTERVAL, previous);
1153/// ```
1154pub mod options {
1155    use core::ffi::c_long;
1156
1157    use crate::sys;
1158
1159    /// One `mi_option_t` setting.
1160    ///
1161    /// The associated constants name this fork's own options plus the handful of upstream
1162    /// ones that interact with them; [`Opt::from_raw`] reaches any other enumerator in
1163    /// [`sys`] — it range-checks against `_mi_option_last`, because the C side indexes an
1164    /// array with this value and an out-of-range option would read out of bounds.
1165    #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
1166    pub struct Opt(sys::mi_option_t);
1167
1168    impl Opt {
1169        /// **Fork addition.** Enable the sampled profiler at process start (`MIMALLOC_PROF`).
1170        pub const PROF: Self = Self(sys::mi_option_prof);
1171        /// **Fork addition.** Average byte interval between profiler samples.
1172        pub const PROF_SAMPLE_RATE: Self = Self(sys::mi_option_prof_sample_rate);
1173        /// **Fork addition.** Maximum captured stack depth for the profiler.
1174        pub const PROF_BT_MAX: Self = Self(sys::mi_option_prof_bt_max);
1175        /// **Fork addition.** Keep cumulative profiler counters until [`crate::prof::reset`].
1176        pub const PROF_ACCUM: Self = Self(sys::mi_option_prof_accum);
1177        /// **Fork addition.** Profiler sampling PRNG seed; 0 = nondeterministic.
1178        pub const PROF_SEED: Self = Self(sys::mi_option_prof_seed);
1179        /// **Fork addition.** Budget in bytes for profiler-internal arena memory.
1180        pub const PROF_MAX_BYTES: Self = Self(sys::mi_option_prof_max_bytes);
1181        /// **Fork addition.** Enable [`crate::memory_events`] accounting
1182        /// (`MIMALLOC_MEMORY_EVENTS`).
1183        pub const MEMORY_EVENTS: Self = Self(sys::mi_option_memory_events);
1184        /// **Fork addition, dead since #80.** Parses, but has no effect. Kept so nothing
1185        /// renumbers; unrelated to [`Opt::PURGE_HOLES_EAGER_ZERO`].
1186        pub const PURGE_ZEROES: Self = Self(sys::mi_option_purge_zeroes);
1187        /// **Fork addition (Bun).** Run the background scavenger thread.
1188        pub const SCAVENGER: Self = Self(sys::mi_option_scavenger);
1189        /// **Fork addition (Bun).** Discard free blocks inside still-used pages on
1190        /// [`crate::on_thread_idle`].
1191        pub const PURGE_HOLES: Self = Self(sys::mi_option_purge_holes);
1192        /// **Fork addition (Bun).** Zero a range before discarding it, so a mis-scoped
1193        /// discard corrupts visibly. Forced on in debug builds.
1194        pub const PURGE_HOLES_EAGER_ZERO: Self = Self(sys::mi_option_purge_holes_eager_zero);
1195        /// **Fork addition (Bun).** Minimum milliseconds between sweeps of one thread's heaps.
1196        pub const PURGE_HOLES_MIN_INTERVAL: Self = Self(sys::mi_option_purge_holes_min_interval);
1197        /// **Fork addition (Bun).** Every N-th sweep walks every page; 0 disables.
1198        pub const PURGE_HOLES_FULL_EVERY: Self = Self(sys::mi_option_purge_holes_full_every);
1199        /// **Fork addition (Bun parity, #338).** Write a heap snapshot at process exit: 0 = off,
1200        /// 1 = pages, 2 = pages + per-block free maps (`MIMALLOC_SNAPSHOT_PATH` names the file).
1201        pub const SNAPSHOT_ON_EXIT: Self = Self(sys::mi_option_snapshot_on_exit);
1202
1203        /// Upstream: milliseconds to delay purging, which the scavenger also honours.
1204        pub const PURGE_DELAY: Self = Self(sys::mi_option_purge_delay);
1205        /// Upstream: print statistics on process termination.
1206        pub const SHOW_STATS: Self = Self(sys::mi_option_show_stats);
1207        /// Upstream: print error messages.
1208        pub const SHOW_ERRORS: Self = Self(sys::mi_option_show_errors);
1209        /// Upstream: print verbose messages.
1210        pub const VERBOSE: Self = Self(sys::mi_option_verbose);
1211
1212        /// Wrap a raw `mi_option_t` from [`sys`], or `None` if it is not a real option.
1213        ///
1214        /// The range check is load bearing: the C implementation indexes its option table
1215        /// with this value, so an out-of-range option is an out-of-bounds read.
1216        #[must_use]
1217        pub fn from_raw(raw: sys::mi_option_t) -> Option<Self> {
1218            if (0..sys::_mi_option_last).contains(&raw) {
1219                Some(Self(raw))
1220            } else {
1221                None
1222            }
1223        }
1224
1225        /// The raw `mi_option_t` value.
1226        #[must_use]
1227        pub fn as_raw(self) -> sys::mi_option_t {
1228            self.0
1229        }
1230
1231        /// The C enumerator's name, e.g. `mi_option_purge_holes`.
1232        #[must_use]
1233        pub fn name(self) -> &'static str {
1234            sys::MI_OPTIONS_IN_ORDER
1235                .get(self.0 as usize)
1236                .map_or("<unknown>", |(name, _)| *name)
1237        }
1238    }
1239
1240    /// Read an option's value.
1241    ///
1242    /// Note the width: mimalloc stores option values in a C `long`, which is 32-bit on
1243    /// Windows and 64-bit on Linux/macOS. Use [`get_size`] for byte counts.
1244    #[must_use]
1245    pub fn get(option: Opt) -> c_long {
1246        unsafe { sys::mi_option_get(option.as_raw()) }
1247    }
1248
1249    /// Read an option's value, clamped into `min..=max`.
1250    #[must_use]
1251    pub fn get_clamp(option: Opt, min: c_long, max: c_long) -> c_long {
1252        unsafe { sys::mi_option_get_clamp(option.as_raw(), min, max) }
1253    }
1254
1255    /// Read an option's value as a `size_t`, for options that count bytes.
1256    #[must_use]
1257    pub fn get_size(option: Opt) -> usize {
1258        unsafe { sys::mi_option_get_size(option.as_raw()) }
1259    }
1260
1261    /// Set an option's value, overriding both the default and the environment.
1262    pub fn set(option: Opt, value: c_long) {
1263        unsafe { sys::mi_option_set(option.as_raw(), value) }
1264    }
1265
1266    /// Set an option's value only if the environment did not already set it.
1267    pub fn set_default(option: Opt, value: c_long) {
1268        unsafe { sys::mi_option_set_default(option.as_raw(), value) }
1269    }
1270
1271    /// Whether a boolean option is on.
1272    #[must_use]
1273    pub fn is_enabled(option: Opt) -> bool {
1274        unsafe { sys::mi_option_is_enabled(option.as_raw()) }
1275    }
1276
1277    /// Turn a boolean option on.
1278    pub fn enable(option: Opt) {
1279        unsafe { sys::mi_option_enable(option.as_raw()) }
1280    }
1281
1282    /// Turn a boolean option off.
1283    pub fn disable(option: Opt) {
1284        unsafe { sys::mi_option_disable(option.as_raw()) }
1285    }
1286
1287    /// Turn a boolean option on or off.
1288    pub fn set_enabled(option: Opt, enabled: bool) {
1289        unsafe { sys::mi_option_set_enabled(option.as_raw(), enabled) }
1290    }
1291
1292    /// Set a boolean option's default, which the environment still overrides.
1293    pub fn set_enabled_default(option: Opt, enabled: bool) {
1294        unsafe { sys::mi_option_set_enabled_default(option.as_raw(), enabled) }
1295    }
1296
1297    /// Print every option's current value to mimalloc's output sink.
1298    ///
1299    /// Goes to the sink rather than to a returned `String` for the same reason as
1300    /// [`crate::purge_holes_report`]: capturing it would mean allocating from inside a
1301    /// callback the allocator drives.
1302    pub fn print() {
1303        unsafe { sys::mi_options_print_out(None, core::ptr::null_mut()) }
1304    }
1305
1306    /// Every option this build knows about, in C declaration order, as
1307    /// `(name, value)` pairs — including the ones without an [`Opt`] constant.
1308    #[must_use]
1309    pub fn all() -> &'static [(&'static str, sys::mi_option_t)] {
1310        sys::MI_OPTIONS_IN_ORDER
1311    }
1312}
1313
1314/// The allocator's own **exact** statistics, as opposed to the sampled numbers
1315/// [`crate::prof::stats`] reports.
1316///
1317/// This is upstream mimalloc's `mimalloc-stats.h` surface. Note what is *not* here:
1318/// hole-purging and idle-sweep gauges are **not** part of `mi_stats_t` — they live in
1319/// [`crate::purge_holes_stats`], because the sweep also covers pages that no heap owns
1320/// and `mi_stats_t` cannot grow (it is embedded in a theap, at the meta-allocator's 8 KB
1321/// block limit).
1322///
1323/// `malloc_requested` is only maintained when the C library was built with `MI_STAT >= 2`
1324/// (upstream enables that for debug builds only); a default release build reports 0 for
1325/// it and for nothing else.
1326pub mod stats {
1327    use core::ops::Deref;
1328    use std::ffi::CStr;
1329
1330    use crate::sys;
1331
1332    /// An owned copy of `mi_stats_t`, boxed because it is ~4 KB.
1333    ///
1334    /// Deref to reach every counter, e.g. `stats.committed.current`.
1335    #[derive(Clone, Debug)]
1336    pub struct Stats(Box<sys::mi_stats_t>);
1337
1338    impl Deref for Stats {
1339        type Target = sys::mi_stats_t;
1340        fn deref(&self) -> &Self::Target {
1341            &self.0
1342        }
1343    }
1344
1345    impl Stats {
1346        /// Render these counters as mimalloc's statistics JSON.
1347        ///
1348        /// Returns `None` on allocation failure. Wraps `mi_stats_as_json`.
1349        #[must_use]
1350        pub fn to_json(&self) -> Option<String> {
1351            // `mi_stats_as_json` takes a non-const pointer but only reads through it.
1352            let mut copy = self.0.clone();
1353            let ptr = unsafe { sys::mi_stats_as_json(&raw mut *copy, 0, core::ptr::null_mut()) };
1354            take_c_string(ptr)
1355        }
1356
1357        /// The raw C struct.
1358        #[must_use]
1359        pub fn as_raw(&self) -> &sys::mi_stats_t {
1360            &self.0
1361        }
1362    }
1363
1364    /// A zeroed `mi_stats_t` with its `size`/`version` header filled in, which every
1365    /// `*_stats_get` entry point checks before writing a single counter.
1366    fn empty() -> Box<sys::mi_stats_t> {
1367        let mut raw: Box<sys::mi_stats_t> = Box::new(unsafe { core::mem::zeroed() });
1368        raw.size = size_of::<sys::mi_stats_t>();
1369        raw.version = sys::MI_STAT_VERSION;
1370        raw
1371    }
1372
1373    /// Copy a `mi_malloc`-family C string out and release it with `mi_free`.
1374    fn take_c_string(ptr: *mut core::ffi::c_char) -> Option<String> {
1375        if ptr.is_null() {
1376            return None;
1377        }
1378        let owned = unsafe { CStr::from_ptr(ptr) }
1379            .to_string_lossy()
1380            .into_owned();
1381        unsafe { sys::mi_free(ptr.cast()) };
1382        Some(owned)
1383    }
1384
1385    /// Which subprocess a subprocess-scoped call refers to.
1386    #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
1387    pub enum Subproc {
1388        /// The process-wide default subprocess (`mi_subproc_main`).
1389        #[default]
1390        Main,
1391        /// The subprocess this thread belongs to (`mi_subproc_current`).
1392        Current,
1393    }
1394
1395    impl Subproc {
1396        fn id(self) -> sys::mi_subproc_id_t {
1397            unsafe {
1398                match self {
1399                    Self::Main => sys::mi_subproc_main(),
1400                    Self::Current => sys::mi_subproc_current(),
1401                }
1402            }
1403        }
1404    }
1405
1406    /// Statistics for the current subprocess and all its heaps, aggregated.
1407    ///
1408    /// Wraps `mi_stats_get`. Returns `None` only if the C library rejects the struct
1409    /// header, which would mean this crate's `mi_stats_t` mirror has drifted from the
1410    /// library it is linked against (`tests/t19_layout.rs` gates exactly that).
1411    #[must_use]
1412    pub fn get() -> Option<Stats> {
1413        let mut raw = empty();
1414        unsafe { sys::mi_stats_get(&raw mut *raw) }.then_some(Stats(raw))
1415    }
1416
1417    /// The same statistics as [`get`], rendered as JSON by the C library.
1418    ///
1419    /// Wraps `mi_stats_get_json`; returns `None` on allocation failure.
1420    #[must_use]
1421    pub fn json() -> Option<String> {
1422        take_c_string(unsafe { sys::mi_stats_get_json(0, core::ptr::null_mut()) })
1423    }
1424
1425    /// Print the current subprocess's statistics to mimalloc's output sink.
1426    ///
1427    /// Wraps `mi_stats_print_out(NULL, NULL)`. Use [`json`] to capture them instead:
1428    /// routing the sink through a Rust closure would allocate from inside a callback the
1429    /// allocator drives.
1430    pub fn print() {
1431        unsafe { sys::mi_stats_print_out(None, core::ptr::null_mut()) }
1432    }
1433
1434    /// The block size served by size bin `bin` (`0..=`[`sys::MI_BIN_HUGE`]), matching the
1435    /// `malloc_bins`/`page_bins` indices.
1436    #[must_use]
1437    pub fn bin_size(bin: usize) -> usize {
1438        unsafe { sys::mi_stats_get_bin_size(bin) }
1439    }
1440
1441    /// Statistics for one subprocess and all its heaps, aggregated.
1442    #[must_use]
1443    pub fn subproc_get(which: Subproc) -> Option<Stats> {
1444        let mut raw = empty();
1445        unsafe { sys::mi_subproc_stats_get(which.id(), &raw mut *raw) }.then_some(Stats(raw))
1446    }
1447
1448    /// Statistics for one subprocess **without** aggregating its heaps.
1449    #[must_use]
1450    pub fn subproc_get_exclusive(which: Subproc) -> Option<Stats> {
1451        let mut raw = empty();
1452        unsafe { sys::mi_subproc_stats_get_exclusive(which.id(), &raw mut *raw) }
1453            .then_some(Stats(raw))
1454    }
1455
1456    /// One subprocess's aggregated statistics as JSON.
1457    #[must_use]
1458    pub fn subproc_json(which: Subproc) -> Option<String> {
1459        take_c_string(unsafe {
1460            sys::mi_subproc_stats_get_json(which.id(), 0, core::ptr::null_mut())
1461        })
1462    }
1463
1464    /// Print one subprocess's aggregated statistics to mimalloc's output sink.
1465    pub fn subproc_print(which: Subproc) {
1466        unsafe { sys::mi_subproc_stats_print_out(which.id(), None, core::ptr::null_mut()) }
1467    }
1468
1469    /// Print one subprocess **and each of its heaps separately** to mimalloc's output sink.
1470    pub fn subproc_heap_print(which: Subproc) {
1471        unsafe {
1472            sys::mi_subproc_heap_stats_print_out(which.id(), None, core::ptr::null_mut());
1473        }
1474    }
1475}
1476
1477/// Opt-in allocation-change accounting and callbacks (`include/mimalloc/memory-events.h`).
1478///
1479/// Independent of the sampled profiler, and **opt-in at compile time** since 0.12.0
1480/// (#414): the C side is built only with the `memory-events` cargo feature (which `dhat`
1481/// implies). Without it the per-allocation hook sites are compiled out -- they were
1482/// measured at 9-13 instructions per malloc/free pair, 18-26% of the pair, even with
1483/// tracking disabled -- and every function here links but reports the subsystem off
1484/// ([`set_enabled`] returns `false`, [`snapshot`] returns `None`). With the feature on,
1485/// tracking is still **off** until [`set_enabled`] or `MIMALLOC_MEMORY_EVENTS=1`.
1486///
1487/// ```
1488/// use mimalloc_pprof::{memory_events, MiMalloc};
1489///
1490/// // The counters only move for allocations that actually reach mimalloc, so this
1491/// // example is only meaningful once mimalloc is the global allocator.
1492/// #[global_allocator]
1493/// static ALLOCATOR: MiMalloc = MiMalloc;
1494///
1495/// fn main() {
1496///     if !memory_events::set_enabled(true) {
1497///         return; // built without the `memory-events` feature: the API is a stub
1498///     }
1499///     let before = memory_events::snapshot().expect("snapshot").accum_count;
1500///     let v = vec![0_u8; 4096];
1501///     std::hint::black_box(&v);
1502///     let after = memory_events::snapshot().expect("snapshot").accum_count;
1503///     assert!(after > before);
1504///     memory_events::set_enabled(false);
1505/// }
1506/// ```
1507pub mod memory_events {
1508    use core::ffi::c_void;
1509    use std::panic::{catch_unwind, AssertUnwindSafe};
1510
1511    use crate::sys;
1512
1513    /// Which kind of change a [`Change`] describes.
1514    #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
1515    #[non_exhaustive]
1516    pub enum ChangeKind {
1517        /// A successful allocation.
1518        Allocate,
1519        /// A successful free.
1520        Free,
1521        /// A successful realloc, whether it grew or shrank.
1522        Resize,
1523    }
1524
1525    impl ChangeKind {
1526        fn from_raw(raw: sys::mi_memory_change_kind_t) -> Option<Self> {
1527            match raw {
1528                sys::MI_MEMORY_ALLOCATE => Some(Self::Allocate),
1529                sys::MI_MEMORY_FREE => Some(Self::Free),
1530                sys::MI_MEMORY_RESIZE => Some(Self::Resize),
1531                _ => None,
1532            }
1533        }
1534
1535        fn slot(self) -> usize {
1536            match self {
1537                Self::Allocate => sys::MI_MEMORY_ALLOCATE as usize,
1538                Self::Free => sys::MI_MEMORY_FREE as usize,
1539                Self::Resize => sys::MI_MEMORY_RESIZE as usize,
1540            }
1541        }
1542    }
1543
1544    /// One observed allocation change, copied out of `mi_memory_change_t`.
1545    #[derive(Clone, Copy, Debug, PartialEq, Eq)]
1546    pub struct Change {
1547        /// What happened.
1548        pub kind: ChangeKind,
1549        /// Tracked global live usable bytes after this operation.
1550        pub total_bytes: u64,
1551        /// Signed change in tracked live usable bytes: positive for allocation/growth,
1552        /// negative for free/shrink, zero for a same-size-class resize.
1553        pub delta_bytes: i64,
1554        /// Caller-requested size for allocate and resize; zero for free.
1555        pub request_size: u64,
1556    }
1557
1558    /// Running totals maintained while tracking is enabled.
1559    ///
1560    /// Counters are **not** reconstructed for time spent with tracking off: a total is
1561    /// exact only if tracking was enabled before the first allocation and never disabled.
1562    #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
1563    pub struct Snapshot {
1564        /// Tracked live usable bytes right now.
1565        pub live_bytes: u64,
1566        /// Cumulative usable bytes ever allocated.
1567        pub accum_bytes: u64,
1568        /// Tracked live allocation count right now.
1569        pub live_count: u64,
1570        /// Cumulative count of successful allocate events.
1571        pub accum_count: u64,
1572    }
1573
1574    /// Enable or disable tracking; returns the previous state.
1575    ///
1576    /// An explicit call is always authoritative over the `MIMALLOC_MEMORY_EVENTS`
1577    /// environment read: called before the first allocation it *replaces* that read;
1578    /// called after, it overrides the cached flag. Re-enabling does not reconstruct what
1579    /// happened while tracking was off.
1580    pub fn set_enabled(enabled: bool) -> bool {
1581        unsafe { sys::mi_memory_tracking_set_enabled(enabled) }
1582    }
1583
1584    /// Whether tracking is on.
1585    #[must_use]
1586    pub fn is_enabled() -> bool {
1587        unsafe { sys::mi_memory_tracking_is_enabled() }
1588    }
1589
1590    /// Read the running totals.
1591    ///
1592    /// Returns `None` only if the C library rejects the struct header, which would mean
1593    /// this crate's mirror has drifted from the library (`tests/t19_layout.rs` gates that).
1594    #[must_use]
1595    pub fn snapshot() -> Option<Snapshot> {
1596        let mut raw: sys::mi_memory_snapshot_t = unsafe { core::mem::zeroed() };
1597        raw.size = size_of::<sys::mi_memory_snapshot_t>();
1598        raw.version = sys::MI_MEMORY_SNAPSHOT_VERSION;
1599        if !unsafe { sys::mi_memory_snapshot(&raw mut raw) } {
1600            return None;
1601        }
1602        Some(Snapshot {
1603            live_bytes: raw.live_bytes,
1604            accum_bytes: raw.accum_bytes,
1605            live_count: raw.live_count,
1606            accum_count: raw.accum_count,
1607        })
1608    }
1609
1610    /// Handlers to install with [`set_callbacks`], one per [`ChangeKind`].
1611    ///
1612    /// Plain `fn` pointers rather than closures on purpose: the C side keeps the
1613    /// registration until it is replaced, so anything captured would have to outlive the
1614    /// process. Route per-instance state through a `static` (an atomic counter, a channel
1615    /// sender in a `OnceLock`) instead.
1616    #[derive(Clone, Copy, Debug, Default)]
1617    pub struct Callbacks {
1618        /// Called after each successful allocation.
1619        pub allocate: Option<fn(&Change)>,
1620        /// Called after each successful free.
1621        pub free: Option<fn(&Change)>,
1622        /// Called after each successful realloc.
1623        pub resize: Option<fn(&Change)>,
1624    }
1625
1626    impl Callbacks {
1627        fn handler(&self, kind: ChangeKind) -> Option<fn(&Change)> {
1628            match kind {
1629                ChangeKind::Allocate => self.allocate,
1630                ChangeKind::Free => self.free,
1631                ChangeKind::Resize => self.resize,
1632            }
1633        }
1634    }
1635
1636    /// The single C-ABI entry point for all three slots. `arg` is the `&'static Callbacks`
1637    /// the caller handed to [`set_callbacks`]; the kind comes out of the change record, so
1638    /// one trampoline serves every slot.
1639    unsafe extern "C" fn dispatch(change: *const sys::mi_memory_change_t, arg: *mut c_void) {
1640        if change.is_null() || arg.is_null() {
1641            return;
1642        }
1643        let raw = unsafe { &*change };
1644        let callbacks = unsafe { &*(arg as *const Callbacks) };
1645        // An unknown kind means the C enum grew: ignore it rather than guessing.
1646        let Some(kind) = ChangeKind::from_raw(raw.kind) else {
1647            return;
1648        };
1649        let Some(handler) = callbacks.handler(kind) else {
1650            return;
1651        };
1652        let change = Change {
1653            kind,
1654            total_bytes: raw.total_bytes,
1655            delta_bytes: raw.delta_bytes,
1656            request_size: raw.request_size,
1657        };
1658        // A panic must not unwind across the C frame that called us.
1659        let _ = catch_unwind(AssertUnwindSafe(|| handler(&change)));
1660    }
1661
1662    /// Install `callbacks`, replacing any previous table. Returns `false` if the C library
1663    /// refused the table.
1664    ///
1665    /// `'static` is what makes this safe: the C side keeps the pointer until the table is
1666    /// replaced or cleared, which is exactly the header's "`arg` pointers are caller-owned
1667    /// and must stay valid" requirement.
1668    ///
1669    /// Callbacks run with no allocator locks held and **may** allocate, but a hook that
1670    /// fires while another hook's callback is running on the same thread is suppressed —
1671    /// so bytes a callback itself allocates never reach the running totals. Keep them
1672    /// short, and let them return normally: a panic is caught and swallowed here, but a
1673    /// C `longjmp` out of one is unsupported.
1674    pub fn set_callbacks(callbacks: &'static Callbacks) -> bool {
1675        let mut raw = sys::mi_memory_callbacks_t {
1676            handlers: [None; sys::MI_MEMORY_CHANGE_COUNT],
1677            args: [core::ptr::null_mut(); sys::MI_MEMORY_CHANGE_COUNT],
1678        };
1679        let arg = (callbacks as *const Callbacks).cast_mut().cast::<c_void>();
1680        for kind in [ChangeKind::Allocate, ChangeKind::Free, ChangeKind::Resize] {
1681            if callbacks.handler(kind).is_some() {
1682                raw.handlers[kind.slot()] = Some(dispatch);
1683                raw.args[kind.slot()] = arg;
1684            }
1685        }
1686        unsafe { sys::mi_memory_set_callbacks(&raw const raw) }
1687    }
1688
1689    /// Remove every installed callback. Accounting (and [`snapshot`]) keeps working.
1690    pub fn clear_callbacks() -> bool {
1691        unsafe { sys::mi_memory_set_callbacks(core::ptr::null()) }
1692    }
1693
1694    unsafe extern "C" fn visit_trampoline<F>(
1695        allocation: *mut c_void,
1696        usable_size: usize,
1697        arg: *mut c_void,
1698    ) -> bool
1699    where
1700        F: FnMut(*mut u8, usize) -> bool,
1701    {
1702        let visitor = unsafe { &mut *(arg as *mut F) };
1703        catch_unwind(AssertUnwindSafe(|| visitor(allocation.cast(), usable_size))).unwrap_or(false)
1704    }
1705
1706    /// Walk the live allocations this thread may safely observe, calling `visitor` with
1707    /// each one's address and usable size. Return `false` from `visitor` to stop early.
1708    ///
1709    /// Diagnostics only. This is **not** a consistent global snapshot: it is built on
1710    /// `mi_heap_visit_blocks`, so another thread may free a reported allocation the
1711    /// instant the callback begins.
1712    ///
1713    /// # Safety
1714    ///
1715    /// - `visitor` must not allocate, free, or otherwise reenter mimalloc while the walk
1716    ///   is active — that includes anything that allocates indirectly, such as `println!`,
1717    ///   growing a `Vec`, or formatting. Collect into a fixed-size buffer, or into
1718    ///   [`crate::unwrapped_malloc`] memory, and process it after this returns.
1719    /// - `visitor` must not panic. Raising a panic allocates its payload and its message
1720    ///   through the global allocator, which reenters mimalloc in the middle of the walk
1721    ///   -- the very thing the bullet above forbids. The `catch_unwind` inside the
1722    ///   trampoline stops the unwind from crossing the C frame; it does **not** and
1723    ///   cannot prevent that allocation, which has already happened by the time it runs.
1724    ///   Report failures by setting a flag the caller reads after the walk returns.
1725    /// - The pointers handed to `visitor` must not be dereferenced, retained, or freed:
1726    ///   they may already be dead. Treat them as addresses, not as references.
1727    /// - No other thread may be freeing into the heaps being walked (the
1728    ///   `mi_heap_visit_blocks` precondition; see `include/mimalloc.h`).
1729    pub unsafe fn visit_live_allocations<F>(mut visitor: F) -> bool
1730    where
1731        F: FnMut(*mut u8, usize) -> bool,
1732    {
1733        unsafe {
1734            sys::mi_memory_visit_live_allocations(
1735                visit_trampoline::<F>,
1736                (&raw mut visitor).cast::<c_void>(),
1737            )
1738        }
1739    }
1740}
1741
1742#[cfg(test)]
1743mod tests {
1744    use super::*;
1745    use std::sync::Mutex;
1746
1747    // The profiler is process-global state, and unit tests within this
1748    // binary may run concurrently by default, so serialize everything that
1749    // starts/stops it. `unwrap_or_else` rides through a poisoned lock rather
1750    // than cascading a single panicking test into every other one.
1751    #[cfg(feature = "pprof")]
1752    static PROF_TEST_LOCK: Mutex<()> = Mutex::new(());
1753    static DHAT_TEST_LOCK: Mutex<()> = Mutex::new(());
1754
1755    #[cfg(feature = "pprof")]
1756    fn reset_profiler() {
1757        if prof::is_enabled() {
1758            prof::stop();
1759        }
1760    }
1761
1762    #[test]
1763    #[cfg(feature = "pprof")]
1764    fn enable_heap_profiling_with_default_config_starts_profiler() {
1765        let _guard = PROF_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1766        reset_profiler();
1767
1768        let config = ProfConfig::default();
1769        assert!(enable_heap_profiling_with(&config));
1770        assert!(prof::is_enabled());
1771
1772        prof::stop();
1773    }
1774
1775    #[test]
1776    #[cfg(feature = "pprof")]
1777    fn enable_heap_profiling_with_override_mode_sets_sample_interval() {
1778        let _guard = PROF_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1779        reset_profiler();
1780
1781        let config = ProfConfig {
1782            mode: ProfConfigMode::Override,
1783            sample_interval: Some(4096),
1784            ..Default::default()
1785        };
1786        assert!(enable_heap_profiling_with(&config));
1787        assert!(prof::is_enabled());
1788        assert_eq!(prof::stats().sample_rate, 4096);
1789
1790        prof::stop();
1791    }
1792
1793    #[test]
1794    #[cfg(not(feature = "pprof"))]
1795    fn heap_profiling_is_unavailable_when_compiled_out() {
1796        assert!(!enable_heap_profiling_with(&ProfConfig::default()));
1797        assert!(!prof::is_enabled());
1798    }
1799
1800    #[test]
1801    fn unwrapped_malloc_write_realloc_grow_verify_free() {
1802        unsafe {
1803            let size = 64usize;
1804            let p = unwrapped_malloc(size, 0);
1805            assert!(!p.is_null());
1806
1807            for i in 0..size {
1808                *p.add(i) = (i % 256) as u8;
1809            }
1810
1811            let new_size = 256usize;
1812            let p2 = unwrapped_realloc(p, new_size, 0);
1813            assert!(!p2.is_null());
1814
1815            for i in 0..size {
1816                assert_eq!(*p2.add(i), (i % 256) as u8);
1817            }
1818
1819            unwrapped_free(p2);
1820        }
1821    }
1822
1823    #[test]
1824    fn unwrapped_free_null_is_noop() {
1825        unsafe {
1826            unwrapped_free(core::ptr::null_mut());
1827        }
1828    }
1829
1830    #[test]
1831    fn unwrapped_malloc_rejects_non_power_of_two_alignment() {
1832        unsafe {
1833            let p = unwrapped_malloc(16, 3);
1834            assert!(p.is_null());
1835        }
1836    }
1837
1838    #[test]
1839    fn unwrapped_realloc_with_null_ptr_behaves_like_malloc() {
1840        unsafe {
1841            let p = unwrapped_realloc(core::ptr::null_mut(), 32, 0);
1842            assert!(!p.is_null());
1843            unwrapped_free(p);
1844        }
1845    }
1846
1847    #[test]
1848    fn unwrapped_realloc_with_zero_size_frees_and_returns_null() {
1849        unsafe {
1850            let p = unwrapped_malloc(32, 0);
1851            assert!(!p.is_null());
1852            let p2 = unwrapped_realloc(p, 0, 0);
1853            assert!(p2.is_null());
1854        }
1855    }
1856
1857    #[cfg(feature = "dhat")]
1858    #[test]
1859    fn dhat_controls_report_lifecycle() {
1860        let _guard = DHAT_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1861        if dhat::is_enabled() {
1862            dhat::stop();
1863        }
1864        assert!(dhat::start());
1865        let active = dhat::stats();
1866        assert!(active.enabled);
1867        dhat::stop();
1868        assert!(!dhat::is_enabled());
1869        assert!(!dhat::stats().enabled);
1870    }
1871
1872    #[cfg(not(feature = "dhat"))]
1873    #[test]
1874    fn dhat_compiled_out_is_inert() {
1875        let _guard = DHAT_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1876        assert!(
1877            !dhat::start(),
1878            "without the dhat feature the observer is compiled out"
1879        );
1880        assert!(!dhat::is_enabled());
1881        assert!(!dhat::stats().enabled);
1882        dhat::stop();
1883    }
1884    #[test]
1885    #[cfg(feature = "diagnostics")]
1886    fn heap_dump_json_reports_well_formed_json_with_current_heap() {
1887        // The default/main heap always has at least one live allocation by the time any
1888        // Rust test runs (the runtime itself allocates), so a pages-only dump of the
1889        // current subprocess must come back non-empty and syntactically balanced.
1890        let json = heap_dump_json(false, false).expect("heap_dump_json should not fail");
1891        assert!(json.contains("\"heaps\""));
1892        assert!(!json.contains("\"blocks\""));
1893        let opens = json.matches('{').count();
1894        let closes = json.matches('}').count();
1895        assert_eq!(opens, closes);
1896
1897        let with_blocks = heap_dump_json(true, true).expect("heap_dump_json should not fail");
1898        assert!(with_blocks.contains("\"blocks\""));
1899
1900        let one_attempt =
1901            heap_dump_json_ex(false, false, 0).expect("heap_dump_json_ex should not fail");
1902        assert!(one_attempt.starts_with("{ \"heaps\": ["));
1903        assert!(one_attempt.contains("\"complete\":"));
1904    }
1905
1906    #[test]
1907    #[cfg(not(feature = "diagnostics"))]
1908    fn heap_dump_and_snapshot_are_inert_when_compiled_out() {
1909        // #414: the API is still here and still safe to call; it just reports nothing.
1910        assert!(heap_dump_json(false, false).is_none());
1911        assert!(heap_dump_json_ex(true, true, 0).is_none());
1912        let path = std::env::temp_dir().join(format!(
1913            "mimalloc-pprof-no-diagnostics-{}.bin",
1914            std::process::id()
1915        ));
1916        assert!(heap_snapshot_to_file(&path, false).is_err());
1917        let _ = std::fs::remove_file(&path);
1918    }
1919}