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}