Skip to main content

mod_alloc/
lib.rs

1//! # mod-alloc
2//!
3//! Allocation profiling for Rust. Tracks allocation counts, total
4//! bytes, peak resident memory, and current resident memory by
5//! wrapping the system allocator via [`GlobalAlloc`].
6//!
7//! Designed as a lean replacement for `dhat` with MSRV 1.75 and
8//! zero external dependencies on the hot path.
9//!
10//! ## Installing as the global allocator
11//!
12//! ```no_run
13//! use mod_alloc::{ModAlloc, Profiler};
14//!
15//! #[global_allocator]
16//! static GLOBAL: ModAlloc = ModAlloc::new();
17//!
18//! fn main() {
19//!     let p = Profiler::start();
20//!
21//!     let v: Vec<u64> = (0..1000).collect();
22//!     drop(v);
23//!
24//!     let stats = p.stop();
25//!     println!("Allocations: {}", stats.alloc_count);
26//!     println!("Total bytes: {}", stats.total_bytes);
27//!     println!("Peak bytes (absolute): {}", stats.peak_bytes);
28//! }
29//! ```
30//!
31//! ## Counter semantics
32//!
33//! The four Tier 1 counters track allocator activity since the
34//! installed [`ModAlloc`] began counting (or since the last
35//! [`ModAlloc::reset`] call):
36//!
37//! | Counter         | Updated on `alloc`            | Updated on `dealloc` |
38//! |-----------------|-------------------------------|----------------------|
39//! | `alloc_count`   | `+= 1`                        | (unchanged)          |
40//! | `total_bytes`   | `+= size`                     | (unchanged)          |
41//! | `current_bytes` | `+= size`                     | `-= size`            |
42//! | `peak_bytes`    | high-water mark of `current`  | (unchanged)          |
43//!
44//! `realloc` is counted as one allocation event. `total_bytes`
45//! increases by the growth delta on a growing realloc and is
46//! unchanged on a shrinking realloc.
47//!
48//! ## Status
49//!
50//! v0.9.1 adds Tier 2 (inline backtrace capture) behind the
51//! `backtraces` feature. v0.9.2 adds symbolication behind the
52//! `symbolicate` feature. v0.9.3 wires up `dhat-compat` to emit
53//! the per-call-site report as DHAT-format JSON that the upstream
54//! `dh_view.html` viewer loads directly. Default builds still
55//! ship Tier 1 counters only.
56//!
57//! ## Backtraces (`backtraces` feature)
58//!
59//! With `mod-alloc = { version = "0.9", features = ["backtraces"] }`
60//! and `RUSTFLAGS="-C force-frame-pointers=yes"`, each tracked
61//! allocation captures up to 8 frames of its call site via inline
62//! frame-pointer walking on `x86_64` and `aarch64`. Per-call-site
63//! aggregation is exposed via `ModAlloc::call_sites` (available
64//! only with the `backtraces` feature); the result is raw return
65//! addresses. Symbolication ships in v0.9.2.
66//!
67//! Aggregation-table size is controlled by the `MOD_ALLOC_BUCKETS`
68//! environment variable at process start (default 4,096 buckets,
69//! ~384 KB).
70
71#![cfg_attr(docsrs, feature(doc_cfg))]
72#![warn(missing_docs)]
73#![warn(rust_2018_idioms)]
74
75#[cfg(feature = "backtraces")]
76mod backtrace;
77
78#[cfg(feature = "backtraces")]
79pub use backtrace::CallSiteStats;
80
81#[cfg(feature = "symbolicate")]
82mod symbolicate;
83
84#[cfg(feature = "symbolicate")]
85pub use symbolicate::{SymbolicatedCallSite, SymbolicatedFrame};
86
87#[cfg(feature = "dhat-compat")]
88mod dhat_json;
89
90use std::alloc::{GlobalAlloc, Layout, System};
91use std::cell::Cell;
92use std::ptr;
93use std::sync::atomic::{AtomicPtr, AtomicU64, Ordering};
94
95// Process-wide handle to the installed `ModAlloc`. Populated lazily
96// on the first non-reentrant alloc call. `Profiler` reads from this
97// to locate the canonical counters without requiring an explicit
98// registration call from the user.
99static GLOBAL_HANDLE: AtomicPtr<ModAlloc> = AtomicPtr::new(ptr::null_mut());
100
101thread_local! {
102    // Reentrancy flag. Set while inside the tracking path; if any
103    // allocation occurs while set, the recursive call bypasses
104    // tracking and forwards directly to the System allocator.
105    //
106    // `const` initialization (stable since 1.59) avoids any lazy
107    // construction allocation inside the TLS access path.
108    static IN_ALLOC: Cell<bool> = const { Cell::new(false) };
109}
110
111// RAII guard for the reentrancy flag. `enter` returns `None` if the
112// current thread is already inside a tracked allocation (caller
113// must skip counter updates) or if TLS is unavailable (e.g. during
114// thread teardown). The guard clears the flag on drop.
115struct ReentryGuard;
116
117impl ReentryGuard {
118    fn enter() -> Option<Self> {
119        IN_ALLOC
120            .try_with(|flag| {
121                if flag.get() {
122                    None
123                } else {
124                    flag.set(true);
125                    Some(ReentryGuard)
126                }
127            })
128            .ok()
129            .flatten()
130    }
131}
132
133impl Drop for ReentryGuard {
134    fn drop(&mut self) {
135        let _ = IN_ALLOC.try_with(|flag| flag.set(false));
136    }
137}
138
139/// Global allocator wrapper that tracks allocations.
140///
141/// Install as `#[global_allocator]` to enable tracking. The wrapper
142/// forwards every allocation, deallocation, reallocation, and
143/// zero-initialised allocation to [`std::alloc::System`] and records
144/// the event in four lock-free [`AtomicU64`] counters.
145///
146/// # Example
147///
148/// ```no_run
149/// use mod_alloc::ModAlloc;
150///
151/// #[global_allocator]
152/// static GLOBAL: ModAlloc = ModAlloc::new();
153///
154/// fn main() {
155///     let v: Vec<u8> = vec![0; 1024];
156///     let stats = GLOBAL.snapshot();
157///     assert!(stats.alloc_count >= 1);
158///     drop(v);
159/// }
160/// ```
161pub struct ModAlloc {
162    alloc_count: AtomicU64,
163    total_bytes: AtomicU64,
164    peak_bytes: AtomicU64,
165    current_bytes: AtomicU64,
166}
167
168impl ModAlloc {
169    /// Construct a new `ModAlloc` allocator wrapper.
170    ///
171    /// All counters start at zero. This function is `const`, which
172    /// allows construction in a `static` for use as
173    /// `#[global_allocator]`.
174    ///
175    /// # Example
176    ///
177    /// ```
178    /// use mod_alloc::ModAlloc;
179    ///
180    /// static GLOBAL: ModAlloc = ModAlloc::new();
181    /// let stats = GLOBAL.snapshot();
182    /// assert_eq!(stats.alloc_count, 0);
183    /// ```
184    pub const fn new() -> Self {
185        Self {
186            alloc_count: AtomicU64::new(0),
187            total_bytes: AtomicU64::new(0),
188            peak_bytes: AtomicU64::new(0),
189            current_bytes: AtomicU64::new(0),
190        }
191    }
192
193    /// Snapshot the current counter values.
194    ///
195    /// Each counter is read independently with `Relaxed` ordering;
196    /// the resulting [`AllocStats`] is a coherent best-effort view
197    /// but does not represent a single atomic moment in time. For
198    /// scoped measurement, prefer [`Profiler`].
199    ///
200    /// # Example
201    ///
202    /// ```
203    /// use mod_alloc::ModAlloc;
204    ///
205    /// let alloc = ModAlloc::new();
206    /// let stats = alloc.snapshot();
207    /// assert_eq!(stats.alloc_count, 0);
208    /// ```
209    pub fn snapshot(&self) -> AllocStats {
210        AllocStats {
211            alloc_count: self.alloc_count.load(Ordering::Relaxed),
212            total_bytes: self.total_bytes.load(Ordering::Relaxed),
213            peak_bytes: self.peak_bytes.load(Ordering::Relaxed),
214            current_bytes: self.current_bytes.load(Ordering::Relaxed),
215        }
216    }
217
218    /// Reset all counters to zero.
219    ///
220    /// Intended for use at the start of a profile run, before any
221    /// outstanding allocations exist. Calling `reset` while
222    /// allocations are live can cause `current_bytes` to wrap on
223    /// subsequent deallocations; the other counters are unaffected.
224    ///
225    /// # Example
226    ///
227    /// ```
228    /// use mod_alloc::ModAlloc;
229    ///
230    /// let alloc = ModAlloc::new();
231    /// alloc.reset();
232    /// let stats = alloc.snapshot();
233    /// assert_eq!(stats.alloc_count, 0);
234    /// ```
235    pub fn reset(&self) {
236        self.alloc_count.store(0, Ordering::Relaxed);
237        self.total_bytes.store(0, Ordering::Relaxed);
238        self.peak_bytes.store(0, Ordering::Relaxed);
239        self.current_bytes.store(0, Ordering::Relaxed);
240    }
241
242    #[inline]
243    fn record_alloc(&self, size: u64) {
244        self.alloc_count.fetch_add(1, Ordering::Relaxed);
245        self.total_bytes.fetch_add(size, Ordering::Relaxed);
246        let new_current = self.current_bytes.fetch_add(size, Ordering::Relaxed) + size;
247        self.peak_bytes.fetch_max(new_current, Ordering::Relaxed);
248    }
249
250    #[inline]
251    fn record_dealloc(&self, size: u64) {
252        self.current_bytes.fetch_sub(size, Ordering::Relaxed);
253    }
254
255    #[inline]
256    fn record_realloc(&self, old_size: u64, new_size: u64) {
257        self.alloc_count.fetch_add(1, Ordering::Relaxed);
258        if new_size > old_size {
259            let delta = new_size - old_size;
260            self.total_bytes.fetch_add(delta, Ordering::Relaxed);
261            let new_current = self.current_bytes.fetch_add(delta, Ordering::Relaxed) + delta;
262            self.peak_bytes.fetch_max(new_current, Ordering::Relaxed);
263        } else if new_size < old_size {
264            self.current_bytes
265                .fetch_sub(old_size - new_size, Ordering::Relaxed);
266        }
267    }
268
269    #[inline]
270    fn register_self(&self) {
271        if GLOBAL_HANDLE.load(Ordering::Relaxed).is_null() {
272            let _ = GLOBAL_HANDLE.compare_exchange(
273                ptr::null_mut(),
274                self as *const ModAlloc as *mut ModAlloc,
275                Ordering::Release,
276                Ordering::Relaxed,
277            );
278        }
279    }
280
281    /// Drain the per-call-site aggregation table into a `Vec`.
282    ///
283    /// Available only with the `backtraces` cargo feature. The
284    /// returned vector contains one [`CallSiteStats`] per unique
285    /// call site observed since the table was first written. Each
286    /// row carries up to 8 raw return addresses (top of stack
287    /// first), the number of allocations attributed to that site,
288    /// and the total bytes.
289    ///
290    /// Symbolication (resolving addresses to function names)
291    /// lands in `v0.9.2`. This method exposes raw addresses only.
292    ///
293    /// # Example
294    ///
295    /// ```no_run
296    /// # #[cfg(feature = "backtraces")]
297    /// # fn demo() {
298    /// use mod_alloc::ModAlloc;
299    ///
300    /// #[global_allocator]
301    /// static GLOBAL: ModAlloc = ModAlloc::new();
302    ///
303    /// let _v: Vec<u8> = vec![0; 1024];
304    /// for site in GLOBAL.call_sites() {
305    ///     println!("{} allocs, {} bytes at {:#x}",
306    ///         site.count, site.total_bytes, site.frames[0]);
307    /// }
308    /// # }
309    /// ```
310    #[cfg(feature = "backtraces")]
311    pub fn call_sites(&self) -> Vec<CallSiteStats> {
312        backtrace::call_sites_report()
313    }
314
315    /// Drain the per-call-site table and symbolicate each frame
316    /// against the running binary's own debug info.
317    ///
318    /// Available only with the `symbolicate` cargo feature, which
319    /// also implies `backtraces`. Returns one
320    /// [`SymbolicatedCallSite`] per unique call site, each
321    /// carrying resolved function names plus (where available)
322    /// source file and line.
323    ///
324    /// Allocates. Safe to call from non-allocator contexts only
325    /// (ordinary user code outside the global-allocator hook).
326    ///
327    /// Results are cached per-address across calls.
328    ///
329    /// # Example
330    ///
331    /// ```no_run
332    /// # #[cfg(feature = "symbolicate")]
333    /// # fn demo() {
334    /// use mod_alloc::ModAlloc;
335    ///
336    /// #[global_allocator]
337    /// static GLOBAL: ModAlloc = ModAlloc::new();
338    ///
339    /// let _v: Vec<u8> = vec![0; 1024];
340    /// for site in GLOBAL.symbolicated_report() {
341    ///     let top = &site.frames[0];
342    ///     println!("{} allocs / {} bytes at {}",
343    ///         site.count,
344    ///         site.total_bytes,
345    ///         top.function.as_deref().unwrap_or("<unresolved>"));
346    /// }
347    /// # }
348    /// ```
349    #[cfg(feature = "symbolicate")]
350    pub fn symbolicated_report(&self) -> Vec<SymbolicatedCallSite> {
351        symbolicate::symbolicated_report()
352    }
353
354    /// Render the per-call-site report as a DHAT-compatible JSON
355    /// string.
356    ///
357    /// Available only with the `dhat-compat` cargo feature (which
358    /// implies `backtraces`). When the `symbolicate` feature is
359    /// also active, frame strings carry function names and
360    /// (where available) source file and line; otherwise they
361    /// carry raw hex addresses.
362    ///
363    /// Allocates. Safe to call from non-allocator contexts only
364    /// (ordinary user code outside the global-allocator hook).
365    ///
366    /// The output schema (`dhatFileVersion: 2`, `mode: "rust-heap"`)
367    /// matches the format consumed by the upstream
368    /// `dh_view.html` viewer shipped with Valgrind.
369    ///
370    /// # Example
371    ///
372    /// ```no_run
373    /// # #[cfg(feature = "dhat-compat")]
374    /// # fn demo() {
375    /// use mod_alloc::ModAlloc;
376    ///
377    /// #[global_allocator]
378    /// static GLOBAL: ModAlloc = ModAlloc::new();
379    ///
380    /// let _v: Vec<u8> = vec![0; 1024];
381    /// let json = GLOBAL.dhat_json_string();
382    /// assert!(json.contains("\"dhatFileVersion\":2"));
383    /// # }
384    /// ```
385    #[cfg(feature = "dhat-compat")]
386    pub fn dhat_json_string(&self) -> String {
387        dhat_json::dhat_json_string()
388    }
389
390    /// Render the per-call-site report and write it to `path` as
391    /// DHAT-compatible JSON.
392    ///
393    /// Available only with the `dhat-compat` cargo feature.
394    /// Mirrors `dhat-rs`'s convention of writing
395    /// `dhat-heap.json` to the current directory; pass that path
396    /// to drop a file the upstream viewer will load directly.
397    ///
398    /// Allocates. Safe to call from non-allocator contexts only.
399    ///
400    /// # Example
401    ///
402    /// ```no_run
403    /// # #[cfg(feature = "dhat-compat")]
404    /// # fn demo() -> std::io::Result<()> {
405    /// use mod_alloc::ModAlloc;
406    ///
407    /// #[global_allocator]
408    /// static GLOBAL: ModAlloc = ModAlloc::new();
409    ///
410    /// let _v: Vec<u8> = vec![0; 1024];
411    /// GLOBAL.write_dhat_json("dhat-heap.json")?;
412    /// # Ok(())
413    /// # }
414    /// ```
415    #[cfg(feature = "dhat-compat")]
416    pub fn write_dhat_json<P: AsRef<std::path::Path>>(&self, path: P) -> std::io::Result<()> {
417        dhat_json::write_dhat_json(path.as_ref())
418    }
419}
420
421impl Default for ModAlloc {
422    fn default() -> Self {
423        Self::new()
424    }
425}
426
427// SAFETY: `ModAlloc` adds counter bookkeeping but performs all
428// underlying allocation through [`std::alloc::System`]. Each method
429// forwards its arguments unchanged to `System` and only inspects
430// the result; size/alignment invariants required by the
431// `GlobalAlloc` contract are passed through unmodified, so the
432// caller's contract to us becomes our contract to System.
433//
434// The counter-update path uses thread-local reentrancy detection
435// (see `ReentryGuard`) so that any allocation triggered transitively
436// inside the tracking path bypasses tracking and forwards directly
437// to System, preserving the "hook MUST NOT itself allocate"
438// invariant from REPS section 4.
439unsafe impl GlobalAlloc for ModAlloc {
440    unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
441        // SAFETY: per `GlobalAlloc::alloc`, `layout` has non-zero
442        // size; we forward unchanged to `System.alloc`, which has
443        // the same contract.
444        let ptr = unsafe { System.alloc(layout) };
445        if !ptr.is_null() {
446            if let Some(_g) = ReentryGuard::enter() {
447                let size = layout.size() as u64;
448                self.record_alloc(size);
449                self.register_self();
450                #[cfg(feature = "backtraces")]
451                backtrace::record_event(size);
452            }
453        }
454        ptr
455    }
456
457    unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 {
458        // SAFETY: same invariants as `alloc`; `layout` forwarded
459        // unchanged. `System.alloc_zeroed` zero-fills the returned
460        // memory, satisfying the `GlobalAlloc::alloc_zeroed`
461        // contract.
462        let ptr = unsafe { System.alloc_zeroed(layout) };
463        if !ptr.is_null() {
464            if let Some(_g) = ReentryGuard::enter() {
465                let size = layout.size() as u64;
466                self.record_alloc(size);
467                self.register_self();
468                #[cfg(feature = "backtraces")]
469                backtrace::record_event(size);
470            }
471        }
472        ptr
473    }
474
475    unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
476        // SAFETY: per `GlobalAlloc::dealloc`, `ptr` was returned by a
477        // prior call to `alloc`/`alloc_zeroed`/`realloc` on this
478        // allocator with the given `layout`; we forwarded all of
479        // those to `System` with the same `layout`, so the inverse
480        // pairing for `System.dealloc(ptr, layout)` is valid.
481        unsafe { System.dealloc(ptr, layout) };
482        if let Some(_g) = ReentryGuard::enter() {
483            self.record_dealloc(layout.size() as u64);
484        }
485    }
486
487    unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 {
488        // SAFETY: per `GlobalAlloc::realloc`, `ptr` was returned by
489        // a prior allocation with `layout`, `new_size` is non-zero,
490        // and the alignment in `layout` remains valid for the new
491        // size. We forward all three to `System.realloc` which has
492        // the same contract.
493        let new_ptr = unsafe { System.realloc(ptr, layout, new_size) };
494        if !new_ptr.is_null() {
495            if let Some(_g) = ReentryGuard::enter() {
496                self.record_realloc(layout.size() as u64, new_size as u64);
497                self.register_self();
498                // Per dhat semantics: realloc records as one event
499                // attributed to `new_size` (including shrinks).
500                #[cfg(feature = "backtraces")]
501                backtrace::record_event(new_size as u64);
502            }
503        }
504        new_ptr
505    }
506}
507
508/// Snapshot of allocation statistics at a point in time.
509///
510/// Produced by [`ModAlloc::snapshot`] and [`Profiler::stop`].
511///
512/// # Example
513///
514/// ```
515/// use mod_alloc::AllocStats;
516///
517/// let stats = AllocStats {
518///     alloc_count: 10,
519///     total_bytes: 1024,
520///     peak_bytes: 512,
521///     current_bytes: 256,
522/// };
523/// assert_eq!(stats.alloc_count, 10);
524/// ```
525#[derive(Debug, Clone, Copy, PartialEq, Eq)]
526pub struct AllocStats {
527    /// Number of allocations performed.
528    pub alloc_count: u64,
529    /// Total bytes allocated across all allocations. Reallocations
530    /// contribute the growth delta (or zero on shrink).
531    pub total_bytes: u64,
532    /// Peak resident bytes (highest `current_bytes` ever observed).
533    pub peak_bytes: u64,
534    /// Currently-allocated bytes (allocations minus deallocations).
535    pub current_bytes: u64,
536}
537
538/// Scoped profiler that captures a delta between start and stop.
539///
540/// Read the snapshot of the installed [`ModAlloc`] on construction
541/// and again on [`Profiler::stop`], returning the difference. If no
542/// `ModAlloc` is installed as `#[global_allocator]` and no
543/// allocation has occurred through it yet, both snapshots are
544/// zero and the delta is zero.
545///
546/// # Example
547///
548/// ```no_run
549/// use mod_alloc::{ModAlloc, Profiler};
550///
551/// #[global_allocator]
552/// static GLOBAL: ModAlloc = ModAlloc::new();
553///
554/// fn main() {
555///     let p = Profiler::start();
556///     let v: Vec<u8> = vec![0; 1024];
557///     drop(v);
558///     let stats = p.stop();
559///     println!("Captured {} alloc events", stats.alloc_count);
560/// }
561/// ```
562pub struct Profiler {
563    baseline: AllocStats,
564}
565
566impl Profiler {
567    /// Begin profiling, capturing the current allocation state.
568    ///
569    /// If no `ModAlloc` is installed as `#[global_allocator]` or no
570    /// allocation has occurred yet, the captured baseline is all
571    /// zeros.
572    ///
573    /// # Example
574    ///
575    /// ```
576    /// use mod_alloc::Profiler;
577    ///
578    /// let p = Profiler::start();
579    /// let _delta = p.stop();
580    /// ```
581    pub fn start() -> Self {
582        Self {
583            baseline: current_snapshot_or_zeros(),
584        }
585    }
586
587    /// Stop profiling and return the delta from start.
588    ///
589    /// `alloc_count`, `total_bytes`, and `current_bytes` are deltas
590    /// from `start()` to `stop()`. `peak_bytes` is the absolute
591    /// high-water mark observed during the profiling window (peak
592    /// has no meaningful delta semantic).
593    ///
594    /// # Example
595    ///
596    /// ```
597    /// use mod_alloc::Profiler;
598    ///
599    /// let p = Profiler::start();
600    /// let stats = p.stop();
601    /// assert_eq!(stats.alloc_count, 0);
602    /// ```
603    pub fn stop(self) -> AllocStats {
604        let now = current_snapshot_or_zeros();
605        AllocStats {
606            alloc_count: now.alloc_count.saturating_sub(self.baseline.alloc_count),
607            total_bytes: now.total_bytes.saturating_sub(self.baseline.total_bytes),
608            current_bytes: now
609                .current_bytes
610                .saturating_sub(self.baseline.current_bytes),
611            peak_bytes: now.peak_bytes,
612        }
613    }
614}
615
616fn current_snapshot_or_zeros() -> AllocStats {
617    let p = GLOBAL_HANDLE.load(Ordering::Acquire);
618    if p.is_null() {
619        AllocStats {
620            alloc_count: 0,
621            total_bytes: 0,
622            peak_bytes: 0,
623            current_bytes: 0,
624        }
625    } else {
626        // SAFETY: `GLOBAL_HANDLE` is only ever set by
627        // `ModAlloc::register_self` to point at the address of a
628        // `#[global_allocator] static` (or any other `'static`
629        // `ModAlloc`). That target has `'static` lifetime, so the
630        // pointer remains valid for the remainder of the program.
631        // We produce only a shared borrow used to call `&self`
632        // methods that read atomic counters; no mutation through
633        // the pointer occurs here.
634        unsafe { (*p).snapshot() }
635    }
636}
637
638#[cfg(test)]
639mod tests {
640    use super::*;
641
642    #[test]
643    fn allocator_constructs() {
644        let _ = ModAlloc::new();
645    }
646
647    #[test]
648    fn snapshot_returns_zeros_initially() {
649        let a = ModAlloc::new();
650        let s = a.snapshot();
651        assert_eq!(s.alloc_count, 0);
652        assert_eq!(s.total_bytes, 0);
653        assert_eq!(s.peak_bytes, 0);
654        assert_eq!(s.current_bytes, 0);
655    }
656
657    #[test]
658    fn reset_works() {
659        let a = ModAlloc::new();
660        a.reset();
661        let s = a.snapshot();
662        assert_eq!(s.alloc_count, 0);
663    }
664
665    #[test]
666    fn record_alloc_updates_counters() {
667        let a = ModAlloc::new();
668        a.record_alloc(128);
669        a.record_alloc(256);
670        let s = a.snapshot();
671        assert_eq!(s.alloc_count, 2);
672        assert_eq!(s.total_bytes, 384);
673        assert_eq!(s.current_bytes, 384);
674        assert_eq!(s.peak_bytes, 384);
675    }
676
677    #[test]
678    fn record_dealloc_decreases_current_only() {
679        let a = ModAlloc::new();
680        a.record_alloc(1000);
681        a.record_dealloc(400);
682        let s = a.snapshot();
683        assert_eq!(s.alloc_count, 1);
684        assert_eq!(s.total_bytes, 1000);
685        assert_eq!(s.current_bytes, 600);
686        assert_eq!(s.peak_bytes, 1000);
687    }
688
689    #[test]
690    fn record_realloc_growth_updates_total_and_peak() {
691        let a = ModAlloc::new();
692        a.record_alloc(100);
693        a.record_realloc(100, 250);
694        let s = a.snapshot();
695        assert_eq!(s.alloc_count, 2);
696        assert_eq!(s.total_bytes, 250);
697        assert_eq!(s.current_bytes, 250);
698        assert_eq!(s.peak_bytes, 250);
699    }
700
701    #[test]
702    fn record_realloc_shrink_only_adjusts_current() {
703        let a = ModAlloc::new();
704        a.record_alloc(500);
705        a.record_realloc(500, 200);
706        let s = a.snapshot();
707        assert_eq!(s.alloc_count, 2);
708        assert_eq!(s.total_bytes, 500);
709        assert_eq!(s.current_bytes, 200);
710        assert_eq!(s.peak_bytes, 500);
711    }
712
713    #[test]
714    fn peak_holds_high_water_mark() {
715        let a = ModAlloc::new();
716        a.record_alloc(1000);
717        a.record_dealloc(1000);
718        a.record_alloc(500);
719        let s = a.snapshot();
720        assert_eq!(s.peak_bytes, 1000);
721        assert_eq!(s.current_bytes, 500);
722    }
723
724    #[test]
725    fn reentry_guard_blocks_nested_entry() {
726        let outer = ReentryGuard::enter();
727        assert!(outer.is_some());
728        let inner = ReentryGuard::enter();
729        assert!(inner.is_none(), "nested entry must be denied");
730        drop(outer);
731        let after = ReentryGuard::enter();
732        assert!(after.is_some(), "entry must be allowed after outer drops");
733    }
734
735    #[test]
736    fn profiler_start_stop_with_no_handle() {
737        let p = Profiler::start();
738        let s = p.stop();
739        assert_eq!(s.alloc_count, 0);
740    }
741}