Skip to main content

forge_alloc/hardening/
statistics.rs

1//! `Statistics<I>` — atomic counters for allocation observability.
2//!
3//! Wrap any allocator during development (or production, when the feature
4//! is opted in) to see allocation patterns: total counts, current and peak
5//! byte usage, and failure counts. The act of wrapping IS the opt-in; an
6//! unwrapped allocator pays zero cost.
7//!
8//! See `docs/ARCHITECTURE.md` for design context.
9
10use core::ptr::NonNull;
11use core::sync::atomic::{AtomicUsize, Ordering};
12
13use forge_alloc_core::{
14    AllocError, Allocator, CachePadded, Deallocator, FixedRange, NonZeroLayout, CACHE_LINE,
15};
16
17/// Snapshot of allocation activity. All counters are atomic; reading is
18/// `Ordering::Relaxed` because counter values are advisory (operators read
19/// them for diagnostics, not for ordering guarantees).
20///
21/// Each counter is wrapped in [`CachePadded`] so concurrent updates from
22/// different code paths (alloc / dealloc / failure) don't ping-pong as a
23/// single cache line when `Statistics` wraps a multi-thread allocator
24/// (e.g. `Statistics<SharedBumpArena>`). The public field type therefore
25/// reads as `CachePadded<AtomicUsize>`; auto-deref keeps the call sites
26/// (`stats.total_allocations.fetch_add(...)`) unchanged.
27///
28/// # Width: `AtomicUsize`, not `AtomicU64`
29///
30/// Each counter is `AtomicUsize` so this crate compiles on 32-bit
31/// bare-metal targets (Cortex-M3/M4, `thumbv7em-none-eabihf`,
32/// `wasm32-unknown-unknown` without the `atomics` feature) that lack
33/// native 64-bit atomic ops. The convenience helpers ([`current_bytes`],
34/// [`peak_bytes`], [`live_count`]) widen each load to `u64` at the
35/// boundary so the public read-API is uniform across host widths.
36///
37/// **Practical impact on 32-bit:**
38///   - `bytes_allocated` / `bytes_peak`: capped at `usize::MAX` (4 GiB
39///     on 32-bit), which equals the address-space ceiling anyway —
40///     a tighter cap is impossible on these targets.
41///   - `total_allocations` / `total_deallocations` / `failures`: cap at
42///     `u32::MAX ≈ 4.3 B`. For long-running 32-bit deployments, the
43///     counter wraps after that many ops; values are advisory only.
44///   - `corruption_events`: cap at `u32::MAX`. Even at one event per
45///     microsecond (already an unrealistic attack rate) the counter
46///     would not wrap for ~71 minutes; real workloads see ≪1 event/year.
47///
48/// Marked `#[non_exhaustive]` so additional observability counters can
49/// be added in future releases without a breaking change.
50///
51/// [`current_bytes`]: AllocStats::current_bytes
52/// [`peak_bytes`]: AllocStats::peak_bytes
53/// [`live_count`]: AllocStats::live_count
54#[non_exhaustive]
55#[derive(Debug)]
56pub struct AllocStats {
57    /// Total successful `allocate` calls observed by this wrapper.
58    pub total_allocations: CachePadded<AtomicUsize>,
59    /// Total `deallocate` calls observed by this wrapper.
60    pub total_deallocations: CachePadded<AtomicUsize>,
61    /// Bytes currently held by live allocations.
62    pub bytes_allocated: CachePadded<AtomicUsize>,
63    /// High-water mark of `bytes_allocated`.
64    pub bytes_peak: CachePadded<AtomicUsize>,
65    /// Failed `allocate` calls (returned `AllocError`).
66    pub failures: CachePadded<AtomicUsize>,
67    /// Detected freelist / metadata corruption events, mirrored from
68    /// the inner allocator's [`Allocator::corruption_events`] counter
69    /// via `fetch_max` on each allocate/deallocate call through this
70    /// wrapper.
71    ///
72    /// The inner allocator is the source of truth (each corruption-
73    /// detection site bumps its allocator-local counter at the moment
74    /// of detection); this mirror lets readers of `AllocStats` see the
75    /// corruption count alongside the other counters without needing to
76    /// reach through to the inner.
77    ///
78    /// **Eventually consistent**: between calls to this wrapper, the
79    /// inner counter may have advanced. The mirror is updated on every
80    /// allocate and deallocate through this wrapper.
81    pub corruption_events: CachePadded<AtomicUsize>,
82}
83
84impl AllocStats {
85    /// Construct fresh zeroed counters.
86    pub const fn new() -> Self {
87        Self {
88            total_allocations: CachePadded::new(AtomicUsize::new(0)),
89            total_deallocations: CachePadded::new(AtomicUsize::new(0)),
90            bytes_allocated: CachePadded::new(AtomicUsize::new(0)),
91            bytes_peak: CachePadded::new(AtomicUsize::new(0)),
92            failures: CachePadded::new(AtomicUsize::new(0)),
93            corruption_events: CachePadded::new(AtomicUsize::new(0)),
94        }
95    }
96
97    /// Bytes currently in use. Widened to `u64` at the boundary so
98    /// callers get a uniform read-API across 32-bit and 64-bit hosts;
99    /// the underlying counter is `AtomicUsize` (capped at `usize::MAX`).
100    #[inline]
101    pub fn current_bytes(&self) -> u64 {
102        self.bytes_allocated.load(Ordering::Relaxed) as u64
103    }
104
105    /// Peak bytes ever in use during the wrapper's lifetime. Widened to
106    /// `u64` at the boundary — see [`current_bytes`](Self::current_bytes).
107    #[inline]
108    pub fn peak_bytes(&self) -> u64 {
109        self.bytes_peak.load(Ordering::Relaxed) as u64
110    }
111
112    /// Net live allocation count (allocations − deallocations).
113    #[inline]
114    pub fn live_count(&self) -> i64 {
115        let a = self.total_allocations.load(Ordering::Relaxed) as i64;
116        let d = self.total_deallocations.load(Ordering::Relaxed) as i64;
117        a - d
118    }
119}
120
121impl Default for AllocStats {
122    fn default() -> Self {
123        Self::new()
124    }
125}
126
127/// Layout-pin static assertions: every contended counter must occupy its
128/// own cache line. If a future refactor reorders or unwraps a field, the
129/// build fails here rather than silently regressing throughput under
130/// multi-thread contention.
131const _: () = {
132    use core::mem::offset_of;
133    let a = offset_of!(AllocStats, total_allocations);
134    let d = offset_of!(AllocStats, total_deallocations);
135    let b = offset_of!(AllocStats, bytes_allocated);
136    let p = offset_of!(AllocStats, bytes_peak);
137    let f = offset_of!(AllocStats, failures);
138    let c = offset_of!(AllocStats, corruption_events);
139    // No two contended counters share a cache line.
140    assert!(a / CACHE_LINE != d / CACHE_LINE);
141    assert!(a / CACHE_LINE != b / CACHE_LINE);
142    assert!(a / CACHE_LINE != p / CACHE_LINE);
143    assert!(a / CACHE_LINE != f / CACHE_LINE);
144    assert!(a / CACHE_LINE != c / CACHE_LINE);
145    assert!(d / CACHE_LINE != b / CACHE_LINE);
146    assert!(d / CACHE_LINE != p / CACHE_LINE);
147    assert!(d / CACHE_LINE != f / CACHE_LINE);
148    assert!(d / CACHE_LINE != c / CACHE_LINE);
149    assert!(b / CACHE_LINE != p / CACHE_LINE);
150    assert!(b / CACHE_LINE != f / CACHE_LINE);
151    assert!(b / CACHE_LINE != c / CACHE_LINE);
152    assert!(p / CACHE_LINE != f / CACHE_LINE);
153    assert!(p / CACHE_LINE != c / CACHE_LINE);
154    assert!(f / CACHE_LINE != c / CACHE_LINE);
155};
156
157/// Wrapper that records allocation activity in [`AllocStats`].
158///
159/// `Send + Sync` if `I: Send + Sync`. Atomic counters are themselves `Sync`.
160///
161/// # Accounting invariant
162///
163/// `Statistics` records bytes by the layout the **outer caller** passed in.
164/// Wrappers below `Statistics` that pass the layout through unchanged
165/// (`PoisonOnFree`, `Quarantine`, `Watermark`) preserve this — the bytes
166/// counted equal the bytes the caller requested. Wrappers below
167/// `Statistics` that **inflate** the inner layout (`Canary`, `CacheJitter`,
168/// `HugePageAligned`, `SplitMetadata`) consume more inner-allocator bytes
169/// than the counter reports; the counter therefore reflects "bytes the
170/// user asked for", not "bytes the underlying region holds". If you need
171/// the latter, wrap `Statistics` INSIDE the layout-inflating wrapper:
172/// `Canary<Statistics<Slab<T>>>` counts what Slab actually carved. The
173/// recommended position for `Statistics` (`Statistics<PoisonOnFree<Slab>>`)
174/// treats "bytes the user asked for" as the right number to
175/// surface to operators; flip the nesting only if you specifically need
176/// physical accounting.
177///
178/// # API-misuse compile-failures (pinned)
179///
180/// `Statistics<I>` inherits the `Sync` property of its inner allocator.
181/// Wrapping a `!Sync` allocator (such as `Slab`, whose `UnsafeCell` free
182/// list head makes it `!Sync` by design) does **not** silently upgrade
183/// it to `Sync`. Calling `stats()` on a shared reference across threads
184/// when the inner is `!Sync` is therefore rejected at compile time:
185///
186/// ```compile_fail
187/// // FAILS TO COMPILE: `Slab` is `!Sync` (UnsafeCell on the freelist
188/// // head), so `Statistics<Slab<...>>` is also `!Sync`, and
189/// // `assert_sync` rejects it.
190/// use forge_alloc::InlineBacked;
191/// use forge_alloc::Statistics;
192/// use forge_alloc::Slab;
193/// fn assert_sync<T: Sync>() {}
194/// assert_sync::<Statistics<Slab<u64, InlineBacked<512>>>>();
195/// ```
196pub struct Statistics<I> {
197    inner: I,
198    stats: AllocStats,
199}
200
201impl<I> Statistics<I> {
202    /// Wrap.
203    #[inline]
204    pub const fn new(inner: I) -> Self {
205        Self {
206            inner,
207            stats: AllocStats::new(),
208        }
209    }
210
211    /// Borrow the counters. Read-only access — counters are updated by the
212    /// allocator's own methods.
213    #[inline]
214    pub fn stats(&self) -> &AllocStats {
215        &self.stats
216    }
217
218    /// Borrow the inner allocator.
219    #[inline]
220    pub fn inner(&self) -> &I {
221        &self.inner
222    }
223}
224
225unsafe impl<I: Allocator> Deallocator for Statistics<I> {
226    #[inline]
227    unsafe fn deallocate(&self, ptr: NonNull<u8>, layout: NonZeroLayout) {
228        // Update before forwarding so a panic inside `inner.deallocate`
229        // doesn't leave the counters in a misleading state if exception
230        // unwinding is enabled. (Both orderings are defensible; "before"
231        // is the convention in libstd's deallocator wrappers.)
232        //
233        // Panic-safety note: if `inner.deallocate` panics
234        // (e.g. a `Canary` corruption check below us), the
235        // `total_deallocations` and `bytes_allocated` counters
236        // already reflect the dealloc that didn't actually complete.
237        // Counters are advisory; under a panicking inner the program
238        // is already in an undefined operational state (corruption
239        // detected, lower-layer assertion fired), so the counter skew
240        // is acceptable. Do not key allocator correctness on the
241        // counter values.
242        //
243        // The `corruption_events` mirror is polled below — also BEFORE
244        // forwarding — for the additional reason that the inner counter
245        // may have been bumped by earlier silent-disarm events that this
246        // call would otherwise have surfaced post-forward; a panicking
247        // forward would suppress that surfacing entirely.
248        self.stats
249            .total_deallocations
250            .fetch_add(1, Ordering::Relaxed);
251        // Hot-path: a single `fetch_sub` is a single locked-add on x86_64
252        // (`lock xadd` with a negated operand). The previous implementation
253        // used `fetch_update` (a CAS loop) to saturate on caller UB; under
254        // contention that retries on every conflicting RMW, which makes
255        // dealloc cost scale with thread count. Per the Deallocator
256        // contract `ptr` was issued by a previous `allocate(layout)` on
257        // `self`, so `prev >= size` always holds for a correct caller —
258        // no saturation is needed on the happy path. Debug builds still
259        // catch the UB caller bug via `debug_assert!` below.
260        // `layout.size()` is already `NonZeroUsize`, so the counter
261        // width and the increment type now match natively — no `as u64`
262        // cast needed since the switch to `AtomicUsize`.
263        let size = layout.size().get();
264        let prev_for_assert = self
265            .stats
266            .bytes_allocated
267            .fetch_sub(size, Ordering::Relaxed);
268        debug_assert!(
269            prev_for_assert >= size,
270            "Statistics::deallocate underflow: prev={prev_for_assert}, size={size}",
271        );
272        // Mirror inner's corruption counter BEFORE forwarding so that
273        // any prior silent-disarm events that have not yet been folded
274        // into our mirror (a Slab freelist corruption detected during
275        // an earlier `allocate` call, for example) are surfaced even
276        // if `inner.deallocate` ends up panicking (e.g. a Canary check
277        // below us). The mirror is monotonic via `fetch_max`, so missing
278        // an update is recoverable on any subsequent call; what is NOT
279        // recoverable is the operator's snapshot read happening between
280        // the bump and the panic. Polling pre-forward closes that window.
281        //
282        // Per-call observation: under panic=abort (typical hardening
283        // build), an inner panic terminates the process before any reader
284        // can observe; under panic=unwind, the mirror reflects all events
285        // committed up to the call boundary.
286        mirror_corruption(
287            &self.stats.corruption_events,
288            self.inner.corruption_events(),
289        );
290        // SAFETY: forwarded; caller upholds Deallocator contract on `self`.
291        unsafe { self.inner.deallocate(ptr, layout) };
292    }
293}
294
295/// Apply `inner_val` to the `mirror` counter under a `fetch_max`-style
296/// monotonic update.
297///
298/// Adds a fast-path that skips the locked RMW when `inner_val` does not
299/// advance the mirror — on x86_64 `AtomicUsize::fetch_max` lowers to a
300/// `lock cmpxchg` CAS loop (there is no native `lock max`), so the
301/// steady-state no-corruption case (`inner_val == 0` against a mirror
302/// at `0`) would otherwise pay one locked CAS per allocate AND per
303/// deallocate. The `Relaxed` load is a plain `mov` and short-circuits
304/// the cost.
305///
306/// Race-safe under the `fetch_max` semantics: a concurrent thread may
307/// advance the mirror between our load and a (skipped) write, but our
308/// skipped write would also have been a no-op against that larger value.
309/// The inner counter is the source of truth; the mirror is monotonic
310/// and eventually consistent.
311///
312/// **Width clamp.** `inner_val: u64` matches the trait return; the
313/// mirror is `AtomicUsize` for 32-bit portability. We clamp the u64 to
314/// `usize::MAX` before the fetch_max so a hypothetical inner counter
315/// already past 4.3 B (on a 32-bit host, only reachable when the inner
316/// is an aggregator like `WithFallback` that saturating-sums multiple
317/// counters into u64) doesn't truncate-wrap into a smaller mirror value.
318/// On 64-bit hosts the clamp is a no-op.
319#[inline]
320fn mirror_corruption(mirror: &AtomicUsize, inner_val: u64) {
321    // Clamp first so the comparison and write are both honest on
322    // 32-bit hosts. The `min` is a free comparison on all targets.
323    let inner_clamped = inner_val.min(usize::MAX as u64) as usize;
324    if inner_clamped > mirror.load(Ordering::Relaxed) {
325        let _ = mirror.fetch_max(inner_clamped, Ordering::Relaxed);
326    }
327}
328
329unsafe impl<I: Allocator> Allocator for Statistics<I> {
330    #[inline]
331    fn allocate(&self, layout: NonZeroLayout) -> Result<NonNull<[u8]>, AllocError> {
332        let result = self.inner.allocate(layout);
333        // Mirror inner's corruption counter. We do this even on the
334        // Err path: a silent-disarm corruption may have triggered
335        // before the inner ultimately ran out of capacity, and the
336        // operator's first signal is the counter rising.
337        //
338        // Hot-path: the polled read goes through a load-and-skip-if-no-
339        // advance helper (`mirror_corruption`) to avoid an unconditional
340        // locked CAS on every successful allocate. See the helper's doc
341        // comment for the rationale; in the steady-state no-corruption
342        // case `inner_val == mirror == 0` and the locked path is skipped.
343        mirror_corruption(
344            &self.stats.corruption_events,
345            self.inner.corruption_events(),
346        );
347        match result {
348            Ok(block) => {
349                self.stats.total_allocations.fetch_add(1, Ordering::Relaxed);
350                // `saturating_add` rather than `+` so a near-`usize::MAX`
351                // `prev` (only reachable via wrap-around after a mismatched
352                // dealloc — UB caller bug — but defended in
353                // `deallocate` below) does not turn that bug into a
354                // debug-mode panic here.
355                //
356                // Width: counter is `AtomicUsize` (for 32-bit portability);
357                // `layout.size().get()` is already `usize`, so this is
358                // width-matched with no cast.
359                let size = layout.size().get();
360                let prev = self
361                    .stats
362                    .bytes_allocated
363                    .fetch_add(size, Ordering::Relaxed);
364                let new = prev.saturating_add(size);
365                // `new` is THIS thread's local "post-add" value. Under
366                // contention another thread's add may already have advanced
367                // the global counter past `new` — that thread's own
368                // fetch_max call will update the peak with its own (larger)
369                // local `new`, so the final peak is still monotonic and
370                // never below the true high-water mark. Relaxed ordering is
371                // fine for advisory counters.
372                //
373                // Fast-path: `fetch_max` on x86_64 lowers to a CAS loop
374                // (`lock cmpxchg`) which contends with every other
375                // wrapper-thread's CAS. Read peak first and skip the CAS
376                // entirely when our `new` doesn't actually move the
377                // high-water mark — common case during steady-state
378                // operation where `new` is well below the long-run peak.
379                let peak_now = self.stats.bytes_peak.load(Ordering::Relaxed);
380                if new > peak_now {
381                    self.stats.bytes_peak.fetch_max(new, Ordering::Relaxed);
382                }
383                Ok(block)
384            }
385            Err(e) => {
386                self.stats.failures.fetch_add(1, Ordering::Relaxed);
387                Err(e)
388            }
389        }
390    }
391
392    #[inline]
393    unsafe fn usable_size(&self, ptr: NonNull<u8>, layout: NonZeroLayout) -> Option<usize> {
394        // Layout-transparent forwarder: `allocate` returns the inner's block
395        // unchanged, so forward `usable_size` too — otherwise an outer scrub
396        // wrapper (`PoisonOnFree`/`ZeroizeOnFree`) over `Statistics` would see
397        // `None` and leave the slack tail un-scrubbed.
398        // SAFETY: forwarded; caller upholds usable_size's contract on inner.
399        unsafe { self.inner.usable_size(ptr, layout) }
400    }
401
402    #[inline]
403    fn capacity_bytes(&self) -> Option<usize> {
404        self.inner.capacity_bytes()
405    }
406
407    #[inline]
408    fn corruption_events(&self) -> u64 {
409        // Forward to inner — the source of truth. The mirror on
410        // `self.stats.corruption_events` is the observable artifact
411        // for AllocStats readers; the trait method returns the live
412        // count.
413        self.inner.corruption_events()
414    }
415}
416
417impl<I: FixedRange> FixedRange for Statistics<I> {
418    #[inline]
419    fn base(&self) -> NonNull<u8> {
420        self.inner.base()
421    }
422
423    #[inline]
424    fn size(&self) -> usize {
425        self.inner.size()
426    }
427
428    /// Pass-through forward so a `commit`-aware consumer reaches the inner
429    /// backing when this wrapper sits over a `lazy_commit` `MmapBacked`.
430    #[inline]
431    fn commit(&self, offset: usize, len: usize) -> Result<(), AllocError> {
432        self.inner.commit(offset, len)
433    }
434}
435
436#[cfg(test)]
437mod tests {
438    use super::*;
439    use crate::backing::InlineBacked;
440    use crate::layout::Slab;
441
442    fn build() -> Statistics<Slab<u64, InlineBacked<512>>> {
443        Statistics::new(Slab::new(16, InlineBacked::<512>::new()).unwrap())
444    }
445
446    #[test]
447    fn counts_increase_with_allocations() {
448        let s = build();
449        let layout = NonZeroLayout::for_type::<u64>().unwrap();
450        assert_eq!(s.stats().total_allocations.load(Ordering::Relaxed), 0);
451        let _ = s.allocate(layout).unwrap();
452        let _ = s.allocate(layout).unwrap();
453        assert_eq!(s.stats().total_allocations.load(Ordering::Relaxed), 2);
454        assert_eq!(s.stats().current_bytes(), 16);
455    }
456
457    #[test]
458    fn peak_updates_above_current() {
459        let s = build();
460        let layout = NonZeroLayout::for_type::<u64>().unwrap();
461        let a = s.allocate(layout).unwrap();
462        let _b = s.allocate(layout).unwrap();
463        assert_eq!(s.stats().peak_bytes(), 16);
464        unsafe { s.deallocate(a.cast(), layout) };
465        // peak does NOT decrease on dealloc.
466        assert_eq!(s.stats().peak_bytes(), 16);
467        assert_eq!(s.stats().current_bytes(), 8);
468    }
469
470    #[test]
471    fn failures_counted_separately() {
472        // Tiny capacity so the second alloc fails.
473        let s: Statistics<Slab<u64, InlineBacked<32>>> =
474            Statistics::new(Slab::new(1, InlineBacked::<32>::new()).unwrap());
475        let layout = NonZeroLayout::for_type::<u64>().unwrap();
476        let _ = s.allocate(layout).unwrap();
477        let _ = s.allocate(layout); // fails
478        assert_eq!(s.stats().failures.load(Ordering::Relaxed), 1);
479        assert_eq!(s.stats().total_allocations.load(Ordering::Relaxed), 1);
480    }
481
482    #[test]
483    fn live_count_tracks_balance() {
484        let s = build();
485        let layout = NonZeroLayout::for_type::<u64>().unwrap();
486        let a = s.allocate(layout).unwrap();
487        let _ = s.allocate(layout).unwrap();
488        assert_eq!(s.stats().live_count(), 2);
489        unsafe { s.deallocate(a.cast(), layout) };
490        assert_eq!(s.stats().live_count(), 1);
491    }
492
493    /// Fresh `Statistics<Slab>` reports zero corruption events both via
494    /// the trait method and via the AllocStats mirror. Allocate +
495    /// deallocate cycles update the mirror to match inner (still 0
496    /// since no corruption was triggered).
497    #[test]
498    fn corruption_events_zero_on_uncorrupted_path() {
499        let s = build();
500        let layout = NonZeroLayout::for_type::<u64>().unwrap();
501        // Trait method on fresh allocator.
502        assert_eq!(s.corruption_events(), 0);
503        // AllocStats mirror starts at 0.
504        assert_eq!(s.stats().corruption_events.load(Ordering::Relaxed), 0);
505        // Round-trip a few allocations — mirror polled on each call.
506        let a = s.allocate(layout).unwrap();
507        let b = s.allocate(layout).unwrap();
508        unsafe {
509            s.deallocate(a.cast(), layout);
510            s.deallocate(b.cast(), layout);
511        }
512        // Still 0 — no corruption was triggered.
513        assert_eq!(s.corruption_events(), 0);
514        assert_eq!(s.stats().corruption_events.load(Ordering::Relaxed), 0);
515    }
516
517    /// `#[non_exhaustive]` on `AllocStats` is the API forward-compat
518    /// promise: callers cannot exhaustively destructure outside the
519    /// crate. We can't actually test the negative compile, but a
520    /// smoke test confirms the field is reachable and zero.
521    #[test]
522    fn alloc_stats_corruption_events_field_accessible() {
523        let stats = AllocStats::new();
524        assert_eq!(stats.corruption_events.load(Ordering::Relaxed), 0);
525    }
526
527    /// Positive end-to-end propagation via the Slab freelist
528    /// out-of-range-next_idx path (a sibling of MAC failure that hits
529    /// the same `corruption_events.fetch_add` site).
530    ///
531    /// **Profile requirement:** this test relies on
532    /// `panic = "unwind"` (the default for `dev` and `test` profiles
533    /// in cargo). Under `panic = "abort"` in a debug build the
534    /// `debug_assert!(false, ...)` inside `Slab::allocate`'s
535    /// corruption branch would abort the process instead of unwinding
536    /// into `catch_unwind`. The workspace has no `panic = "abort"`
537    /// profile today; if one is added in the future, gate this test
538    /// with `#[cfg(panic = "unwind")]` or move the corruption-trigger
539    /// behind a release-only guard.
540    ///
541    /// Setup: allocate p (slot 0) and p2 (slot 1) through
542    /// `Statistics<Slab>`, deallocate p (puts a FreeLink at slot 0
543    /// with `next_idx=0` and the freelist head pointing 1-based at
544    /// slot 0). Then corrupt slot 0's `next_idx` to `u32::MAX` via
545    /// a raw write through the backing's `base()` pointer. The next
546    /// `allocate` pops slot 0, sees `next_idx > capacity` (the
547    /// defense-in-depth check beside MAC verify), and bumps
548    /// `corruption_events` BEFORE the `debug_assert!(false, ...)`
549    /// panics. We use `catch_unwind` so the debug assert doesn't fail
550    /// the test process — in release the assert is compiled out and
551    /// `allocate` falls through to `next_uncarved`.
552    ///
553    /// Two assertions:
554    /// 1. `s.corruption_events()` (forwarded to `Slab::corruption_events()`,
555    ///    a plain atomic load — no mutex) reflects the bump in both
556    ///    build profiles.
557    /// 2. The `AllocStats.corruption_events` mirror catches up on the
558    ///    next call through `Statistics`. We use a *deallocate* (of
559    ///    the pre-allocated p2) because `Statistics::deallocate` polls
560    ///    the inner counter and updates the mirror **before**
561    ///    forwarding — guaranteeing the update lands regardless of
562    ///    what the inner does. (`Statistics::allocate` polls after
563    ///    forwarding, which would deadlock the debug-mode catchup
564    ///    against the still-corrupted freelist.)
565    ///
566    #[test]
567    #[cfg(feature = "std")]
568    fn corruption_events_propagates_from_slab_mac_failure() {
569        use forge_alloc_core::FixedRange;
570        let s = build();
571        let layout = NonZeroLayout::for_type::<u64>().unwrap();
572        assert_eq!(s.corruption_events(), 0);
573
574        // Allocate two slots so we have a known-valid p2 to deallocate
575        // later (the mirror-catchup vehicle that survives a corrupted
576        // freelist).
577        let p = s.allocate(layout).unwrap();
578        let p2 = s.allocate(layout).unwrap();
579        // Deallocate p — slot 0 now holds a FreeLink, freelist head = 1
580        // (1-based, pointing at slot 0).
581        unsafe { s.deallocate(p.cast(), layout) };
582
583        // Corrupt slot 0's FreeLink: write u32::MAX over the `next_idx`
584        // field (the first 4 bytes of the slot, since `FreeLink` is
585        // `next_idx: u32, mac: u32`).
586        //
587        // SAFETY: the backing region is owned by the still-live
588        // Statistics → Slab; `base()` returns a raw pointer to it. The
589        // bytes we're overwriting belong to a *deallocated* slot
590        // (Slab considers them part of its freelist-link metadata,
591        // not user data). The slab is `!Sync` and we hold `&s`, so no
592        // concurrent reader. No `T` destructor runs (the user code
593        // already deallocated `p`).
594        let base = s.base().as_ptr();
595        unsafe {
596            core::ptr::write(base as *mut u32, u32::MAX);
597        }
598
599        // Trigger the corruption-detect branch. With the default
600        // `NoProtection` MAC the `verify` step trivially passes
601        // (zero-MAC matches), so the failure comes from the
602        // defense-in-depth `next_idx > capacity` check beside it —
603        // which bumps the same `corruption_events` counter in
604        // lockstep with the MAC-failure path. catch_unwind swallows
605        // the `debug_assert!(false, ...)` panic in debug builds; in
606        // release the closure returns normally (allocate falls
607        // through to next_uncarved after blanking the corrupted
608        // head).
609        let s_ref = &s;
610        let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
611            let _ = s_ref.allocate(layout);
612        }));
613
614        // (1) Inner counter bumped — works in both debug and release
615        // because `Slab::corruption_events()` is just an atomic load
616        // (no mutex / no panic-poisoning concern).
617        let inner = s.corruption_events();
618        assert!(
619            inner >= 1,
620            "Slab::corruption_events should have incremented on the freelist-OOB-next_idx path (got {inner})",
621        );
622
623        // (2) Mirror catchup via deallocate. `Statistics::deallocate`
624        // mirrors the inner counter BEFORE the forward, so this works
625        // even in debug where a follow-up allocate would re-trip the
626        // corrupted freelist branch. p2 is a known-valid slot 1
627        // pointer; Slab::deallocate just writes a FreeLink at slot 1
628        // and links it ahead of the corrupted slot 0 — no panic.
629        unsafe { s.deallocate(p2.cast(), layout) };
630        // Mirror is `AtomicUsize` (32-bit portability); `inner` is the
631        // trait method's `u64` return. Compare as u64 — the cast is a
632        // widen on 32-bit (lossless) and a no-op on 64-bit.
633        let mirror = s.stats().corruption_events.load(Ordering::Relaxed) as u64;
634        assert!(
635            mirror >= inner,
636            "AllocStats.corruption_events mirror must catch up to inner ({inner}) on the next call (mirror={mirror})",
637        );
638    }
639}