Skip to main content

fxcp_core/
buffer.rs

1// SPDX-License-Identifier: GPL-2.0-or-later
2// Copyright (C) 2025 Joel Wirāmu Pauling <aenertia@aenertia.net>
3//
4// fxcp-core/src/buffer.rs  --  Aligned buffer pool for io_uring registered I/O
5
6//! Memory-aligned buffer pool for io_uring fixed-buffer operations.
7//! Provides pinned, aligned allocations that can be registered with the kernel.
8
9use std::alloc::{alloc, dealloc, Layout};
10use std::{ops::{Deref, DerefMut}, slice};
11use tracing::{debug, error, trace, info};
12use crate::metrics::{GLOBAL_BUFFER_COUNT, GLOBAL_BUFFER_LIMIT, GLOBAL_MEMORY_USAGE_BYTES};
13use std::sync::atomic::{AtomicUsize, Ordering};
14use std::sync::Arc;
15use std::cell::UnsafeCell;
16use crossbeam::queue::ArrayQueue;
17use crate::error::{FxcpError, Result};
18use crate::constants;
19use std::ptr;
20
21pub(crate) struct AlignedBuffer {
22    ptr: *mut u8,
23    layout: Layout,
24    capacity: usize,
25    len: AtomicUsize
26}
27// SAFETY: AlignedBuffer owns its heap allocation exclusively. The raw pointer
28// is never aliased, and all length mutations use atomic operations, so it is
29// safe to send the buffer to another thread.
30unsafe impl Send for AlignedBuffer {}
31// SAFETY: All mutable state (len) is behind AtomicUsize, so concurrent reads
32// from multiple threads are data-race-free. The raw pointer is only written
33// through &mut self (DerefMut), which the borrow checker serializes.
34unsafe impl Sync for AlignedBuffer {}
35
36impl AlignedBuffer {
37    /// NUMA-aware allocation stub for enterprise server configurations.
38    /// Falls back to standard allocation if node < 0 or mbind unavailable.
39    #[allow(dead_code, unused_variables)]
40    pub fn try_new_numa(capacity: usize, alignment: usize, numa_node: i32) -> Result<Self> {
41        // TODO: On dual-socket servers, use mmap + mbind(MPOL_BIND) to pin
42        // buffers to the same NUMA node as the NVMe controller to avoid
43        // QPI cross-talk. Discover node via /sys/class/block/*/device/numa_node.
44        // For now, fall back to standard allocation.
45        Self::try_new(capacity, alignment)
46    }
47
48    /// Allocate an aligned buffer with the given capacity and alignment.
49    pub fn try_new(capacity: usize, alignment: usize) -> Result<Self> {
50        // Fix: Limit is already in bytes, don't multiply by 1024*1024 again
51        let limit_bytes = GLOBAL_BUFFER_LIMIT.get() as u64;
52        let align = alignment.max(constants::MINIMUM_ALIGNMENT_BYTES);
53        let actual_capacity = capacity.max(align);
54        
55        let _prev = GLOBAL_BUFFER_COUNT.try_update(
56            Ordering::SeqCst,
57            Ordering::SeqCst,
58            |current| {
59                if current + actual_capacity as u64 > limit_bytes {
60                    None
61                } else {
62                    Some(current + actual_capacity as u64)
63                }
64            }
65        ).map_err(|_| FxcpError::MemoryExhausted(format!(
66            "Global memory limit exceeded. Refusing allocation of {} bytes.", capacity
67        )))?;
68
69        // Update Prometheus Gauge
70        GLOBAL_MEMORY_USAGE_BYTES.add(actual_capacity as f64);
71
72        let layout = Layout::from_size_align(actual_capacity, align)
73            .map_err(|_e| {
74                GLOBAL_BUFFER_COUNT.fetch_sub(actual_capacity as u64, Ordering::SeqCst);
75                GLOBAL_MEMORY_USAGE_BYTES.sub(actual_capacity as f64);
76                FxcpError::System(nix::Error::from(nix::errno::Errno::EINVAL))
77            })?;
78            
79        // SAFETY: layout is valid (from_size_align succeeded above) with non-zero
80        // size and power-of-two alignment. alloc returns a valid pointer or null.
81        let ptr = unsafe { alloc(layout) };
82        if ptr.is_null() {
83            GLOBAL_BUFFER_COUNT.fetch_sub(actual_capacity as u64, Ordering::SeqCst);
84            GLOBAL_MEMORY_USAGE_BYTES.sub(actual_capacity as f64);
85            return Err(FxcpError::MemoryExhausted("Physical memory allocation failed".to_string()));
86        }
87        // SAFETY: ptr is non-null (checked above), and actual_capacity matches
88        // the allocation size, so the write stays within the allocated region.
89        unsafe { ptr::write_bytes(ptr, 0, actual_capacity); }
90
91        // Pre-fault pages for large buffers to avoid page faults during io_uring I/O.
92        // MADV_POPULATE_WRITE (Linux 5.14+) is an optimization; silently ignore EINVAL
93        // on older kernels.
94        if actual_capacity >= 1_048_576
95            && let Some(nn) = std::ptr::NonNull::new(ptr) {
96                // SAFETY: ptr is a valid allocation of actual_capacity bytes from alloc().
97                // MADV_POPULATE_WRITE pre-faults pages for write access.
98                let _ = unsafe {
99                    nix::sys::mman::madvise(nn.cast(), actual_capacity, nix::sys::mman::MmapAdvise::MADV_POPULATE_WRITE)
100                };
101            }
102        trace!("AlignedBuffer: Allocated {} bytes.", actual_capacity);
103        Ok(Self {
104            ptr,
105            layout,
106            capacity: actual_capacity,
107            len: AtomicUsize::new(0)
108        })
109    }
110    #[inline(always)]
111    pub fn clear(&self) {
112        self.len.store(0, Ordering::Release);
113    }
114    #[inline(always)]
115    pub fn capacity(&self) -> usize { self.capacity }
116    #[inline(always)]
117    #[allow(dead_code)]
118    pub fn set_full_len(&self) {
119        self.len.store(self.capacity, Ordering::Release);
120    }
121    #[inline(always)]
122    pub fn set_len(&self, len: usize) -> Result<()> {
123        if len > self.capacity {
124            return Err(FxcpError::Config(format!(
125                "AlignedBuffer::set_len: {} exceeds capacity {}", len, self.capacity
126            )));
127        }
128        self.len.store(len, Ordering::Release);
129        Ok(())
130    }
131    #[inline(always)]
132    pub fn get_len(&self) -> usize {
133        self.len.load(Ordering::Acquire)
134    }
135    #[inline(always)]
136    pub fn ptr(&self) -> *mut u8 { self.ptr }
137    #[inline(always)]
138    #[allow(dead_code)]
139    pub fn alignment(&self) -> usize { self.layout.align() }
140}
141
142impl Drop for AlignedBuffer {
143    fn drop(&mut self) {
144        let _prev = GLOBAL_BUFFER_COUNT.fetch_sub(self.capacity as u64, Ordering::SeqCst);
145        GLOBAL_MEMORY_USAGE_BYTES.sub(self.capacity as f64);
146        // SAFETY: self.ptr was allocated with self.layout in try_new, and this
147        // is the only deallocation (Drop runs exactly once).
148        unsafe { dealloc(self.ptr, self.layout); }
149    }
150}
151
152impl Deref for AlignedBuffer {
153    type Target = [u8];
154    fn deref(&self) -> &Self::Target {
155        // SAFETY: self.ptr is valid for self.capacity bytes (allocated in try_new),
156        // and get_len() is always <= capacity (enforced by set_len bounds check).
157        unsafe { slice::from_raw_parts(self.ptr, self.get_len()) }
158    }
159}
160
161impl DerefMut for AlignedBuffer {
162    fn deref_mut(&mut self) -> &mut Self::Target {
163        // SAFETY: same as Deref, plus we have &mut self so no aliasing is possible.
164        unsafe { slice::from_raw_parts_mut(self.ptr, self.get_len()) }
165    }
166}
167
168enum QueueStrategy {
169    Shared(ArrayQueue<u16>),
170    Local(UnsafeCell<Vec<u16>>),
171}
172// SAFETY: QueueStrategy::Shared wraps ArrayQueue (already Send+Sync).
173// QueueStrategy::Local wraps UnsafeCell<Vec<u16>>, which is only accessed
174// from a single thread in local_mode (thread-local pool pattern).
175unsafe impl Send for QueueStrategy {}
176unsafe impl Sync for QueueStrategy {}
177
178struct BufferPoolInner {
179    buffers: Vec<UnsafeCell<AlignedBuffer>>,
180    free_indices: QueueStrategy,
181    capacity: usize,
182    chunk_size: usize,
183    alignment: usize,
184}
185// SAFETY: BufferPoolInner's Vec<UnsafeCell<AlignedBuffer>> is accessed through
186// index-based acquire/release discipline. Each buffer index is held by at most
187// one caller at a time (enforced by the free_indices queue), preventing aliasing.
188unsafe impl Sync for BufferPoolInner {}
189unsafe impl Send for BufferPoolInner {}
190
191/// Thread-safe pool of pre-allocated aligned buffers for io_uring fixed-buffer I/O.
192#[derive(Clone)]
193pub struct BufferPool {
194    inner: Arc<BufferPoolInner>,
195}
196
197impl BufferPool {
198    /// Create a shared buffer pool with cross-thread access.
199    pub fn new(requested_buffers: usize, requested_chunk_size: usize, alignment: usize) -> Result<Self> {
200        Self::create_pool(requested_buffers, requested_chunk_size, alignment, false)
201    }
202    /// Create a thread-local buffer pool (no cross-thread synchronization).
203    pub fn new_local(requested_buffers: usize, requested_chunk_size: usize, alignment: usize) -> Result<Self> {
204        Self::create_pool(requested_buffers, requested_chunk_size, alignment, true)
205    }
206    fn create_pool(requested_buffers: usize, requested_chunk_size: usize, alignment: usize, local_mode: bool) -> Result<Self> {
207        if requested_buffers > constants::BUFFER_POOL_MAX_REGISTERED_BUFFERS as usize {
208            return Err(FxcpError::Config("BufferPool: max 65535 buffers allowed".to_string()));
209        }
210        let align = alignment.max(constants::MINIMUM_ALIGNMENT_BYTES);
211        let min_chunk_size = constants::BUFFER_POOL_MIN_CHUNK_SIZE;
212        let strategies = [
213            (requested_buffers, requested_chunk_size),
214            (requested_buffers / 2, requested_chunk_size),
215            (requested_buffers / 4, requested_chunk_size),
216            (requested_buffers, requested_chunk_size / 2),
217            (requested_buffers / 2, requested_chunk_size / 2),
218            (8, min_chunk_size),
219        ];
220        
221        for (count, size) in strategies {
222            if count < 2 || size < min_chunk_size { continue; }
223            let mut buffers = Vec::with_capacity(count);
224            let mut allocated_successfully = true;
225            for i in 0..count {
226                match AlignedBuffer::try_new(size, align) {
227                    Ok(buf) => buffers.push(UnsafeCell::new(buf)),
228                    Err(e) => {
229                        debug!("BufferPool: Allocation failed at buffer {} of {}: {:?}", i, count, e);
230                        allocated_successfully = false;
231                        break;
232                    }
233                }
234            }
235            if !allocated_successfully { continue; }
236            
237            let actual_capacity = buffers.len();
238            let queue = if local_mode {
239                let mut v = Vec::with_capacity(actual_capacity);
240                for i in 0..actual_capacity {
241                    v.push(i as u16);
242                }
243                QueueStrategy::Local(UnsafeCell::new(v))
244            } else {
245                let q = ArrayQueue::new(actual_capacity);
246                for i in 0..actual_capacity {
247                    let _ = q.push(i as u16);
248                }
249                QueueStrategy::Shared(q)
250            };
251            
252            let total_mb = (actual_capacity as u64 * size as u64) / 1024 / 1024;
253            info!("BufferPool: Allocated {} x {}KB buffers ({}MB total, Mode: {})",
254                  actual_capacity, size / 1024, total_mb, if local_mode { "Thread-Local" } else { "Shared" });
255            crate::metrics::BUFFER_POOL_CAPACITY.set(actual_capacity as f64);
256            crate::metrics::BUFFER_POOL_CHUNK_SIZE.set(size as f64);
257            crate::metrics::BUFFER_POOL_TOTAL_BYTES.set((actual_capacity as u64 * size as u64) as f64);
258            
259            return Ok(Self {
260                inner: Arc::new(BufferPoolInner {
261                    buffers,
262                    free_indices: queue,
263                    capacity: actual_capacity,
264                    chunk_size: size,
265                    alignment: align,
266                })
267            });
268        }
269        Err(FxcpError::MemoryExhausted("BufferPool: All allocation strategies failed.".to_string()))
270    }
271    
272    /// Returns the total number of buffers in the pool.
273    #[inline(always)]
274    pub fn capacity(&self) -> usize { self.inner.capacity }
275    /// Returns the size in bytes of each buffer chunk.
276    #[inline(always)]
277    pub fn chunk_size(&self) -> usize { self.inner.chunk_size }
278    /// Returns the memory alignment of each buffer in bytes.
279    #[inline(always)]
280    pub fn alignment(&self) -> usize { self.inner.alignment }
281    /// Returns the raw pointer for the buffer at the given index.
282    #[inline(always)]
283    pub fn get_ptr(&self, index: u16) -> Option<*mut u8> {
284        self.inner.buffers.get(index as usize).map(|cell| {
285            // Build io_uring iovec descriptors from the pool's buffers.
286            // SAFETY: index was obtained from acquire(), so no other caller
287            // holds this index. We only read the pointer, not mutate the buffer.
288            unsafe { (*cell.get()).ptr() }
289        })
290    }
291    /// Returns the current data length of the buffer at the given index.
292    #[inline(always)]
293    pub fn get_len(&self, index: u16) -> usize {
294        if let Some(cell) = self.inner.buffers.get(index as usize) {
295            // SAFETY: index bounds-checked by get(). Caller holds exclusive
296            // logical ownership of this index via acquire/release protocol.
297            unsafe { (*cell.get()).get_len() }
298        } else {
299            0
300        }
301    }
302    /// Sets the data length of the buffer at the given index.
303    #[inline(always)]
304    pub fn set_len(&self, index: u16, len: usize) -> Result<()> {
305        if let Some(cell) = self.inner.buffers.get(index as usize) {
306            // SAFETY: caller holds exclusive logical ownership of this index.
307            // set_len only stores to an AtomicUsize, so no data race.
308            unsafe {
309                let buf = &*cell.get();
310                buf.set_len(len)?;
311            }
312        }
313        Ok(())
314    }
315    /// Build io_uring iovec descriptors from the pool's buffers.
316    pub fn as_io_vecs(&mut self) -> Vec<libc::iovec> {
317        self.inner.buffers.iter().map(|cell| {
318            // SAFETY: &mut self guarantees exclusive access to the pool.
319            // No buffers are acquired (caller must ensure this before registering).
320            unsafe {
321                let buf = &*cell.get();
322                libc::iovec { iov_base: buf.ptr() as _, iov_len: buf.capacity() }
323            }
324        }).collect()
325    }
326    /// Acquires a free buffer index from the pool, returning `None` if exhausted.
327    #[inline]
328    pub fn acquire(&self) -> Option<u16> {
329        match &self.inner.free_indices {
330            QueueStrategy::Shared(q) => q.pop(),
331            QueueStrategy::Local(cell) => {
332                // SAFETY: Local mode is single-threaded; only one caller
333                // accesses this UnsafeCell at a time.
334                let vec = unsafe { &mut *cell.get() };
335                vec.pop()
336            }
337        }
338    }
339    /// Returns a buffer to the pool, clearing its data length.
340    #[inline]
341    pub fn release(&self, index: u16) {
342        if let Some(cell) = self.inner.buffers.get(index as usize) {
343            // SAFETY: caller is returning exclusive ownership of this index.
344            // clear() only stores to an AtomicUsize.
345            unsafe { (*cell.get()).clear(); }
346            match &self.inner.free_indices {
347                QueueStrategy::Shared(q) => {
348                    let _ = q.push(index);
349                },
350                QueueStrategy::Local(c) => {
351                    // SAFETY: Local mode is single-threaded.
352                    let vec = unsafe { &mut *c.get() };
353                    vec.push(index);
354                }
355            }
356        } else {
357            error!("BufferPool: Attempted to release invalid index {}", index);
358        }
359    }
360    /// Returns the number of currently available buffers in the pool.
361    #[inline]
362    pub fn free_count(&self) -> usize {
363        match &self.inner.free_indices {
364            QueueStrategy::Shared(q) => q.len(),
365            // SAFETY: Local mode is single-threaded.
366            QueueStrategy::Local(c) => unsafe { (*c.get()).len() },
367        }
368    }
369}
370
371#[cfg(test)]
372mod tests {
373    #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
374    use super::*;
375    use std::sync::Mutex;
376
377    /// Tests that mutate `GLOBAL_BUFFER_LIMIT` must hold this lock to prevent
378    /// parallel test threads from racing on the shared Prometheus gauge.
379    static BUFFER_LIMIT_LOCK: Mutex<()> = Mutex::new(());
380
381    #[test]
382    fn test_buffer_set_len_overflow_returns_error() {
383        let _guard = BUFFER_LIMIT_LOCK.lock().unwrap();
384        let saved = GLOBAL_BUFFER_LIMIT.get();
385        GLOBAL_BUFFER_LIMIT.set(1024.0 * 1024.0);
386
387        let buf = AlignedBuffer::try_new(16, 4096).unwrap();
388        let cap = buf.capacity();
389        assert!(buf.set_len(cap + 1).is_err(), "set_len beyond capacity should return Err");
390        assert!(buf.set_len(cap).is_ok(), "set_len at exact capacity should succeed");
391        assert!(buf.set_len(0).is_ok(), "set_len to zero should succeed");
392
393        GLOBAL_BUFFER_LIMIT.set(saved);
394    }
395
396    #[test]
397    fn test_large_buffer_madv_populate() {
398        let _guard = BUFFER_LIMIT_LOCK.lock().unwrap();
399        let saved = GLOBAL_BUFFER_LIMIT.get();
400        GLOBAL_BUFFER_LIMIT.set(64.0 * 1024.0 * 1024.0);
401
402        let buf = AlignedBuffer::try_new(1_048_576, 4096);
403        assert!(buf.is_ok(), "1MB buffer allocation should succeed");
404        let buf = buf.unwrap();
405        assert!(buf.capacity() >= 1_048_576);
406
407        GLOBAL_BUFFER_LIMIT.set(saved);
408    }
409
410    #[test]
411    fn test_small_buffer_no_populate() {
412        let _guard = BUFFER_LIMIT_LOCK.lock().unwrap();
413        let saved = GLOBAL_BUFFER_LIMIT.get();
414        GLOBAL_BUFFER_LIMIT.set(64.0 * 1024.0 * 1024.0);
415
416        let buf = AlignedBuffer::try_new(4096, 4096);
417        assert!(buf.is_ok(), "4KB buffer allocation should succeed");
418        assert!(buf.unwrap().capacity() >= 4096);
419
420        GLOBAL_BUFFER_LIMIT.set(saved);
421    }
422}