Skip to main content

oximedia_gpu/
buffer_pool.rs

1//! Zero-copy buffer pool for GPU-style memory management.
2//!
3//! Provides a reuse-oriented pool of byte buffers, inspired by GPU memory
4//! management patterns.  Buffers are acquired by size and alignment,
5//! used by the caller, and released back to the pool rather than freed.
6//! Unused buffers older than 60 seconds are evicted by [`BufferPool::defragment`].
7
8#![allow(clippy::cast_precision_loss)]
9
10use std::time::Instant;
11
12// ---------------------------------------------------------------------------
13// GpuBuffer
14// ---------------------------------------------------------------------------
15
16/// A raw byte buffer managed by a [`BufferPool`].
17pub struct GpuBuffer {
18    /// Unique identifier assigned by the owning pool.
19    pub id: u64,
20    /// Allocated capacity in bytes.
21    pub size_bytes: usize,
22    /// Alignment guarantee (in bytes).
23    pub alignment: usize,
24    /// Backing storage.
25    data: Vec<u8>,
26    /// Whether this buffer is currently checked out by a caller.
27    pub(crate) in_use: bool,
28    /// Monotonic timestamp of the most recent acquisition or release.
29    pub(crate) created_at: Instant,
30    /// Monotonic timestamp of last release (used for eviction).
31    pub(crate) last_released_at: Option<Instant>,
32}
33
34impl GpuBuffer {
35    /// Allocate a new buffer with the given `size` and `alignment`.
36    ///
37    /// The alignment hint is recorded but the backing `Vec<u8>` uses the
38    /// default allocator.  For truly aligned allocations a custom allocator
39    /// would be required; the pool still respects the alignment in
40    /// compatibility checks.
41    #[must_use]
42    pub fn new(id: u64, size: usize, alignment: usize) -> Self {
43        let effective_alignment = alignment.max(1);
44        Self {
45            id,
46            size_bytes: size,
47            alignment: effective_alignment,
48            data: vec![0u8; size],
49            in_use: false,
50            created_at: Instant::now(),
51            last_released_at: None,
52        }
53    }
54
55    /// View the buffer contents as a byte slice.
56    #[must_use]
57    pub fn as_slice(&self) -> &[u8] {
58        &self.data
59    }
60
61    /// View the buffer contents as a mutable byte slice.
62    #[must_use]
63    pub fn as_mut_slice(&mut self) -> &mut [u8] {
64        &mut self.data
65    }
66
67    /// Fill the entire buffer with `value` (memset equivalent).
68    pub fn fill(&mut self, value: u8) {
69        self.data.fill(value);
70    }
71
72    /// Whether this buffer is currently checked out.
73    #[must_use]
74    pub fn is_in_use(&self) -> bool {
75        self.in_use
76    }
77}
78
79impl std::fmt::Debug for GpuBuffer {
80    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81        f.debug_struct("GpuBuffer")
82            .field("id", &self.id)
83            .field("size_bytes", &self.size_bytes)
84            .field("alignment", &self.alignment)
85            .field("in_use", &self.in_use)
86            .finish()
87    }
88}
89
90// ---------------------------------------------------------------------------
91// Pool statistics
92// ---------------------------------------------------------------------------
93
94/// Snapshot of pool health metrics.
95#[derive(Debug, Clone)]
96pub struct PoolStats {
97    /// Total number of buffers held by the pool (in-use + available).
98    pub total_buffers: usize,
99    /// Buffers currently checked out by callers.
100    pub in_use_buffers: usize,
101    /// Buffers available for immediate reuse.
102    pub available_buffers: usize,
103    /// Sum of all allocated buffer capacities in bytes.
104    pub total_allocated_bytes: usize,
105    /// Fraction of acquisitions satisfied from the pool (0.0 – 1.0).
106    pub reuse_rate: f64,
107}
108
109// ---------------------------------------------------------------------------
110// BufferPool
111// ---------------------------------------------------------------------------
112
113/// A pool of reusable GPU-style byte buffers.
114///
115/// Callers acquire a buffer via [`acquire`][BufferPool::acquire] (receiving its
116/// ID), read/write via [`get_mut`][BufferPool::get_mut], then return it to the
117/// pool with [`release`][BufferPool::release].
118pub struct BufferPool {
119    buffers: Vec<GpuBuffer>,
120    next_id: u64,
121    total_allocated: usize,
122    max_pool_bytes: usize,
123    /// Number of acquisitions satisfied by reusing an existing buffer.
124    reuse_count: u64,
125    /// Total acquisitions ever made (reused + newly allocated).
126    alloc_count: u64,
127}
128
129impl BufferPool {
130    /// Create a new pool that will hold at most `max_pool_bytes` of backing
131    /// storage before refusing new allocations.
132    #[must_use]
133    pub fn new(max_pool_bytes: usize) -> Self {
134        Self {
135            buffers: Vec::new(),
136            next_id: 1,
137            total_allocated: 0,
138            max_pool_bytes,
139            reuse_count: 0,
140            alloc_count: 0,
141        }
142    }
143
144    // -----------------------------------------------------------------------
145    // Acquire
146    // -----------------------------------------------------------------------
147
148    /// Check out a buffer of at least `size_bytes` with at least `alignment`.
149    ///
150    /// Strategy: find the *smallest* existing compatible free buffer to
151    /// minimise fragmentation.  If none exists, allocate a new one (provided
152    /// the pool is below its byte budget).
153    ///
154    /// Returns the buffer `id` on success, or `None` if no buffer is available
155    /// and allocating a new one would exceed the pool's byte budget.
156    pub fn acquire(&mut self, size_bytes: usize, alignment: usize) -> Option<u64> {
157        self.alloc_count += 1;
158
159        // Find the best (smallest compatible) free buffer.
160        let best_idx = self
161            .buffers
162            .iter()
163            .enumerate()
164            .filter(|(_, b)| {
165                !b.in_use && b.size_bytes >= size_bytes && b.alignment >= alignment.max(1)
166            })
167            .min_by_key(|(_, b)| b.size_bytes)
168            .map(|(idx, _)| idx);
169
170        if let Some(idx) = best_idx {
171            self.buffers[idx].in_use = true;
172            self.buffers[idx].created_at = Instant::now();
173            self.reuse_count += 1;
174            return Some(self.buffers[idx].id);
175        }
176
177        // No compatible free buffer — try to allocate a new one.
178        let effective_alignment = alignment.max(1);
179        let new_size = self.total_allocated + size_bytes;
180        if new_size > self.max_pool_bytes {
181            return None; // over budget
182        }
183
184        let id = self.next_id;
185        self.next_id += 1;
186
187        let mut buf = GpuBuffer::new(id, size_bytes, effective_alignment);
188        buf.in_use = true;
189        self.total_allocated += size_bytes;
190        self.buffers.push(buf);
191
192        Some(id)
193    }
194
195    // -----------------------------------------------------------------------
196    // Release
197    // -----------------------------------------------------------------------
198
199    /// Return a buffer to the pool by `id`.
200    ///
201    /// The buffer is kept for future reuse but marked as available.
202    /// Returns `true` if the buffer was found and released, `false` otherwise.
203    pub fn release(&mut self, id: u64) -> bool {
204        if let Some(buf) = self.buffers.iter_mut().find(|b| b.id == id) {
205            buf.in_use = false;
206            buf.last_released_at = Some(Instant::now());
207            true
208        } else {
209            false
210        }
211    }
212
213    // -----------------------------------------------------------------------
214    // Accessors
215    // -----------------------------------------------------------------------
216
217    /// Borrow the buffer with the given `id`.
218    #[must_use]
219    pub fn get(&self, id: u64) -> Option<&GpuBuffer> {
220        self.buffers.iter().find(|b| b.id == id)
221    }
222
223    /// Mutably borrow the buffer with the given `id`.
224    #[must_use]
225    pub fn get_mut(&mut self, id: u64) -> Option<&mut GpuBuffer> {
226        self.buffers.iter_mut().find(|b| b.id == id)
227    }
228
229    // -----------------------------------------------------------------------
230    // Defragmentation
231    // -----------------------------------------------------------------------
232
233    /// Evict all free buffers that have not been used for more than 60 seconds.
234    ///
235    /// In-use buffers are never evicted.
236    pub fn defragment(&mut self) {
237        let now = Instant::now();
238        let eviction_threshold = std::time::Duration::from_secs(60);
239
240        let mut bytes_freed = 0usize;
241        self.buffers.retain(|buf| {
242            if buf.in_use {
243                return true; // never evict live buffers
244            }
245            let idle_since = buf.last_released_at.unwrap_or(buf.created_at);
246            if now.duration_since(idle_since) > eviction_threshold {
247                bytes_freed += buf.size_bytes;
248                false // evict
249            } else {
250                true
251            }
252        });
253        self.total_allocated = self.total_allocated.saturating_sub(bytes_freed);
254    }
255
256    // -----------------------------------------------------------------------
257    // Stats
258    // -----------------------------------------------------------------------
259
260    /// Snapshot of pool metrics.
261    #[must_use]
262    pub fn stats(&self) -> PoolStats {
263        let in_use = self.buffers.iter().filter(|b| b.in_use).count();
264        let available = self.buffers.len() - in_use;
265        let reuse_rate = if self.alloc_count == 0 {
266            0.0
267        } else {
268            self.reuse_count as f64 / self.alloc_count as f64
269        };
270        PoolStats {
271            total_buffers: self.buffers.len(),
272            in_use_buffers: in_use,
273            available_buffers: available,
274            total_allocated_bytes: self.total_allocated,
275            reuse_rate,
276        }
277    }
278
279    /// Total bytes currently under management.
280    #[must_use]
281    pub fn total_allocated_bytes(&self) -> usize {
282        self.total_allocated
283    }
284
285    /// Maximum pool capacity in bytes.
286    #[must_use]
287    pub fn max_pool_bytes(&self) -> usize {
288        self.max_pool_bytes
289    }
290}
291
292impl std::fmt::Debug for BufferPool {
293    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
294        f.debug_struct("BufferPool")
295            .field("buffers", &self.buffers.len())
296            .field("total_allocated", &self.total_allocated)
297            .field("max_pool_bytes", &self.max_pool_bytes)
298            .field("alloc_count", &self.alloc_count)
299            .field("reuse_count", &self.reuse_count)
300            .finish()
301    }
302}
303
304// ---------------------------------------------------------------------------
305// SubAllocator — bump-pointer sub-allocator within a single large buffer
306// ---------------------------------------------------------------------------
307
308/// A sub-allocation record tracking a live region within a backing buffer.
309#[derive(Debug, Clone, PartialEq, Eq)]
310pub struct SubAllocation {
311    /// Unique identifier for this allocation.
312    pub id: u64,
313    /// Byte offset from the start of the backing buffer.
314    pub offset: u64,
315    /// Size of this allocation in bytes.
316    pub size: u64,
317}
318
319/// Bump-pointer sub-allocator that partitions a single large backing buffer
320/// into smaller regions.
321///
322/// Allocation is O(1); individual `free` is O(n) over live allocations;
323/// `defrag` compacts the live allocations to the front of the buffer,
324/// reclaiming all freed space.
325pub struct SubAllocator {
326    /// Total capacity of the backing buffer in bytes.
327    backing_buffer_size: u64,
328    /// Next byte offset to assign (the "bump pointer").
329    current_offset: u64,
330    /// All currently live allocations.
331    allocations: Vec<SubAllocation>,
332    /// Alignment requirement for every allocation (must be a power of two).
333    alignment: u64,
334    /// Counter for generating unique allocation IDs.
335    next_id: u64,
336    /// Set of IDs that have been freed (logically dead).
337    freed_ids: std::collections::HashSet<u64>,
338}
339
340impl SubAllocator {
341    /// Create a new `SubAllocator` backed by a buffer of `backing_size` bytes,
342    /// with all offsets aligned to `alignment` bytes.
343    ///
344    /// `alignment` is clamped to at least 1.
345    #[must_use]
346    pub fn new(backing_size: u64, alignment: u64) -> Self {
347        let alignment = alignment.max(1);
348        Self {
349            backing_buffer_size: backing_size,
350            current_offset: 0,
351            allocations: Vec::new(),
352            alignment,
353            next_id: 1,
354            freed_ids: std::collections::HashSet::new(),
355        }
356    }
357
358    /// Allocate `size` bytes from the backing buffer.
359    ///
360    /// Returns `Some(SubAllocation)` if there is room, `None` if the backing
361    /// buffer is exhausted.
362    pub fn alloc(&mut self, size: u64) -> Option<SubAllocation> {
363        if size == 0 {
364            return None;
365        }
366
367        // Align the current offset up to the required boundary.
368        let aligned_offset = Self::align_up(self.current_offset, self.alignment);
369        let end = aligned_offset.checked_add(size)?;
370
371        if end > self.backing_buffer_size {
372            return None; // not enough contiguous space
373        }
374
375        let id = self.next_id;
376        self.next_id += 1;
377        self.current_offset = end;
378
379        let alloc = SubAllocation {
380            id,
381            offset: aligned_offset,
382            size,
383        };
384        self.allocations.push(alloc.clone());
385        Some(alloc)
386    }
387
388    /// Mark the allocation with the given `id` as freed.
389    ///
390    /// Freed allocations are not reclaimed until [`defrag`][Self::defrag] is called.
391    pub fn free(&mut self, id: u64) {
392        if let Some(pos) = self.allocations.iter().position(|a| a.id == id) {
393            self.freed_ids.insert(id);
394            self.allocations.remove(pos);
395        }
396    }
397
398    /// Compact all live allocations to the front of the backing buffer,
399    /// reclaiming the space left by freed allocations.
400    ///
401    /// After defragmentation the bump pointer is set to just after the last
402    /// live allocation, making that space available for future `alloc` calls.
403    pub fn defrag(&mut self) {
404        // Remove any stale freed IDs (already removed on free(), but belt-and-suspenders).
405        self.allocations.retain(|a| !self.freed_ids.contains(&a.id));
406        self.freed_ids.clear();
407
408        // Re-layout the live allocations from offset 0.
409        let mut cursor: u64 = 0;
410        for alloc in &mut self.allocations {
411            let aligned = Self::align_up(cursor, self.alignment);
412            alloc.offset = aligned;
413            cursor = aligned + alloc.size;
414        }
415        self.current_offset = cursor;
416    }
417
418    /// Fraction of the backing buffer that is currently occupied by live
419    /// allocations (0.0 = empty, 1.0 = full).
420    #[must_use]
421    pub fn utilization(&self) -> f64 {
422        if self.backing_buffer_size == 0 {
423            return 0.0;
424        }
425        let live_bytes: u64 = self.allocations.iter().map(|a| a.size).sum();
426        live_bytes as f64 / self.backing_buffer_size as f64
427    }
428
429    /// Number of live allocations.
430    #[must_use]
431    pub fn allocation_count(&self) -> usize {
432        self.allocations.len()
433    }
434
435    /// Current value of the bump pointer (first unassigned byte offset).
436    #[must_use]
437    pub fn current_offset(&self) -> u64 {
438        self.current_offset
439    }
440
441    /// Total backing buffer size in bytes.
442    #[must_use]
443    pub fn capacity(&self) -> u64 {
444        self.backing_buffer_size
445    }
446
447    /// Alignment used for all allocations.
448    #[must_use]
449    pub fn alignment(&self) -> u64 {
450        self.alignment
451    }
452
453    // ── private helpers ──────────────────────────────────────────────────────
454
455    /// Round `offset` up to the next multiple of `alignment`.
456    fn align_up(offset: u64, alignment: u64) -> u64 {
457        if alignment <= 1 {
458            return offset;
459        }
460        let rem = offset % alignment;
461        if rem == 0 {
462            offset
463        } else {
464            offset + (alignment - rem)
465        }
466    }
467}
468
469impl std::fmt::Debug for SubAllocator {
470    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
471        f.debug_struct("SubAllocator")
472            .field("capacity", &self.backing_buffer_size)
473            .field("current_offset", &self.current_offset)
474            .field("live_allocs", &self.allocations.len())
475            .field("alignment", &self.alignment)
476            .finish()
477    }
478}
479
480// ---------------------------------------------------------------------------
481// Tests
482// ---------------------------------------------------------------------------
483
484#[cfg(test)]
485mod tests {
486    use super::*;
487
488    // --- GpuBuffer ---
489
490    #[test]
491    fn test_gpu_buffer_new() {
492        let buf = GpuBuffer::new(1, 1024, 64);
493        assert_eq!(buf.id, 1);
494        assert_eq!(buf.size_bytes, 1024);
495        assert_eq!(buf.alignment, 64);
496        assert_eq!(buf.as_slice().len(), 1024);
497        assert!(!buf.is_in_use());
498    }
499
500    #[test]
501    fn test_gpu_buffer_fill() {
502        let mut buf = GpuBuffer::new(2, 16, 4);
503        buf.fill(0xAB);
504        assert!(buf.as_slice().iter().all(|&b| b == 0xAB));
505    }
506
507    #[test]
508    fn test_gpu_buffer_as_mut_slice() {
509        let mut buf = GpuBuffer::new(3, 8, 1);
510        buf.as_mut_slice()[0] = 42;
511        assert_eq!(buf.as_slice()[0], 42);
512    }
513
514    // --- BufferPool::new ---
515
516    #[test]
517    fn test_pool_new_empty() {
518        let pool = BufferPool::new(1024 * 1024);
519        let stats = pool.stats();
520        assert_eq!(stats.total_buffers, 0);
521        assert_eq!(stats.reuse_rate, 0.0);
522    }
523
524    // --- acquire / release ---
525
526    #[test]
527    fn test_pool_acquire_and_release() {
528        let mut pool = BufferPool::new(1024 * 1024);
529        let id = pool.acquire(256, 4).expect("acquire failed");
530        assert!(pool.get(id).expect("missing").is_in_use());
531
532        let released = pool.release(id);
533        assert!(released, "release should succeed");
534        assert!(!pool.get(id).expect("missing").is_in_use());
535    }
536
537    #[test]
538    fn test_pool_reuse() {
539        let mut pool = BufferPool::new(1024 * 1024);
540        let id1 = pool.acquire(512, 4).expect("first acquire");
541        pool.release(id1);
542        let id2 = pool.acquire(512, 4).expect("second acquire");
543        // The pool should have reused the same buffer.
544        assert_eq!(id1, id2, "expected buffer reuse");
545        let stats = pool.stats();
546        assert!(stats.reuse_rate > 0.0);
547    }
548
549    #[test]
550    fn test_pool_smallest_compatible_preferred() {
551        let mut pool = BufferPool::new(4 * 1024 * 1024);
552        // Allocate two free buffers of different sizes.
553        let big = pool.acquire(4096, 4).expect("big");
554        let small = pool.acquire(256, 4).expect("small");
555        pool.release(big);
556        pool.release(small);
557        // Requesting 128 bytes: should get the 256-byte buffer (smallest compat).
558        let id = pool.acquire(128, 4).expect("reacquire");
559        assert_eq!(id, small, "should prefer smaller buffer");
560    }
561
562    #[test]
563    fn test_pool_budget_exceeded() {
564        let mut pool = BufferPool::new(100);
565        // First acquisition should succeed.
566        let id = pool.acquire(80, 1).expect("first");
567        // Second would exceed budget while first is in use.
568        let result = pool.acquire(80, 1);
569        assert!(result.is_none(), "should fail over budget");
570        pool.release(id);
571    }
572
573    #[test]
574    fn test_pool_release_unknown_id() {
575        let mut pool = BufferPool::new(1024);
576        assert!(
577            !pool.release(9999),
578            "releasing unknown id should return false"
579        );
580    }
581
582    #[test]
583    fn test_pool_get_missing() {
584        let pool = BufferPool::new(1024);
585        assert!(pool.get(42).is_none());
586    }
587
588    // --- get_mut ---
589
590    #[test]
591    fn test_pool_get_mut_write() {
592        let mut pool = BufferPool::new(1024 * 1024);
593        let id = pool.acquire(64, 1).expect("acquire");
594        {
595            let buf = pool.get_mut(id).expect("get_mut");
596            buf.as_mut_slice()[0] = 0xFF;
597        }
598        assert_eq!(pool.get(id).expect("get").as_slice()[0], 0xFF);
599    }
600
601    // --- stats ---
602
603    #[test]
604    fn test_pool_stats_in_use_count() {
605        let mut pool = BufferPool::new(1024 * 1024);
606        let id1 = pool.acquire(128, 1).expect("a1");
607        let _id2 = pool.acquire(128, 1).expect("a2");
608        pool.release(id1);
609        let stats = pool.stats();
610        assert_eq!(stats.total_buffers, 2);
611        assert_eq!(stats.in_use_buffers, 1);
612        assert_eq!(stats.available_buffers, 1);
613    }
614
615    // --- defragment ---
616
617    #[test]
618    fn test_pool_defragment_keeps_in_use() {
619        let mut pool = BufferPool::new(1024 * 1024);
620        let id = pool.acquire(64, 1).expect("acquire");
621        // Run defragment while buffer is in use — it should survive.
622        pool.defragment();
623        assert!(
624            pool.get(id).is_some(),
625            "in-use buffer should not be evicted"
626        );
627    }
628
629    #[test]
630    fn test_pool_defragment_recently_released_kept() {
631        let mut pool = BufferPool::new(1024 * 1024);
632        let id = pool.acquire(64, 1).expect("acquire");
633        pool.release(id);
634        // Buffer was just released — defragment should keep it (not 60s old).
635        pool.defragment();
636        assert!(
637            pool.get(id).is_some(),
638            "recently released buffer should survive"
639        );
640    }
641
642    // ── SubAllocator tests ────────────────────────────────────────────────────
643
644    #[test]
645    fn test_sub_alloc_basic() {
646        let mut sa = SubAllocator::new(1024, 4);
647        let a = sa.alloc(64).expect("alloc 64 bytes");
648        assert_eq!(a.offset, 0);
649        assert_eq!(a.size, 64);
650        assert_eq!(sa.allocation_count(), 1);
651    }
652
653    #[test]
654    fn test_sub_alloc_fills_buffer() {
655        let mut sa = SubAllocator::new(128, 1);
656        sa.alloc(128).expect("should fill exactly");
657        // Next alloc must fail — buffer is full.
658        assert!(sa.alloc(1).is_none(), "buffer exhausted");
659    }
660
661    #[test]
662    fn test_sub_alloc_alignment_respected() {
663        let alignment = 16u64;
664        let mut sa = SubAllocator::new(4096, alignment);
665        // First alloc: offset must be 0 (already aligned).
666        let a1 = sa.alloc(1).expect("first alloc");
667        assert_eq!(a1.offset % alignment, 0, "offset must be aligned");
668        // Second alloc: bump pointer is at 1, should jump to 16.
669        let a2 = sa.alloc(1).expect("second alloc");
670        assert_eq!(
671            a2.offset, 16,
672            "second alloc should start at aligned offset 16"
673        );
674        assert_eq!(a2.offset % alignment, 0, "all offsets must be aligned");
675    }
676
677    #[test]
678    fn test_sub_alloc_free_reduces_count() {
679        let mut sa = SubAllocator::new(1024, 4);
680        let a1 = sa.alloc(100).expect("alloc 1");
681        let a2 = sa.alloc(100).expect("alloc 2");
682        assert_eq!(sa.allocation_count(), 2);
683        sa.free(a1.id);
684        assert_eq!(sa.allocation_count(), 1);
685        sa.free(a2.id);
686        assert_eq!(sa.allocation_count(), 0);
687    }
688
689    #[test]
690    fn test_sub_alloc_defrag_reclaims_space() {
691        let mut sa = SubAllocator::new(200, 1);
692        let a1 = sa.alloc(100).expect("a1");
693        let _a2 = sa.alloc(100).expect("a2");
694        // Buffer is now full; next alloc must fail.
695        assert!(sa.alloc(1).is_none(), "should be full before defrag");
696        // Free a1 and defrag — that reclaims 100 bytes.
697        sa.free(a1.id);
698        sa.defrag();
699        // Now there should be room for another 100-byte alloc.
700        let a3 = sa.alloc(100).expect("a3 after defrag");
701        assert!(a3.offset < 200, "a3 offset must be within backing buffer");
702    }
703
704    #[test]
705    fn test_sub_alloc_defrag_zeroes_utilization_when_all_freed() {
706        let mut sa = SubAllocator::new(512, 8);
707        let a1 = sa.alloc(100).expect("a1");
708        let a2 = sa.alloc(100).expect("a2");
709        assert!(sa.utilization() > 0.0);
710        sa.free(a1.id);
711        sa.free(a2.id);
712        sa.defrag();
713        assert_eq!(
714            sa.utilization(),
715            0.0,
716            "utilization must be 0 after all freed + defrag"
717        );
718        assert_eq!(sa.current_offset(), 0);
719    }
720
721    #[test]
722    fn test_sub_alloc_utilization_rises_and_falls() {
723        let mut sa = SubAllocator::new(1000, 1);
724        assert_eq!(sa.utilization(), 0.0);
725        let a = sa.alloc(500).expect("alloc 500");
726        // utilization = 500/1000 = 0.5
727        assert!((sa.utilization() - 0.5).abs() < 1e-9);
728        sa.free(a.id);
729        sa.defrag();
730        assert_eq!(sa.utilization(), 0.0);
731    }
732
733    #[test]
734    fn test_sub_alloc_zero_size_returns_none() {
735        let mut sa = SubAllocator::new(1024, 4);
736        assert!(sa.alloc(0).is_none(), "zero-size alloc must return None");
737    }
738
739    #[test]
740    fn test_sub_alloc_ids_are_unique() {
741        let mut sa = SubAllocator::new(4096, 4);
742        let a1 = sa.alloc(10).expect("a1");
743        let a2 = sa.alloc(10).expect("a2");
744        let a3 = sa.alloc(10).expect("a3");
745        assert_ne!(a1.id, a2.id);
746        assert_ne!(a2.id, a3.id);
747    }
748
749    #[test]
750    fn test_sub_alloc_capacity_and_alignment_accessors() {
751        let sa = SubAllocator::new(8192, 64);
752        assert_eq!(sa.capacity(), 8192);
753        assert_eq!(sa.alignment(), 64);
754    }
755
756    #[test]
757    fn test_sub_alloc_debug_fmt() {
758        let sa = SubAllocator::new(1024, 4);
759        let s = format!("{sa:?}");
760        assert!(s.contains("SubAllocator"));
761    }
762
763    // ── Memory leak / allocate-free cycle tests ──────────────────────────────
764
765    #[test]
766    fn test_buffer_pool_alloc_free_100_cycles() {
767        let mut pool = BufferPool::new(100 * 1024 * 1024); // 100 MB budget
768        let mut ids = Vec::with_capacity(100);
769        for _ in 0..100 {
770            let id = pool.acquire(1024, 8).expect("acquire in cycle");
771            ids.push(id);
772        }
773        for id in &ids {
774            pool.release(*id);
775        }
776        let stats = pool.stats();
777        assert_eq!(
778            stats.in_use_buffers, 0,
779            "all buffers must be freed after release"
780        );
781    }
782
783    #[test]
784    fn test_buffer_pool_alloc_free_alloc_reuse() {
785        let mut pool = BufferPool::new(1024 * 1024);
786        let id1 = pool.acquire(512, 4).expect("first alloc");
787        pool.release(id1);
788        let id2 = pool.acquire(512, 4).expect("second alloc after free");
789        assert_eq!(id1, id2, "should reuse freed buffer");
790        assert!(pool.stats().reuse_rate > 0.0);
791        pool.release(id2);
792    }
793
794    #[test]
795    fn test_buffer_pool_alloc_1000_then_free_all() {
796        let budget = 1000 * 64 + 1024; // enough for 1000 x 64-byte buffers
797        let mut pool = BufferPool::new(budget);
798        let mut ids = Vec::with_capacity(1000);
799        for _ in 0..1000 {
800            let id = pool.acquire(64, 1).expect("acquire 64 bytes");
801            ids.push(id);
802        }
803        for id in &ids {
804            pool.release(*id);
805        }
806        let stats = pool.stats();
807        assert_eq!(stats.in_use_buffers, 0);
808        // total_allocated_bytes counter must not overflow (it is a usize, saturating)
809        assert!(stats.total_allocated_bytes <= budget);
810    }
811
812    #[test]
813    fn test_sub_alloc_alloc_free_cycle_many() {
814        let mut sa = SubAllocator::new(1024 * 1024, 16);
815        for _ in 0..100 {
816            let a = sa.alloc(256).expect("alloc in cycle");
817            sa.free(a.id);
818            sa.defrag();
819        }
820        assert_eq!(sa.allocation_count(), 0);
821        assert_eq!(sa.utilization(), 0.0);
822    }
823}