Skip to main content

commonware_runtime/iobuf/pool/
mod.rs

1//! Buffer pool for efficient I/O operations.
2//!
3//! Provides pooled, aligned buffers that can be reused to reduce allocation
4//! overhead. Buffer alignment is configurable: use page alignment for storage I/O
5//! (required for direct I/O and DMA), or cache-line alignment for network I/O
6//! (reduces fragmentation).
7//!
8//! # Thread Safety
9//!
10//! [`BufferPool`] is `Send + Sync` and can be safely shared across threads.
11//! Allocation and deallocation use atomic counters together with a global
12//! freelist split across mutex-protected stripes, each with a fixed slot limit,
13//! plus per-thread caches.
14//!
15//! Global freelist operations use blocking mutexes. After a local cache miss or
16//! spill, an operation can wait for a stripe lock. A preempted lock holder can
17//! therefore delay the operation even when another stripe could provide or
18//! accept a buffer.
19//!
20//! # Pool Lifecycle
21//!
22//! Tracked buffers held by pooled views or cached in thread-local bins keep a
23//! strong reference to the originating size class. Buffers can outlive the
24//! public [`BufferPool`] handle and still return to their original size class.
25//! - Untracked fallback allocations store no class reference and deallocate
26//!   directly when dropped.
27//! - Requests smaller than [`BufferPoolConfig::pool_min_size`] bypass pooling
28//!   entirely and return untracked aligned allocations from both
29//!   [`BufferPool::try_alloc`] and [`BufferPool::alloc`].
30//! - Dropping [`BufferPool`] drains only the shared global freelists. Pooled
31//!   views and buffers cached in a live thread's local cache can keep their
32//!   size class alive until they are dropped or the thread exits.
33//!
34//! # Size Classes
35//!
36//! Buffers are organized into power-of-two size classes. The enabled classes
37//! do not need to be contiguous, and each class has its own tracked-buffer
38//! limit. For example, with enabled classes 4096, 8192, and 32768:
39//! - Class 0: 4096 bytes
40//! - Class 1: 8192 bytes
41//! - Class 2: 32768 bytes
42//!
43//! Allocation requests round up to the smallest enabled class that fits, so a
44//! 16000-byte request above is served by the 32768-byte class. Requests larger
45//! than the largest enabled class return [`PoolError::Oversized`] from
46//! [`BufferPool::try_alloc`], or fall back to an untracked aligned heap
47//! allocation from [`BufferPool::alloc`]. A request routed to an exhausted
48//! class returns [`PoolError::Exhausted`] without trying larger classes.
49//!
50//! # Cache Structure
51//!
52//! Each size class uses a two-level allocator:
53//! - a small per-thread local cache for steady-state same-thread reuse
54//! - a shared global freelist for refill and spill between threads
55//!
56//! When a local cache misses, the pool refills a small batch from the global
57//! freelist before attempting to create a new tracked buffer. Returned buffers
58//! first try to re-enter the dropping thread's local cache, spilling a bounded
59//! batch back to the global freelist if needed.
60
61mod class;
62mod freelist;
63
64use super::{IoBufMut, page_size};
65use crate::{
66    iobuf::owner::PooledBuffer,
67    telemetry::metrics::{Counter, CounterFamily, EncodeLabelSet, GaugeFamily, Register, raw},
68};
69pub use class::BufferPoolThreadCache;
70use class::SizeClassHandle;
71pub(crate) use class::SizeClassLease;
72use commonware_utils::{NZU32, NZUsize};
73pub(super) use freelist::Freelist;
74use std::{
75    collections::BTreeMap,
76    num::{NonZeroU32, NonZeroUsize},
77    sync::atomic::{AtomicUsize, Ordering},
78};
79use thiserror::Error;
80
81cfg_if::cfg_if! {
82    if #[cfg(feature = "loom")] {
83        use loom::sync::Arc;
84    } else {
85        use std::sync::Arc;
86    }
87}
88
89/// Error returned when buffer pool allocation fails.
90#[derive(Error, Debug, Clone, Copy, PartialEq, Eq)]
91pub enum PoolError {
92    /// The requested capacity exceeds the maximum buffer size.
93    #[error("requested capacity exceeds maximum buffer size")]
94    Oversized,
95    /// The pool is exhausted for the required size class.
96    #[error("pool exhausted for required size class")]
97    Exhausted,
98}
99
100/// Policy for sizing each thread's cache within a buffer pool size class.
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub(crate) enum BufferPoolThreadCacheConfig {
103    /// Enable thread-local caching.
104    ///
105    /// `None` derives the per-thread cache size from the pool's per-class
106    /// capacity and expected parallelism, reserving about half of each class
107    /// for the shared freelist. Small per-class budgets may resolve to zero,
108    /// disabling thread-local caching so free buffers do not become stranded in
109    /// other threads.
110    ///
111    /// `Some(n)` uses an explicit per-thread cache size, clamped independently
112    /// to each size class's limit.
113    Enabled(Option<NonZeroUsize>),
114    /// Disable thread-local caching and route all reuse through the shared global freelist.
115    Disabled,
116}
117
118/// Configuration for one enabled buffer pool size class.
119#[derive(Clone, Copy, Debug, PartialEq, Eq)]
120pub struct BufferPoolClassConfig {
121    /// Buffer size for this class. Must be a power of two.
122    pub size: NonZeroUsize,
123    /// Maximum number of tracked buffers in this class.
124    ///
125    /// Size-class slots are identified by `u32`, so the per-class limit is
126    /// capped by this type.
127    pub max_buffers: NonZeroU32,
128}
129
130impl From<(NonZeroUsize, NonZeroU32)> for BufferPoolClassConfig {
131    fn from((size, max_buffers): (NonZeroUsize, NonZeroU32)) -> Self {
132        Self { size, max_buffers }
133    }
134}
135
136/// Configuration for a buffer pool.
137///
138/// The class layout is a set of power-of-two size classes, each with its own
139/// tracked-buffer limit. Enabled classes do not need to be contiguous, and
140/// requests route to the smallest enabled class that fits.
141///
142/// Shape builders do not commute. Each builder applies to the layout produced
143/// by the previous one: replacement builders ([`Self::with_size_class_range`],
144/// [`Self::with_size_classes`]) discard the current layout, uniform builders
145/// ([`Self::with_max_per_class`], [`Self::with_bytes_per_class`]) overwrite
146/// every enabled limit, and [`Self::with_budget_bytes`] snapshots and rescales
147/// the shape that exists at that call.
148#[derive(Clone, Debug)]
149pub struct BufferPoolConfig {
150    /// Minimum request size that should use pooled allocation.
151    ///
152    /// Requests smaller than this bypass the pool and use direct aligned
153    /// allocation instead. A value of `0` means all eligible requests use the
154    /// pool.
155    pool_min_size: usize,
156    /// Enabled size classes, keyed by power-of-two size. Sizes absent from the
157    /// map are disabled.
158    ///
159    /// Builders maintain the invariant that at least one class is enabled.
160    class_limits: BTreeMap<NonZeroUsize, NonZeroU32>,
161    /// Whether to create every tracked buffer during pool construction.
162    ///
163    /// When enabled, each size class creates its configured limit of buffers
164    /// and parks them in the class-global freelist before the pool is
165    /// returned. This moves allocation cost to startup and makes the first
166    /// reuse path avoid heap allocation.
167    prefill: bool,
168    /// Buffer alignment. Must be a power of two.
169    alignment: NonZeroUsize,
170    /// Expected number of threads concurrently accessing the pool.
171    ///
172    /// This sizes the shared global freelist stripes. It is also used to derive
173    /// thread-cache capacity when the thread-cache policy is automatic, using
174    /// approximately half of each class limit divided across expected threads.
175    parallelism: NonZeroUsize,
176    /// Policy for sizing the per-thread local cache in each size class.
177    ///
178    /// By default, thread-cache capacity is derived from [`Self::parallelism`]
179    /// and each class limit. [`Self::with_max_thread_cache_capacity`] uses an
180    /// explicit per-thread cache size clamped to each class limit.
181    /// [`Self::with_thread_cache_disabled`] bypasses thread-local caches.
182    pub(crate) thread_cache_config: BufferPoolThreadCacheConfig,
183}
184
185impl BufferPoolConfig {
186    /// Network I/O preset: 1KB to 128KB buffers, 4096 per class, not prefilled.
187    ///
188    /// Network operations typically need multiple concurrent buffers per
189    /// connection (message, encoding, encryption) so we allow 4096 buffers per
190    /// size class.
191    pub fn for_network() -> Self {
192        Self {
193            pool_min_size: 0,
194            class_limits: BTreeMap::new(),
195            prefill: false,
196            alignment: NZUsize!(1),
197            parallelism: NZUsize!(1),
198            thread_cache_config: BufferPoolThreadCacheConfig::Enabled(None),
199        }
200        .with_size_class_range(NZUsize!(1024), NZUsize!(128 * 1024), NZU32!(4096))
201    }
202
203    /// Storage I/O preset: `page_size` (usually 4KB) to 8MB buffers, 64 per class,
204    /// not prefilled.
205    pub fn for_storage() -> Self {
206        Self {
207            pool_min_size: 0,
208            class_limits: BTreeMap::new(),
209            prefill: false,
210            // TODO (#2960): this needs to be page/block aligned for O_DIRECT
211            alignment: NZUsize!(1),
212            parallelism: NZUsize!(1),
213            thread_cache_config: BufferPoolThreadCacheConfig::Enabled(None),
214        }
215        .with_size_class_range(
216            NZUsize!(page_size()),
217            NZUsize!(8 * 1024 * 1024),
218            NZU32!(64),
219        )
220    }
221
222    /// Validates a class size, panicking on invalid values.
223    ///
224    /// Sizes above `isize::MAX` are rejected here because `Layout` cannot
225    /// represent them, which would otherwise surface as a misleading panic at
226    /// pool construction.
227    const fn validate_class_size(size: NonZeroUsize) {
228        assert!(
229            size.get().is_power_of_two(),
230            "class size must be a power of two"
231        );
232        assert!(
233            size.get() <= isize::MAX as usize,
234            "class size must not exceed isize::MAX"
235        );
236    }
237
238    /// Returns a copy of this config with a new minimum request size that uses pooling.
239    pub const fn with_pool_min_size(mut self, pool_min_size: usize) -> Self {
240        self.pool_min_size = pool_min_size;
241        self
242    }
243
244    /// Returns a copy of this config whose layout is the inclusive, contiguous
245    /// power-of-two range from `min` to `max` with a uniform limit.
246    ///
247    /// This replaces the complete class layout.
248    ///
249    /// # Panics
250    ///
251    /// - `min` or `max` is not a power of two
252    /// - `min` or `max` exceeds `isize::MAX`
253    /// - `max < min`
254    pub fn with_size_class_range(
255        self,
256        min: NonZeroUsize,
257        max: NonZeroUsize,
258        max_buffers: NonZeroU32,
259    ) -> Self {
260        Self::validate_class_size(min);
261        Self::validate_class_size(max);
262        assert!(max >= min, "max size must be >= min size");
263
264        self.with_size_classes(
265            (min.get().trailing_zeros()..=max.get().trailing_zeros())
266                .map(|exponent| (NZUsize!(1 << exponent), max_buffers)),
267        )
268    }
269
270    /// Returns a copy of this config whose layout is exactly the given classes.
271    ///
272    /// This replaces the complete class layout. Input order does not matter,
273    /// classes are normalized into ascending size order.
274    ///
275    /// # Panics
276    ///
277    /// - `classes` is empty
278    /// - a class size is not a power of two
279    /// - a class size exceeds `isize::MAX`
280    /// - two classes have the same size
281    pub fn with_size_classes<I, C>(mut self, classes: I) -> Self
282    where
283        I: IntoIterator<Item = C>,
284        C: Into<BufferPoolClassConfig>,
285    {
286        let mut limits = BTreeMap::new();
287        for class in classes {
288            let class = class.into();
289            Self::validate_class_size(class.size);
290            assert!(
291                limits.insert(class.size, class.max_buffers).is_none(),
292                "duplicate class size {}",
293                class.size
294            );
295        }
296        assert!(
297            !limits.is_empty(),
298            "class layout must enable at least one class"
299        );
300        self.class_limits = limits;
301        self
302    }
303
304    /// Returns a copy of this config with the given class enabled, replacing
305    /// its limit if it is already enabled.
306    ///
307    /// # Panics
308    ///
309    /// - `size` is not a power of two
310    /// - `size` exceeds `isize::MAX`
311    pub fn with_size_class(mut self, size: NonZeroUsize, max_buffers: NonZeroU32) -> Self {
312        Self::validate_class_size(size);
313        self.class_limits.insert(size, max_buffers);
314        self
315    }
316
317    /// Returns a copy of this config with the given class removed.
318    ///
319    /// Requests that previously routed to the removed class route to the next
320    /// larger enabled class.
321    ///
322    /// # Panics
323    ///
324    /// - `size` is not a power of two
325    /// - `size` exceeds `isize::MAX`
326    /// - no class with `size` is enabled
327    /// - the class is the final enabled class
328    pub fn without_size_class(mut self, size: NonZeroUsize) -> Self {
329        Self::validate_class_size(size);
330        assert!(
331            self.class_limits.remove(&size).is_some(),
332            "cannot remove a class that is not enabled"
333        );
334        assert!(
335            !self.class_limits.is_empty(),
336            "cannot remove the final enabled class"
337        );
338        self
339    }
340
341    /// Returns a copy of this config with the same limit on every enabled class.
342    pub fn with_max_per_class(mut self, max_buffers: NonZeroU32) -> Self {
343        for limit in self.class_limits.values_mut() {
344            *limit = max_buffers;
345        }
346        self
347    }
348
349    /// Returns a copy of this config where every enabled class has
350    /// approximately the same tracked-byte weight.
351    ///
352    /// Each enabled class's limit becomes `max(1, bytes / size)`, so limits
353    /// halve as class sizes double. This is a one-shot count transformation,
354    /// not a stored byte policy, and it never disables a class.
355    ///
356    /// # Panics
357    ///
358    /// Panics if a derived limit exceeds `u32::MAX`.
359    pub fn with_bytes_per_class(mut self, bytes: NonZeroUsize) -> Self {
360        for (size, limit) in self.class_limits.iter_mut() {
361            let count = bytes.get() / size.get();
362            assert!(
363                count <= u32::MAX as usize,
364                "per-class byte weight derives a limit above u32::MAX"
365            );
366            *limit = NonZeroU32::new(count.max(1) as u32).expect("count is at least one");
367        }
368        self
369    }
370
371    /// Returns a copy of this config with a new expected parallelism.
372    ///
373    /// The global freelist derives its stripe count from this target and the
374    /// class capacity. This value also controls thread-cache capacity when the
375    /// thread-cache policy is automatic. The automatic policy reserves about
376    /// half of each class for the global freelist and divides the remaining
377    /// capacity across expected threads.
378    pub const fn with_parallelism(mut self, parallelism: NonZeroUsize) -> Self {
379        self.parallelism = parallelism;
380        self
381    }
382
383    /// Returns a copy of this config with an explicit per-thread cache size.
384    ///
385    /// Each size class keeps a small per-thread cache of free buffers for
386    /// same-thread reuse. By default its capacity is derived per class from
387    /// the class limit and [`Self::parallelism`], reserving about half of the
388    /// class for the shared global freelist. An explicit capacity replaces
389    /// that derivation and may be larger or smaller than the derived value.
390    ///
391    /// The effective capacity for each class is `min(capacity, class limit)`.
392    /// Clamping happens independently per class, so one small class cannot
393    /// invalidate the configuration.
394    ///
395    /// Buffers held in a thread's cache are invisible to other threads until
396    /// they spill to the global freelist or the thread exits, and each thread
397    /// can retain up to the effective capacity of every class it touches.
398    /// Larger values favor same-thread reuse while smaller values favor
399    /// cross-thread visibility and a lower per-thread memory ceiling.
400    ///
401    /// Global-freelist striping is set separately by [`Self::with_parallelism`].
402    pub const fn with_max_thread_cache_capacity(mut self, capacity: NonZeroUsize) -> Self {
403        self.thread_cache_config = BufferPoolThreadCacheConfig::Enabled(Some(capacity));
404        self
405    }
406
407    /// Returns a copy of this config with thread-local caching disabled.
408    ///
409    /// Global-freelist striping is set separately by [`Self::with_parallelism`].
410    pub const fn with_thread_cache_disabled(mut self) -> Self {
411        self.thread_cache_config = BufferPoolThreadCacheConfig::Disabled;
412        self
413    }
414
415    /// Returns a copy of this config with a new prefill setting.
416    pub const fn with_prefill(mut self, prefill: bool) -> Self {
417        self.prefill = prefill;
418        self
419    }
420
421    /// Returns a copy of this config with a new alignment.
422    pub const fn with_alignment(mut self, alignment: NonZeroUsize) -> Self {
423        self.alignment = alignment;
424        self
425    }
426
427    /// Returns a copy of this config with all class limits proportionally
428    /// rescaled under a strict tracked-byte ceiling.
429    ///
430    /// This snapshots the currently enabled classes and their limits, then
431    /// chooses the greatest common proportional scale for which the total
432    /// tracked capacity `sum(size * scaled_limit)` stays within `budget`,
433    /// where `scaled_limit = max(1, floor(limit * scale))`. Scaling may raise
434    /// or lower limits, never disables a class, and may deliberately leave
435    /// part of the budget unused rather than distort the requested shape.
436    ///
437    /// The budget covers tracked buffer payload capacity only. It does not
438    /// include allocator metadata, alignment overhead, or pool bookkeeping.
439    ///
440    /// This is a one-shot transformation, not a stored policy. Later builder
441    /// calls may change the resulting total, and calling this again rescales
442    /// the already scaled limits rather than the shape they were derived from.
443    ///
444    /// # Panics
445    ///
446    /// - `budget` is smaller than one buffer from every enabled class
447    /// - the budget would require scaling a limit above `u32::MAX`
448    pub fn with_budget_bytes(mut self, budget: NonZeroUsize) -> Self {
449        let budget = budget.get() as u128;
450
451        // The smallest expressible footprint keeps one buffer per class.
452        let minimum: u128 = self
453            .class_limits
454            .keys()
455            .map(|size| size.get() as u128)
456            .sum();
457        assert!(
458            budget >= minimum,
459            "budget must cover at least one buffer from every enabled class"
460        );
461
462        // Scales are unsigned Q64.64 fixed-point. Since class limits fit in
463        // u32, consecutive distinct count-change breakpoints k1/c1 and k2/c2
464        // are separated by at least 1/(c1*c2) > 2^-64, i.e. more than one
465        // fixed-point step, so the maximal feasible count plateau always
466        // contains a representable scale and the binary search below finds an
467        // optimal count vector.
468        const FRACTION_BITS: u32 = 64;
469
470        // Scaled limit for one class. The u32 slot-identifier bound is
471        // enforced by `evaluate` and the post-search assert below. Saturating
472        // math keeps the evaluation monotonic for scales beyond that bound
473        // instead of overflowing.
474        let scaled = |limit: NonZeroU32, scale: u128| -> u128 {
475            ((limit.get() as u128).saturating_mul(scale) >> FRACTION_BITS).max(1)
476        };
477        // Saturating total tracked bytes at a scale, and whether every scaled
478        // limit still fits u32 slot identifiers. Both constraints are
479        // monotonically violated as the scale grows, so feasibility is a
480        // prefix of the scale axis and binary search applies.
481        let evaluate = |scale: u128| -> (u128, bool) {
482            self.class_limits
483                .iter()
484                .fold((0u128, true), |(total, fits), (&size, &limit)| {
485                    let count = scaled(limit, scale);
486                    (
487                        total.saturating_add(count.saturating_mul(size.get() as u128)),
488                        fits && count <= u32::MAX as u128,
489                    )
490                })
491        };
492        let feasible = |scale: u128| -> bool {
493            let (total, fits) = evaluate(scale);
494            total <= budget && fits
495        };
496
497        // Scale zero floors every class at one buffer, which the minimum
498        // check above proved feasible. The upper bound exceeds any scale that
499        // could keep the smallest possible limit within u32, so it is
500        // infeasible and the search invariant holds at both ends.
501        let mut lo: u128 = 0;
502        let mut hi: u128 = (u32::MAX as u128 + 1) << FRACTION_BITS;
503        assert!(feasible(lo), "scale zero must be feasible");
504        while hi - lo > 1 {
505            let mid = lo + (hi - lo) / 2;
506            if feasible(mid) {
507                lo = mid;
508            } else {
509                hi = mid;
510            }
511        }
512
513        // The next representable scale is infeasible. If its total would
514        // still fit the budget, the binding constraint is the u32 limit
515        // bound, which the caller must resolve instead of silently capping.
516        let (next_total, _) = evaluate(lo + 1);
517        assert!(
518            next_total > budget,
519            "budget requires scaling a class limit above u32::MAX"
520        );
521
522        // Rescale each limit in place at the optimal feasible scale.
523        for limit in self.class_limits.values_mut() {
524            let count = u32::try_from(scaled(*limit, lo)).expect("feasible count fits u32");
525            *limit = NonZeroU32::new(count).expect("count is at least one");
526        }
527        self
528    }
529
530    /// Returns an iterator over enabled classes in ascending size order.
531    pub fn size_classes(&self) -> impl ExactSizeIterator<Item = BufferPoolClassConfig> + '_ {
532        self.class_limits
533            .iter()
534            .map(|(&size, &max_buffers)| BufferPoolClassConfig { size, max_buffers })
535    }
536
537    /// Returns the enabled class that serves a pooled request of `size` bytes,
538    /// or `None` if `size` exceeds the largest enabled class.
539    ///
540    /// Requests route to the smallest enabled class that fits, so in sparse
541    /// layouts the returned class may be much larger than the request. This
542    /// reports class shape only: zero-sized requests and requests below
543    /// [`Self::pool_min_size`] bypass the pool, and oversized requests fall
544    /// back to untracked aligned allocations with capacity at least as large
545    /// as the request.
546    pub fn class_for(&self, size: usize) -> Option<BufferPoolClassConfig> {
547        self.size_classes().find(|class| class.size.get() >= size)
548    }
549
550    /// Returns the minimum request size that uses pooled allocation.
551    pub const fn pool_min_size(&self) -> usize {
552        self.pool_min_size
553    }
554
555    /// Returns whether every tracked buffer is created during pool construction.
556    pub const fn prefill(&self) -> bool {
557        self.prefill
558    }
559
560    /// Returns the buffer alignment.
561    pub const fn alignment(&self) -> NonZeroUsize {
562        self.alignment
563    }
564
565    /// Returns the expected number of threads concurrently accessing the pool.
566    pub const fn parallelism(&self) -> NonZeroUsize {
567        self.parallelism
568    }
569
570    /// Returns the smallest enabled class size.
571    pub fn min_size(&self) -> NonZeroUsize {
572        *self
573            .class_limits
574            .first_key_value()
575            .expect("class layout must enable at least one class")
576            .0
577    }
578
579    /// Returns the largest enabled class size.
580    pub fn max_size(&self) -> NonZeroUsize {
581        *self
582            .class_limits
583            .last_key_value()
584            .expect("class layout must enable at least one class")
585            .0
586    }
587
588    /// Returns `sum(class size * class limit)`, saturating at `usize::MAX`.
589    ///
590    /// A saturated result means the configured maximum tracked capacity is at
591    /// least that large.
592    pub fn max_tracked_bytes(&self) -> usize {
593        self.class_limits
594            .iter()
595            .map(|(size, limit)| size.get().saturating_mul(limit.get() as usize))
596            .fold(0usize, usize::saturating_add)
597    }
598
599    /// Validates cross-field constraints, panicking on invalid values.
600    ///
601    /// Layout-local mistakes panic at the builder call that introduces them.
602    /// The constraints here span independently configured fields, so they are
603    /// deferred to pool construction to keep builder order unrestricted.
604    ///
605    /// # Panics
606    ///
607    /// - `alignment` is not a power of two
608    /// - the smallest enabled class is smaller than `alignment`
609    /// - `pool_min_size` is larger than the smallest enabled class
610    fn validate(&self) {
611        assert!(
612            self.alignment.is_power_of_two(),
613            "alignment must be a power of two"
614        );
615        let min_size = self.min_size();
616        assert!(
617            min_size >= self.alignment,
618            "smallest class ({}) must be >= alignment ({})",
619            min_size,
620            self.alignment
621        );
622        assert!(
623            self.pool_min_size <= min_size.get(),
624            "pool_min_size ({}) must be <= smallest class ({})",
625            self.pool_min_size,
626            min_size
627        );
628    }
629
630    /// Resolves the effective per-thread cache size for one size class.
631    ///
632    /// Derived capacities divide half of the class limit across the expected
633    /// parallelism so cross-thread reuse remains effective. Small class limits
634    /// may resolve to zero. An explicit capacity replaces the derivation and
635    /// clamps to the class limit.
636    fn resolve_thread_cache_capacity(&self, class_limit: NonZeroU32) -> usize {
637        let class_limit = class_limit.get() as usize;
638        match self.thread_cache_config {
639            BufferPoolThreadCacheConfig::Enabled(None) => {
640                let effective_threads = self.parallelism.get().min(class_limit);
641                class_limit / effective_threads.saturating_mul(2)
642            }
643            BufferPoolThreadCacheConfig::Enabled(Some(capacity)) => capacity.get().min(class_limit),
644            BufferPoolThreadCacheConfig::Disabled => 0,
645        }
646    }
647}
648
649/// Label for buffer pool metrics, identifying the size class.
650#[derive(Clone, Debug, Hash, PartialEq, Eq, EncodeLabelSet)]
651struct SizeClassLabel {
652    size_class: u64,
653}
654
655/// Metrics for the buffer pool.
656struct PoolMetrics {
657    /// Number of tracked buffers created for the size class.
658    created: GaugeFamily<SizeClassLabel>,
659    /// Total number of failed allocations (pool exhausted).
660    exhausted_total: CounterFamily<SizeClassLabel>,
661    /// Total number of oversized allocation requests.
662    oversized_total: Counter,
663}
664
665impl PoolMetrics {
666    fn new(registry: &mut impl Register) -> Self {
667        Self {
668            created: registry.register(
669                "buffer_pool_created",
670                "Number of tracked buffers created for the pool",
671                raw::Family::default(),
672            ),
673            // Counters are registered without the `_total` suffix because the
674            // prometheus encoder appends it to counter names.
675            exhausted_total: registry.register(
676                "buffer_pool_exhausted",
677                "Total number of failed allocations due to pool exhaustion",
678                raw::Family::default(),
679            ),
680            oversized_total: registry.register(
681                "buffer_pool_oversized",
682                "Total number of allocation requests exceeding max buffer size",
683                raw::Counter::default(),
684            ),
685        }
686    }
687}
688
689/// Internal allocation result for pooled allocations.
690struct Allocation {
691    buffer: PooledBuffer,
692    is_new: bool,
693}
694
695/// Internal state of the buffer pool.
696pub(crate) struct BufferPoolInner {
697    config: BufferPoolConfig,
698    /// Exponent-indexed routing vector.
699    ///
700    /// Entry `i` serves requests that round up to `min_size << i`. Disabled
701    /// exponents hold cloned handles that alias the next enabled class, so
702    /// the allocation path resolves any request with plain arithmetic and one
703    /// vector index. Aliased entries form contiguous runs that end at the
704    /// enabled class's own exponent.
705    classes: Vec<SizeClassHandle>,
706    /// Smallest enabled class size, cached off [`BufferPoolConfig`] so the
707    /// allocation path never scans the configuration table.
708    min_size: usize,
709    /// Largest enabled class size, cached for the same reason.
710    max_size: usize,
711    metrics: PoolMetrics,
712}
713
714impl Drop for BufferPoolInner {
715    fn drop(&mut self) {
716        // The public pool is going away. Drain globally parked buffers while
717        // the pool-owned class handles are still live. Pooled views and live
718        // TLS cache entries own their own size-class references. If they
719        // return later, they park their buffer and release the reference that
720        // kept the class alive.
721        //
722        // Routing entries alias the next enabled class in contiguous runs, so
723        // dropping consecutive duplicates leaves one live handle per unique
724        // class and drains each class once.
725        self.classes.dedup_by(|a, b| a.same_class(b));
726        assert_eq!(self.classes.len(), self.config.size_classes().len());
727        for class in &self.classes {
728            class.drain_global();
729        }
730    }
731}
732
733impl BufferPoolInner {
734    /// Try to allocate a buffer from the given size class.
735    ///
736    /// Uses a three-tier strategy:
737    /// 1. **Thread-local cache** (fast path): no atomics, no contention.
738    /// 2. **Global freelist**: striped pop, then batch-refill the local cache
739    ///    when the local bin is large enough to amortize shared-queue traffic.
740    /// 3. **New allocation**: reserve a slot in the global freelist, then
741    ///    allocate from the heap.
742    ///
743    /// If `zero_on_new` is true, newly-created buffers are allocated with
744    /// `alloc_zeroed`. Reused buffers are never re-zeroed here.
745    #[inline(always)]
746    fn try_alloc(&self, class_index: usize, zero_on_new: bool) -> Option<Allocation> {
747        let class = &self.classes[class_index];
748
749        // Reuse path: try the thread-local cache first, then the global
750        // freelist with batch refill when the local cache is large enough.
751        if let Some(buffer) = BufferPoolThreadCache::pop(class) {
752            return Some(Allocation {
753                buffer,
754                is_new: false,
755            });
756        }
757
758        // Slow path: create a new tracked buffer and update metrics.
759        self.try_alloc_new(class, zero_on_new)
760    }
761
762    /// Creates a new tracked buffer after the reuse path fails.
763    ///
764    /// This is separate from [`Self::try_alloc`] so the steady-state allocation
765    /// path can inline the TLS hit without also carrying slot reservation,
766    /// metrics, and heap-allocation code.
767    #[inline(never)]
768    fn try_alloc_new(&self, class: &SizeClassHandle, zeroed: bool) -> Option<Allocation> {
769        let label = SizeClassLabel {
770            size_class: class.size() as u64,
771        };
772        let Some(buffer) = class.try_create(zeroed) else {
773            self.metrics.exhausted_total.get_or_create(&label).inc();
774            return None;
775        };
776
777        self.metrics.created.get_or_create(&label).inc();
778        Some(Allocation {
779            buffer,
780            is_new: true,
781        })
782    }
783}
784
785/// A pool of reusable, aligned buffers.
786///
787/// Buffers are organized into power-of-two size classes. When a buffer is
788/// requested, the smallest size class that fits is used. Pooled buffers are
789/// automatically returned when their final owning view is dropped.
790///
791/// # Alignment
792///
793/// Buffer alignment is guaranteed only at the allocation base, where a
794/// freshly allocated buffer's pointer starts. After [`bytes::Buf::advance`],
795/// the pointer returned by `as_mut_ptr()` may no longer be aligned. For
796/// direct I/O operations that require alignment, do not advance the buffer
797/// before use.
798///
799/// # Thread-local caching
800///
801/// Returned buffers are cached per thread for reuse. After the pool is
802/// dropped, buffers still cached on other threads are reclaimed when those
803/// threads exit or call [`BufferPoolThreadCache::flush`]. A long-lived
804/// thread that used a since-dropped pool retains its cached buffers until
805/// then. Processes that create and drop many pools should reuse threads'
806/// pools or flush explicitly.
807#[derive(Clone)]
808pub struct BufferPool {
809    inner: Arc<BufferPoolInner>,
810}
811
812impl std::fmt::Debug for BufferPool {
813    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
814        f.debug_struct("BufferPool")
815            .field("config", &self.inner.config)
816            .field("num_classes", &self.inner.config.size_classes().len())
817            .finish()
818    }
819}
820
821/// Global allocator for size-class TLS registry ids.
822///
823/// `class_id` is the key used by each thread's cache registry. It must be global, not
824/// pool-local, because the same thread-local registry serves every
825/// [`BufferPool`] touched by the thread. Without a global id, two different
826/// pools could share a class index and accidentally share one local cache.
827///
828/// Ids are monotonic and never reused. Reuse would make stale per-thread cache
829/// state ambiguous after a pool is dropped and a later pool creates a new size
830/// class with the same id. Avoiding reuse means the hot path can index directly
831/// without generation checks, at the cost of possible holes in each thread's
832/// sparse registry.
833///
834/// Relaxed ordering is sufficient: the atomic operation is only used to assign
835/// unique ids, not to publish any associated size-class state.
836static NEXT_SIZE_CLASS_ID: AtomicUsize = AtomicUsize::new(0);
837
838impl BufferPool {
839    /// Creates a new buffer pool with the given configuration.
840    ///
841    /// # Panics
842    ///
843    /// Panics if the configuration is invalid.
844    pub(crate) fn new(config: BufferPoolConfig, registry: &mut impl Register) -> Self {
845        config.validate();
846        let metrics = PoolMetrics::new(registry);
847        let min_size = config.min_size().get();
848        let max_size = config.max_size().get();
849        let min_exponent = min_size.trailing_zeros() as usize;
850        let max_exponent = max_size.trailing_zeros() as usize;
851
852        // Create one allocator per enabled class and expand the exponent-indexed
853        // routing vector up to it. Every exponent in the enabled span resolves
854        // to the smallest enabled class at or above it, so the gap entries
855        // `resize` fills below each class alias that class. Prefill happens
856        // inside `SizeClassHandle::new`, once per unique class.
857        let mut classes = Vec::with_capacity(max_exponent - min_exponent + 1);
858        for class_config in config.size_classes() {
859            let class_id = NEXT_SIZE_CLASS_ID.fetch_add(1, Ordering::Relaxed);
860            let handle = SizeClassHandle::new(
861                class_id,
862                class_config.size.get(),
863                config.alignment.get(),
864                class_config.max_buffers,
865                config.parallelism,
866                config.resolve_thread_cache_capacity(class_config.max_buffers),
867                config.prefill,
868            );
869
870            // Initialize created metrics after constructor prefill.
871            if config.prefill {
872                let label = SizeClassLabel {
873                    size_class: class_config.size.get() as u64,
874                };
875                metrics
876                    .created
877                    .get_or_create(&label)
878                    .set(class_config.max_buffers.get() as i64);
879            }
880
881            let index = class_config.size.get().trailing_zeros() as usize - min_exponent;
882            classes.resize(index + 1, handle);
883        }
884
885        Self {
886            inner: Arc::new(BufferPoolInner {
887                config,
888                classes,
889                min_size,
890                max_size,
891                metrics,
892            }),
893        }
894    }
895
896    /// Returns the routing index for a given size, or `None` if `size` exceeds
897    /// the largest enabled class.
898    ///
899    /// The routing vector is exponent-indexed, so this arithmetic is identical
900    /// for contiguous and sparse layouts. Disabled exponents resolve to an
901    /// aliased handle for the next enabled class.
902    #[inline(always)]
903    fn class_index(&self, size: usize) -> Option<usize> {
904        let min_size = self.inner.min_size;
905        let max_size = self.inner.max_size;
906        if size > max_size {
907            return None;
908        }
909        if size <= min_size {
910            return Some(0);
911        }
912
913        // Pool construction guarantees `min_size` and `max_size` are powers of
914        // two. Since `min_size < size <= max_size`, `next_power_of_two()`
915        // resolves to a valid routing entry and its exponent must be greater
916        // than `min_size`'s exponent. Use wrapping arithmetic to avoid a
917        // release overflow-check branch in this hot helper.
918        Some(
919            size.next_power_of_two()
920                .trailing_zeros()
921                .wrapping_sub(min_size.trailing_zeros()) as usize,
922        )
923    }
924
925    /// Returns the size class index for `capacity`, recording oversized metrics on failure.
926    #[inline]
927    fn class_index_or_record_oversized(&self, capacity: usize) -> Option<usize> {
928        let class_index = self.class_index(capacity);
929        if class_index.is_none() {
930            self.inner.metrics.oversized_total.inc();
931        }
932        class_index
933    }
934
935    /// Attempts to allocate a buffer without falling back on pool miss.
936    ///
937    /// Unlike [`Self::alloc`], this method does not fall back to untracked
938    /// allocation on exhaustion or oversized requests. Requests smaller than
939    /// [`BufferPoolConfig::pool_min_size`] intentionally bypass pooling and
940    /// return an untracked aligned allocation instead.
941    ///
942    /// The returned buffer has `len() == 0` and `capacity() >= capacity`.
943    ///
944    /// Zero-capacity requests return a detached empty buffer without touching
945    /// the pool.
946    ///
947    /// # Initialization
948    ///
949    /// The returned buffer contains **uninitialized memory**. Do not read from
950    /// it until data has been written.
951    ///
952    /// # Errors
953    ///
954    /// - [`PoolError::Oversized`]: `capacity` exceeds `max_size`
955    /// - [`PoolError::Exhausted`]: pool exhausted for the required size class
956    #[inline(always)]
957    pub fn try_alloc(&self, capacity: usize) -> Result<IoBufMut, PoolError> {
958        if capacity == 0 {
959            return Ok(IoBufMut::default());
960        }
961        if capacity < self.inner.config.pool_min_size {
962            return Ok(IoBufMut::with_alignment(
963                capacity,
964                self.inner.config.alignment,
965            ));
966        }
967
968        let class_index = self
969            .class_index_or_record_oversized(capacity)
970            .ok_or(PoolError::Oversized)?;
971
972        let buffer = self
973            .inner
974            .try_alloc(class_index, false)
975            .map(|allocation| {
976                // SAFETY: pooled allocations returned by the pool have an
977                // initialized live lease.
978                unsafe { IoBufMut::from_pooled_parts(allocation.buffer) }
979            })
980            .ok_or(PoolError::Exhausted)?;
981        Ok(buffer)
982    }
983
984    /// Allocates a buffer with capacity for at least `capacity` bytes.
985    ///
986    /// The returned buffer has `len() == 0` and `capacity() >= capacity`,
987    /// matching the semantics of [`IoBufMut::with_capacity`] and
988    /// [`bytes::BytesMut::with_capacity`]. Use [`bytes::BufMut::put_slice`] or
989    /// other [`bytes::BufMut`] methods to write data to the buffer.
990    ///
991    /// Zero-capacity requests return a detached empty buffer without touching
992    /// the pool.
993    ///
994    /// If the pool can provide a buffer (capacity within limits and pool not
995    /// exhausted), this returns a pooled buffer that will be returned to the
996    /// pool when dropped. Requests smaller than [`BufferPoolConfig::pool_min_size`]
997    /// bypass pooling and return an untracked aligned allocation. Oversized or
998    /// exhausted requests also fall back to an untracked aligned heap allocation
999    /// that is deallocated when dropped.
1000    ///
1001    /// Use [`Self::try_alloc`] if eligible requests must fail instead of falling
1002    /// back to direct allocation.
1003    ///
1004    /// # Initialization
1005    ///
1006    /// The returned buffer contains **uninitialized memory**. Do not read from
1007    /// it until data has been written.
1008    #[inline]
1009    pub fn alloc(&self, capacity: usize) -> IoBufMut {
1010        self.try_alloc(capacity).unwrap_or_else(|_| {
1011            let size = capacity.max(1);
1012            IoBufMut::with_alignment(size, self.inner.config.alignment)
1013        })
1014    }
1015
1016    /// Allocates a buffer and sets its readable length to `len` without
1017    /// initializing bytes.
1018    ///
1019    /// Equivalent to [`Self::alloc`] followed by [`IoBufMut::set_len`].
1020    ///
1021    /// # Safety
1022    ///
1023    /// Caller must ensure all bytes are initialized before any read operation.
1024    pub unsafe fn alloc_len(&self, len: usize) -> IoBufMut {
1025        let mut buf = self.alloc(len);
1026        // SAFETY: guaranteed by caller.
1027        unsafe { buf.set_len(len) };
1028        buf
1029    }
1030
1031    /// Attempts to allocate a zero-initialized buffer without falling back on
1032    /// pool miss.
1033    ///
1034    /// Unlike [`Self::alloc_zeroed`], this method does not fall back to
1035    /// untracked allocation on exhaustion or oversized requests. Requests
1036    /// smaller than [`BufferPoolConfig::pool_min_size`] intentionally bypass
1037    /// pooling and return an untracked aligned allocation instead.
1038    ///
1039    /// The returned buffer has `len() == len` and `capacity() >= len`.
1040    /// Zero-length requests return a detached empty buffer without touching
1041    /// the pool.
1042    ///
1043    /// # Initialization
1044    ///
1045    /// Bytes in `0..len` are initialized to zero. Bytes in `len..capacity`
1046    /// may be uninitialized.
1047    ///
1048    /// # Errors
1049    ///
1050    /// - [`PoolError::Oversized`]: `len` exceeds `max_size`
1051    /// - [`PoolError::Exhausted`]: pool exhausted for the required size class
1052    pub fn try_alloc_zeroed(&self, len: usize) -> Result<IoBufMut, PoolError> {
1053        if len == 0 {
1054            return Ok(IoBufMut::default());
1055        }
1056        if len < self.inner.config.pool_min_size {
1057            return Ok(IoBufMut::zeroed_with_alignment(
1058                len,
1059                self.inner.config.alignment,
1060            ));
1061        }
1062
1063        let class_index = self
1064            .class_index_or_record_oversized(len)
1065            .ok_or(PoolError::Oversized)?;
1066        let allocation = self
1067            .inner
1068            .try_alloc(class_index, true)
1069            .ok_or(PoolError::Exhausted)?;
1070        // SAFETY: pooled allocations returned by the pool have an initialized
1071        // live lease.
1072        let mut buf = unsafe { IoBufMut::from_pooled_parts(allocation.buffer) };
1073        if allocation.is_new {
1074            // SAFETY: newly allocated zeroed buffers have `capacity() >= len`
1075            // and the readable bytes are initialized by zeroed allocation.
1076            unsafe { buf.set_len(len) };
1077        } else {
1078            // Reused buffers may contain old bytes, re-zero requested readable range.
1079            // SAFETY: `as_mut_ptr()` is valid for writes up to `capacity() >= len` bytes.
1080            unsafe {
1081                std::ptr::write_bytes(buf.as_mut_ptr(), 0, len);
1082                buf.set_len(len);
1083            }
1084        }
1085        Ok(buf)
1086    }
1087
1088    /// Allocates a zero-initialized buffer with readable length `len`.
1089    ///
1090    /// The returned buffer has `len() == len` and `capacity() >= len`.
1091    /// Zero-length requests return a detached empty buffer without touching
1092    /// the pool.
1093    ///
1094    /// If the pool can provide a buffer (len within limits and pool not
1095    /// exhausted), this returns a pooled buffer that will be returned to the
1096    /// pool when dropped. Requests smaller than [`BufferPoolConfig::pool_min_size`]
1097    /// bypass pooling and return an untracked aligned allocation. Oversized or
1098    /// exhausted requests also fall back to an untracked aligned heap allocation
1099    /// that is deallocated when dropped.
1100    ///
1101    /// Use this for read APIs that require an initialized `&mut [u8]`. This
1102    /// avoids `unsafe set_len` at callsites.
1103    ///
1104    /// Use [`Self::try_alloc_zeroed`] if eligible requests must fail instead of
1105    /// falling back to direct allocation.
1106    ///
1107    /// # Initialization
1108    ///
1109    /// Bytes in `0..len` are initialized to zero. Bytes in `len..capacity`
1110    /// may be uninitialized.
1111    pub fn alloc_zeroed(&self, len: usize) -> IoBufMut {
1112        self.try_alloc_zeroed(len).unwrap_or_else(|_| {
1113            // Pool exhausted or oversized: allocate untracked zeroed memory.
1114            let size = len.max(1);
1115            let mut buf = IoBufMut::zeroed_with_alignment(size, self.inner.config.alignment);
1116            buf.truncate(len);
1117            buf
1118        })
1119    }
1120
1121    /// Returns the pool configuration.
1122    pub fn config(&self) -> &BufferPoolConfig {
1123        &self.inner.config
1124    }
1125}
1126
1127#[cfg(all(test, not(feature = "loom")))]
1128mod tests {
1129    use super::{
1130        class::tests::{
1131            get_global_created, get_global_len, get_global_num_stripes, get_local_len,
1132            get_thread_cache_capacity,
1133        },
1134        *,
1135    };
1136    use crate::{
1137        iobuf::{IoBuf, cache_line_size},
1138        telemetry::metrics::Registry,
1139    };
1140    use bytes::{Buf, BufMut};
1141    use commonware_utils::NZU32;
1142    use std::{
1143        sync::{Arc, mpsc},
1144        thread,
1145    };
1146
1147    fn test_pool(config: BufferPoolConfig) -> BufferPool {
1148        let mut registry = Registry::default();
1149        BufferPool::new(config, &mut registry)
1150    }
1151
1152    /// Creates a test config with page alignment.
1153    fn test_config(min_size: usize, max_size: usize, max_per_class: u32) -> BufferPoolConfig {
1154        BufferPoolConfig::for_network()
1155            .with_pool_min_size(0)
1156            .with_size_class_range(
1157                NZUsize!(min_size),
1158                NZUsize!(max_size),
1159                NZU32!(max_per_class),
1160            )
1161            .with_alignment(NZUsize!(page_size()))
1162    }
1163
1164    /// Creates a page-aligned test config with exactly the given classes.
1165    fn sparse_config(classes: impl IntoIterator<Item = (usize, u32)>) -> BufferPoolConfig {
1166        BufferPoolConfig::for_network()
1167            .with_pool_min_size(0)
1168            .with_size_classes(
1169                classes
1170                    .into_iter()
1171                    .map(|(size, max_buffers)| (NZUsize!(size), NZU32!(max_buffers))),
1172            )
1173            .with_alignment(NZUsize!(page_size()))
1174    }
1175
1176    /// Collects the enabled classes as `(size, max_buffers)` pairs.
1177    fn classes_of(config: &BufferPoolConfig) -> Vec<(usize, u32)> {
1178        config
1179            .size_classes()
1180            .map(|class| (class.size.get(), class.max_buffers.get()))
1181            .collect()
1182    }
1183
1184    /// Helper to get the number of caller-owned tracked buffers for a size class.
1185    ///
1186    /// With TLS enabled, tracked buffers can be free in either the shared
1187    /// freelist or the current thread's local cache.
1188    fn get_allocated(pool: &BufferPool, size: usize) -> usize {
1189        let class_index = pool.class_index(size).unwrap();
1190        let class = &pool.inner.classes[class_index];
1191        get_global_created(class) - get_global_len(class) - get_local_len(class)
1192    }
1193
1194    /// Helper to get the number of free buffers visible to the current thread.
1195    fn get_available(pool: &BufferPool, size: usize) -> i64 {
1196        let class_index = pool.class_index(size).unwrap();
1197        let class = &pool.inner.classes[class_index];
1198        (get_global_len(class) + get_local_len(class)) as i64
1199    }
1200
1201    #[test]
1202    fn test_page_size() {
1203        let size = page_size();
1204        assert!(size >= 4096);
1205        assert!(size.is_power_of_two());
1206    }
1207
1208    #[test]
1209    fn test_config_validation() {
1210        let page = page_size();
1211        let config = test_config(page, page * 4, 10);
1212        config.validate();
1213    }
1214
1215    #[test]
1216    fn test_explicit_thread_cache_capacity_clamps_to_class_limit() {
1217        let page = page_size();
1218        // An explicit capacity above a class limit clamps to that limit
1219        // instead of invalidating the configuration.
1220        let config = test_config(page, page * 4, 10).with_max_thread_cache_capacity(NZUsize!(11));
1221        config.validate();
1222        let pool = test_pool(config);
1223        let class_index = pool.class_index(page).unwrap();
1224        assert_eq!(
1225            get_thread_cache_capacity(&pool.inner.classes[class_index]),
1226            10
1227        );
1228
1229        // Per-class limits clamp independently: a small class cannot lower a
1230        // larger class's explicit capacity.
1231        let config = BufferPoolConfig::for_network()
1232            .with_size_classes([(NZUsize!(1024), NZU32!(4)), (NZUsize!(4096), NZU32!(64))])
1233            .with_max_thread_cache_capacity(NZUsize!(16));
1234        let pool = test_pool(config);
1235        let small_index = pool.class_index(1024).unwrap();
1236        let large_index = pool.class_index(4096).unwrap();
1237        assert_eq!(
1238            get_thread_cache_capacity(&pool.inner.classes[small_index]),
1239            4
1240        );
1241        assert_eq!(
1242            get_thread_cache_capacity(&pool.inner.classes[large_index]),
1243            16
1244        );
1245    }
1246
1247    #[test]
1248    #[should_panic(expected = "class size must be a power of two")]
1249    fn test_config_invalid_min_size() {
1250        let _ = BufferPoolConfig::for_network().with_size_class_range(
1251            NZUsize!(3000),
1252            NZUsize!(8192),
1253            NZU32!(10),
1254        );
1255    }
1256
1257    #[test]
1258    #[should_panic(expected = "class size must be a power of two")]
1259    fn test_config_invalid_max_size() {
1260        let _ = BufferPoolConfig::for_network().with_size_class_range(
1261            NZUsize!(4096),
1262            NZUsize!(12000),
1263            NZU32!(10),
1264        );
1265    }
1266
1267    #[test]
1268    #[should_panic(expected = "max size must be >= min size")]
1269    fn test_config_range_rejects_max_below_min() {
1270        let _ = BufferPoolConfig::for_network().with_size_class_range(
1271            NZUsize!(8192),
1272            NZUsize!(1024),
1273            NZU32!(10),
1274        );
1275    }
1276
1277    #[test]
1278    #[should_panic(expected = "class size must not exceed isize::MAX")]
1279    fn test_config_rejects_class_size_above_isize_max() {
1280        let _ = BufferPoolConfig::for_network()
1281            .with_size_class(NZUsize!(1usize << (usize::BITS - 1)), NZU32!(1));
1282    }
1283
1284    #[test]
1285    #[should_panic(expected = "alignment must be a power of two")]
1286    fn test_config_invalid_alignment() {
1287        let page = page_size();
1288        let config = test_config(page, page, 10).with_alignment(NZUsize!(page - 1));
1289        config.validate();
1290    }
1291
1292    #[test]
1293    #[should_panic(expected = "must be >= alignment")]
1294    fn test_config_min_size_below_alignment() {
1295        let page = page_size();
1296        let config = test_config(page, page, 10).with_alignment(NZUsize!(page * 2));
1297        config.validate();
1298    }
1299
1300    #[test]
1301    #[should_panic(expected = "pool_min_size")]
1302    fn test_config_pool_min_size_above_min_size() {
1303        let page = page_size();
1304        let config = test_config(page, page, 10).with_pool_min_size(page + 1);
1305        config.validate();
1306    }
1307
1308    #[test]
1309    fn test_pool_class_index() {
1310        let page = page_size();
1311        let pool = test_pool(test_config(page, page * 8, 10));
1312
1313        // Classes: page, page*2, page*4, page*8
1314        assert_eq!(pool.inner.classes.len(), 4);
1315
1316        assert_eq!(pool.class_index(1), Some(0));
1317        assert_eq!(pool.class_index(page), Some(0));
1318        assert_eq!(pool.class_index(page + 1), Some(1));
1319        assert_eq!(pool.class_index(page * 2), Some(1));
1320        assert_eq!(pool.class_index(page * 4 + 1), Some(3));
1321        assert_eq!(pool.class_index(page * 8 - 1), Some(3));
1322        assert_eq!(pool.class_index(page * 8), Some(3));
1323        assert_eq!(pool.class_index(page * 8 + 1), None);
1324    }
1325
1326    #[test]
1327    fn test_size_classes_replacement_normalizes_and_iterates() {
1328        // Unsorted explicit input normalizes into ascending size order.
1329        let config = BufferPoolConfig::for_network().with_size_classes([
1330            (NZUsize!(1 << 20), NZU32!(16)),
1331            (NZUsize!(4096), NZU32!(1024)),
1332            (NZUsize!(65536), NZU32!(256)),
1333        ]);
1334        assert_eq!(
1335            classes_of(&config),
1336            vec![(4096, 1024), (65536, 256), (1 << 20, 16)]
1337        );
1338        assert_eq!(config.size_classes().len(), 3);
1339        assert_eq!(config.min_size().get(), 4096);
1340        assert_eq!(config.max_size().get(), 1 << 20);
1341        assert_eq!(
1342            config.max_tracked_bytes(),
1343            4096 * 1024 + 65536 * 256 + (1 << 20) * 16
1344        );
1345
1346        // BufferPoolClassConfig values work as inputs too.
1347        let explicit = BufferPoolConfig::for_network().with_size_classes([BufferPoolClassConfig {
1348            size: NZUsize!(512),
1349            max_buffers: NZU32!(2),
1350        }]);
1351        assert_eq!(classes_of(&explicit), vec![(512, 2)]);
1352    }
1353
1354    #[test]
1355    #[should_panic(expected = "class layout must enable at least one class")]
1356    fn test_size_classes_rejects_empty_input() {
1357        let _ = BufferPoolConfig::for_network()
1358            .with_size_classes(std::iter::empty::<BufferPoolClassConfig>());
1359    }
1360
1361    #[test]
1362    #[should_panic(expected = "duplicate class size 4096")]
1363    fn test_size_classes_rejects_duplicates() {
1364        let _ = BufferPoolConfig::for_network()
1365            .with_size_classes([(NZUsize!(4096), NZU32!(1)), (NZUsize!(4096), NZU32!(2))]);
1366    }
1367
1368    #[test]
1369    #[should_panic(expected = "class size must be a power of two")]
1370    fn test_size_classes_rejects_non_power_of_two() {
1371        let _ = BufferPoolConfig::for_network().with_size_classes([(NZUsize!(3000), NZU32!(1))]);
1372    }
1373
1374    #[test]
1375    fn test_size_class_upsert_and_removal() {
1376        let base = BufferPoolConfig::for_network().with_size_class_range(
1377            NZUsize!(1024),
1378            NZUsize!(8192),
1379            NZU32!(8),
1380        );
1381
1382        // Upsert replaces an enabled class's limit in place.
1383        let tuned = base.clone().with_size_class(NZUsize!(2048), NZU32!(64));
1384        assert_eq!(
1385            classes_of(&tuned),
1386            vec![(1024, 8), (2048, 64), (4096, 8), (8192, 8)]
1387        );
1388
1389        // Upsert can also add a class outside the current span.
1390        let extended = base.clone().with_size_class(NZUsize!(32768), NZU32!(2));
1391        assert_eq!(extended.max_size().get(), 32768);
1392        assert_eq!(classes_of(&extended).len(), 5);
1393
1394        // Removing a middle class leaves a gap.
1395        let sparse = base.clone().without_size_class(NZUsize!(2048));
1396        assert_eq!(classes_of(&sparse), vec![(1024, 8), (4096, 8), (8192, 8)]);
1397
1398        // Removing an endpoint narrows the derived bounds.
1399        let narrowed = base.without_size_class(NZUsize!(1024));
1400        assert_eq!(narrowed.min_size().get(), 2048);
1401        let narrowed = narrowed.without_size_class(NZUsize!(8192));
1402        assert_eq!(narrowed.max_size().get(), 4096);
1403
1404        // Uniform overwrite applies to every enabled class.
1405        let uniform = sparse.with_max_per_class(NZU32!(3));
1406        assert_eq!(classes_of(&uniform), vec![(1024, 3), (4096, 3), (8192, 3)]);
1407    }
1408
1409    #[test]
1410    #[should_panic(expected = "cannot remove a class that is not enabled")]
1411    fn test_without_size_class_rejects_absent_class() {
1412        let _ = BufferPoolConfig::for_network().without_size_class(NZUsize!(1 << 30));
1413    }
1414
1415    #[test]
1416    #[should_panic(expected = "cannot remove the final enabled class")]
1417    fn test_without_size_class_rejects_final_class() {
1418        let _ = BufferPoolConfig::for_network()
1419            .with_size_classes([(NZUsize!(4096), NZU32!(1))])
1420            .without_size_class(NZUsize!(4096));
1421    }
1422
1423    #[test]
1424    fn test_bytes_per_class_gives_equal_byte_weight() {
1425        let config = BufferPoolConfig::for_network()
1426            .with_size_classes([
1427                (NZUsize!(1024), NZU32!(1)),
1428                (NZUsize!(4096), NZU32!(1)),
1429                (NZUsize!(1 << 20), NZU32!(1)),
1430            ])
1431            .with_bytes_per_class(NZUsize!(64 * 1024));
1432        // Classes smaller than the target get bytes/size buffers, classes
1433        // larger than the target floor at one buffer.
1434        assert_eq!(
1435            classes_of(&config),
1436            vec![(1024, 64), (4096, 16), (1 << 20, 1)]
1437        );
1438
1439        // A target equal to the class size derives exactly one buffer.
1440        let exact = BufferPoolConfig::for_network()
1441            .with_size_classes([(NZUsize!(4096), NZU32!(7))])
1442            .with_bytes_per_class(NZUsize!(4096));
1443        assert_eq!(classes_of(&exact), vec![(4096, 1)]);
1444    }
1445
1446    #[test]
1447    fn test_class_for_routes_to_smallest_fitting_class() {
1448        let config = BufferPoolConfig::for_network()
1449            .with_size_classes([(NZUsize!(4096), NZU32!(4)), (NZUsize!(32768), NZU32!(2))]);
1450
1451        // Requests at or below the smallest class route to it.
1452        assert_eq!(config.class_for(0).unwrap().size.get(), 4096);
1453        assert_eq!(config.class_for(4096).unwrap().size.get(), 4096);
1454
1455        // Requests in the gap route to the next enabled class, even when
1456        // their natural power-of-two exponent is disabled.
1457        assert_eq!(config.class_for(4097).unwrap().size.get(), 32768);
1458        assert_eq!(config.class_for(16384).unwrap().size.get(), 32768);
1459        assert_eq!(config.class_for(32768).unwrap().size.get(), 32768);
1460
1461        // Requests above the largest class have no serving class.
1462        assert_eq!(config.class_for(32769), None);
1463    }
1464
1465    #[test]
1466    fn test_sparse_routing_allocates_next_enabled_class() {
1467        // Classes `page` and `8 * page` with the two exponents between them
1468        // disabled: requests in the gap route forward to the larger class.
1469        let page = page_size();
1470        let pool = test_pool(sparse_config([(page, 4), (page * 8, 4)]));
1471
1472        // Below the first class routes to it.
1473        let buf = pool.try_alloc(1).unwrap();
1474        assert_eq!(buf.capacity(), page);
1475
1476        // Exact fit for the first class.
1477        let buf = pool.try_alloc(page).unwrap();
1478        assert_eq!(buf.capacity(), page);
1479
1480        // One byte into the gap routes to the next enabled class.
1481        let buf = pool.try_alloc(page + 1).unwrap();
1482        assert_eq!(buf.capacity(), page * 8);
1483
1484        // A request whose natural class is disabled routes forward too.
1485        let buf = pool.try_alloc(page * 4).unwrap();
1486        assert_eq!(buf.capacity(), page * 8);
1487
1488        // Exact fit for the last class.
1489        let buf = pool.try_alloc(page * 8).unwrap();
1490        assert_eq!(buf.capacity(), page * 8);
1491
1492        // Above the last class is oversized.
1493        assert_eq!(
1494            pool.try_alloc(page * 8 + 1).unwrap_err(),
1495            PoolError::Oversized
1496        );
1497    }
1498
1499    #[test]
1500    fn test_sparse_routing_exhaustion_does_not_cascade() {
1501        let page = page_size();
1502        let pool = test_pool(sparse_config([(page, 1), (page * 8, 1)]));
1503
1504        // Exhaust the small class. A page-sized request must report
1505        // exhaustion even though the larger class still has capacity.
1506        let _small = pool.try_alloc(page).unwrap();
1507        assert_eq!(pool.try_alloc(page).unwrap_err(), PoolError::Exhausted);
1508
1509        // The larger class is unaffected.
1510        let _large = pool.try_alloc(page * 8).unwrap();
1511
1512        // The untracked fallback is sized from the request rather than a pool
1513        // class. The aligned owner may round its usable capacity up by at most
1514        // seven bytes.
1515        let fallback = pool.alloc(page);
1516        assert!(!fallback.is_pooled());
1517        assert_eq!(fallback.capacity(), page);
1518        let small_fallback = pool.alloc(100);
1519        assert!(!small_fallback.is_pooled());
1520        assert!((100..108).contains(&small_fallback.capacity()));
1521    }
1522
1523    #[test]
1524    fn test_sparse_metrics_use_enabled_class_labels() {
1525        // Metrics attribute to the enabled class that served the request, so
1526        // a request routed through a gap lands on the larger class's label.
1527        let page = page_size();
1528        let mut registry = Registry::default();
1529        let pool = BufferPool::new(sparse_config([(page, 1), (page * 8, 1)]), &mut registry);
1530
1531        // Allocate through the gap, then exhaust the routed class through it.
1532        let _held = pool.try_alloc(page * 2).unwrap();
1533        assert!(pool.try_alloc(page * 2).is_err());
1534        // Request above the largest class records an oversized attempt.
1535        assert!(pool.try_alloc(page * 16).is_err());
1536
1537        let encoded = registry.encode();
1538        // Both created and exhausted count against the larger class, and the
1539        // disabled exponent of the natural request never appears as a label.
1540        assert!(
1541            encoded.contains(&format!(
1542                "buffer_pool_created{{size_class=\"{}\"}} 1",
1543                page * 8
1544            )),
1545            "metrics output: {encoded}"
1546        );
1547        assert!(
1548            encoded.contains(&format!(
1549                "buffer_pool_exhausted_total{{size_class=\"{}\"}} 1",
1550                page * 8
1551            )),
1552            "metrics output: {encoded}"
1553        );
1554        assert!(
1555            !encoded.contains(&format!("size_class=\"{}\"", page * 2)),
1556            "metrics output: {encoded}"
1557        );
1558        assert!(
1559            encoded.contains("buffer_pool_oversized_total 1"),
1560            "metrics output: {encoded}"
1561        );
1562    }
1563
1564    #[test]
1565    fn test_sparse_gap_allocations_share_one_class() {
1566        // Requests routed through a gap and requests hitting the class
1567        // directly must share the same allocator, TLS cache, and metrics.
1568        let page = page_size();
1569        let pool = test_pool(sparse_config([(page, 4), (page * 8, 4)]));
1570
1571        // All gap exponents alias the same class as the direct exponent.
1572        let direct = pool.class_index(page * 8).unwrap();
1573        for size in [page + 1, page * 2, page * 4, page * 8] {
1574            let index = pool.class_index(size).unwrap();
1575            assert!(
1576                pool.inner.classes[index].same_class(&pool.inner.classes[direct]),
1577                "size {size} must alias the largest class"
1578            );
1579        }
1580
1581        // A buffer allocated through the gap returns to the aliased class and
1582        // is reusable through the direct route.
1583        let mut via_gap = pool.try_alloc(page * 2).unwrap();
1584        let ptr = via_gap.as_mut_ptr();
1585        drop(via_gap);
1586        let mut direct_reuse = pool.try_alloc(page * 8).unwrap();
1587        assert_eq!(direct_reuse.as_mut_ptr(), ptr);
1588    }
1589
1590    #[test]
1591    fn test_sparse_pool_drop_drains_each_unique_class_once() {
1592        // Dropping a sparse pool must reclaim globally parked buffers exactly
1593        // as the contiguous pool does, draining each unique class once even
1594        // though several routing entries alias it.
1595        let page = page_size();
1596        let pool =
1597            test_pool(sparse_config([(page, 2), (page * 16, 2)]).with_thread_cache_disabled());
1598
1599        let class_index = pool.class_index(page * 16).unwrap();
1600        // Keep a test-owned handle so the class remains inspectable after
1601        // the pool is dropped below.
1602        let class = pool.inner.classes[class_index].clone();
1603
1604        // Park one buffer allocated through the gap in the global freelist.
1605        let buf = pool.try_alloc(page * 2).unwrap();
1606        drop(buf);
1607        assert_eq!(get_global_len(&class), 1);
1608
1609        drop(pool);
1610        assert_eq!(get_global_len(&class), 0);
1611        assert_eq!(get_global_created(&class), 1);
1612    }
1613
1614    #[test]
1615    fn test_sparse_pool_debug_reports_unique_classes() {
1616        let page = page_size();
1617        let pool = test_pool(sparse_config([(page, 2), (page * 16, 2)]));
1618        // Two enabled classes span five exponents, Debug must report two.
1619        assert_eq!(pool.inner.classes.len(), 5);
1620        let debug = format!("{pool:?}");
1621        assert!(debug.contains("num_classes: 2"), "debug output: {debug}");
1622    }
1623
1624    #[test]
1625    fn test_sparse_prefill_creates_per_class_limits() {
1626        let page = page_size();
1627        let pool = test_pool(sparse_config([(page, 3), (page * 4, 1)]).with_prefill(true));
1628
1629        // Each unique class prefilled exactly its own limit, aliases add none.
1630        let small = &pool.inner.classes[pool.class_index(page).unwrap()];
1631        let large = &pool.inner.classes[pool.class_index(page * 4).unwrap()];
1632        assert_eq!(get_global_created(small), 3);
1633        assert_eq!(get_global_len(small), 3);
1634        assert_eq!(get_global_created(large), 1);
1635        assert_eq!(get_global_len(large), 1);
1636
1637        // Prefilled capacity is immediately allocatable and bounded.
1638        let a = pool.try_alloc(page).unwrap();
1639        let b = pool.try_alloc(page).unwrap();
1640        let c = pool.try_alloc(page).unwrap();
1641        assert!(pool.try_alloc(page).is_err());
1642        drop((a, b, c));
1643        let _gap = pool.try_alloc(page * 2).unwrap();
1644        assert!(pool.try_alloc(page * 4).is_err());
1645    }
1646
1647    #[test]
1648    fn test_pool_alloc_and_return() {
1649        let page = page_size();
1650        let pool = test_pool(test_config(page, page * 4, 2));
1651
1652        // Allocate a buffer - returns buffer with len=0, capacity >= requested
1653        let buf = pool.try_alloc(page).unwrap();
1654        assert!(buf.capacity() >= page);
1655        assert_eq!(buf.len(), 0);
1656
1657        // Drop returns to pool
1658        drop(buf);
1659
1660        // Can allocate again
1661        let buf2 = pool.try_alloc(page).unwrap();
1662        assert!(buf2.capacity() >= page);
1663        assert_eq!(buf2.len(), 0);
1664    }
1665
1666    #[test]
1667    fn test_alloc_len_sets_len() {
1668        let page = page_size();
1669        let pool = test_pool(test_config(page, page * 4, 2));
1670
1671        // SAFETY: we immediately initialize all bytes before reading.
1672        let mut buf = unsafe { pool.alloc_len(100) };
1673        assert_eq!(buf.len(), 100);
1674        buf.as_mut().fill(0xAB);
1675        let frozen = buf.freeze();
1676        assert_eq!(frozen.as_ref(), &[0xAB; 100]);
1677    }
1678
1679    #[test]
1680    fn test_alloc_zeroed_sets_len_and_zeros() {
1681        let page = page_size();
1682        let pool = test_pool(test_config(page, page * 4, 2));
1683
1684        let buf = pool.alloc_zeroed(100);
1685        assert_eq!(buf.len(), 100);
1686        assert!(buf.as_ref().iter().all(|&b| b == 0));
1687    }
1688
1689    #[test]
1690    fn test_try_alloc_zeroed_sets_len_and_zeros() {
1691        let page = page_size();
1692        let pool = test_pool(test_config(page, page * 4, 2));
1693
1694        let buf = pool.try_alloc_zeroed(page).unwrap();
1695        assert!(buf.is_pooled());
1696        assert_eq!(buf.len(), page);
1697        assert!(buf.as_ref().iter().all(|&b| b == 0));
1698    }
1699
1700    #[test]
1701    fn test_alloc_zeroed_fallback_uses_untracked_zeroed_buffer() {
1702        let page = page_size();
1703        let pool = test_pool(test_config(page, page, 1));
1704
1705        // Exhaust pooled capacity for this class.
1706        let _pooled = pool.try_alloc(page).unwrap();
1707
1708        let buf = pool.alloc_zeroed(100);
1709        assert!(!buf.is_pooled());
1710        assert_eq!(buf.len(), 100);
1711        assert!(buf.as_ref().iter().all(|&b| b == 0));
1712    }
1713
1714    #[test]
1715    fn test_alloc_zeroed_reuses_dirty_pooled_buffer() {
1716        let page = page_size();
1717        let pool = test_pool(test_config(page, page, 1));
1718
1719        let mut first = pool.alloc_zeroed(page);
1720        assert!(first.is_pooled());
1721        assert!(first.as_ref().iter().all(|&b| b == 0));
1722
1723        // Dirty the buffer before returning it to the pool.
1724        first.as_mut().fill(0xAB);
1725        drop(first);
1726
1727        let second = pool.alloc_zeroed(page);
1728        assert!(second.is_pooled());
1729        assert_eq!(second.len(), page);
1730        assert!(second.as_ref().iter().all(|&b| b == 0));
1731    }
1732
1733    #[test]
1734    fn test_requests_smaller_than_pool_min_size_bypass_pool() {
1735        let pool = test_pool(
1736            BufferPoolConfig::for_network()
1737                .with_pool_min_size(512)
1738                .with_size_class_range(NZUsize!(512), NZUsize!(1024), NZU32!(2))
1739                .with_alignment(NZUsize!(128)),
1740        );
1741
1742        let buf = pool.try_alloc(200).unwrap();
1743        assert!(!buf.is_pooled());
1744        assert_eq!(buf.capacity(), 200);
1745
1746        let zeroed = pool.try_alloc_zeroed(200).unwrap();
1747        assert!(!zeroed.is_pooled());
1748        assert_eq!(zeroed.len(), 200);
1749        assert!(zeroed.as_ref().iter().all(|&b| b == 0));
1750
1751        let pooled = pool.try_alloc(512).unwrap();
1752        assert!(pooled.is_pooled());
1753        assert_eq!(pooled.capacity(), 512);
1754    }
1755
1756    #[test]
1757    fn test_zero_capacity_requests_bypass_pool() {
1758        // A single-slot pool: if a zero-capacity request claimed a buffer,
1759        // the follow-up real allocation would fail with Exhausted.
1760        let page = page_size();
1761        let pool = test_pool(test_config(page, page, 1));
1762
1763        let empty = pool.try_alloc(0).unwrap();
1764        assert!(!empty.is_pooled());
1765        assert_eq!(empty.capacity(), 0);
1766
1767        let zeroed = pool.try_alloc_zeroed(0).unwrap();
1768        assert!(!zeroed.is_pooled());
1769        assert_eq!(zeroed.len(), 0);
1770        assert_eq!(zeroed.capacity(), 0);
1771
1772        assert_eq!(pool.alloc(0).capacity(), 0);
1773        assert_eq!(pool.alloc_zeroed(0).len(), 0);
1774
1775        let real = pool.try_alloc(page).unwrap();
1776        assert!(real.is_pooled());
1777        assert_eq!(real.capacity(), page);
1778    }
1779
1780    #[test]
1781    fn test_pool_size_classes() {
1782        let page = page_size();
1783        let pool = test_pool(test_config(page, page * 4, 10));
1784
1785        // Small request gets smallest class
1786        let buf1 = pool.try_alloc(page).unwrap();
1787        assert_eq!(buf1.capacity(), page);
1788
1789        // Larger request gets appropriate class
1790        let buf2 = pool.try_alloc(page + 1).unwrap();
1791        assert_eq!(buf2.capacity(), page * 2);
1792
1793        let buf3 = pool.try_alloc(page * 3).unwrap();
1794        assert_eq!(buf3.capacity(), page * 4);
1795    }
1796
1797    #[test]
1798    fn test_prefill() {
1799        let page = NZUsize!(page_size());
1800        let pool = test_pool(
1801            BufferPoolConfig::for_network()
1802                .with_pool_min_size(0)
1803                .with_size_class_range(page, page, NZU32!(5))
1804                .with_alignment(page)
1805                .with_prefill(true),
1806        );
1807
1808        // Should be able to allocate max_per_class buffers immediately
1809        let mut bufs = Vec::new();
1810        for _ in 0..5 {
1811            bufs.push(pool.try_alloc(page.get()).expect("alloc should succeed"));
1812        }
1813
1814        // Next allocation should fail
1815        assert!(pool.try_alloc(page.get()).is_err());
1816    }
1817
1818    #[test]
1819    fn test_config_for_network() {
1820        let config = BufferPoolConfig::for_network();
1821        config.validate();
1822        assert_eq!(config.pool_min_size, 0);
1823        assert_eq!(config.min_size().get(), 1024);
1824        assert_eq!(config.max_size().get(), 128 * 1024);
1825        let expected: Vec<(usize, u32)> = (10..=17).map(|e| (1usize << e, 4096)).collect();
1826        assert_eq!(classes_of(&config), expected);
1827        assert_eq!(config.parallelism, NZUsize!(1));
1828        assert_eq!(
1829            config.thread_cache_config,
1830            BufferPoolThreadCacheConfig::Enabled(None)
1831        );
1832        assert!(!config.prefill);
1833        assert_eq!(config.alignment.get(), 1);
1834    }
1835
1836    #[test]
1837    fn test_config_for_storage() {
1838        let config = BufferPoolConfig::for_storage();
1839        config.validate();
1840        assert_eq!(config.pool_min_size, 0);
1841        assert_eq!(config.min_size().get(), page_size());
1842        assert_eq!(config.max_size().get(), 8 * 1024 * 1024);
1843        let min_exponent = page_size().trailing_zeros();
1844        let expected: Vec<(usize, u32)> = (min_exponent..=23).map(|e| (1usize << e, 64)).collect();
1845        assert_eq!(classes_of(&config), expected);
1846        assert_eq!(config.parallelism, NZUsize!(1));
1847        assert_eq!(
1848            config.thread_cache_config,
1849            BufferPoolThreadCacheConfig::Enabled(None)
1850        );
1851        assert!(!config.prefill);
1852        assert_eq!(config.alignment.get(), 1);
1853    }
1854
1855    #[test]
1856    fn test_storage_config_supports_default_allocations() {
1857        // The storage preset's max_size (8 MB) should be allocatable out of the box.
1858        let pool = test_pool(BufferPoolConfig::for_storage());
1859
1860        let buf = pool.try_alloc(8 * 1024 * 1024).unwrap();
1861        assert_eq!(buf.capacity(), 8 * 1024 * 1024);
1862    }
1863
1864    #[test]
1865    fn test_config_builders() {
1866        let page = NZUsize!(page_size());
1867        let config = BufferPoolConfig::for_storage()
1868            .with_pool_min_size(1024)
1869            .with_parallelism(NZUsize!(4))
1870            .with_max_thread_cache_capacity(NZUsize!(8))
1871            .with_prefill(true)
1872            .with_size_class_range(page, NZUsize!(128 * 1024), NZU32!(64));
1873
1874        config.validate();
1875        assert_eq!(config.pool_min_size, 1024);
1876        assert_eq!(config.min_size(), page);
1877        assert_eq!(config.max_size().get(), 128 * 1024);
1878        assert!(
1879            config
1880                .size_classes()
1881                .all(|class| class.max_buffers.get() == 64)
1882        );
1883        assert_eq!(config.parallelism, NZUsize!(4));
1884        assert_eq!(
1885            config.thread_cache_config,
1886            BufferPoolThreadCacheConfig::Enabled(Some(NZUsize!(8)))
1887        );
1888        assert!(config.prefill);
1889        assert_eq!(config.alignment.get(), 1);
1890
1891        // Alignment can be tuned explicitly as long as the smallest class is
1892        // also adjusted.
1893        let aligned = BufferPoolConfig::for_network()
1894            .with_pool_min_size(256)
1895            .with_parallelism(NZUsize!(4))
1896            .with_alignment(NZUsize!(256))
1897            .with_size_class_range(NZUsize!(256), NZUsize!(128 * 1024), NZU32!(4096));
1898        aligned.validate();
1899        assert_eq!(aligned.parallelism, NZUsize!(4));
1900        assert_eq!(
1901            aligned.thread_cache_config,
1902            BufferPoolThreadCacheConfig::Enabled(None)
1903        );
1904        assert_eq!(aligned.alignment.get(), 256);
1905        assert_eq!(aligned.min_size().get(), 256);
1906    }
1907
1908    #[test]
1909    fn test_parallelism_policy_resolves_thread_cache_capacity() {
1910        let page = page_size();
1911
1912        // Half the class budget is divided across expected threads.
1913        let pool = test_pool(test_config(page, page, 64).with_parallelism(NZUsize!(8)));
1914        let class_index = pool.class_index(page).unwrap();
1915        assert_eq!(
1916            get_thread_cache_capacity(&pool.inner.classes[class_index]),
1917            4
1918        );
1919
1920        // Large classes scale past the previous eight-slot cap.
1921        let pool = test_pool(test_config(page, page, 4096).with_parallelism(NZUsize!(8)));
1922        let class_index = pool.class_index(page).unwrap();
1923        assert_eq!(
1924            get_thread_cache_capacity(&pool.inner.classes[class_index]),
1925            256
1926        );
1927    }
1928
1929    #[test]
1930    fn test_auto_thread_cache_disables_when_parallelism_exceeds_budget() {
1931        let page = page_size();
1932
1933        // With only two buffers and eight expected threads, the auto policy's
1934        // per-thread share is zero: 2 / (2 * min(8, 2)) == 0. In that case the
1935        // pool should disable TLS instead of forcing every thread to retain at
1936        // least one buffer.
1937        let pool = test_pool(test_config(page, page, 2).with_parallelism(NZUsize!(8)));
1938        let class_index = pool.class_index(page).unwrap();
1939        let class = &pool.inner.classes[class_index];
1940        assert_eq!(get_thread_cache_capacity(class), 0);
1941
1942        // Exhaust the size class so the only way the main thread can allocate
1943        // again is if the worker's returned buffers are globally visible.
1944        let first = pool.try_alloc(page).expect("first tracked allocation");
1945        let second = pool.try_alloc(page).expect("second tracked allocation");
1946
1947        let pool_for_thread = pool.clone();
1948        let (returned_tx, returned_rx) = mpsc::channel();
1949        let (release_tx, release_rx) = mpsc::channel();
1950        let handle = thread::spawn(move || {
1951            // Return both buffers from another thread. The thread stays alive
1952            // after the drops, so any TLS entries it retained would remain
1953            // invisible to the main thread until `release_rx` fires.
1954            drop(first);
1955            drop(second);
1956            returned_tx.send(()).expect("signal returned buffers");
1957            release_rx.recv().expect("release worker");
1958            drop(pool_for_thread);
1959        });
1960
1961        returned_rx.recv().expect("wait for returned buffers");
1962
1963        // Both allocations must succeed while the worker thread is still
1964        // alive. Before auto capacity could resolve to zero, one returned
1965        // buffer could remain stranded in the worker's TLS cache and this
1966        // second allocation would report exhaustion.
1967        let _first = pool.try_alloc(page).expect("first global reuse");
1968        let _second = pool.try_alloc(page).expect("second global reuse");
1969
1970        release_tx.send(()).expect("release worker");
1971        handle.join().expect("worker should not panic");
1972    }
1973
1974    #[test]
1975    fn test_parallelism_policy_resolves_freelist_stripes() {
1976        let page = page_size();
1977        let pool = test_pool(test_config(page, page, 64).with_parallelism(NZUsize!(16)));
1978
1979        let class_index = pool.class_index(page).unwrap();
1980        assert_eq!(get_global_num_stripes(&pool.inner.classes[class_index]), 16);
1981
1982        // When expected parallelism rounds above capacity, the freelist caps
1983        // stripes so every stripe can contain at least one slot.
1984        let pool = test_pool(test_config(page, page, 12).with_parallelism(NZUsize!(9)));
1985
1986        let class_index = pool.class_index(page).unwrap();
1987        assert_eq!(get_global_num_stripes(&pool.inner.classes[class_index]), 8);
1988
1989        // Disabling thread-local caches should not change global striping.
1990        let pool = test_pool(
1991            test_config(page, page, 64)
1992                .with_parallelism(NZUsize!(16))
1993                .with_thread_cache_disabled(),
1994        );
1995
1996        let class_index = pool.class_index(page).unwrap();
1997        assert_eq!(get_global_num_stripes(&pool.inner.classes[class_index]), 16);
1998    }
1999
2000    #[test]
2001    fn test_fixed_thread_cache_capacity_overrides_auto_capacity() {
2002        let page = page_size();
2003        let pool = test_pool(
2004            test_config(page, page, 64)
2005                .with_parallelism(NZUsize!(8))
2006                .with_max_thread_cache_capacity(NZUsize!(7)),
2007        );
2008        let class_index = pool.class_index(page).unwrap();
2009
2010        // Fixed capacity should bypass the derived parallelism heuristic.
2011        assert_eq!(
2012            get_thread_cache_capacity(&pool.inner.classes[class_index]),
2013            7
2014        );
2015        assert_eq!(get_global_num_stripes(&pool.inner.classes[class_index]), 8);
2016    }
2017
2018    #[test]
2019    fn test_disabled_thread_cache_does_not_retain_buffers_locally() {
2020        let page = page_size();
2021        let pool = test_pool(test_config(page, page, 2).with_thread_cache_disabled());
2022        let class_index = pool.class_index(page).unwrap();
2023        let class = &pool.inner.classes[class_index];
2024
2025        let tracked = pool.try_alloc(page).expect("tracked allocation");
2026        drop(tracked);
2027
2028        // Disabled thread caching still routes returns through the global
2029        // freelist, but should never retain buffers in the current thread.
2030        assert_eq!(get_thread_cache_capacity(class), 0);
2031        assert_eq!(get_local_len(class), 0);
2032        assert_eq!(get_global_len(class), 1);
2033    }
2034
2035    #[test]
2036    fn test_config_with_budget_bytes() {
2037        // Classes: 4, 8, 16 (sum = 28). Budget 280 scales the uniform shape
2038        // to exactly 10 buffers per class.
2039        let base = BufferPoolConfig::for_network().with_size_class_range(
2040            NZUsize!(4),
2041            NZUsize!(16),
2042            NZU32!(1),
2043        );
2044        let config = base.clone().with_budget_bytes(NZUsize!(280));
2045        assert_eq!(classes_of(&config), vec![(4, 10), (8, 10), (16, 10)]);
2046        assert_eq!(config.max_tracked_bytes(), 280);
2047
2048        // The budget is a strict ceiling: 279 cannot afford the tenth round.
2049        let config = base.clone().with_budget_bytes(NZUsize!(279));
2050        assert_eq!(classes_of(&config), vec![(4, 9), (8, 9), (16, 9)]);
2051
2052        // The minimum footprint keeps one buffer per class.
2053        let config = base.clone().with_budget_bytes(NZUsize!(28));
2054        assert_eq!(classes_of(&config), vec![(4, 1), (8, 1), (16, 1)]);
2055
2056        // Scaling preserves a nonuniform shape proportionally: limits (4, 1)
2057        // over sizes (4, 16) cost 32 per round, so budget 96 affords a 3x
2058        // scale of the whole shape.
2059        let shaped_base = BufferPoolConfig::for_network()
2060            .with_size_classes([(NZUsize!(4), NZU32!(4)), (NZUsize!(16), NZU32!(1))]);
2061        let shaped = shaped_base.clone().with_budget_bytes(NZUsize!(96));
2062        assert_eq!(classes_of(&shaped), vec![(4, 12), (16, 3)]);
2063
2064        // Scaling can also shrink an existing shape.
2065        let shrunk = shaped_base.with_budget_bytes(NZUsize!(20));
2066        assert_eq!(classes_of(&shrunk), vec![(4, 1), (16, 1)]);
2067
2068        // Rounding never disables a class, so an uneven budget leaves an
2069        // intentionally unused remainder.
2070        let uneven = base.with_budget_bytes(NZUsize!(30));
2071        assert_eq!(classes_of(&uneven), vec![(4, 1), (8, 1), (16, 1)]);
2072    }
2073
2074    #[test]
2075    fn test_config_with_budget_bytes_is_one_shot() {
2076        // The budget is not a stored policy: later builder calls apply to the
2077        // scaled limits and may exceed the former budget.
2078        let config = BufferPoolConfig::for_network()
2079            .with_size_class_range(NZUsize!(4), NZUsize!(16), NZU32!(1))
2080            .with_budget_bytes(NZUsize!(280));
2081        assert_eq!(config.max_tracked_bytes(), 280);
2082
2083        let overridden = config.clone().with_max_per_class(NZU32!(100));
2084        assert_eq!(overridden.max_tracked_bytes(), 2800);
2085
2086        let upserted = config.with_size_class(NZUsize!(32), NZU32!(100));
2087        assert_eq!(upserted.max_tracked_bytes(), 280 + 32 * 100);
2088
2089        // Rescaling applies to the already scaled limits, not the shape they
2090        // were derived from, so repeating a budget can compound the rounding
2091        // floors into a slightly different shape.
2092        let base = BufferPoolConfig::for_network()
2093            .with_size_classes([(NZUsize!(1), NZU32!(3)), (NZUsize!(8), NZU32!(2))]);
2094        let once = base.with_budget_bytes(NZUsize!(21));
2095        assert_eq!(classes_of(&once), vec![(1, 4), (8, 2)]);
2096        let twice = once.with_budget_bytes(NZUsize!(21));
2097        assert_eq!(classes_of(&twice), vec![(1, 5), (8, 2)]);
2098    }
2099
2100    #[test]
2101    #[should_panic(expected = "budget must cover at least one buffer from every enabled class")]
2102    fn test_config_with_budget_bytes_below_minimum() {
2103        let _ = BufferPoolConfig::for_network()
2104            .with_size_class_range(NZUsize!(4), NZUsize!(16), NZU32!(1))
2105            .with_budget_bytes(NZUsize!(27));
2106    }
2107
2108    #[test]
2109    #[should_panic(expected = "budget requires scaling a class limit above u32::MAX")]
2110    fn test_config_with_budget_bytes_above_u32() {
2111        // One-byte class: any budget beyond u32::MAX buffers must panic
2112        // instead of silently capping the limit.
2113        let _ = BufferPoolConfig::for_network()
2114            .with_size_classes([(NZUsize!(1), NZU32!(1))])
2115            .with_budget_bytes(NZUsize!(u32::MAX as usize + 2));
2116    }
2117
2118    #[test]
2119    fn test_config_with_budget_bytes_near_u32_breakpoints() {
2120        // Two classes with limits near u32::MAX exercise the tightest
2121        // count-change breakpoint separation the Q64.64 scale must resolve.
2122        // Reduce the budget under miri so the brute-force reference stays
2123        // fast in the interpreter.
2124        cfg_if::cfg_if! {
2125            if #[cfg(miri)] {
2126                let budget = 10_000usize;
2127            } else {
2128                let budget = 1_000_000usize;
2129            }
2130        }
2131        let a = u32::MAX;
2132        let b = u32::MAX - 1;
2133        let config = BufferPoolConfig::for_network()
2134            .with_size_classes([
2135                (NZUsize!(1), NonZeroU32::new(a).unwrap()),
2136                (NZUsize!(2), NonZeroU32::new(b).unwrap()),
2137            ])
2138            .with_budget_bytes(NonZeroUsize::new(budget).unwrap());
2139        // Brute-force the optimal proportional vector along the scale axis.
2140        let expected = brute_force_budget(&[(1, a), (2, b)], budget as u128);
2141        assert_eq!(
2142            classes_of(&config)
2143                .into_iter()
2144                .map(|(_, limit)| limit)
2145                .collect::<Vec<_>>(),
2146            expected
2147        );
2148    }
2149
2150    /// Reference implementation of proportional budget scaling.
2151    ///
2152    /// Walks the count vectors produced along the scale axis in breakpoint
2153    /// order and returns the last one whose total fits the budget.
2154    fn brute_force_budget(shape: &[(usize, u32)], budget: u128) -> Vec<u32> {
2155        // Collect candidate scales k/c for every class and every count k the
2156        // budget could possibly afford, then evaluate the count vector at each.
2157        let mut best: Option<Vec<u32>> = None;
2158        let mut best_total = 0u128;
2159        let mut candidates: Vec<(u128, u128)> = vec![(0, 1)];
2160        for &(size, limit) in shape {
2161            let max_count = (budget / size as u128).min(u32::MAX as u128);
2162            for k in 1..=max_count {
2163                candidates.push((k, limit as u128));
2164            }
2165        }
2166        for (k, c) in candidates {
2167            // counts_i = max(1, floor(c_i * k / c))
2168            let counts: Vec<u128> = shape
2169                .iter()
2170                .map(|&(_, limit)| ((limit as u128 * k) / c).max(1))
2171                .collect();
2172            if counts.iter().any(|&count| count > u32::MAX as u128) {
2173                continue;
2174            }
2175            let total: u128 = counts
2176                .iter()
2177                .zip(shape.iter())
2178                .map(|(&count, &(size, _))| count * size as u128)
2179                .sum();
2180            // Candidate vectors are componentwise ordered along the scale
2181            // axis, so the maximal fitting total identifies a unique vector.
2182            if total <= budget && total >= best_total {
2183                best_total = total;
2184                best = Some(counts.iter().map(|&count| count as u32).collect());
2185            }
2186        }
2187        best.expect("budget covers one buffer per class")
2188    }
2189
2190    #[test]
2191    fn test_pool_error_display() {
2192        assert_eq!(
2193            PoolError::Oversized.to_string(),
2194            "requested capacity exceeds maximum buffer size"
2195        );
2196        assert_eq!(
2197            PoolError::Exhausted.to_string(),
2198            "pool exhausted for required size class"
2199        );
2200    }
2201
2202    #[test]
2203    fn test_pool_debug_and_config_accessor() {
2204        // Debug formatting and config accessor should be consistent.
2205        let page = page_size();
2206        let pool = test_pool(test_config(page, page, 2));
2207
2208        let debug = format!("{pool:?}");
2209        assert!(debug.contains("BufferPool"));
2210        assert!(debug.contains("num_classes"));
2211        assert_eq!(pool.config().min_size().get(), page);
2212    }
2213
2214    #[test]
2215    fn test_pooled_debug_and_empty_freeze_paths() {
2216        // Debug formatting for pooled mutable/immutable handles, and empty
2217        // freeze should detach without retaining the pool allocation.
2218        let page = page_size();
2219        let pool = test_pool(test_config(page, page, 3));
2220
2221        let pooled_mut = pool.try_alloc(page).expect("pooled allocation");
2222        let pooled_mut_debug = format!("{pooled_mut:?}");
2223        assert!(pooled_mut_debug.contains("IoBufMut"));
2224        assert!(pooled_mut_debug.contains("cap"));
2225        assert!(pooled_mut.is_pooled());
2226
2227        let empty = pool.try_alloc(page).expect("pooled allocation").freeze();
2228        assert!(empty.is_empty());
2229        assert!(!empty.is_pooled());
2230
2231        let mut non_empty = pool.try_alloc(page).expect("pooled allocation");
2232        non_empty.put_slice(b"abc");
2233        let pooled = non_empty.freeze();
2234        let pooled_debug = format!("{pooled:?}");
2235        assert!(pooled_debug.contains("IoBuf"));
2236        assert!(pooled_debug.contains("pooled"));
2237        assert!(pooled.is_pooled());
2238
2239        BufferPoolThreadCache::flush();
2240    }
2241
2242    #[test]
2243    fn test_freeze_returns_buffer_to_pool() {
2244        let page = page_size();
2245        let pool = test_pool(test_config(page, page, 2));
2246
2247        // Initially: 0 allocated, 0 available
2248        assert_eq!(get_allocated(&pool, page), 0);
2249        assert_eq!(get_available(&pool, page), 0);
2250
2251        // Allocate, write, and freeze. Empty freeze deliberately detaches from
2252        // the pool, so this test keeps a non-empty immutable view alive.
2253        let mut buf = pool.try_alloc(page).unwrap();
2254        buf.put_slice(b"x");
2255        assert_eq!(get_allocated(&pool, page), 1);
2256        assert_eq!(get_available(&pool, page), 0);
2257
2258        let iobuf = buf.freeze();
2259        // Still allocated (held by IoBuf)
2260        assert_eq!(get_allocated(&pool, page), 1);
2261
2262        // Drop the IoBuf - buffer should return to pool
2263        drop(iobuf);
2264        assert_eq!(get_allocated(&pool, page), 0);
2265        assert_eq!(get_available(&pool, page), 1);
2266    }
2267
2268    #[test]
2269    fn test_refcount_and_copy_to_bytes_paths() {
2270        let page = page_size();
2271        let pool = test_pool(test_config(page, page, 2));
2272
2273        // Refcount behavior:
2274        // - clone/slice keep the pooled allocation alive
2275        // - empty slice does not keep ownership
2276        {
2277            let mut buf = pool.try_alloc(page).unwrap();
2278            buf.put_slice(&[0xAA; 100]);
2279            let iobuf = buf.freeze();
2280            let clone = iobuf.clone();
2281            let slice = iobuf.slice(10..40);
2282            let empty = iobuf.slice(10..10);
2283            assert!(empty.is_empty());
2284            drop(iobuf);
2285            assert_eq!(get_allocated(&pool, page), 1);
2286            drop(slice);
2287            assert_eq!(get_allocated(&pool, page), 1);
2288            drop(clone);
2289            assert_eq!(get_allocated(&pool, page), 0);
2290        }
2291
2292        // IoBuf::copy_to_bytes behavior:
2293        // - zero-length copy is empty and non-advancing
2294        // - partial copy advances while keeping ownership alive
2295        // - full drain transfers ownership out of source
2296        // - zero-length copy on already-empty source stays detached
2297        {
2298            let mut buf = pool.try_alloc(page).unwrap();
2299            buf.put_slice(&[0x42; 100]);
2300            let mut iobuf = buf.freeze();
2301
2302            let zero = iobuf.copy_to_bytes(0);
2303            assert!(zero.is_empty());
2304            assert_eq!(iobuf.remaining(), 100);
2305
2306            let partial = iobuf.copy_to_bytes(30);
2307            assert_eq!(&partial[..], &[0x42; 30]);
2308            assert_eq!(iobuf.remaining(), 70);
2309
2310            let rest = iobuf.copy_to_bytes(70);
2311            assert_eq!(&rest[..], &[0x42; 70]);
2312            assert_eq!(iobuf.remaining(), 0);
2313
2314            // Zero-length copy on empty should not transfer ownership.
2315            let empty = iobuf.copy_to_bytes(0);
2316            assert!(empty.is_empty());
2317
2318            drop(iobuf);
2319            assert_eq!(get_allocated(&pool, page), 1);
2320            drop(zero);
2321            drop(partial);
2322            assert_eq!(get_allocated(&pool, page), 1);
2323            drop(rest);
2324            assert_eq!(get_allocated(&pool, page), 0);
2325        }
2326
2327        // IoBufMut::copy_to_bytes mirrors the immutable ownership semantics.
2328        {
2329            let buf = pool.try_alloc(page).unwrap();
2330            let mut iobufmut = buf;
2331            iobufmut.put_slice(&[0x7E; 100]);
2332
2333            let zero = iobufmut.copy_to_bytes(0);
2334            assert!(zero.is_empty());
2335            assert_eq!(iobufmut.remaining(), 100);
2336
2337            let partial = iobufmut.copy_to_bytes(30);
2338            assert_eq!(&partial[..], &[0x7E; 30]);
2339            assert_eq!(iobufmut.remaining(), 70);
2340
2341            let rest = iobufmut.copy_to_bytes(70);
2342            assert_eq!(&rest[..], &[0x7E; 70]);
2343            assert_eq!(iobufmut.remaining(), 0);
2344
2345            drop(iobufmut);
2346            assert_eq!(get_allocated(&pool, page), 1);
2347            drop(zero);
2348            drop(partial);
2349            assert_eq!(get_allocated(&pool, page), 1);
2350            drop(rest);
2351            assert_eq!(get_allocated(&pool, page), 0);
2352        }
2353    }
2354
2355    #[test]
2356    fn test_iobuf_to_iobufmut_conversion_reuses_pool_for_non_full_unique_view() {
2357        // IoBuf -> IoBufMut should recover pooled ownership for unique non-full views.
2358        let page = page_size();
2359        let pool = test_pool(test_config(page, page, 2));
2360
2361        let mut buf = pool.try_alloc(page).unwrap();
2362        buf.put_slice(b"non-full");
2363        assert_eq!(get_allocated(&pool, page), 1);
2364
2365        let iobuf = buf.freeze();
2366        assert_eq!(iobuf.len(), 8);
2367        assert_eq!(get_allocated(&pool, page), 1);
2368
2369        let iobufmut: IoBufMut = iobuf.into();
2370        assert_eq!(iobufmut.as_ref(), b"non-full");
2371
2372        // Conversion reused pooled storage instead of copying.
2373        assert_eq!(
2374            get_allocated(&pool, page),
2375            1,
2376            "pooled buffer should remain allocated after zero-copy IoBuf->IoBufMut conversion"
2377        );
2378        assert_eq!(get_available(&pool, page), 0);
2379
2380        // Dropping returns the pooled buffer.
2381        drop(iobufmut);
2382        assert_eq!(get_allocated(&pool, page), 0);
2383        assert_eq!(get_available(&pool, page), 1);
2384    }
2385
2386    #[test]
2387    fn test_iobuf_try_into_mut_recycles_full_unique_view() {
2388        // try_into_mut on a uniquely-owned full-view pooled IoBuf should recover
2389        // mutable ownership without copying, preserving data and pool tracking.
2390        let page = page_size();
2391        let pool = test_pool(test_config(page, page, 2));
2392
2393        let mut buf = pool.try_alloc(page).unwrap();
2394        buf.put_slice(&vec![0xAB; page]);
2395        let iobuf = buf.freeze();
2396        assert_eq!(get_allocated(&pool, page), 1);
2397
2398        // Unique full view should recycle.
2399        let recycled = iobuf
2400            .try_into_mut()
2401            .expect("unique full-view pooled buffer should recycle");
2402        assert_eq!(recycled.len(), page);
2403        assert!(recycled.as_ref().iter().all(|&b| b == 0xAB));
2404        assert_eq!(recycled.capacity(), page);
2405        assert_eq!(get_allocated(&pool, page), 1);
2406
2407        drop(recycled);
2408        assert_eq!(get_allocated(&pool, page), 0);
2409        assert_eq!(get_available(&pool, page), 1);
2410    }
2411
2412    #[test]
2413    fn test_iobuf_try_into_mut_succeeds_for_unique_slice_and_fails_for_shared() {
2414        let page = page_size();
2415        let pool = test_pool(test_config(page, page, 2));
2416
2417        // Unique sliced views can recover mutable ownership without copying.
2418        let mut buf = pool.try_alloc(page).unwrap();
2419        buf.put_slice(&vec![0xCD; page]);
2420        let iobuf = buf.freeze();
2421        let sliced = iobuf.slice(1..page);
2422        drop(iobuf);
2423        let recycled = sliced
2424            .try_into_mut()
2425            .expect("unique sliced pooled buffer should recycle");
2426        assert_eq!(recycled.len(), page - 1);
2427        assert!(recycled.as_ref().iter().all(|&b| b == 0xCD));
2428        assert_eq!(recycled.capacity(), page - 1);
2429        assert_eq!(get_allocated(&pool, page), 1);
2430        drop(recycled);
2431        assert_eq!(get_allocated(&pool, page), 0);
2432        assert_eq!(get_available(&pool, page), 1);
2433
2434        // Shared views still cannot recover mutable ownership.
2435        let mut buf = pool.try_alloc(page).unwrap();
2436        buf.put_slice(&vec![0xEF; page]);
2437        let iobuf = buf.freeze();
2438        let cloned = iobuf.clone();
2439        let iobuf = iobuf
2440            .try_into_mut()
2441            .expect_err("shared pooled buffer must not convert to mutable");
2442
2443        drop(cloned);
2444        drop(iobuf);
2445        assert_eq!(get_allocated(&pool, page), 0);
2446        assert!(get_available(&pool, page) >= 1);
2447    }
2448
2449    #[test]
2450    fn test_multithreaded_alloc_freeze_return() {
2451        let page = page_size();
2452        let pool = Arc::new(test_pool(test_config(page, page, 100)));
2453
2454        let mut handles = vec![];
2455
2456        // Reduce iterations under miri (atomics are slow)
2457        cfg_if::cfg_if! {
2458            if #[cfg(miri)] {
2459                let iterations = 100;
2460            } else {
2461                let iterations = 1000;
2462            }
2463        }
2464
2465        // Spawn multiple threads that allocate, freeze, clone, and drop
2466        for _ in 0..10 {
2467            let pool = pool.clone();
2468            let handle = thread::spawn(move || {
2469                for _ in 0..iterations {
2470                    let mut buf = pool.try_alloc(page).unwrap();
2471                    // Write a byte so freeze produces a live pooled owner
2472                    // (freezing an empty buffer detaches and releases it,
2473                    // which would leave the refcount protocol unexercised).
2474                    buf.put_slice(b"x");
2475                    let iobuf = buf.freeze();
2476
2477                    // Clone a few times
2478                    let clones: Vec<_> = (0..5).map(|_| iobuf.clone()).collect();
2479                    drop(iobuf);
2480
2481                    // Drop clones
2482                    for clone in clones {
2483                        drop(clone);
2484                    }
2485                }
2486            });
2487            handles.push(handle);
2488        }
2489
2490        // Wait for all threads
2491        for handle in handles {
2492            handle.join().unwrap();
2493        }
2494
2495        // Worker threads may retain free buffers in their own local caches, so
2496        // the main thread cannot assert that all of them are visible here.
2497        // It should still be able to allocate successfully once the workers finish.
2498        let _buf = pool
2499            .try_alloc(page)
2500            .expect("pool should remain usable after multithreaded test");
2501    }
2502
2503    #[test]
2504    fn test_cross_thread_buffer_return() {
2505        // Allocate on one thread, freeze, send to another thread, drop there
2506        let page = page_size();
2507        let pool = test_pool(test_config(page, page, 100));
2508
2509        let (tx, rx) = mpsc::channel();
2510
2511        // Allocate and freeze on main thread
2512        for _ in 0..50 {
2513            let mut buf = pool.try_alloc(page).unwrap();
2514            buf.put_slice(b"x");
2515            let iobuf = buf.freeze();
2516            tx.send(iobuf).unwrap();
2517        }
2518        drop(tx);
2519
2520        // Receive and drop on another thread. Cross-thread returns initialize
2521        // the dropping thread's local cache, so the buffers remain local to that
2522        // thread instead of bouncing through the global freelist.
2523        let handle = thread::spawn(move || {
2524            while let Ok(iobuf) = rx.recv() {
2525                drop(iobuf);
2526            }
2527
2528            let class_index = pool
2529                .class_index(page)
2530                .expect("class exists for page-sized buffer");
2531            assert_eq!(get_local_len(&pool.inner.classes[class_index]), 50);
2532            assert_eq!(get_global_len(&pool.inner.classes[class_index]), 0);
2533
2534            for _ in 0..50 {
2535                let _buf = pool
2536                    .try_alloc(page)
2537                    .expect("dropping thread should be able to reuse locally returned buffers");
2538            }
2539        });
2540
2541        handle.join().unwrap();
2542    }
2543
2544    #[test]
2545    fn test_pool_dropped_before_buffer() {
2546        // What happens if the pool is dropped while buffers are still in use?
2547        // The size class remains alive until the last tracked buffer is dropped.
2548
2549        let page = page_size();
2550        let pool = test_pool(test_config(page, page, 2));
2551
2552        let mut buf = pool.try_alloc(page).unwrap();
2553        buf.put_slice(&[0u8; 100]);
2554        let iobuf = buf.freeze();
2555
2556        // Drop the pool while buffer is still alive
2557        drop(pool);
2558
2559        // Buffer should still be usable
2560        assert_eq!(iobuf.len(), 100);
2561
2562        // Dropping the buffer should not panic and should return to the retained size class.
2563        drop(iobuf);
2564        // No assertion here - we just want to make sure it doesn't panic
2565    }
2566
2567    #[test]
2568    fn test_pool_exhaustion_and_recovery() {
2569        // Test pool exhaustion and recovery.
2570        let page = page_size();
2571        let pool = test_pool(test_config(page, page, 3));
2572
2573        // Exhaust the pool
2574        let buf1 = pool.try_alloc(page).expect("first alloc");
2575        let buf2 = pool.try_alloc(page).expect("second alloc");
2576        let buf3 = pool.try_alloc(page).expect("third alloc");
2577        assert!(pool.try_alloc(page).is_err(), "pool should be exhausted");
2578
2579        // Return one buffer
2580        drop(buf1);
2581
2582        // Should be able to allocate again
2583        let buf4 = pool.try_alloc(page).expect("alloc after return");
2584        assert!(pool.try_alloc(page).is_err(), "pool exhausted again");
2585
2586        // Return all and verify freelist reuse
2587        drop(buf2);
2588        drop(buf3);
2589        drop(buf4);
2590
2591        assert_eq!(get_allocated(&pool, page), 0);
2592        assert_eq!(get_available(&pool, page), 3);
2593
2594        // Allocate again - should reuse from freelist
2595        let _buf5 = pool.try_alloc(page).expect("reuse from freelist");
2596        assert_eq!(get_available(&pool, page), 2);
2597    }
2598
2599    #[test]
2600    fn test_try_alloc_errors() {
2601        // Test try_alloc error variants.
2602        let page = page_size();
2603        let pool = test_pool(test_config(page, page, 2));
2604
2605        // Oversized request
2606        let result = pool.try_alloc(page * 10);
2607        assert_eq!(result.unwrap_err(), PoolError::Oversized);
2608
2609        // Exhaust pool
2610        let _buf1 = pool.try_alloc(page).unwrap();
2611        let _buf2 = pool.try_alloc(page).unwrap();
2612        let result = pool.try_alloc(page);
2613        assert_eq!(result.unwrap_err(), PoolError::Exhausted);
2614    }
2615
2616    #[test]
2617    fn test_pool_metrics_track_created_exhausted_oversized() {
2618        let page = page_size();
2619        let mut registry = Registry::default();
2620        let pool = BufferPool::new(test_config(page, page, 1), &mut registry);
2621
2622        // One created buffer, then exhaustion, then an oversized request.
2623        let buf = pool.try_alloc(page).unwrap();
2624        assert_eq!(pool.try_alloc(page).unwrap_err(), PoolError::Exhausted);
2625        assert_eq!(pool.try_alloc(page * 2).unwrap_err(), PoolError::Oversized);
2626
2627        let encoded = registry.encode();
2628        assert!(
2629            encoded.contains(&format!("buffer_pool_created{{size_class=\"{page}\"}} 1")),
2630            "created gauge missing: {encoded}"
2631        );
2632        assert!(
2633            encoded.contains(&format!(
2634                "buffer_pool_exhausted_total{{size_class=\"{page}\"}} 1"
2635            )),
2636            "exhausted counter missing: {encoded}"
2637        );
2638        assert!(
2639            encoded.contains("buffer_pool_oversized_total 1"),
2640            "oversized counter missing: {encoded}"
2641        );
2642        drop(buf);
2643    }
2644
2645    #[test]
2646    fn test_try_alloc_zeroed_errors() {
2647        // try_alloc_zeroed should return the same error variants as try_alloc.
2648        let page = page_size();
2649        let pool = test_pool(test_config(page, page, 2));
2650
2651        // Oversized request.
2652        let result = pool.try_alloc_zeroed(page * 10);
2653        assert_eq!(result.unwrap_err(), PoolError::Oversized);
2654
2655        // Exhaust pool, then verify Exhausted error.
2656        let _buf1 = pool.try_alloc_zeroed(page).unwrap();
2657        let _buf2 = pool.try_alloc_zeroed(page).unwrap();
2658        let result = pool.try_alloc_zeroed(page);
2659        assert_eq!(result.unwrap_err(), PoolError::Exhausted);
2660    }
2661
2662    #[test]
2663    fn test_fallback_allocation() {
2664        // Test fallback allocation when pool is exhausted or oversized.
2665        let page = page_size();
2666        let pool = test_pool(test_config(page, page, 2));
2667
2668        // Exhaust the pool
2669        let buf1 = pool.try_alloc(page).unwrap();
2670        let buf2 = pool.try_alloc(page).unwrap();
2671        assert!(buf1.is_pooled());
2672        assert!(buf2.is_pooled());
2673
2674        // Fallback via alloc() when exhausted - still aligned, but untracked,
2675        // and sized from the requested capacity.
2676        let mut fallback_exhausted = pool.alloc(page);
2677        assert!(!fallback_exhausted.is_pooled());
2678        assert!((fallback_exhausted.as_mut_ptr() as usize).is_multiple_of(page));
2679        assert_eq!(fallback_exhausted.capacity(), page);
2680
2681        let fallback_small = pool.alloc(100);
2682        assert!(!fallback_small.is_pooled());
2683        assert!((100..108).contains(&fallback_small.capacity()));
2684
2685        // Fallback via alloc() when oversized - still aligned, but untracked.
2686        let mut fallback_oversized = pool.alloc(page * 10);
2687        assert!(!fallback_oversized.is_pooled());
2688        assert!((fallback_oversized.as_mut_ptr() as usize).is_multiple_of(page));
2689        assert_eq!(fallback_oversized.capacity(), page * 10);
2690
2691        // Verify pool counters unchanged by fallback allocations
2692        assert_eq!(get_allocated(&pool, page), 2);
2693
2694        // Drop fallback buffers - should not affect pool counters
2695        drop(fallback_exhausted);
2696        drop(fallback_oversized);
2697        assert_eq!(get_allocated(&pool, page), 2);
2698
2699        // Drop tracked buffers - counters should decrease
2700        drop(buf1);
2701        drop(buf2);
2702        assert_eq!(get_allocated(&pool, page), 0);
2703    }
2704
2705    #[test]
2706    fn test_is_pooled() {
2707        // IoBufMut from the pool should report is_pooled, while heap-backed
2708        // buffers should not.
2709        let page = page_size();
2710        let pool = test_pool(test_config(page, page, 10));
2711
2712        let pooled = pool.try_alloc(page).unwrap();
2713        assert!(pooled.is_pooled());
2714
2715        let owned = IoBufMut::with_capacity(100);
2716        assert!(!owned.is_pooled());
2717    }
2718
2719    #[test]
2720    fn test_iobuf_is_pooled() {
2721        let page = page_size();
2722        let pool = test_pool(test_config(page, page, 2));
2723
2724        let mut pooled = pool.try_alloc(page).unwrap();
2725        pooled.put_slice(b"x");
2726        let pooled = pooled.freeze();
2727        assert!(pooled.is_pooled());
2728
2729        // Oversized alloc uses untracked fallback allocation.
2730        let fallback = pool.alloc(page * 10).freeze();
2731        assert!(!fallback.is_pooled());
2732
2733        let bytes = IoBuf::copy_from_slice(b"hello");
2734        assert!(!bytes.is_pooled());
2735    }
2736
2737    #[test]
2738    fn test_buffer_alignment() {
2739        let page = page_size();
2740        let cache_line = cache_line_size();
2741
2742        // Reduce the class limits under miri (atomics are slow)
2743        cfg_if::cfg_if! {
2744            if #[cfg(miri)] {
2745                let storage_config = BufferPoolConfig::for_storage()
2746                    .with_alignment(NZUsize!(page))
2747                    .with_max_per_class(NZU32!(32));
2748                let network_config = BufferPoolConfig::for_network()
2749                    .with_alignment(NZUsize!(cache_line))
2750                    .with_max_per_class(NZU32!(32));
2751            } else {
2752                let storage_config =
2753                    BufferPoolConfig::for_storage().with_alignment(NZUsize!(page));
2754                let network_config =
2755                    BufferPoolConfig::for_network().with_alignment(NZUsize!(cache_line));
2756            }
2757        }
2758
2759        // Storage preset - page aligned
2760        let storage_buffer_pool = test_pool(storage_config);
2761        let mut buf = storage_buffer_pool.try_alloc(100).unwrap();
2762        assert_eq!(
2763            buf.as_mut_ptr() as usize % page,
2764            0,
2765            "storage buffer not page-aligned"
2766        );
2767
2768        // Network preset - cache-line aligned
2769        let network_buffer_pool = test_pool(network_config);
2770        let mut buf = network_buffer_pool.try_alloc(100).unwrap();
2771        assert_eq!(
2772            buf.as_mut_ptr() as usize % cache_line,
2773            0,
2774            "network buffer not cache-line aligned"
2775        );
2776    }
2777}
2778
2779#[cfg(all(test, feature = "loom"))]
2780mod loom_tests {
2781    use super::*;
2782    use crate::telemetry::metrics::Registry;
2783    use bytes::BufMut;
2784    use loom::thread;
2785
2786    // Models the pooled buffer lifecycle across threads: checkout, freeze,
2787    // clone, cross-thread final drop, and reuse from the same pool. Whichever
2788    // thread drops last must return the buffer to the global freelist with
2789    // the refcount sentinel intact so the next checkout works without
2790    // reinitialization. The thread cache is disabled so the return path is
2791    // the loom-modeled global freelist rather than OS thread-local state,
2792    // which loom cannot reset between interleavings.
2793    #[test]
2794    fn freeze_clone_cross_thread_drop_then_reuse() {
2795        loom::model(|| {
2796            let mut registry = Registry::default();
2797            let config = BufferPoolConfig::for_network()
2798                .with_size_class_range(NZUsize!(64), NZUsize!(64), NZU32!(2))
2799                .with_thread_cache_disabled();
2800            let pool = BufferPool::new(config, &mut registry);
2801
2802            let mut buf = pool.alloc(64);
2803            assert!(buf.is_pooled());
2804            buf.put_slice(b"payload");
2805            let frozen = buf.freeze();
2806            let clone = frozen.clone();
2807
2808            let t = thread::spawn(move || {
2809                assert_eq!(clone.as_ref(), b"payload");
2810                drop(clone);
2811            });
2812            assert_eq!(frozen.as_ref(), b"payload");
2813            drop(frozen);
2814            t.join().unwrap();
2815
2816            // The buffer returned through whichever drop was final. Checkout
2817            // must succeed and expose a writable buffer again.
2818            let mut again = pool.alloc(64);
2819            assert!(again.is_pooled());
2820            again.put_slice(b"reuse");
2821            assert_eq!(again.as_ref(), b"reuse");
2822        });
2823    }
2824
2825    // Models the teardown edge: a pooled buffer's final drop (which parks the
2826    // buffer and then releases its size-class lease) racing the public pool's
2827    // drop (which drains the global freelist and then releases the pool-owned
2828    // class reference). Whichever release is last drops the SizeClass and its
2829    // freelist. Parking strictly before releasing is what keeps the freelist
2830    // alive until the return finishes publishing under its stripe lock. Loom
2831    // performs no liveness check when tracked state is dropped, so swapping
2832    // that order manifests as a use-after-free of the freed freelist state.
2833    // This corrupts Loom's internal object state and is caught by its internal
2834    // assertions in practice. The Loom-tracked class strong count still
2835    // verifies the release accounting itself.
2836    #[test]
2837    fn final_drop_races_pool_teardown() {
2838        loom::model(|| {
2839            let mut registry = Registry::default();
2840            let config = BufferPoolConfig::for_network()
2841                .with_size_class_range(NZUsize!(64), NZUsize!(64), NZU32!(1))
2842                .with_thread_cache_disabled();
2843            let pool = BufferPool::new(config, &mut registry);
2844
2845            let mut buf = pool.alloc(64);
2846            assert!(buf.is_pooled());
2847            buf.put_slice(b"x");
2848            let frozen = buf.freeze();
2849
2850            let t = thread::spawn(move || drop(frozen));
2851            drop(pool);
2852            t.join().unwrap();
2853        });
2854    }
2855
2856    // Models a re-checkout racing the final drop of a shared pooled buffer.
2857    // The final drop leaves the refcount at the sentinel (the losing handle's
2858    // Release decrement lands on 1, or the race-final path re-stores it with
2859    // a Relaxed store) before the freelist's Release publication. A
2860    // successful concurrent take must observe that sentinel (asserted in
2861    // Freelist::claim under loom) before handing the slot to a new mutable
2862    // handle.
2863    #[test]
2864    fn final_drop_races_recheckout() {
2865        loom::model(|| {
2866            let mut registry = Registry::default();
2867            let config = BufferPoolConfig::for_network()
2868                .with_size_class_range(NZUsize!(64), NZUsize!(64), NZU32!(1))
2869                .with_thread_cache_disabled();
2870            let pool = BufferPool::new(config, &mut registry);
2871
2872            let mut buf = pool.alloc(64);
2873            assert!(buf.is_pooled());
2874            buf.put_slice(b"x");
2875            let frozen = buf.freeze();
2876            let clone = frozen.clone();
2877
2878            let t = thread::spawn(move || drop(clone));
2879            drop(frozen);
2880
2881            // The single slot may still be checked out (Exhausted) or already
2882            // returned by whichever drop was final. A successful claim must
2883            // expose a writable buffer with the sentinel restored.
2884            if let Ok(mut again) = pool.try_alloc(64) {
2885                assert!(again.is_pooled());
2886                again.put_slice(b"y");
2887                assert_eq!(again.as_ref(), b"y");
2888            }
2889            t.join().unwrap();
2890        });
2891    }
2892}