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}