Skip to main content

forge_alloc/layout/
extendable_slab.rs

1//! `ExtendableSlab<T, M>` — growable typed allocator backed by a `Vec` of
2//! [`Slab`](crate::layout::Slab) segments. On exhaustion, a new fixed-capacity
3//! segment is appended; freelist offsets within each segment remain valid
4//! forever (no segment is ever reallocated).
5//!
6//! Unlike [`Slab`](crate::layout::Slab), `ExtendableSlab` does NOT implement
7//! [`FixedRange`] — its address range grows as segments are added.
8//!
9//! Requires `std` because growth uses `alloc::vec::Vec` and segments are
10//! backed by `MmapBacked`.
11//!
12//! See `docs/ARCHITECTURE.md` for the extendable-slab design.
13
14#![cfg(feature = "std")]
15
16use core::ptr::NonNull;
17
18use crate::backing::MmapBacked;
19use forge_alloc_core::{
20    AllocError, Allocator, Deallocator, FreelistProtection, NoProtection, NonZeroLayout,
21};
22use std::sync::atomic::{AtomicUsize, Ordering};
23use std::sync::Mutex;
24use std::vec::Vec;
25
26use crate::layout::Slab;
27
28/// Growable typed slab.
29///
30/// Each segment is a fixed-capacity `Slab<T, MmapBacked, M>`. Growth allocates
31/// a new `MmapBacked` and appends a new segment; the previous segments'
32/// pointers remain valid. Segments are never reallocated or moved.
33///
34/// # Thread safety
35///
36/// `Send + Sync`. The segment list is guarded by a `Mutex` because we may
37/// need to push to a `Vec` on growth; per-segment slabs are `!Sync` but the
38/// mutex serializes access to the list itself. (For high-contention
39/// workloads, switch to `SlabOwner`/`SlabRemote` for per-thread
40/// segments.)
41///
42/// # Panic safety
43///
44/// If a thread panics while holding the segments mutex, the mutex is
45/// poisoned. `allocate`, `deallocate`, `segment_count`, and the internal
46/// `build_segment` helper all call `expect("mutex poisoned")` and
47/// re-panic on a poisoned lock. This is **intentional** and asymmetric
48/// with [`SlabOwner::drop`](crate::layout::SlabOwner)'s `into_inner()` recovery:
49///
50/// - `SlabOwner::drop` runs during unwind and MUST NOT double-panic, so
51///   it recovers the inner state via `into_inner()` even on a poisoned
52///   mutex.
53/// - `ExtendableSlab::{allocate, deallocate, build_segment,
54///   segment_count}` run on the normal call path. A poisoned mutex
55///   means some prior call panicked under the lock — the allocator
56///   state is suspect and the safest policy is fail-loud rather than
57///   silently continue. Once poisoned, the wrapper is permanently
58///   unusable; the application should treat this as a fatal allocator
59///   error and recreate the `ExtendableSlab`.
60///
61/// This asymmetry is intentional and documented here for future maintainers.
62pub struct ExtendableSlab<T, M: FreelistProtection = NoProtection> {
63    segment_capacity: usize,
64    mac_factory: fn() -> M,
65    /// All segments allocated so far. Each segment is independently a Slab.
66    /// We hold the Vec inside a Mutex; allocate() acquires the lock only
67    /// when no current segment can serve the request.
68    segments: Mutex<Vec<Slab<T, MmapBacked, M>>>,
69    /// Hint: index of the lowest segment that *might* have free slots.
70    /// Updated by `allocate` (forward, on full-segment skip) and by
71    /// `deallocate` (backward, when freeing into an earlier segment).
72    /// Saves an O(N) walk through known-full segments on the alloc hot
73    /// path; correctness still depends on the freelist's `Err` signal
74    /// when the hint is stale.
75    ///
76    /// Currently `AtomicUsize`, but every read and write happens while
77    /// the [`segments`](Self::segments) mutex is held, so the atomicity
78    /// is vestigial — a plain `usize` (inside the `Mutex` payload) would
79    /// suffice. The atomic stays here only to avoid a layout-disturbing
80    /// refactor; do not migrate any access out from under the mutex
81    /// without also relaxing the freelist's `Err`-based fallback path,
82    /// which currently relies on the mutex's serialization across the
83    /// hint read + the segment retry walk.
84    ///
85    /// Note: `allocate` briefly drops the mutex during the growth path
86    /// to avoid holding it through the `mmap` syscall (see Phase 2 in
87    /// [`Allocator::allocate`](Self::allocate)). During that window the
88    /// hint is NOT read or written — only the local `len_before_drop`
89    /// snapshot is used to detect concurrent growth on lock re-acquire.
90    /// All hint touches remain under the mutex.
91    first_open_hint: AtomicUsize,
92    /// Count of `deallocate` calls where the supplied pointer was not
93    /// found in any segment. In debug builds the call panics; in
94    /// release it silently returns. Either way it represents either a
95    /// caller-contract violation (wrong pointer, wrong allocator) or
96    /// an in-progress attack probing for UAF — operators want this
97    /// observable via [`Allocator::corruption_events`].
98    ///
99    /// `AtomicUsize` (not `AtomicU64`) for portability to 32-bit
100    /// bare-metal targets that lack native 64-bit atomics. The trait
101    /// method returns `u64`; cast happens at the boundary. See the
102    /// `Slab::corruption_events` field for the rationale and overflow
103    /// analysis.
104    routing_failures: AtomicUsize,
105}
106
107impl<T> ExtendableSlab<T, NoProtection> {
108    /// Construct an empty ExtendableSlab with `NoProtection`. Segments are
109    /// added lazily on first allocate.
110    #[inline]
111    pub fn new(segment_capacity: usize) -> Self {
112        Self::with_protection(segment_capacity, || NoProtection)
113    }
114}
115
116impl<T, M: FreelistProtection> ExtendableSlab<T, M> {
117    /// Construct an empty ExtendableSlab with an explicit freelist-protection
118    /// factory.
119    #[inline]
120    pub fn with_protection(segment_capacity: usize, mac_factory: fn() -> M) -> Self {
121        Self {
122            segment_capacity,
123            mac_factory,
124            segments: Mutex::new(Vec::new()),
125            first_open_hint: AtomicUsize::new(0),
126            routing_failures: AtomicUsize::new(0),
127        }
128    }
129
130    /// Construct with `initial_segments` segments pre-allocated.
131    pub fn with_initial_segments(
132        count: usize,
133        segment_capacity: usize,
134        mac_factory: fn() -> M,
135    ) -> Result<Self, AllocError> {
136        let mut v = Vec::with_capacity(count);
137        for _ in 0..count {
138            v.push(Self::build_segment(segment_capacity, mac_factory)?);
139        }
140        Ok(Self {
141            segment_capacity,
142            mac_factory,
143            segments: Mutex::new(v),
144            first_open_hint: AtomicUsize::new(0),
145            routing_failures: AtomicUsize::new(0),
146        })
147    }
148
149    /// Number of segments currently allocated.
150    #[inline]
151    pub fn segment_count(&self) -> usize {
152        self.segments
153            .lock()
154            .expect("ExtendableSlab mutex poisoned")
155            .len()
156    }
157
158    /// Helper: construct one segment with a freshly-built MmapBacked + MAC.
159    fn build_segment(
160        capacity: usize,
161        mac_factory: fn() -> M,
162    ) -> Result<Slab<T, MmapBacked, M>, AllocError> {
163        // Mirror Slab's internal layout math so we mmap exactly what Slab
164        // will request — no 50%-plus-4 KiB slop. Slab uses
165        //   block_stride = align_up(max(size_of::<T>(), 8), slot_align)
166        //   slot_align   = max(align_of::<T>(), 4)   // FreeLink alignment is 4
167        //   total        = capacity * block_stride
168        // and `MmapBacked::new(total)` rounds `total` up to a page already, so
169        // we add only `slot_align - 1` worst-case alignment slack on top
170        // (handles the rare case where the OS hands back a base that isn't
171        // already `slot_align`-aligned — pages are typically >= 4 KiB so this
172        // is defensive).
173        let slot_align = core::cmp::max(core::mem::align_of::<T>(), 4);
174        let raw_stride = core::cmp::max(core::mem::size_of::<T>(), 8);
175        let block_stride = raw_stride
176            .checked_add(slot_align - 1)
177            .map(|v| v & !(slot_align - 1))
178            .ok_or(AllocError)?;
179        let total = block_stride.checked_mul(capacity).ok_or(AllocError)?;
180        let with_slack = total.checked_add(slot_align - 1).ok_or(AllocError)?;
181        let backing = MmapBacked::new(with_slack)?;
182        Slab::with_protection(capacity, backing, mac_factory())
183    }
184}
185
186unsafe impl<T, M: FreelistProtection + Send> Deallocator for ExtendableSlab<T, M>
187where
188    T: Send,
189{
190    #[inline]
191    unsafe fn deallocate(&self, ptr: NonNull<u8>, layout: NonZeroLayout) {
192        // Find the segment whose range contains ptr. Segments are appended
193        // and never removed, so we can safely walk the list under the mutex.
194        let segs = self.segments.lock().expect("ExtendableSlab mutex poisoned");
195        for (i, seg) in segs.iter().enumerate() {
196            // SAFETY of forwarded deallocate: the slab issued the pointer, so
197            // the layout matches. We just need to find the right slab.
198            use forge_alloc_core::FixedRange;
199            if seg.contains(ptr) {
200                unsafe { seg.deallocate(ptr, layout) };
201                // This segment now has at least one free slot. Pull the
202                // hint back if it had advanced past this index.
203                self.first_open_hint.fetch_min(i, Ordering::Relaxed);
204                return;
205            }
206        }
207        // Pointer not found in any segment. Bump the routing-failure
208        // counter so operators reading `corruption_events` see this as
209        // a security event (caller-contract violation or UAF probe).
210        // `Relaxed` is correct: advisory observability counter.
211        self.routing_failures.fetch_add(1, Ordering::Relaxed);
212        debug_assert!(
213            false,
214            "ExtendableSlab::deallocate: pointer not in any segment"
215        );
216    }
217}
218
219unsafe impl<T, M: FreelistProtection + Send> Allocator for ExtendableSlab<T, M>
220where
221    T: Send,
222{
223    #[inline]
224    fn allocate(&self, layout: NonZeroLayout) -> Result<NonNull<[u8]>, AllocError> {
225        // Phase 1: try to satisfy the request from existing segments,
226        // entirely under the lock. Common-case fast path is one
227        // acquire+release with no syscalls.
228        let len_before_drop = {
229            let segs = self.segments.lock().expect("ExtendableSlab mutex poisoned");
230            // Start from the hinted first-open segment to skip a linear
231            // walk through segments known-full last time we checked.
232            // Clamp to len in case the hint outran the segment count
233            // (e.g. fresh empty slab where hint == 0 and segs.len() == 0).
234            let start = self.first_open_hint.load(Ordering::Relaxed).min(segs.len());
235            // Try from `start` forward.
236            for (offset, seg) in segs.iter().enumerate().skip(start) {
237                if let Ok(block) = seg.allocate(layout) {
238                    // Push hint forward if every segment before us has
239                    // signaled full at least once on this path. We only
240                    // nudge forward, never backward — backward moves come
241                    // from `deallocate`.
242                    if offset > start {
243                        let _ = self.first_open_hint.fetch_max(offset, Ordering::Relaxed);
244                    }
245                    return Ok(block);
246                }
247            }
248            // Hint missed and we'd jump segments — re-walk from the
249            // start in case a concurrent `deallocate` freed an earlier
250            // slot since the last hint update.
251            if start > 0 {
252                for (i, seg) in segs.iter().enumerate().take(start) {
253                    if let Ok(block) = seg.allocate(layout) {
254                        self.first_open_hint.fetch_min(i, Ordering::Relaxed);
255                        return Ok(block);
256                    }
257                }
258            }
259            // All current segments full — snapshot len so Phase 3 can
260            // detect concurrent growth, then drop the lock for the mmap
261            // syscall in Phase 2.
262            segs.len()
263        };
264
265        // Phase 2: build a new segment WITHOUT the lock. `Self::build_segment`
266        // performs an `mmap` syscall, which can take milliseconds under
267        // memory pressure. Holding the mutex across that syscall would
268        // block every concurrent allocate for the duration — defeating
269        // the point of the shared structure.
270        let new_seg = Self::build_segment(self.segment_capacity, self.mac_factory)?;
271
272        // Phase 3: re-acquire the lock to install the new segment.
273        let mut segs = self.segments.lock().expect("ExtendableSlab mutex poisoned");
274
275        // Race-against-self: any number of concurrent allocate calls may
276        // have raced ours through Phase 2 — each one independently saw
277        // all-segments-full, dropped the lock, built its own segment, and
278        // is queueing up here to install. If any of THEIR segments landed
279        // before ours and can serve our request, allocate from theirs
280        // and let our `new_seg` drop on the return path (RAII cleanup
281        // unmaps the unused mmap region). This bounds over-growth to one
282        // redundant segment per racing thread, never unbounded.
283        if segs.len() > len_before_drop {
284            for (offset, seg) in segs.iter().enumerate().skip(len_before_drop) {
285                if let Ok(block) = seg.allocate(layout) {
286                    let _ = self.first_open_hint.fetch_max(offset, Ordering::Relaxed);
287                    return Ok(block);
288                }
289            }
290        }
291
292        // No racing thread's segment could serve us. Install ours.
293        let block = new_seg.allocate(layout)?;
294        let new_idx = segs.len();
295        segs.push(new_seg);
296        // The new segment is the last one (and the only one with space
297        // right after this).
298        self.first_open_hint.store(new_idx, Ordering::Relaxed);
299        Ok(block)
300    }
301
302    #[inline]
303    unsafe fn usable_size(&self, ptr: NonNull<u8>, layout: NonZeroLayout) -> Option<usize> {
304        // Each segment is a `Slab` whose slots are `block_stride` bytes, which
305        // can exceed the requested size — report the owning segment's usable
306        // size so an outer scrub wrapper wipes the whole slot, not just the
307        // requested prefix (the same slack `Slab`/`SizeClassed` now report).
308        // Routed by segment provenance, exactly like `deallocate`.
309        use forge_alloc_core::FixedRange;
310        let segs = self.segments.lock().expect("ExtendableSlab mutex poisoned");
311        for seg in segs.iter() {
312            if seg.contains(ptr) {
313                // SAFETY: ptr is in this segment's range → it came from this
314                // segment's allocate with `layout`.
315                return unsafe { seg.usable_size(ptr, layout) };
316            }
317        }
318        None
319    }
320
321    fn capacity_bytes(&self) -> Option<usize> {
322        // Total bytes across all current segments. Growth means this number
323        // can increase between calls — Watermark callers should call this
324        // each check (growth means the value can increase between calls).
325        let segs = self.segments.lock().expect("ExtendableSlab mutex poisoned");
326        let total: usize = segs.iter().filter_map(|s| s.capacity_bytes()).sum();
327        Some(total)
328    }
329
330    #[inline]
331    fn corruption_events(&self) -> u64 {
332        // Two sources: per-segment Slab counters (MAC + out-of-range
333        // next_idx silent disarms) plus our own `routing_failures`
334        // counter (deallocate called with a pointer not in any
335        // segment). `saturating_add` guards against u64 overflow.
336        //
337        // `routing_failures` stores `usize` for 32-bit portability; cast
338        // to `u64` here so the sum matches the trait return type. The
339        // per-segment `s.corruption_events()` already returns `u64`.
340        let routing = self.routing_failures.load(Ordering::Relaxed) as u64;
341        let segs = self.segments.lock().expect("ExtendableSlab mutex poisoned");
342        segs.iter()
343            .map(|s| s.corruption_events())
344            .fold(routing, |acc, x| acc.saturating_add(x))
345    }
346}
347
348// Note: ExtendableSlab deliberately does NOT implement FixedRange. Its
349// address range grows on each new segment, so it cannot be used as the
350// Primary in WithFallback. If you need that pattern, route at the
351// application level.
352
353#[cfg(test)]
354mod tests {
355    use super::*;
356
357    #[test]
358    #[cfg_attr(miri, ignore = "miri-incompatible: ExtendableSlab uses MmapBacked")]
359    fn fresh_extendable_has_no_segments() {
360        let s: ExtendableSlab<u64> = ExtendableSlab::new(16);
361        assert_eq!(s.segment_count(), 0);
362    }
363
364    #[test]
365    #[cfg_attr(miri, ignore = "miri-incompatible: ExtendableSlab uses MmapBacked")]
366    fn first_allocate_creates_segment() {
367        let s: ExtendableSlab<u64> = ExtendableSlab::new(8);
368        let layout = NonZeroLayout::for_type::<u64>().unwrap();
369        let _ = s.allocate(layout).unwrap();
370        assert_eq!(s.segment_count(), 1);
371    }
372
373    #[test]
374    #[cfg_attr(miri, ignore = "miri-incompatible: ExtendableSlab uses MmapBacked")]
375    fn growth_on_segment_exhaustion() {
376        let s: ExtendableSlab<u64> = ExtendableSlab::new(4); // 4 slots per segment
377        let layout = NonZeroLayout::for_type::<u64>().unwrap();
378        // Allocate 5 — forces a second segment.
379        let mut ptrs = Vec::new();
380        for _ in 0..5 {
381            ptrs.push(s.allocate(layout).unwrap());
382        }
383        assert_eq!(s.segment_count(), 2);
384    }
385
386    #[test]
387    #[cfg_attr(miri, ignore = "miri-incompatible: ExtendableSlab uses MmapBacked")]
388    fn deallocate_routes_to_correct_segment() {
389        let s: ExtendableSlab<u64> = ExtendableSlab::new(2);
390        let layout = NonZeroLayout::for_type::<u64>().unwrap();
391        let a = s.allocate(layout).unwrap();
392        let _b = s.allocate(layout).unwrap();
393        let c = s.allocate(layout).unwrap(); // triggers growth
394        let _d = s.allocate(layout).unwrap();
395        // Now free a (segment 0) and c (segment 1) — both must complete
396        // without debug_assert.
397        unsafe {
398            s.deallocate(a.cast(), layout);
399            s.deallocate(c.cast(), layout);
400        }
401    }
402
403    /// `usable_size` reports the per-segment slab's slot stride, not the
404    /// requested size, so an outer scrub wrapper wipes the whole slot. An
405    /// `ExtendableSlab<u8>` has a 1-byte type but an 8-byte stride.
406    #[test]
407    #[cfg_attr(miri, ignore = "miri-incompatible: ExtendableSlab uses MmapBacked")]
408    fn usable_size_reports_segment_stride() {
409        let s: ExtendableSlab<u8> = ExtendableSlab::new(8);
410        let layout = NonZeroLayout::from_size_align(1, 1).unwrap();
411        let block = s.allocate(layout).unwrap();
412        let ptr = block.cast::<u8>();
413        let us = unsafe { s.usable_size(ptr, layout) };
414        assert_eq!(
415            us,
416            Some(8),
417            "usable_size must report the segment slot stride"
418        );
419        unsafe { s.deallocate(ptr, layout) };
420    }
421
422    #[test]
423    #[cfg_attr(miri, ignore = "miri-incompatible: ExtendableSlab uses MmapBacked")]
424    fn capacity_grows_with_segments() {
425        let s: ExtendableSlab<u64> = ExtendableSlab::new(4);
426        let layout = NonZeroLayout::for_type::<u64>().unwrap();
427        let _ = s.allocate(layout).unwrap();
428        let c1 = s.capacity_bytes().unwrap();
429        for _ in 0..4 {
430            let _ = s.allocate(layout).unwrap();
431        }
432        let c2 = s.capacity_bytes().unwrap();
433        assert!(c2 > c1, "capacity should grow with second segment");
434    }
435
436    #[test]
437    #[cfg_attr(miri, ignore = "miri-incompatible: ExtendableSlab uses MmapBacked")]
438    fn initial_segments_preallocated() {
439        let s: ExtendableSlab<u64, NoProtection> =
440            ExtendableSlab::with_initial_segments(3, 4, || NoProtection).unwrap();
441        assert_eq!(s.segment_count(), 3);
442    }
443
444    /// Boundary: `segment_capacity = 0` produces a Slab that rejects at
445    /// construction (`Slab::new(0, _)` returns Err). The first allocate
446    /// surfaces the failure as `AllocError` rather than panicking.
447    #[test]
448    #[cfg_attr(miri, ignore = "miri-incompatible: ExtendableSlab uses MmapBacked")]
449    fn segment_capacity_zero_fails_first_allocate() {
450        let s: ExtendableSlab<u64> = ExtendableSlab::new(0);
451        let layout = NonZeroLayout::for_type::<u64>().unwrap();
452        assert!(
453            s.allocate(layout).is_err(),
454            "segment_capacity=0 must propagate as AllocError",
455        );
456    }
457
458    /// `segment_capacity = 1, allocate 100 times` — each allocate
459    /// produces a fresh segment. Verifies the segment-growth loop
460    /// doesn't degenerate.
461    #[test]
462    #[cfg_attr(miri, ignore = "miri-incompatible: ExtendableSlab uses MmapBacked")]
463    fn one_slot_per_segment_grows_one_segment_per_alloc() {
464        let s: ExtendableSlab<u64> = ExtendableSlab::new(1);
465        let layout = NonZeroLayout::for_type::<u64>().unwrap();
466        let mut ptrs = Vec::new();
467        for _ in 0..100 {
468            ptrs.push(s.allocate(layout).unwrap());
469        }
470        assert_eq!(s.segment_count(), 100);
471        // All pointers must be distinct (one segment each).
472        let addrs: std::collections::HashSet<usize> = ptrs
473            .iter()
474            .map(|p| p.cast::<u8>().as_ptr() as usize)
475            .collect();
476        assert_eq!(addrs.len(), 100, "every alloc must give a distinct slot");
477    }
478
479    /// Regression: `ExtendableSlab::allocate` previously held the segments
480    /// mutex through `Self::build_segment`, which performs an `mmap` syscall.
481    /// The fix drops the lock for the syscall and re-acquires for install,
482    /// with a race-against-self check to avoid unbounded over-growth when
483    /// multiple threads grow concurrently.
484    ///
485    /// This test exercises the concurrent-growth path: many threads
486    /// each force segment creation. Properties:
487    /// 1. All allocations succeed.
488    /// 2. No two allocations return the same pointer.
489    /// 3. Segment count is bounded in `[total/segment_capacity,
490    ///    total/segment_capacity + N_THREADS]`. The upper bound is the
491    ///    worst case where every racing thread in Phase 3 fails to find
492    ///    space in any newly-installed segment (because they're full)
493    ///    and so commits its own. At most one extra segment can be
494    ///    committed per concurrent racer, so per-episode over-growth is
495    ///    bounded by the number of threads currently in Phase 2, which
496    ///    is in turn bounded by N_THREADS. In practice the actual count
497    ///    is far closer to the lower bound — see the race-against-self
498    ///    walk at lines 270-277.
499    #[test]
500    #[cfg_attr(
501        miri,
502        ignore = "miri-incompatible: ExtendableSlab uses MmapBacked + threads"
503    )]
504    fn concurrent_growth_does_not_deadlock_or_double_allocate() {
505        use std::sync::Arc;
506        use std::thread;
507
508        const N_THREADS: usize = 8;
509        const ALLOCS_PER_THREAD: usize = 16;
510        const SEGMENT_CAP: usize = 4;
511        let total = N_THREADS * ALLOCS_PER_THREAD;
512
513        let slab: Arc<ExtendableSlab<u64>> = Arc::new(ExtendableSlab::new(SEGMENT_CAP));
514        let layout = NonZeroLayout::for_type::<u64>().unwrap();
515
516        // Each thread returns Vec<usize> of pointer addresses since
517        // NonNull<[u8]> is !Send. Main thread reconstructs NonNull for
518        // deallocate (ExtendableSlab is Send+Sync, deallocate takes &self).
519        let handles: Vec<_> = (0..N_THREADS)
520            .map(|_| {
521                let slab = Arc::clone(&slab);
522                thread::spawn(move || {
523                    let mut local = Vec::with_capacity(ALLOCS_PER_THREAD);
524                    for _ in 0..ALLOCS_PER_THREAD {
525                        let p = slab.allocate(layout).expect("concurrent allocate");
526                        local.push(p.cast::<u8>().as_ptr() as usize);
527                    }
528                    local
529                })
530            })
531            .collect();
532
533        let mut all_addrs: Vec<usize> = Vec::with_capacity(total);
534        for h in handles {
535            all_addrs.extend(h.join().expect("thread"));
536        }
537
538        // Property 1: all served.
539        assert_eq!(all_addrs.len(), total);
540
541        // Property 2: all distinct.
542        let uniq: std::collections::HashSet<usize> = all_addrs.iter().copied().collect();
543        assert_eq!(uniq.len(), total, "concurrent allocs must all be distinct");
544
545        // Property 3: segment count bounded.
546        let min_segments = total / SEGMENT_CAP;
547        let max_segments = min_segments + N_THREADS;
548        let actual = slab.segment_count();
549        assert!(
550            (min_segments..=max_segments).contains(&actual),
551            "segment_count {actual} not in [{min_segments}, {max_segments}] \
552             — over-growth from race-against-self is bounded by N_THREADS",
553        );
554
555        // Cleanup — verify deallocate routing still works across all segments.
556        for addr in all_addrs {
557            let p = unsafe { NonNull::new_unchecked(addr as *mut u8) };
558            unsafe { slab.deallocate(p, layout) };
559        }
560    }
561}