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}