Skip to main content

forge_alloc/layout/
size_classed.rs

1//! `SizeClassed<B, CLASSES>` — array of `CLASSES` untyped slabs with
2//! geometrically increasing block sizes. Routes each allocate request
3//! to the smallest slab whose stride satisfies the request; oversized
4//! requests fall through to the backing.
5//!
6//! See `docs/ARCHITECTURE.md` for the composable-allocator design.
7
8use core::cell::UnsafeCell;
9use core::mem::{align_of, size_of};
10use core::ptr::NonNull;
11use core::sync::atomic::{AtomicUsize, Ordering};
12
13use forge_alloc_core::{AllocError, Allocator, Deallocator, FixedRange, NonZeroLayout};
14
15/// Default class sizes (bytes): 8, 16, 32, 64, 128, 256, 512, 1024.
16pub const DEFAULT_CLASS_SIZES_8: [usize; 8] = [8, 16, 32, 64, 128, 256, 512, 1024];
17
18/// Free-list link stored inside a free slot. Same layout as `Slab`'s
19/// internal `FreeLink` (1-based indices + zero sentinel) so the
20/// algorithm is identical; we duplicate it here rather than expose it
21/// from the typed slab to keep crate-internal types separate.
22#[repr(C)]
23#[derive(Copy, Clone)]
24struct FreeLink {
25    next_idx: u32,
26}
27
28/// Erased `Slab<u8>` with a runtime block stride. Internal — wrap with
29/// `SizeClassed` for the public API.
30///
31/// Stores a **byte offset** from the backing's `base()` rather than an
32/// absolute pointer. This mirrors the stale-base-pointer fix applied to
33/// `Slab`, `BumpArena`, `SharedBumpArena`, and `StackAlloc`: an
34/// absolute pointer cached at construction time would point at the
35/// backing's *pre-move* location forever once `SizeClassed` is returned
36/// by value, silently corrupting any subsequent `allocate`/`deallocate`
37/// on a structure-relative backing such as `InlineBacked<N>`. By
38/// storing the offset and resolving `backing_base + offset` at each
39/// access, the slab follows the backing wherever it lives.
40struct UntypedSlab {
41    base_offset: usize,
42    block_stride: usize,
43    capacity: u32,
44    /// 1-based slot index; `0` = freelist empty.
45    free_head: UnsafeCell<u32>,
46    /// 0-based index of the first slot never yet carved.
47    next_uncarved: UnsafeCell<u32>,
48    /// Detected corruption events at this class: `next_idx > capacity`
49    /// defense-in-depth tripwire on the freelist, plus `slot_index`-
50    /// returns-None caller-contract violations on `free_slot`. Exposed
51    /// via `SizeClassed::corruption_events`.
52    ///
53    /// `AtomicUsize` (not `AtomicU64`) for 32-bit bare-metal portability;
54    /// see `Slab::corruption_events` for the rationale and overflow
55    /// analysis.
56    corruption_events: AtomicUsize,
57}
58
59impl UntypedSlab {
60    /// Construct over a pre-allocated region of `capacity * block_stride`
61    /// bytes starting at `backing_base + base_offset`. The caller is
62    /// responsible for ensuring the region is at least that large and
63    /// stays valid for the slab's lifetime.
64    fn new(base_offset: usize, block_stride: usize, capacity: u32) -> Self {
65        Self {
66            base_offset,
67            block_stride,
68            capacity,
69            free_head: UnsafeCell::new(0),
70            next_uncarved: UnsafeCell::new(0),
71            corruption_events: AtomicUsize::new(0),
72        }
73    }
74
75    /// Resolve the slab's current absolute base from the live backing
76    /// base pointer. Per-call recompute defeats the pre-move-cached-
77    /// pointer UAF described in the stale-base-pointer design note.
78    #[inline]
79    fn base_ptr(&self, backing_base: NonNull<u8>) -> NonNull<u8> {
80        // SAFETY: at construction we placed the region inside
81        // [backing_base, backing_base + backing.size()) and segments are
82        // never reallocated; `add(base_offset)` therefore lands inside
83        // the same allocation.
84        unsafe { NonNull::new_unchecked(backing_base.as_ptr().add(self.base_offset)) }
85    }
86
87    #[inline]
88    fn slot_ptr(&self, backing_base: NonNull<u8>, idx: u32) -> *mut u8 {
89        // SAFETY: caller verifies idx < capacity.
90        unsafe {
91            self.base_ptr(backing_base)
92                .as_ptr()
93                .add(idx as usize * self.block_stride)
94        }
95    }
96
97    /// Compute the 0-based slot index for `ptr`, or `None` if `ptr` is
98    /// not aligned to a slot boundary or out of range.
99    fn slot_index(&self, backing_base: NonNull<u8>, ptr: NonNull<u8>) -> Option<u32> {
100        let p = ptr.as_ptr() as usize;
101        let base = self.base_ptr(backing_base).as_ptr() as usize;
102        if p < base {
103            return None;
104        }
105        let offset = p - base;
106        if offset % self.block_stride != 0 {
107            return None;
108        }
109        let idx = offset / self.block_stride;
110        if idx >= self.capacity as usize {
111            return None;
112        }
113        u32::try_from(idx).ok()
114    }
115
116    /// `Some(ptr)` if a slot was popped; `None` if both the freelist and
117    /// the uncarved region are exhausted.
118    ///
119    /// # Safety
120    ///
121    /// Caller must be the unique accessor of this slab (the wrapping
122    /// `SizeClassed` is `!Sync`).
123    unsafe fn allocate_slot(&self, backing_base: NonNull<u8>) -> Option<NonNull<u8>> {
124        // SAFETY: !Sync — single-threaded access.
125        let head = unsafe { *self.free_head.get() };
126        if head != 0 {
127            // Freelist non-empty — pop.
128            let slot_idx = head - 1;
129            // SAFETY: head was a valid 1-based slot index by construction.
130            let slot_ptr = self.slot_ptr(backing_base, slot_idx);
131            // SAFETY: slot holds a FreeLink because allocate_slot/free_slot
132            // are the only writers and they only put FreeLink there when
133            // pushing onto the free list. Aligned read is sound because
134            // `block_stride` is a power of two >= size_of::<FreeLink>() = 4
135            // and base came from `backing.allocate(layout)` with align ==
136            // stride, so every slot_ptr satisfies align_of::<FreeLink>().
137            let link = unsafe { slot_ptr.cast::<FreeLink>().read() };
138            // Defense-in-depth: UntypedSlab has no MAC, so a corrupted
139            // next_idx would route the next pop to an arbitrary slot index.
140            // Reject `next_idx > capacity` and abandon the freelist — the
141            // wider class-region bound limits blast radius, and carving
142            // fresh from `next_uncarved` lets the slab continue safely.
143            if link.next_idx > self.capacity {
144                // Record BEFORE the debug_assert so the counter reflects
145                // the detection in both debug and release builds. See
146                // `Slab::allocate` for the full rationale. `Relaxed` is
147                // correct: advisory observability counter, no other
148                // state synchronizes against it.
149                self.corruption_events.fetch_add(1, Ordering::Relaxed);
150                debug_assert!(
151                    false,
152                    "UntypedSlab freelist corruption: next_idx={} > capacity={}",
153                    link.next_idx, self.capacity,
154                );
155                unsafe { *self.free_head.get() = 0 };
156                // Fall through to next_uncarved carve below.
157            } else {
158                unsafe { *self.free_head.get() = link.next_idx };
159                return Some(unsafe { NonNull::new_unchecked(slot_ptr) });
160            }
161        }
162        // Freelist empty — try to carve from the uncarved region.
163        let nuc = unsafe { *self.next_uncarved.get() };
164        if nuc >= self.capacity {
165            return None;
166        }
167        let p = self.slot_ptr(backing_base, nuc);
168        unsafe { *self.next_uncarved.get() = nuc + 1 };
169        Some(unsafe { NonNull::new_unchecked(p) })
170    }
171
172    /// # Safety
173    ///
174    /// `ptr` must have been returned by a previous call to
175    /// [`allocate_slot`](Self::allocate_slot) on *this* slab. Caller is
176    /// the unique accessor (wrapped by `!Sync` `SizeClassed`).
177    unsafe fn free_slot(&self, backing_base: NonNull<u8>, ptr: NonNull<u8>) {
178        let Some(idx) = self.slot_index(backing_base, ptr) else {
179            // Record BEFORE the debug_assert so the counter reflects the
180            // detection in both debug and release builds. See
181            // `Slab::allocate` for the full rationale. `Relaxed` is
182            // correct: advisory observability counter.
183            self.corruption_events.fetch_add(1, Ordering::Relaxed);
184            debug_assert!(false, "UntypedSlab::free_slot: pointer outside range");
185            return;
186        };
187        // SAFETY: !Sync.
188        let old_head = unsafe { *self.free_head.get() };
189        // SAFETY: slot is at least `block_stride` bytes (>= size_of::<FreeLink>()
190        // = 4) and slot_ptr is `block_stride`-aligned, which is also a
191        // multiple of align_of::<FreeLink>() = 4. Aligned write is sound.
192        unsafe {
193            ptr.as_ptr()
194                .cast::<FreeLink>()
195                .write(FreeLink { next_idx: old_head });
196            *self.free_head.get() = idx + 1;
197        };
198    }
199}
200
201/// SizeClassed allocator.
202///
203/// `CLASSES` size classes are configured at construction. An allocate
204/// request is routed to the smallest class whose stride is `>= size`
205/// AND whose stride is a multiple of the requested alignment (any
206/// class with `stride < align` is skipped). Oversized or
207/// over-aligned requests fall through to `backing.allocate(layout)`.
208///
209/// Deallocation routes by provenance: if `ptr` falls inside one of
210/// the class regions, it's a class allocation and gets pushed onto
211/// that class's freelist. Otherwise it came from the fallback path
212/// and is forwarded to `backing.deallocate`.
213///
214/// `!Sync` — concurrent allocate would race on the per-class
215/// `free_head` / `next_uncarved` cells.
216///
217/// **Reset is unsupported:** like [`Slab`](crate::Slab), `SizeClassed` does not implement
218/// `Allocator::reset` (it returns the default `Err`). Bulk reclaim is via
219/// `Drop` (rebuild the allocator); there is no in-place "empty all classes"
220/// operation. Do not reset the backing arena out-of-band — the per-class
221/// freelists would then point into logically-reclaimed (but not re-zeroed)
222/// memory.
223pub struct SizeClassed<B: Allocator + FixedRange, const CLASSES: usize> {
224    backing: B,
225    class_sizes: [usize; CLASSES],
226    slabs: [UntypedSlab; CLASSES],
227    /// We allocated this many backing layouts for the class regions;
228    /// remember them so Drop can return them to `backing`.
229    class_layouts: [NonZeroLayout; CLASSES],
230    /// O(1) routing table for `pick_class`. Indexed by
231    /// `req.next_power_of_two().trailing_zeros()` (i.e. log2 of the
232    /// smallest pow2 ≥ req). Stores the class index whose stride is the
233    /// smallest pow2 ≥ 2^k, or `CLASS_LOOKUP_NONE` for "no class fits".
234    /// 32 entries covers all addressable sizes on 32-bit; on 64-bit a
235    /// request that overflows `next_power_of_two` falls through to backing.
236    class_lookup: [u8; 32],
237    /// Cached `region_size` per class — `slabs[i].capacity * slabs[i].block_stride`.
238    /// Lives here (not on UntypedSlab) because `route_dealloc` needs the
239    /// pair `(base_offset, region_size)` in cache-hot form for fast bounds
240    /// rejection. The base address is computed *live* via
241    /// `self.backing.base() + slabs[i].base_offset` — caching it would
242    /// reintroduce the pre-move-stale-pointer UAF previously
243    /// surfaced for `Slab`/`BumpArena`/`SharedBumpArena`/`StackAlloc`.
244    region_sizes: [usize; CLASSES],
245}
246
247/// Sentinel in `class_lookup` meaning "no class fits this size; use backing fallback".
248const CLASS_LOOKUP_NONE: u8 = u8::MAX;
249
250impl<B: Allocator + FixedRange, const CLASSES: usize> SizeClassed<B, CLASSES> {
251    /// Construct with explicit class sizes and a uniform per-class
252    /// slot count.
253    ///
254    /// Each class allocates a region of `class_size * slots_per_class`
255    /// bytes from `backing`. Class sizes must be strictly increasing
256    /// and each must be a power of two (the inferred per-class
257    /// alignment requirement). Returns `Err(AllocError)` if any
258    /// constraint is violated, if the backing rejects a class region,
259    /// or if `CLASSES == 0`.
260    ///
261    /// `FreeLink` is 4 bytes (u32), so the minimum class size is 4.
262    pub fn with_class_sizes(
263        backing: B,
264        class_sizes: [usize; CLASSES],
265        slots_per_class: u32,
266    ) -> Result<Self, AllocError> {
267        if CLASSES == 0 || slots_per_class == 0 {
268            return Err(AllocError);
269        }
270        // Validate strictly increasing, power-of-two, >= sizeof(FreeLink).
271        let min_size = core::cmp::max(size_of::<FreeLink>(), align_of::<FreeLink>());
272        let mut last = 0usize;
273        for &s in &class_sizes {
274            if s < min_size || !s.is_power_of_two() || s <= last {
275                return Err(AllocError);
276            }
277            last = s;
278        }
279        // Allocate one region per class.
280        // We use MaybeUninit to defer initialising the slab array until
281        // we've successfully built every entry; on partial failure the
282        // already-allocated regions get returned to the backing in
283        // reverse order. The class_layouts array is also init-tracked.
284        // `MaybeUninit::uninit().assume_init()` on an array OF MaybeUninits
285        // is sound: each element is uninit, but `MaybeUninit<T>` itself is
286        // always "init" regardless of T's state (this is the documented
287        // MSRV-friendly idiom; `MaybeUninit::uninit_array` is unstable, and
288        // `[const { ... }; N]` requires Rust 1.79+ vs. our 1.70 MSRV).
289        let mut slabs: [core::mem::MaybeUninit<UntypedSlab>; CLASSES] =
290            unsafe { core::mem::MaybeUninit::uninit().assume_init() };
291        let mut class_layouts: [core::mem::MaybeUninit<NonZeroLayout>; CLASSES] =
292            unsafe { core::mem::MaybeUninit::uninit().assume_init() };
293        let mut built = 0usize;
294        for i in 0..CLASSES {
295            let stride = class_sizes[i];
296            let total = match stride.checked_mul(slots_per_class as usize) {
297                Some(t) => t,
298                None => break,
299            };
300            let layout = match NonZeroLayout::from_size_align(total, stride) {
301                Ok(l) => l,
302                Err(_) => break,
303            };
304            let region = match backing.allocate(layout) {
305                Ok(b) => b,
306                Err(_) => break,
307            };
308            // Convert the absolute region pointer to an offset *now*,
309            // BEFORE `backing` is moved into `Self` below. Storing the
310            // offset and resolving `backing.base() + offset` at each
311            // access keeps the slab pointing at the live backing region
312            // even when the constructor's return-by-value moves the
313            // backing's storage (cf. `InlineBacked<N>::base()` returning
314            // `&self.storage`). This is the same anti-UAF pattern
315            // applied to `Slab`/`BumpArena`/`SharedBumpArena`/
316            // `StackAlloc`.
317            let region_addr = region.cast::<u8>().as_ptr() as usize;
318            let backing_addr = backing.base().as_ptr() as usize;
319            debug_assert!(
320                region_addr >= backing_addr,
321                "backing.allocate produced a pointer below backing.base() — \
322                 backing impl bug, not a SizeClassed bug",
323            );
324            let base_offset = region_addr - backing_addr;
325            slabs[i].write(UntypedSlab::new(base_offset, stride, slots_per_class));
326            class_layouts[i].write(layout);
327            built += 1;
328        }
329        if built != CLASSES {
330            // Undo partial construction in reverse order.
331            for j in (0..built).rev() {
332                // SAFETY: slabs[j] is initialised; same for class_layouts[j].
333                let slab = unsafe { slabs[j].assume_init_read() };
334                let layout = unsafe { class_layouts[j].assume_init_read() };
335                // Compute the absolute base from the still-construction-
336                // local `backing`. We have not moved `backing` into Self
337                // yet, so this is the same address we returned from
338                // `backing.allocate(layout)` above.
339                let base = unsafe {
340                    NonNull::new_unchecked(backing.base().as_ptr().add(slab.base_offset))
341                };
342                unsafe { backing.deallocate(base, layout) };
343            }
344            return Err(AllocError);
345        }
346        // All slabs built; convert MaybeUninit arrays to T arrays.
347        // SAFETY: every element is initialised.
348        let slabs = unsafe {
349            core::mem::transmute_copy::<
350                [core::mem::MaybeUninit<UntypedSlab>; CLASSES],
351                [UntypedSlab; CLASSES],
352            >(&slabs)
353        };
354        let class_layouts = unsafe {
355            core::mem::transmute_copy::<
356                [core::mem::MaybeUninit<NonZeroLayout>; CLASSES],
357                [NonZeroLayout; CLASSES],
358            >(&class_layouts)
359        };
360        // Build the O(1) routing tables now that every slab base is known.
361        // class_lookup[k] = index of smallest class whose stride >= 2^k.
362        // CLASSES <= 255 is enforced by `u8` storage.
363        if CLASSES > CLASS_LOOKUP_NONE as usize {
364            // Undo construction; this is an API misuse caught at construction.
365            for j in (0..CLASSES).rev() {
366                let base = unsafe {
367                    NonNull::new_unchecked(backing.base().as_ptr().add(slabs[j].base_offset))
368                };
369                unsafe { backing.deallocate(base, class_layouts[j]) };
370            }
371            return Err(AllocError);
372        }
373        let mut class_lookup = [CLASS_LOOKUP_NONE; 32];
374        // First pass: mark each class size's bit position with that class index.
375        for (i, &s) in class_sizes.iter().enumerate() {
376            let bit = s.trailing_zeros() as usize;
377            if bit < class_lookup.len() && class_lookup[bit] == CLASS_LOOKUP_NONE {
378                class_lookup[bit] = i as u8;
379            }
380        }
381        // Second pass (right-to-left): fill gaps so requests landing on a
382        // bit position without an exact class round UP to the next-larger
383        // class. After this pass, class_lookup[k] is the smallest class
384        // whose stride is >= 2^k (or NONE if none exists, including all
385        // bit positions above the largest class).
386        let mut next = CLASS_LOOKUP_NONE;
387        for k in (0..class_lookup.len()).rev() {
388            if class_lookup[k] != CLASS_LOOKUP_NONE {
389                next = class_lookup[k];
390            } else {
391                class_lookup[k] = next;
392            }
393        }
394
395        // Cache per-class region sizes for O(1) dealloc routing. The
396        // base address is recomputed *live* on each route_dealloc call
397        // via `self.backing.base() + slabs[i].base_offset`; caching the
398        // absolute base here would reintroduce the move-stale-pointer
399        // UAF previously fixed for `Slab`/`BumpArena`/etc.
400        let mut region_sizes = [0usize; CLASSES];
401        for i in 0..CLASSES {
402            region_sizes[i] = slabs[i].capacity as usize * slabs[i].block_stride;
403        }
404
405        Ok(Self {
406            backing,
407            class_sizes,
408            slabs,
409            class_layouts,
410            class_lookup,
411            region_sizes,
412        })
413    }
414
415    /// Borrow the inner backing.
416    #[inline]
417    pub fn backing(&self) -> &B {
418        &self.backing
419    }
420
421    /// Class sizes in bytes.
422    #[inline]
423    pub fn class_sizes(&self) -> &[usize; CLASSES] {
424        &self.class_sizes
425    }
426
427    /// Find the smallest class whose stride `>= size` AND `>= align`,
428    /// or `None` if no class fits.
429    ///
430    /// O(1): one `next_power_of_two` (lzcnt-class instruction on modern x86)
431    /// + one array lookup against the precomputed `class_lookup` table.
432    #[inline]
433    fn pick_class(&self, size: usize, align: usize) -> Option<usize> {
434        let req = if size > align { size } else { align };
435        // `req` is always >= 1 (NonZeroLayout guarantees size>0 and align>=1).
436        // For `req > 2^(usize::BITS - 1)`, `next_power_of_two` would overflow;
437        // such requests can't fit any class anyway, so route to backing.
438        let max_bit = usize::BITS as usize - 1;
439        if req > (1usize << max_bit) {
440            return None;
441        }
442        let pow2 = req.next_power_of_two();
443        let bit = pow2.trailing_zeros() as usize;
444        // class_lookup has 32 entries; on 64-bit a request > 2^31 lands here
445        // too. The table fill leaves NONE for any uncovered bit position.
446        if bit >= self.class_lookup.len() {
447            return None;
448        }
449        let idx = self.class_lookup[bit];
450        if idx == CLASS_LOOKUP_NONE {
451            None
452        } else {
453            Some(idx as usize)
454        }
455    }
456
457    /// Find the class whose region contains `ptr`, or `None` for
458    /// fallback-path pointers.
459    ///
460    /// Walks `(backing_base + base_offset, region_size)` per class.
461    /// `backing_base` is read once from `self.backing.base()` and is
462    /// the *live* base — this is what defeats the move-stale-pointer
463    /// class of bug. `region_sizes[i]` is the const cached size.
464    /// With `CLASSES <= 16` and const-known length the loop unrolls.
465    #[inline]
466    fn route_dealloc(&self, ptr: NonNull<u8>) -> Option<usize> {
467        let p = ptr.as_ptr() as usize;
468        let backing_base = self.backing.base().as_ptr() as usize;
469        (0..CLASSES).find(|&i| {
470            // p - (backing_base + base_offset) < region_size, via wrapping
471            // sub so a region whose mathematical end exceeds usize::MAX
472            // (impossible on 64-bit but possible in pathological 32-bit
473            // configurations) doesn't misroute.
474            p.wrapping_sub(backing_base.wrapping_add(self.slabs[i].base_offset))
475                < self.region_sizes[i]
476        })
477    }
478}
479
480/// Convenience constructor for the spec's default 8-class set.
481impl<B: Allocator + FixedRange> SizeClassed<B, 8> {
482    /// Construct with the spec-default 8 classes (8, 16, 32, 64, 128,
483    /// 256, 512, 1024 bytes) and `slots_per_class` per class.
484    pub fn with_default_classes(backing: B, slots_per_class: u32) -> Result<Self, AllocError> {
485        Self::with_class_sizes(backing, DEFAULT_CLASS_SIZES_8, slots_per_class)
486    }
487}
488
489unsafe impl<B: Allocator + FixedRange, const CLASSES: usize> Deallocator
490    for SizeClassed<B, CLASSES>
491{
492    #[inline]
493    unsafe fn deallocate(&self, ptr: NonNull<u8>, layout: NonZeroLayout) {
494        match self.route_dealloc(ptr) {
495            Some(i) => {
496                // Resolve the live backing base once; the slab's
497                // free_slot uses it to recompute its current region.
498                let backing_base = self.backing.base();
499                // SAFETY: ptr came from this class's allocate; layout
500                // bounded by class stride (caller-contract).
501                unsafe { self.slabs[i].free_slot(backing_base, ptr) };
502            }
503            None => {
504                // Fallback path. SAFETY: ptr came from backing.allocate
505                // with the same layout.
506                unsafe { self.backing.deallocate(ptr, layout) };
507            }
508        }
509    }
510}
511
512unsafe impl<B: Allocator + FixedRange, const CLASSES: usize> Allocator for SizeClassed<B, CLASSES> {
513    #[inline]
514    fn allocate(&self, layout: NonZeroLayout) -> Result<NonNull<[u8]>, AllocError> {
515        let size = layout.size().get();
516        let align = layout.align().get();
517        if let Some(i) = self.pick_class(size, align) {
518            // Resolve the live backing base once per call; allocate_slot
519            // uses it to recompute the class region's current base.
520            let backing_base = self.backing.base();
521            // SAFETY: !Sync — single-threaded access.
522            if let Some(ptr) = unsafe { self.slabs[i].allocate_slot(backing_base) } {
523                return Ok(NonNull::slice_from_raw_parts(ptr, self.class_sizes[i]));
524            }
525            // Class exhausted — fall through to backing.
526        }
527        self.backing.allocate(layout)
528    }
529
530    #[inline]
531    unsafe fn usable_size(&self, ptr: NonNull<u8>, layout: NonZeroLayout) -> Option<usize> {
532        // A class-routed allocation occupies a full class slot, which can far
533        // exceed `layout.size()` (a 5-byte request → 8-byte slot). Report the
534        // class slot size so an outer scrub wrapper (`PoisonOnFree`/
535        // `ZeroizeOnFree`) wipes the whole slot on free, not just the requested
536        // prefix. Fallback allocations forward to the backing. Routed by
537        // provenance (same as `deallocate`), so `n >= layout.size()` holds:
538        // `pick_class` always chose a class with stride >= the request.
539        match self.route_dealloc(ptr) {
540            Some(i) => Some(self.class_sizes[i]),
541            // SAFETY: a fallback ptr came from `backing.allocate(layout)`.
542            None => unsafe { self.backing.usable_size(ptr, layout) },
543        }
544    }
545
546    fn capacity_bytes(&self) -> Option<usize> {
547        // Sum across classes; fallback's capacity is not included
548        // because it may be unbounded.
549        let mut total = 0usize;
550        for slab in &self.slabs {
551            total = total.checked_add(slab.capacity as usize * slab.block_stride)?;
552        }
553        Some(total)
554    }
555
556    #[inline]
557    fn corruption_events(&self) -> u64 {
558        // Sum per-class UntypedSlab counters plus the backing fallback's
559        // counter (in case it's a hardened type like Slab<_, _, SipHashMAC>).
560        // `saturating_add` guards against u64 overflow.
561        //
562        // Per-class counters are `AtomicUsize` (for 32-bit portability);
563        // cast each load to `u64` before the fold so the saturating sum
564        // operates in u64 width — preventing premature saturation when a
565        // 32-bit host has multiple classes each near u32::MAX.
566        let class_total = self
567            .slabs
568            .iter()
569            .map(|s| s.corruption_events.load(Ordering::Relaxed) as u64)
570            .fold(0u64, |acc, x| acc.saturating_add(x));
571        class_total.saturating_add(self.backing.corruption_events())
572    }
573}
574
575impl<B: Allocator + FixedRange, const CLASSES: usize> Drop for SizeClassed<B, CLASSES> {
576    fn drop(&mut self) {
577        // Return each class region to the backing. We recompute each
578        // region's current absolute base from `backing.base() +
579        // base_offset` rather than reading a cached pointer — the
580        // backing's `base()` is the live address after any moves the
581        // wrapper underwent. (See the stale-base-pointer UAF
582        // previously fixed for Slab/BumpArena/etc.)
583        for i in 0..CLASSES {
584            // SAFETY: we issued backing.allocate(class_layouts[i]) at
585            // construction, recorded the offset from backing.base(),
586            // and the backing region is still live (we're inside its
587            // Drop chain).
588            let base = unsafe {
589                NonNull::new_unchecked(self.backing.base().as_ptr().add(self.slabs[i].base_offset))
590            };
591            unsafe { self.backing.deallocate(base, self.class_layouts[i]) };
592        }
593    }
594}
595
596// Test module uses `MmapBacked`, which since 0.3.1 is gated on
597// `all(feature = "std", any(unix, windows))`. The previous gate of
598// just `feature = "std"` would fail to compile on
599// `wasm32-wasip1+std` (and is masked today only by the proptest
600// dev-dep also failing on wasm32).
601#[cfg(all(test, feature = "std", any(unix, windows)))]
602mod tests {
603    use super::*;
604    use crate::backing::{InlineBacked, MmapBacked};
605    use crate::layout::BumpArena;
606
607    /// Build a SizeClassed using an MmapBacked-backed BumpArena —
608    /// supports up to page alignment, so class strides up to a page
609    /// (typically 4 KiB) work.
610    fn build_mmap() -> SizeClassed<BumpArena<MmapBacked>, 4> {
611        SizeClassed::with_class_sizes(
612            BumpArena::new(MmapBacked::new(64 * 1024).unwrap()).unwrap(),
613            [8, 16, 32, 64],
614            4,
615        )
616        .expect("valid")
617    }
618
619    #[test]
620    fn rejects_zero_slots_per_class() {
621        let r =
622            SizeClassed::<_, 4>::with_class_sizes(InlineBacked::<8192>::new(), [8, 16, 32, 64], 0);
623        assert!(r.is_err());
624    }
625
626    #[test]
627    fn rejects_non_power_of_two() {
628        let r =
629            SizeClassed::<_, 4>::with_class_sizes(InlineBacked::<8192>::new(), [8, 24, 32, 64], 4);
630        assert!(r.is_err());
631    }
632
633    #[test]
634    fn rejects_non_increasing() {
635        let r =
636            SizeClassed::<_, 4>::with_class_sizes(InlineBacked::<8192>::new(), [8, 8, 16, 32], 4);
637        assert!(r.is_err());
638    }
639
640    #[test]
641    fn rejects_classes_below_freelink_size() {
642        // size_of::<FreeLink>() = 4; class size of 2 must be rejected.
643        let r =
644            SizeClassed::<_, 4>::with_class_sizes(InlineBacked::<8192>::new(), [2, 4, 8, 16], 4);
645        assert!(r.is_err());
646    }
647
648    #[test]
649    #[cfg_attr(miri, ignore = "miri-incompatible: mmap / threads")]
650    fn pick_class_picks_smallest_fit() {
651        let sc = build_mmap();
652        // size=5, align=1 fits in 8-byte class.
653        let layout = NonZeroLayout::from_size_align(5, 1).unwrap();
654        let block = sc.allocate(layout).unwrap();
655        assert_eq!(block.len(), 8, "slice length == class stride");
656        unsafe { sc.deallocate(block.cast(), layout) };
657    }
658
659    #[test]
660    #[cfg_attr(miri, ignore = "miri-incompatible: mmap / threads")]
661    fn alignment_drives_class_pick() {
662        let sc = build_mmap();
663        // size=4 fits 8-class, but align=16 forces class 16 minimum.
664        let layout = NonZeroLayout::from_size_align(4, 16).unwrap();
665        let block = sc.allocate(layout).unwrap();
666        let addr = block.cast::<u8>().as_ptr() as usize;
667        assert_eq!(addr % 16, 0, "alignment must be respected");
668        assert!(block.len() >= 16, "class stride should accommodate align");
669        unsafe { sc.deallocate(block.cast(), layout) };
670    }
671
672    #[test]
673    #[cfg_attr(miri, ignore = "miri-incompatible: mmap / threads")]
674    fn oversized_falls_back_to_backing() {
675        let sc = build_mmap();
676        // Max class is 64; ask for 128 → falls through.
677        let layout = NonZeroLayout::from_size_align(128, 8).unwrap();
678        let block = sc.allocate(layout).unwrap();
679        // The slice length should be the requested size (backing path),
680        // not a class stride.
681        assert_eq!(block.len(), 128);
682        unsafe { sc.deallocate(block.cast(), layout) };
683    }
684
685    #[test]
686    #[cfg_attr(miri, ignore = "miri-incompatible: mmap / threads")]
687    fn freelist_recycles_within_class() {
688        let sc = build_mmap();
689        let layout = NonZeroLayout::from_size_align(8, 8).unwrap();
690        let a = sc.allocate(layout).unwrap();
691        let addr_a = a.cast::<u8>().as_ptr() as usize;
692        unsafe { sc.deallocate(a.cast(), layout) };
693        let b = sc.allocate(layout).unwrap();
694        let addr_b = b.cast::<u8>().as_ptr() as usize;
695        assert_eq!(addr_a, addr_b, "freed slot must be reused");
696        unsafe { sc.deallocate(b.cast(), layout) };
697    }
698
699    #[test]
700    #[cfg_attr(miri, ignore = "miri-incompatible: mmap / threads")]
701    fn class_exhaustion_falls_through_to_backing() {
702        let sc = build_mmap();
703        // 4 slots in the 8-class. Allocate 5 to exhaust + fall through.
704        let layout = NonZeroLayout::from_size_align(8, 8).unwrap();
705        let mut ptrs = Vec::new();
706        for _ in 0..4 {
707            ptrs.push(sc.allocate(layout).unwrap());
708        }
709        // 5th must come from the backing path (size 8 fits in InlineBacked).
710        let extra = sc.allocate(layout).unwrap();
711        ptrs.push(extra);
712        for p in &ptrs {
713            unsafe { sc.deallocate(p.cast(), layout) };
714        }
715    }
716
717    /// `SizeClassed::with_class_sizes` derives the class-region
718    /// alignment from `stride` (line 274 — `NonZeroLayout::from_size_align(
719    /// total, stride)`), so a stride that exceeds the backing's
720    /// `MAX_ALIGN` is rejected by `backing.allocate(...)` at
721    /// construction. `InlineBacked<N>` caps alignment at
722    /// `MAX_ALIGN = 16`, so any class ≥ 32 is unbuildable on this
723    /// backing — `with_default_classes` (which uses up to 1024) MUST
724    /// return `Err(AllocError)` rather than constructing a slab whose
725    /// `allocate` would later hand out under-aligned pointers.
726    ///
727    /// This is a RUNTIME check (the backing's `Allocator::allocate`
728    /// surface returns `AllocError` for over-alignment); the type
729    /// system does not currently enforce
730    /// `backing::MAX_ALIGN >= max(class_sizes)` at compile time — the
731    /// safety contract on the type does not yet enforce this statically.
732    #[test]
733    fn inline_backed_rejects_oversize_class_at_construction() {
734        // Stride 32 already exceeds InlineBacked's MAX_ALIGN = 16, so
735        // even this minimal "two-class" misuse must fail.
736        let r = SizeClassed::<_, 2>::with_class_sizes(InlineBacked::<8192>::new(), [8, 32], 4);
737        assert!(
738            r.is_err(),
739            "InlineBacked (MAX_ALIGN=16) must reject class stride 32"
740        );
741        // And the default 8-class set (up to 1024) is unbuildable on
742        // InlineBacked for the same reason — pinning so a future
743        // backing-alignment upgrade doesn't silently start succeeding.
744        let r2 = SizeClassed::<_, 8>::with_default_classes(InlineBacked::<{ 8 * 1024 }>::new(), 4);
745        assert!(
746            r2.is_err(),
747            "InlineBacked cannot satisfy 1024-byte class alignment"
748        );
749    }
750
751    #[test]
752    #[cfg_attr(miri, ignore = "miri-incompatible: mmap / threads")]
753    fn default_8_class_constructor_builds() {
754        // 8 classes × 4 slots × largest 1024 = needs ≥8 KiB of backing
755        // plus alignment slack up to the 1024 stride. InlineBacked tops
756        // out at MAX_ALIGN=16, so we must use MmapBacked here.
757        let inner = BumpArena::new(MmapBacked::new(64 * 1024).unwrap()).unwrap();
758        let sc = SizeClassed::<_, 8>::with_default_classes(inner, 4);
759        assert!(sc.is_ok());
760    }
761
762    #[test]
763    #[cfg_attr(miri, ignore = "miri-incompatible: mmap / threads")]
764    fn pick_class_handles_non_sequential_powers_of_two() {
765        // Sparse class set: 8, 32, 128 (skipping 16, 64). The lookup
766        // table's gap-fill must route size=16 to class 32 (smallest pow2
767        // ≥ 16 that's a configured class) and size=64 to class 128.
768        let sc: SizeClassed<BumpArena<MmapBacked>, 3> = SizeClassed::with_class_sizes(
769            BumpArena::new(MmapBacked::new(16 * 1024).unwrap()).unwrap(),
770            [8, 32, 128],
771            4,
772        )
773        .expect("valid");
774        // size=16 → class 32 (gap fill: bit 4 routes to class index 1)
775        let l16 = NonZeroLayout::from_size_align(16, 1).unwrap();
776        let b16 = sc.allocate(l16).unwrap();
777        assert_eq!(b16.len(), 32);
778        // size=64 → class 128 (gap fill: bit 6 routes to class index 2)
779        let l64 = NonZeroLayout::from_size_align(64, 1).unwrap();
780        let b64 = sc.allocate(l64).unwrap();
781        assert_eq!(b64.len(), 128);
782        unsafe {
783            sc.deallocate(b16.cast(), l16);
784            sc.deallocate(b64.cast(), l64);
785        }
786    }
787
788    #[test]
789    #[cfg_attr(miri, ignore = "miri-incompatible: mmap / threads")]
790    fn capacity_bytes_sums_classes() {
791        let sc = build_mmap();
792        // 4 + 16 + 32 + 64 striped, times 4 slots: 8*4 + 16*4 + 32*4 + 64*4
793        // = 32 + 64 + 128 + 256 = 480
794        let expected = (8 + 16 + 32 + 64) * 4usize;
795        assert_eq!(sc.capacity_bytes(), Some(expected));
796    }
797
798    /// `usable_size` reports the class slot size, not the requested size, so an
799    /// outer scrub wrapper wipes the whole class-rounded slot on free. A 5-byte
800    /// request routes to the 8-byte class. (Uses the mmap-backed helper because
801    /// the 64-byte class needs 64-byte alignment, beyond `InlineBacked`'s
802    /// `MAX_ALIGN`.)
803    #[test]
804    #[cfg_attr(miri, ignore = "miri-incompatible: mmap / threads")]
805    fn usable_size_reports_class_size() {
806        let sc = build_mmap();
807        let layout = NonZeroLayout::from_size_align(5, 1).unwrap();
808        let block = sc.allocate(layout).unwrap();
809        let ptr = block.cast::<u8>();
810        let us = unsafe { sc.usable_size(ptr, layout) };
811        assert_eq!(us, Some(8), "usable_size must report the class slot size");
812        unsafe { sc.deallocate(ptr, layout) };
813    }
814}