Skip to main content

subetha_cxc/
frame_region.rs

1//! `FrameRegion` - concurrent fixed-block payload region for the
2//! self-describing offset path shared by every `AdaptiveRing` shape.
3//!
4//! Records too large to inline in a ring slot spill here: the producer
5//! allocates a block, copies the payload in, and writes the block index
6//! into the ring descriptor; the consumer reads the block and frees it.
7//! Because the offset payloads of every shape (SPSC / MPSC / MPMC /
8//! Vyukov) land in one region, the allocator must be safe for many
9//! producers allocating and many consumers freeing at once, in any
10//! order. That is a Treiber-stack free list with an ABA counter plus a
11//! bump high-water mark - the same allocator
12//! [`SharedRegion`](crate::shared_region::SharedRegion) ships, here with
13//! a runtime block size instead of a const-generic `T` so the
14//! `AdaptiveRing` can size its blocks to the workload.
15//!
16//! Reclaim order does not matter: a freed block returns to the stack
17//! and is handed to the next allocation regardless of which consumer
18//! freed it, so no FIFO bookkeeping is needed across consumers.
19
20use std::fs::{File, OpenOptions};
21use std::path::Path;
22use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
23
24use memmap2::{MmapMut, MmapOptions};
25
26use crate::shared_ring::RingError;
27
28/// Magic identifying a `FrameRegion` layout. ASCII "FRGN" + version.
29pub const FRAME_REGION_MAGIC: u64 = 0x4652_474e_0000_0001;
30
31/// Free-list sentinel: "no next block".
32const NIL: u32 = u32::MAX;
33
34/// Smallest block: must hold the 4-byte free-list link.
35pub const MIN_BLOCK_SIZE: usize = 8;
36
37#[inline]
38fn pack(counter: u32, index: u32) -> u64 {
39    ((counter as u64) << 32) | (index as u64)
40}
41#[inline]
42fn unpack(v: u64) -> (u32, u32) {
43    ((v >> 32) as u32, v as u32)
44}
45
46/// Header: metadata line, then the bump cursor and the free-list head
47/// each on their own cache line so allocators and freers do not
48/// false-share.
49#[repr(C, align(64))]
50struct FrameRegionHeader {
51    magic: AtomicU64,
52    block_size: u64,
53    block_count: u64,
54    _pad_meta: [u8; 64 - 24],
55    /// Bump high-water mark (next never-yet-allocated block).
56    bump_next: AtomicU32,
57    _pad_bump: [u8; 64 - 4],
58    /// Treiber-stack free-list head, ABA-tagged (`counter << 32 | idx`).
59    free_head: AtomicU64,
60    _pad_free: [u8; 64 - 8],
61}
62
63/// Total mapped bytes for `block_count` blocks of `block_size`.
64pub const fn frame_region_file_size(block_size: usize, block_count: usize) -> usize {
65    std::mem::size_of::<FrameRegionHeader>() + block_size * block_count
66}
67
68#[allow(dead_code)]
69enum RegionBacking {
70    Anon(MmapMut),
71    File(File, MmapMut),
72    Shm(crate::shm_file::ShmFile),
73}
74
75/// Concurrent fixed-block region. Multi-producer `alloc`,
76/// multi-consumer `free`, any-order reclaim.
77pub struct FrameRegion {
78    _backing: RegionBacking,
79    raw_ptr: *mut u8,
80    block_size: usize,
81    block_count: usize,
82    blocks_base: usize,
83}
84
85unsafe impl Send for FrameRegion {}
86unsafe impl Sync for FrameRegion {}
87
88fn validate(block_size: usize, block_count: usize) -> Result<(), RingError> {
89    if block_size < MIN_BLOCK_SIZE || !block_size.is_multiple_of(8) {
90        return Err(RingError::LayoutMismatch);
91    }
92    if block_count < 1 || block_count >= NIL as usize {
93        return Err(RingError::LayoutMismatch);
94    }
95    Ok(())
96}
97
98/// Lay out a fresh region. The magic is published last with `Release`, because
99/// attachers on every backing spin on it and must observe the cursors first.
100unsafe fn init_region(ptr: *mut u8, block_size: usize, block_count: usize) {
101    unsafe {
102        std::ptr::write(ptr as *mut FrameRegionHeader, FrameRegionHeader {
103            magic: AtomicU64::new(0),
104            block_size: block_size as u64,
105            block_count: block_count as u64,
106            _pad_meta: [0; 64 - 24],
107            bump_next: AtomicU32::new(0),
108            _pad_bump: [0; 64 - 4],
109            free_head: AtomicU64::new(pack(0, NIL)),
110            _pad_free: [0; 64 - 8],
111        });
112        (*(ptr as *const FrameRegionHeader))
113            .magic
114            .store(FRAME_REGION_MAGIC, std::sync::atomic::Ordering::Release);
115    }
116}
117
118impl FrameRegion {
119    /// Anonymous in-process region.
120    pub fn create_anon(block_size: usize, block_count: usize) -> Result<Self, RingError> {
121        validate(block_size, block_count)?;
122        let total = frame_region_file_size(block_size, block_count);
123        let mut mmap = MmapOptions::new().len(total).map_anon()?;
124        unsafe { init_region(mmap.as_mut_ptr(), block_size, block_count) };
125        let raw_ptr = mmap.as_mut_ptr();
126        Ok(Self::from_parts(RegionBacking::Anon(mmap), raw_ptr, block_size, block_count))
127    }
128
129    /// File-backed region; cross-process via the page cache. Obtains the
130    /// region at `path`: initializes it only when the path does not yet
131    /// exist, and otherwise attaches with allocated blocks and the free
132    /// list in place, so a late joiner never wipes a region a producer
133    /// already filled. A region built with a different geometry is a
134    /// `LayoutMismatch`. [`reset`](Self::reset) reinitializes.
135    pub fn create(
136        path: impl AsRef<Path>, block_size: usize, block_count: usize,
137    ) -> Result<Self, RingError> {
138        Self::create_or_open_file(path, block_size, block_count)
139    }
140
141    /// Truncate the region at `path` and initialize an empty one,
142    /// invalidating every offset live peers hold. For a caller that
143    /// knows it owns the path.
144    pub fn reset(
145        path: impl AsRef<Path>, block_size: usize, block_count: usize,
146    ) -> Result<Self, RingError> {
147        validate(block_size, block_count)?;
148        let total = frame_region_file_size(block_size, block_count);
149        let file = OpenOptions::new()
150            .read(true).write(true).create(true).truncate(true)
151            .open(path.as_ref())?;
152        file.set_len(total as u64)?;
153        let mut mmap = unsafe { MmapOptions::new().len(total).map_mut(&file)? };
154        unsafe { init_region(mmap.as_mut_ptr(), block_size, block_count) };
155        let raw_ptr = mmap.as_mut_ptr();
156        Ok(Self::from_parts(RegionBacking::File(file, mmap), raw_ptr, block_size, block_count))
157    }
158
159    /// Open an existing file-backed region. Validates the header.
160    pub fn open(
161        path: impl AsRef<Path>, block_size: usize, block_count: usize,
162    ) -> Result<Self, RingError> {
163        validate(block_size, block_count)?;
164        let total = frame_region_file_size(block_size, block_count);
165        let file = OpenOptions::new().read(true).write(true).open(path.as_ref())?;
166        if (file.metadata()?.len() as usize) < total {
167            return Err(RingError::LayoutMismatch);
168        }
169        let mut mmap = unsafe { MmapOptions::new().len(total).map_mut(&file)? };
170        Self::check_header(mmap.as_ptr(), block_size, block_count)?;
171        let raw_ptr = mmap.as_mut_ptr();
172        Ok(Self::from_parts(RegionBacking::File(file, mmap), raw_ptr, block_size, block_count))
173    }
174
175    /// Build a region on a named RAM-resident shared-memory backing.
176    pub fn create_from_shm(
177        mut shm: crate::shm_file::ShmFile, block_size: usize, block_count: usize,
178    ) -> Result<Self, RingError> {
179        validate(block_size, block_count)?;
180        if shm.len() < frame_region_file_size(block_size, block_count) {
181            return Err(RingError::LayoutMismatch);
182        }
183        let raw_ptr = shm.as_mut_slice().as_mut_ptr();
184        unsafe { init_region(raw_ptr, block_size, block_count) };
185        Ok(Self::from_parts(RegionBacking::Shm(shm), raw_ptr, block_size, block_count))
186    }
187
188    /// Open an existing named ShmFs-backed region (no re-init).
189    pub fn open_from_shm(
190        mut shm: crate::shm_file::ShmFile, block_size: usize, block_count: usize,
191    ) -> Result<Self, RingError> {
192        validate(block_size, block_count)?;
193        if shm.len() < frame_region_file_size(block_size, block_count) {
194            return Err(RingError::LayoutMismatch);
195        }
196        let raw_ptr = shm.as_mut_slice().as_mut_ptr();
197        Self::check_header(raw_ptr, block_size, block_count)?;
198        Ok(Self::from_parts(RegionBacking::Shm(shm), raw_ptr, block_size, block_count))
199    }
200
201    /// Create-or-open a named ShmFs frame region. The first attacher
202    /// CAS-initialises the layout and publishes the magic; racing
203    /// attachers spin until it lands, so a late-joining consumer never
204    /// wipes a region a producer already filled. This is the shared
205    /// payload region the cross-process offset-frame path needs: the
206    /// producer create-or-opens it on the first offset `send_frame`,
207    /// and every consumer create-or-opens the SAME region on the first
208    /// offset `recv_frame` (the descriptor it popped implies the
209    /// producer already created it).
210    pub fn create_or_open_shm(
211        name: &str, block_size: usize, block_count: usize,
212    ) -> Result<Self, RingError> {
213        validate(block_size, block_count)?;
214        let total = frame_region_file_size(block_size, block_count);
215        let mut shm = crate::shm_file::ShmFile::create_or_open_named(name, total)
216            .map_err(|_| RingError::LayoutMismatch)?;
217        if shm.len() < total {
218            return Err(RingError::LayoutMismatch);
219        }
220        let raw_ptr = shm.as_mut_slice().as_mut_ptr();
221        Self::guarded_init_or_attach(raw_ptr, block_size, block_count)?;
222        Ok(Self::from_parts(RegionBacking::Shm(shm), raw_ptr, block_size, block_count))
223    }
224
225    /// Create-or-open a file-backed frame region: the file-locale peer
226    /// of [`create_or_open_shm`](Self::create_or_open_shm), for rings
227    /// backed by [`AdaptiveRing::create`](crate::adaptive_ring::AdaptiveRing::create)
228    /// / `open`.
229    pub fn create_or_open_file(
230        path: impl AsRef<Path>, block_size: usize, block_count: usize,
231    ) -> Result<Self, RingError> {
232        validate(block_size, block_count)?;
233        let total = frame_region_file_size(block_size, block_count);
234        let file = OpenOptions::new()
235            .read(true).write(true).create(true).truncate(false)
236            .open(path.as_ref())?;
237        if (file.metadata()?.len() as usize) < total {
238            file.set_len(total as u64)?;
239        }
240        let mut mmap = unsafe { MmapOptions::new().len(total).map_mut(&file)? };
241        let raw_ptr = mmap.as_mut_ptr();
242        Self::guarded_init_or_attach(raw_ptr, block_size, block_count)?;
243        Ok(Self::from_parts(RegionBacking::File(file, mmap), raw_ptr, block_size, block_count))
244    }
245
246    /// CAS-guarded init used by both create-or-open paths: the winner of
247    /// the `magic: 0 -> in-progress` CAS writes the geometry + cursors
248    /// and publishes `FRAME_REGION_MAGIC` (Release); racing attachers
249    /// spin until they observe it (Acquire), then both validate the
250    /// geometry matches what the caller asked for.
251    fn guarded_init_or_attach(
252        raw_ptr: *mut u8, block_size: usize, block_count: usize,
253    ) -> Result<(), RingError> {
254        const INIT_INPROGRESS: u64 = 1;
255        let h = unsafe { &*(raw_ptr as *const FrameRegionHeader) };
256        if h
257            .magic
258            .compare_exchange(0, INIT_INPROGRESS, Ordering::AcqRel, Ordering::Acquire)
259            .is_ok()
260        {
261            unsafe {
262                let hdr = raw_ptr as *mut FrameRegionHeader;
263                (*hdr).block_size = block_size as u64;
264                (*hdr).block_count = block_count as u64;
265                (*hdr).bump_next.store(0, Ordering::Relaxed);
266                (*hdr).free_head.store(pack(0, NIL), Ordering::Relaxed);
267            }
268            h.magic.store(FRAME_REGION_MAGIC, Ordering::Release);
269        } else {
270            let mut spins = 0u32;
271            while h.magic.load(Ordering::Acquire) != FRAME_REGION_MAGIC {
272                std::hint::spin_loop();
273                spins += 1;
274                if spins > 100_000_000 {
275                    return Err(RingError::LayoutMismatch);
276                }
277            }
278        }
279        if h.block_size != block_size as u64 || h.block_count != block_count as u64 {
280            return Err(RingError::LayoutMismatch);
281        }
282        Ok(())
283    }
284
285    fn from_parts(
286        backing: RegionBacking, raw_ptr: *mut u8, block_size: usize, block_count: usize,
287    ) -> Self {
288        Self {
289            _backing: backing, raw_ptr, block_size, block_count,
290            blocks_base: std::mem::size_of::<FrameRegionHeader>(),
291        }
292    }
293
294    fn check_header(ptr: *const u8, block_size: usize, block_count: usize) -> Result<(), RingError> {
295        let h = unsafe { &*(ptr as *const FrameRegionHeader) };
296        if h.magic.load(Ordering::Acquire) != FRAME_REGION_MAGIC
297            || h.block_size != block_size as u64
298            || h.block_count != block_count as u64
299        {
300            return Err(RingError::LayoutMismatch);
301        }
302        Ok(())
303    }
304
305    /// Largest payload a block holds.
306    pub fn block_size(&self) -> usize { self.block_size }
307    /// Number of blocks.
308    pub fn block_count(&self) -> usize { self.block_count }
309
310    fn header(&self) -> &FrameRegionHeader {
311        unsafe { &*(self.raw_ptr as *const FrameRegionHeader) }
312    }
313
314    fn block_ptr(&self, idx: u32) -> *mut u8 {
315        unsafe { self.raw_ptr.add(self.blocks_base + idx as usize * self.block_size) }
316    }
317
318    /// The block's first 4 bytes reinterpreted as the free-list link
319    /// (only meaningful while the block is free).
320    fn next_link(&self, idx: u32) -> &AtomicU32 {
321        unsafe { &*(self.block_ptr(idx) as *const AtomicU32) }
322    }
323
324    /// Allocate a block. Free list first, then bump. `None` when full.
325    pub fn alloc(&self) -> Option<u32> {
326        loop {
327            let head = self.header().free_head.load(Ordering::Acquire);
328            let (counter, idx) = unpack(head);
329            if idx == NIL {
330                break;
331            }
332            let next = self.next_link(idx).load(Ordering::Acquire);
333            let new_head = pack(counter.wrapping_add(1), next);
334            if self.header().free_head.compare_exchange(
335                head, new_head, Ordering::AcqRel, Ordering::Acquire,
336            ).is_ok() {
337                return Some(idx);
338            }
339        }
340        let idx = self.header().bump_next.fetch_add(1, Ordering::AcqRel);
341        if idx >= self.block_count as u32 {
342            self.header().bump_next.fetch_sub(1, Ordering::AcqRel);
343            return None;
344        }
345        Some(idx)
346    }
347
348    /// Return a block to the free list. Any consumer may free any block.
349    pub fn free(&self, idx: u32) {
350        if idx as usize >= self.block_count {
351            return;
352        }
353        loop {
354            let head = self.header().free_head.load(Ordering::Acquire);
355            let (counter, old_top) = unpack(head);
356            self.next_link(idx).store(old_top, Ordering::Release);
357            let new_head = pack(counter.wrapping_add(1), idx);
358            if self.header().free_head.compare_exchange(
359                head, new_head, Ordering::AcqRel, Ordering::Acquire,
360            ).is_ok() {
361                return;
362            }
363        }
364    }
365
366    /// Copy `payload` into block `idx`. Caller guarantees
367    /// `payload.len() <= block_size`.
368    pub fn write_block(&self, idx: u32, payload: &[u8]) {
369        debug_assert!(payload.len() <= self.block_size);
370        unsafe {
371            std::ptr::copy_nonoverlapping(
372                payload.as_ptr(), self.block_ptr(idx), payload.len(),
373            );
374        }
375    }
376
377    /// Copy `len` bytes out of block `idx` into `out` (appended).
378    pub fn read_block_into(&self, idx: u32, len: usize, out: &mut Vec<u8>) {
379        debug_assert!(len <= self.block_size);
380        out.reserve(len);
381        unsafe {
382            std::ptr::copy_nonoverlapping(
383                self.block_ptr(idx),
384                out.spare_capacity_mut().as_mut_ptr() as *mut u8,
385                len,
386            );
387            let new_len = out.len() + len;
388            out.set_len(new_len);
389        }
390    }
391}
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396    use std::sync::Arc;
397    use std::sync::atomic::AtomicUsize;
398    use std::thread;
399
400    #[test]
401    fn alloc_write_read_free_cycle() {
402        let r = FrameRegion::create_anon(256, 8).unwrap();
403        let idx = r.alloc().unwrap();
404        let payload = vec![0xABu8; 200];
405        r.write_block(idx, &payload);
406        let mut out = Vec::new();
407        r.read_block_into(idx, 200, &mut out);
408        assert_eq!(out, payload);
409        r.free(idx);
410        // Freed block is reused by the next alloc.
411        let idx2 = r.alloc().unwrap();
412        assert_eq!(idx2, idx, "freed block returns to the stack");
413    }
414
415    #[test]
416    fn exhausts_then_full() {
417        let r = FrameRegion::create_anon(64, 4).unwrap();
418        let a: Vec<u32> = (0..4).map(|_| r.alloc().unwrap()).collect();
419        assert_eq!(a.len(), 4);
420        assert!(r.alloc().is_none(), "region full");
421        r.free(a[1]);
422        assert!(r.alloc().is_some(), "freeing reopens a block");
423    }
424
425    #[test]
426    fn concurrent_alloc_free_no_double_issue() {
427        // Many threads alloc + free in a loop; assert no index is ever
428        // held by two threads at once (a double-issue would corrupt).
429        let r = Arc::new(FrameRegion::create_anon(64, 64).unwrap());
430        let held: Arc<Vec<AtomicUsize>> =
431            Arc::new((0..64).map(|_| AtomicUsize::new(0)).collect());
432        let mut handles = Vec::new();
433        for _ in 0..8 {
434            let r = r.clone();
435            let held = held.clone();
436            handles.push(thread::spawn(move || {
437                for _ in 0..20_000 {
438                    if let Some(idx) = r.alloc() {
439                        let prev = held[idx as usize].fetch_add(1, Ordering::AcqRel);
440                        assert_eq!(prev, 0, "block {idx} double-issued");
441                        held[idx as usize].fetch_sub(1, Ordering::AcqRel);
442                        r.free(idx);
443                    }
444                }
445            }));
446        }
447        for h in handles {
448            h.join().unwrap();
449        }
450    }
451
452    #[test]
453    fn shm_cross_handle() {
454        use crate::shm_file::ShmFile;
455        let nonce = std::time::SystemTime::now()
456            .duration_since(std::time::UNIX_EPOCH)
457            .map(|d| d.as_nanos())
458            .unwrap_or(0);
459        let name = format!("frame_region_{}_{}", std::process::id(), nonce);
460        let (bs, bc) = (256usize, 8usize);
461        let size = frame_region_file_size(bs, bc);
462        let a = FrameRegion::create_from_shm(
463            ShmFile::create_or_open_named(&name, size).unwrap(), bs, bc).unwrap();
464        let b = FrameRegion::open_from_shm(
465            ShmFile::create_or_open_named(&name, size).unwrap(), bs, bc).unwrap();
466        let idx = a.alloc().unwrap();
467        a.write_block(idx, b"shared across handles");
468        let mut out = Vec::new();
469        b.read_block_into(idx, 21, &mut out);
470        assert_eq!(out, b"shared across handles");
471    }
472
473    #[test]
474    fn rejects_bad_params() {
475        assert!(matches!(FrameRegion::create_anon(7, 8), Err(RingError::LayoutMismatch)));
476        assert!(matches!(FrameRegion::create_anon(64, 0), Err(RingError::LayoutMismatch)));
477    }
478
479    /// A second create attaches with allocated blocks in place; reset is
480    /// what strips them.
481    #[test]
482    fn second_create_attaches_and_keeps_blocks() {
483        let p = std::env::temp_dir().join(format!(
484            "subetha-frame-region-attach-{}.bin", std::process::id(),
485        ));
486        std::fs::remove_file(&p).ok();
487        let (bs, bc) = (64usize, 8usize);
488
489        let r = FrameRegion::create(&p, bs, bc).unwrap();
490        let first = r.alloc().expect("alloc");
491
492        let r2 = FrameRegion::create(&p, bs, bc).unwrap();
493        assert_ne!(
494            r2.alloc().expect("second alloc"),
495            first,
496            "attach handed back a block the first handle already holds",
497        );
498        assert!(matches!(
499            FrameRegion::create(&p, bs, bc * 2),
500            Err(RingError::LayoutMismatch),
501        ));
502
503        // Windows refuses to truncate a mapped file, so every handle goes
504        // before the reset.
505        drop(r);
506        drop(r2);
507        let fresh = FrameRegion::reset(&p, bs, bc).unwrap();
508        assert_eq!(fresh.alloc().expect("alloc after reset"), first,
509                   "reset kept an allocation");
510        drop(fresh);
511        std::fs::remove_file(&p).ok();
512    }
513}