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}