Skip to main content

subetha_cxc/
frame_ring.rs

1//! `FrameRing` - self-describing variable-payload SPSC ring.
2//!
3//! Where [`SpscRingCore`](crate::spsc_ring::SpscRingCore) carries a
4//! fixed 64-byte payload and rejects anything larger
5//! ([`RingError::PayloadTooLarge`]), `FrameRing` makes the payload
6//! layout part of the record itself. Every record is a self-describing
7//! frame - a one-byte class tag plus a length - so the ring carries a
8//! payload of *any* size, inlining the small ones and spilling the
9//! large ones to a co-located byte region, with the consumer reading
10//! the class to know which path to take. This is the QUIC frame model
11//! (a type tag plus length-delimited fields) applied to the ring slot.
12//!
13//! # The two layers
14//!
15//! 1. **Descriptor ring** - a fixed-stride Lamport SPSC ring (one
16//!    producer-owned `desc_head`, one consumer-owned `desc_tail`).
17//!    Fixed stride keeps the O(1) `index -> address` arithmetic, the
18//!    one-Acquire-one-Release atomic budget, and cache-line isolation
19//!    that the raw SPSC ring earns. Each slot is
20//!    `[class:u8][_pad:3][len:u32][ inline-bytes | region_off:u64 ]`.
21//! 2. **Payload region** - a bip-buffer byte ring (absolute-monotonic
22//!    `region_head` / `region_tail` cursors). Records spill here only
23//!    when they exceed the inline budget; the descriptor then carries
24//!    the region offset instead of the bytes.
25//!
26//! # Per-op layout selection
27//!
28//! `send` picks inline when `payload.len() <= inline_budget`, else the
29//! region. `send_as` lets the producer override
30//! ([`LayoutHint::ForceInline`] / [`LayoutHint::ForceOffset`]). The
31//! consumer never overrides: it reads the class tag the producer wrote,
32//! because the consumer cannot know the layout without reading it.
33//!
34//! # Wrap correctness
35//!
36//! The region cursors are absolute monotonic counters addressed
37//! `% region_bytes`. When a record would straddle the region end the
38//! producer skip-pads to the next wrap boundary and records the
39//! post-skip offset in the descriptor. Region payloads are capped at
40//! `region_bytes / 2` so a skip-pad on an empty region can never report
41//! a false `Full` (the skipped tail plus the record always fit).
42//!
43//! # Crash recovery
44//!
45//! Identical in shape to the raw SPSC ring: a producer that dies
46//! between writing a slot and the Release-store on `desc_head` leaves
47//! the slot unpublished, so the consumer never reads it. Region bytes
48//! are published before the descriptor, so a consumer that observes a
49//! descriptor always observes its region bytes.
50
51use std::cell::UnsafeCell;
52use std::fs::{File, OpenOptions};
53use std::path::Path;
54use std::sync::atomic::{AtomicU64, Ordering};
55
56use memmap2::{MmapMut, MmapOptions};
57
58use crate::shared_ring::RingError;
59
60/// Magic identifying a `FrameRing` layout. ASCII "FRMR" + version byte.
61pub const FRAME_MAGIC: u64 = 0x4652_4d52_0000_0001;
62
63/// Descriptor header bytes: `class:u8` + `_pad:3` + `len:u32`. The
64/// inline payload (or the 8-byte region offset) follows at byte 8.
65pub const DESC_HEADER_BYTES: usize = 8;
66
67/// Smallest descriptor slot: 8-byte header + 8-byte region offset.
68pub const MIN_SLOT_SIZE: usize = DESC_HEADER_BYTES + 8;
69
70/// How a record's payload is stored. The producer writes the tag; the
71/// consumer reads it to know how to recover the bytes.
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73#[repr(u8)]
74pub enum FrameClass {
75    /// Payload bytes live inline in the descriptor slot.
76    Inline = 0,
77    /// Payload bytes live in the byte region; the descriptor carries
78    /// the region offset.
79    Offset = 1,
80}
81
82/// Producer-side override for the per-record layout decision.
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
84pub enum LayoutHint {
85    /// Inline when it fits the budget, else spill to the region.
86    #[default]
87    Auto,
88    /// Force inline; returns [`RingError::PayloadTooLarge`] if the
89    /// payload exceeds the inline budget.
90    ForceInline,
91    /// Force the region path even when the payload would fit inline.
92    ForceOffset,
93}
94
95/// Header for a `FrameRing`. Five cache lines: metadata, then each
96/// cursor on its own line so producer and consumer never false-share.
97#[repr(C, align(64))]
98struct FrameHeader {
99    magic: u64,
100    capacity: u64,
101    slot_size: u64,
102    region_bytes: u64,
103    inline_budget: u64,
104    _pad_meta: [u8; 64 - 40],
105    /// Producer-owned descriptor head.
106    desc_head: AtomicU64,
107    _pad_dh: [u8; 64 - 8],
108    /// Consumer-owned descriptor tail.
109    desc_tail: AtomicU64,
110    _pad_dt: [u8; 64 - 8],
111    /// Producer-owned region byte head (absolute monotonic).
112    region_head: AtomicU64,
113    _pad_rh: [u8; 64 - 8],
114    /// Consumer-owned region byte tail (absolute monotonic).
115    region_tail: AtomicU64,
116    _pad_rt: [u8; 64 - 8],
117}
118
119/// Total mapped bytes for a frame ring of `capacity` descriptor slots
120/// (`slot_size` each) plus a `region_bytes` payload region.
121pub const fn frame_ring_file_size(
122    capacity: usize, slot_size: usize, region_bytes: usize,
123) -> usize {
124    std::mem::size_of::<FrameHeader>() + capacity * slot_size + region_bytes
125}
126
127/// Marker so the header pointer is treated as shared mutable state.
128#[allow(dead_code)]
129struct FrameCell(UnsafeCell<u8>);
130
131#[allow(dead_code)]
132enum FrameBacking {
133    Anon(MmapMut),
134    File(File, MmapMut),
135    Shm(crate::shm_file::ShmFile),
136}
137
138/// Self-describing variable-payload SPSC ring. One producer, one
139/// consumer. Carries any payload size: small inline, large via the
140/// co-located byte region, the layout chosen per record and recorded
141/// in the descriptor.
142pub struct FrameRing {
143    _backing: FrameBacking,
144    raw_ptr: *mut u8,
145    capacity: usize,
146    slot_size: usize,
147    region_bytes: usize,
148    inline_budget: usize,
149    desc_base: usize,
150    region_base: usize,
151}
152
153unsafe impl Send for FrameRing {}
154unsafe impl Sync for FrameRing {}
155
156fn validate_params(capacity: usize, slot_size: usize, region_bytes: usize)
157    -> Result<(), RingError>
158{
159    if !capacity.is_power_of_two() || capacity < 2 {
160        return Err(RingError::LayoutMismatch);
161    }
162    if slot_size < MIN_SLOT_SIZE {
163        return Err(RingError::LayoutMismatch);
164    }
165    if !region_bytes.is_power_of_two() || region_bytes < 2 {
166        return Err(RingError::LayoutMismatch);
167    }
168    Ok(())
169}
170
171/// Lay out an empty frame ring: header and descriptor slots zeroed
172/// (a zero class byte is an unpublished slot; the four cursors start
173/// at zero), the config fields, then the magic, last, because
174/// attachers spin on it.
175///
176/// # Safety
177/// `ptr` addresses at least `frame_ring_file_size(capacity,
178/// slot_size, region_bytes)` writable bytes.
179unsafe fn init_frame_layout_raw(
180    ptr: *mut u8, capacity: usize, slot_size: usize, region_bytes: usize,
181) {
182    let inline_budget = slot_size - DESC_HEADER_BYTES;
183    unsafe {
184        let desc_base = std::mem::size_of::<FrameHeader>();
185        std::ptr::write_bytes(ptr, 0, desc_base + capacity * slot_size);
186        let hdr = ptr as *mut FrameHeader;
187        (*hdr).capacity = capacity as u64;
188        (*hdr).slot_size = slot_size as u64;
189        (*hdr).region_bytes = region_bytes as u64;
190        (*hdr).inline_budget = inline_budget as u64;
191        std::ptr::write_volatile(&raw mut (*hdr).magic, FRAME_MAGIC);
192    }
193}
194
195impl FrameRing {
196    /// Anonymous in-process frame ring. `slot_size` is the descriptor
197    /// stride (inline budget is `slot_size - 8`); `region_bytes` sizes
198    /// the spill region (payloads cap at `region_bytes / 2`).
199    pub fn create_anon(
200        capacity: usize, slot_size: usize, region_bytes: usize,
201    ) -> Result<Self, RingError> {
202        validate_params(capacity, slot_size, region_bytes)?;
203        let total = frame_ring_file_size(capacity, slot_size, region_bytes);
204        let mut mmap = MmapOptions::new().len(total).map_anon()?;
205        unsafe { init_frame_layout_raw(mmap.as_mut_ptr(), capacity, slot_size, region_bytes) };
206        let raw_ptr = mmap.as_mut_ptr();
207        Ok(Self::from_parts(
208            FrameBacking::Anon(mmap), raw_ptr, capacity, slot_size, region_bytes,
209        ))
210    }
211
212    /// File-backed frame ring; cross-process via the OS page cache.
213    /// Obtains the ring at `path`: initializes an empty one if the path
214    /// does not yet exist and attaches to it if it does. Attaching
215    /// leaves queued frames and both region cursors in place; a ring
216    /// built with different parameters is a `LayoutMismatch`.
217    /// [`reset`](Self::reset) reinitializes.
218    pub fn create(
219        path: impl AsRef<Path>, capacity: usize, slot_size: usize, region_bytes: usize,
220    ) -> Result<Self, RingError> {
221        validate_params(capacity, slot_size, region_bytes)?;
222        let total = frame_ring_file_size(capacity, slot_size, region_bytes);
223        let (file, mut mmap) = crate::mmf_attach::create_or_attach(
224            path.as_ref(),
225            total,
226            |ptr| unsafe { init_frame_layout_raw(ptr, capacity, slot_size, region_bytes) },
227            |ptr| unsafe { (*(ptr as *const FrameHeader)).magic == FRAME_MAGIC },
228        )?;
229        Self::check_header(mmap.as_ptr(), capacity, slot_size, region_bytes)?;
230        let raw_ptr = mmap.as_mut_ptr();
231        Ok(Self::from_parts(
232            FrameBacking::File(file, mmap), raw_ptr, capacity, slot_size, region_bytes,
233        ))
234    }
235
236    /// Truncate the ring at `path` and initialize an empty one,
237    /// discarding queued frames live peers hold. For a caller that
238    /// knows it owns the path.
239    pub fn reset(
240        path: impl AsRef<Path>, capacity: usize, slot_size: usize, region_bytes: usize,
241    ) -> Result<Self, RingError> {
242        validate_params(capacity, slot_size, region_bytes)?;
243        let total = frame_ring_file_size(capacity, slot_size, region_bytes);
244        let (file, mut mmap) = crate::mmf_attach::reset(
245            path.as_ref(),
246            total,
247            |ptr| unsafe { init_frame_layout_raw(ptr, capacity, slot_size, region_bytes) },
248        )?;
249        let raw_ptr = mmap.as_mut_ptr();
250        Ok(Self::from_parts(
251            FrameBacking::File(file, mmap), raw_ptr, capacity, slot_size, region_bytes,
252        ))
253    }
254
255    /// Open an existing file-backed frame ring. Validates the header.
256    pub fn open(
257        path: impl AsRef<Path>, capacity: usize, slot_size: usize, region_bytes: usize,
258    ) -> Result<Self, RingError> {
259        validate_params(capacity, slot_size, region_bytes)?;
260        let total = frame_ring_file_size(capacity, slot_size, region_bytes);
261        let file = OpenOptions::new().read(true).write(true).open(path.as_ref())?;
262        if (file.metadata()?.len() as usize) < total {
263            return Err(RingError::LayoutMismatch);
264        }
265        let mut mmap = unsafe { MmapOptions::new().len(total).map_mut(&file)? };
266        Self::check_header(mmap.as_ptr(), capacity, slot_size, region_bytes)?;
267        let raw_ptr = mmap.as_mut_ptr();
268        Ok(Self::from_parts(
269            FrameBacking::File(file, mmap), raw_ptr, capacity, slot_size, region_bytes,
270        ))
271    }
272
273    /// Build a fresh frame ring on a named RAM-resident shared-memory
274    /// backing (cross-process, never touches the page cache).
275    pub fn create_from_shm(
276        mut shm: crate::shm_file::ShmFile,
277        capacity: usize, slot_size: usize, region_bytes: usize,
278    ) -> Result<Self, RingError> {
279        validate_params(capacity, slot_size, region_bytes)?;
280        let total = frame_ring_file_size(capacity, slot_size, region_bytes);
281        if shm.len() < total {
282            return Err(RingError::LayoutMismatch);
283        }
284        let raw_ptr = shm.as_mut_slice().as_mut_ptr();
285        unsafe { init_frame_layout_raw(raw_ptr, capacity, slot_size, region_bytes) };
286        Ok(Self::from_parts(
287            FrameBacking::Shm(shm), raw_ptr, capacity, slot_size, region_bytes,
288        ))
289    }
290
291    /// Open an existing named ShmFs-backed frame ring (no re-init).
292    pub fn open_from_shm(
293        mut shm: crate::shm_file::ShmFile,
294        capacity: usize, slot_size: usize, region_bytes: usize,
295    ) -> Result<Self, RingError> {
296        validate_params(capacity, slot_size, region_bytes)?;
297        let total = frame_ring_file_size(capacity, slot_size, region_bytes);
298        if shm.len() < total {
299            return Err(RingError::LayoutMismatch);
300        }
301        let raw_ptr = shm.as_mut_slice().as_mut_ptr();
302        Self::check_header(raw_ptr, capacity, slot_size, region_bytes)?;
303        Ok(Self::from_parts(
304            FrameBacking::Shm(shm), raw_ptr, capacity, slot_size, region_bytes,
305        ))
306    }
307
308    fn from_parts(
309        backing: FrameBacking, raw_ptr: *mut u8,
310        capacity: usize, slot_size: usize, region_bytes: usize,
311    ) -> Self {
312        let desc_base = std::mem::size_of::<FrameHeader>();
313        let region_base = desc_base + capacity * slot_size;
314        Self {
315            _backing: backing, raw_ptr, capacity, slot_size, region_bytes,
316            inline_budget: slot_size - DESC_HEADER_BYTES,
317            desc_base, region_base,
318        }
319    }
320
321    fn check_header(
322        ptr: *const u8, capacity: usize, slot_size: usize, region_bytes: usize,
323    ) -> Result<(), RingError> {
324        let header = unsafe { &*(ptr as *const FrameHeader) };
325        if header.magic != FRAME_MAGIC
326            || header.capacity != capacity as u64
327            || header.slot_size != slot_size as u64
328            || header.region_bytes != region_bytes as u64
329        {
330            return Err(RingError::LayoutMismatch);
331        }
332        Ok(())
333    }
334
335    /// Descriptor slot count (power of 2).
336    pub fn capacity(&self) -> usize { self.capacity }
337    /// Descriptor stride in bytes.
338    pub fn slot_size(&self) -> usize { self.slot_size }
339    /// Largest payload stored inline (`slot_size - 8`).
340    pub fn inline_budget(&self) -> usize { self.inline_budget }
341    /// Byte-region size. Region payloads cap at half this.
342    pub fn region_bytes(&self) -> usize { self.region_bytes }
343    /// Largest payload the region accepts (`region_bytes / 2`).
344    pub fn max_payload(&self) -> usize { self.region_bytes / 2 }
345
346    fn header(&self) -> &FrameHeader {
347        unsafe { &*(self.raw_ptr as *const FrameHeader) }
348    }
349
350    fn desc_slot_ptr(&self, idx: u64) -> *mut u8 {
351        let masked = (idx as usize) & (self.capacity - 1);
352        unsafe { self.raw_ptr.add(self.desc_base + masked * self.slot_size) }
353    }
354
355    fn region_ptr(&self) -> *mut u8 {
356        unsafe { self.raw_ptr.add(self.region_base) }
357    }
358
359    /// Items waiting in the descriptor ring (`desc_head - desc_tail`).
360    pub fn approx_len(&self) -> usize {
361        let h = self.header();
362        h.desc_head.load(Ordering::Acquire)
363            .saturating_sub(h.desc_tail.load(Ordering::Acquire)) as usize
364    }
365
366    /// Send a payload, letting the ring pick inline vs region.
367    pub fn send(&self, payload: &[u8]) -> Result<FrameClass, RingError> {
368        self.send_as(payload, LayoutHint::Auto)
369    }
370
371    /// Send a payload with an explicit layout override. **Caller is the
372    /// sole producer.**
373    pub fn send_as(&self, payload: &[u8], hint: LayoutHint)
374        -> Result<FrameClass, RingError>
375    {
376        let h = self.header();
377        let head = h.desc_head.load(Ordering::Relaxed);
378        let tail = h.desc_tail.load(Ordering::Acquire);
379        if head.wrapping_sub(tail) >= self.capacity as u64 {
380            return Err(RingError::Full);
381        }
382
383        let inline = match hint {
384            LayoutHint::ForceInline => {
385                if payload.len() > self.inline_budget {
386                    return Err(RingError::PayloadTooLarge);
387                }
388                true
389            }
390            LayoutHint::ForceOffset => false,
391            LayoutHint::Auto => payload.len() <= self.inline_budget,
392        };
393
394        let slot = self.desc_slot_ptr(head);
395        let len = payload.len() as u32;
396
397        let class = if inline {
398            unsafe {
399                slot.write(FrameClass::Inline as u8);
400                std::ptr::copy_nonoverlapping(
401                    len.to_le_bytes().as_ptr(), slot.add(4), 4,
402                );
403                std::ptr::copy_nonoverlapping(
404                    payload.as_ptr(), slot.add(DESC_HEADER_BYTES), payload.len(),
405                );
406            }
407            FrameClass::Inline
408        } else {
409            if payload.len() > self.max_payload() {
410                return Err(RingError::PayloadTooLarge);
411            }
412            let rh = h.region_head.load(Ordering::Relaxed);
413            let rt = h.region_tail.load(Ordering::Acquire);
414            let rb = self.region_bytes as u64;
415            let phys = rh % rb;
416            // Skip-pad to the next wrap boundary if the record would
417            // straddle the region end.
418            let start = if phys + payload.len() as u64 > rb {
419                rh + (rb - phys)
420            } else {
421                rh
422            };
423            if start.wrapping_add(payload.len() as u64).wrapping_sub(rt) > rb {
424                return Err(RingError::Full);
425            }
426            let pstart = (start % rb) as usize;
427            unsafe {
428                std::ptr::copy_nonoverlapping(
429                    payload.as_ptr(), self.region_ptr().add(pstart), payload.len(),
430                );
431            }
432            // Publish region bytes before the descriptor that points at
433            // them.
434            h.region_head.store(start + payload.len() as u64, Ordering::Release);
435            unsafe {
436                slot.write(FrameClass::Offset as u8);
437                std::ptr::copy_nonoverlapping(
438                    len.to_le_bytes().as_ptr(), slot.add(4), 4,
439                );
440                std::ptr::copy_nonoverlapping(
441                    start.to_le_bytes().as_ptr(), slot.add(DESC_HEADER_BYTES), 8,
442                );
443            }
444            FrameClass::Offset
445        };
446
447        h.desc_head.store(head + 1, Ordering::Release);
448        crate::cache_ops::cldemote(slot as *const u8);
449        Ok(class)
450    }
451
452    /// Receive the next payload into `out` (cleared then filled).
453    /// Returns the [`FrameClass`] the producer used. **Caller is the
454    /// sole consumer.**
455    pub fn recv_into(&self, out: &mut Vec<u8>) -> Result<FrameClass, RingError> {
456        let h = self.header();
457        let tail = h.desc_tail.load(Ordering::Relaxed);
458        let head = h.desc_head.load(Ordering::Acquire);
459        if tail == head {
460            return Err(RingError::Empty);
461        }
462        let slot = self.desc_slot_ptr(tail);
463        let class_byte = unsafe { slot.read() };
464        let len = unsafe {
465            let mut b = [0u8; 4];
466            std::ptr::copy_nonoverlapping(slot.add(4), b.as_mut_ptr(), 4);
467            u32::from_le_bytes(b) as usize
468        };
469
470        out.clear();
471        out.reserve(len);
472        let class = if class_byte == FrameClass::Inline as u8 {
473            unsafe {
474                std::ptr::copy_nonoverlapping(
475                    slot.add(DESC_HEADER_BYTES),
476                    out.spare_capacity_mut().as_mut_ptr() as *mut u8,
477                    len,
478                );
479                out.set_len(len);
480            }
481            FrameClass::Inline
482        } else {
483            let off = unsafe {
484                let mut b = [0u8; 8];
485                std::ptr::copy_nonoverlapping(slot.add(DESC_HEADER_BYTES), b.as_mut_ptr(), 8);
486                u64::from_le_bytes(b)
487            };
488            let pstart = (off % self.region_bytes as u64) as usize;
489            unsafe {
490                std::ptr::copy_nonoverlapping(
491                    self.region_ptr().add(pstart),
492                    out.spare_capacity_mut().as_mut_ptr() as *mut u8,
493                    len,
494                );
495                out.set_len(len);
496            }
497            // Reclaim region space up to the end of this record.
498            h.region_tail.store(off + len as u64, Ordering::Release);
499            FrameClass::Offset
500        };
501
502        h.desc_tail.store(tail + 1, Ordering::Release);
503        crate::cache_ops::cldemote(slot as *const u8);
504        Ok(class)
505    }
506
507    /// Receive the next payload as a fresh `Vec`.
508    pub fn recv(&self) -> Result<Vec<u8>, RingError> {
509        let mut out = Vec::new();
510        self.recv_into(&mut out)?;
511        Ok(out)
512    }
513
514    /// Force any dirty MMF pages to disk (file backing only).
515    pub fn flush(&self) -> Result<(), RingError> {
516        if let FrameBacking::File(_, mmap) = &self._backing {
517            mmap.flush()?;
518        }
519        Ok(())
520    }
521}
522
523#[cfg(test)]
524mod tests {
525    use super::*;
526    use std::sync::Arc;
527    use std::thread;
528
529    fn ring() -> FrameRing {
530        // 64-byte slots (56-byte inline budget), 64 KiB region.
531        FrameRing::create_anon(16, 64, 1 << 16).unwrap()
532    }
533
534    #[test]
535    fn inline_round_trip() {
536        let r = ring();
537        let payload = b"small payload under the inline budget";
538        assert_eq!(r.send(payload).unwrap(), FrameClass::Inline);
539        let got = r.recv().unwrap();
540        assert_eq!(got, payload);
541    }
542
543    #[test]
544    fn offset_round_trip_large() {
545        let r = ring();
546        let payload = vec![0xABu8; 4096]; // far over the 56-byte budget
547        assert_eq!(r.send(&payload).unwrap(), FrameClass::Offset);
548        let got = r.recv().unwrap();
549        assert_eq!(got, payload);
550    }
551
552    #[test]
553    fn boundary_inline_vs_offset() {
554        let r = ring();
555        // Exactly the inline budget stays inline.
556        let at = vec![1u8; r.inline_budget()];
557        assert_eq!(r.send(&at).unwrap(), FrameClass::Inline);
558        assert_eq!(r.recv().unwrap(), at);
559        // One byte over spills to the region.
560        let over = vec![2u8; r.inline_budget() + 1];
561        assert_eq!(r.send(&over).unwrap(), FrameClass::Offset);
562        assert_eq!(r.recv().unwrap(), over);
563    }
564
565    #[test]
566    fn empty_payload_round_trip() {
567        let r = ring();
568        assert_eq!(r.send(&[]).unwrap(), FrameClass::Inline);
569        assert_eq!(r.recv().unwrap(), Vec::<u8>::new());
570    }
571
572    #[test]
573    fn force_offset_overrides_small() {
574        let r = ring();
575        assert_eq!(r.send_as(b"tiny", LayoutHint::ForceOffset).unwrap(),
576                   FrameClass::Offset);
577        assert_eq!(r.recv().unwrap(), b"tiny");
578    }
579
580    #[test]
581    fn force_inline_rejects_oversize() {
582        let r = ring();
583        let big = vec![0u8; r.inline_budget() + 1];
584        assert_eq!(r.send_as(&big, LayoutHint::ForceInline).unwrap_err(),
585                   RingError::PayloadTooLarge);
586    }
587
588    #[test]
589    fn payload_over_region_cap_rejected() {
590        let r = ring();
591        let too_big = vec![0u8; r.max_payload() + 1];
592        assert_eq!(r.send(&too_big).unwrap_err(), RingError::PayloadTooLarge);
593    }
594
595    #[test]
596    fn descriptor_full_then_drains() {
597        let r = FrameRing::create_anon(4, 64, 1 << 16).unwrap();
598        for i in 0..4u8 {
599            r.send(&[i; 8]).unwrap();
600        }
601        assert_eq!(r.send(&[9u8; 8]).unwrap_err(), RingError::Full);
602        assert_eq!(r.recv().unwrap(), &[0u8; 8]);
603        r.send(&[9u8; 8]).unwrap();
604    }
605
606    #[test]
607    fn region_wraps_with_skip_pad() {
608        // Small region forces many wraps; alternate large records so the
609        // region head laps the buffer end repeatedly. Each record is
610        // pushed then immediately drained so the region tail follows.
611        let region = 1usize << 12; // 4 KiB region, max payload 2 KiB
612        let r = FrameRing::create_anon(8, 64, region).unwrap();
613        for i in 0..200u32 {
614            let len = 600 + (i as usize % 700); // 600..1299 bytes, all > budget
615            let payload: Vec<u8> = (0..len).map(|k| (k as u32 ^ i) as u8).collect();
616            assert_eq!(r.send(&payload).unwrap(), FrameClass::Offset);
617            let got = r.recv().unwrap();
618            assert_eq!(got, payload, "record {i} survived the region wrap");
619        }
620    }
621
622    #[test]
623    fn mixed_inline_and_offset_fifo_order() {
624        let r = FrameRing::create_anon(64, 64, 1 << 16).unwrap();
625        let mut expected = Vec::new();
626        for i in 0..40u32 {
627            // Alternate small (inline) and large (offset) records.
628            let len = if i % 2 == 0 { 16 } else { 500 };
629            let p: Vec<u8> = (0..len).map(|k| (k as u32 + i) as u8).collect();
630            r.send(&p).unwrap();
631            expected.push(p);
632        }
633        for want in expected {
634            assert_eq!(r.recv().unwrap(), want);
635        }
636        assert_eq!(r.recv().unwrap_err(), RingError::Empty);
637    }
638
639    #[test]
640    fn two_thread_mixed_size_stream() {
641        let r = Arc::new(FrameRing::create_anon(256, 64, 1 << 20).unwrap());
642        let rp = r.clone();
643        let rc = r.clone();
644        const N: u32 = 50_000;
645
646        let producer = thread::spawn(move || {
647            for i in 0..N {
648                // Sizes sweep the inline/offset boundary. Content is a
649                // per-byte ramp keyed on the item id so a torn or
650                // mis-ordered record is caught at any length (the id
651                // alone would not distinguish two items that alias the
652                // same slot modulo capacity).
653                let len = (i as usize % 300) + 1;
654                let p: Vec<u8> =
655                    (0..len).map(|k| i.wrapping_add(k as u32) as u8).collect();
656                while rp.send(&p).is_err() {
657                    std::hint::spin_loop();
658                }
659            }
660        });
661
662        let consumer = thread::spawn(move || {
663            let mut buf = Vec::new();
664            let mut got = 0u32;
665            while got < N {
666                if rc.recv_into(&mut buf).is_ok() {
667                    let len = (got as usize % 300) + 1;
668                    assert_eq!(buf.len(), len, "item {got} length");
669                    for (k, &b) in buf.iter().enumerate() {
670                        assert_eq!(b, got.wrapping_add(k as u32) as u8,
671                                   "item {got} byte {k}");
672                    }
673                    got += 1;
674                } else {
675                    std::hint::spin_loop();
676                }
677            }
678        });
679
680        producer.join().unwrap();
681        consumer.join().unwrap();
682    }
683
684    #[test]
685    fn shm_cross_handle_visibility() {
686        use crate::shm_file::ShmFile;
687        let nonce = std::time::SystemTime::now()
688            .duration_since(std::time::UNIX_EPOCH)
689            .map(|d| d.as_nanos())
690            .unwrap_or(0);
691        let name = format!("frame_shm_{}_{}", std::process::id(), nonce);
692        let (cap, slot, region) = (16usize, 64usize, 1usize << 16);
693        let size = frame_ring_file_size(cap, slot, region);
694
695        let shm_a = ShmFile::create_or_open_named(&name, size).unwrap();
696        let producer = FrameRing::create_from_shm(shm_a, cap, slot, region).unwrap();
697        let shm_b = ShmFile::create_or_open_named(&name, size).unwrap();
698        let consumer = FrameRing::open_from_shm(shm_b, cap, slot, region).unwrap();
699
700        let small = b"inline across handles";
701        let large = vec![0x5Au8; 2000];
702        producer.send(small).unwrap();
703        producer.send(&large).unwrap();
704        assert_eq!(consumer.recv().unwrap(), small);
705        assert_eq!(consumer.recv().unwrap(), large);
706    }
707
708    #[test]
709    fn file_round_trips() {
710        let p = std::env::temp_dir().join(format!(
711            "subetha-frame-{}.bin", std::process::id(),
712        ));
713        std::fs::remove_file(&p).ok();
714        let (cap, slot, region) = (16usize, 128usize, 1usize << 16);
715        {
716            let r = FrameRing::create(&p, cap, slot, region).unwrap();
717            r.send(b"persisted inline").unwrap();
718            r.send(&vec![7u8; 3000]).unwrap();
719            r.flush().unwrap();
720        }
721        let r2 = FrameRing::open(&p, cap, slot, region).unwrap();
722        assert_eq!(r2.recv().unwrap(), b"persisted inline");
723        assert_eq!(r2.recv().unwrap(), vec![7u8; 3000]);
724        std::fs::remove_file(&p).ok();
725    }
726
727    #[test]
728    fn rejects_bad_params() {
729        assert!(matches!(FrameRing::create_anon(3, 64, 1 << 16),
730                         Err(RingError::LayoutMismatch))); // capacity not pow2
731        assert!(matches!(FrameRing::create_anon(16, 8, 1 << 16),
732                         Err(RingError::LayoutMismatch))); // slot < MIN_SLOT_SIZE
733        assert!(matches!(FrameRing::create_anon(16, 64, 1000),
734                         Err(RingError::LayoutMismatch))); // region not pow2
735    }
736
737    /// A second create attaches with queued frames in place; reset is
738    /// what strips them.
739    #[test]
740    fn second_create_attaches_and_keeps_frames() {
741        let p = std::env::temp_dir().join(format!(
742            "subetha-frame-attach-{}.bin", std::process::id(),
743        ));
744        std::fs::remove_file(&p).ok();
745        let (cap, slot, region) = (16usize, 128usize, 1usize << 16);
746
747        let r = FrameRing::create(&p, cap, slot, region).unwrap();
748        r.send(b"inline survives attach").unwrap();
749        r.send(&vec![9u8; 3000]).unwrap();
750
751        let r2 = FrameRing::create(&p, cap, slot, region).unwrap();
752        assert_eq!(r2.recv().unwrap(), b"inline survives attach",
753                   "attach lost a queued inline frame");
754        assert_eq!(r2.recv().unwrap(), vec![9u8; 3000],
755                   "attach lost a queued region frame");
756        assert!(matches!(
757            FrameRing::create(&p, 8, slot, region),
758            Err(RingError::LayoutMismatch),
759        ));
760
761        // Windows refuses to truncate a mapped file, so every handle
762        // goes before the reset.
763        drop(r);
764        drop(r2);
765        let fresh = FrameRing::reset(&p, cap, slot, region).unwrap();
766        assert_eq!(fresh.approx_len(), 0, "reset kept a queued frame");
767        drop(fresh);
768        std::fs::remove_file(&p).ok();
769    }
770}