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
171unsafe fn init_frame_layout_raw(
172    ptr: *mut u8, capacity: usize, slot_size: usize, region_bytes: usize,
173) {
174    let inline_budget = slot_size - DESC_HEADER_BYTES;
175    unsafe {
176        std::ptr::write(ptr as *mut FrameHeader, FrameHeader {
177            magic: FRAME_MAGIC,
178            capacity: capacity as u64,
179            slot_size: slot_size as u64,
180            region_bytes: region_bytes as u64,
181            inline_budget: inline_budget as u64,
182            _pad_meta: [0; 64 - 40],
183            desc_head: AtomicU64::new(0),
184            _pad_dh: [0; 64 - 8],
185            desc_tail: AtomicU64::new(0),
186            _pad_dt: [0; 64 - 8],
187            region_head: AtomicU64::new(0),
188            _pad_rh: [0; 64 - 8],
189            region_tail: AtomicU64::new(0),
190            _pad_rt: [0; 64 - 8],
191        });
192        // Zero the descriptor slots so a stale class byte from a prior
193        // mapping cannot be misread before its slot is published.
194        let desc_base = std::mem::size_of::<FrameHeader>();
195        std::ptr::write_bytes(ptr.add(desc_base), 0, capacity * slot_size);
196    }
197}
198
199impl FrameRing {
200    /// Anonymous in-process frame ring. `slot_size` is the descriptor
201    /// stride (inline budget is `slot_size - 8`); `region_bytes` sizes
202    /// the spill region (payloads cap at `region_bytes / 2`).
203    pub fn create_anon(
204        capacity: usize, slot_size: usize, region_bytes: usize,
205    ) -> Result<Self, RingError> {
206        validate_params(capacity, slot_size, region_bytes)?;
207        let total = frame_ring_file_size(capacity, slot_size, region_bytes);
208        let mut mmap = MmapOptions::new().len(total).map_anon()?;
209        unsafe { init_frame_layout_raw(mmap.as_mut_ptr(), capacity, slot_size, region_bytes) };
210        let raw_ptr = mmap.as_mut_ptr();
211        Ok(Self::from_parts(
212            FrameBacking::Anon(mmap), raw_ptr, capacity, slot_size, region_bytes,
213        ))
214    }
215
216    /// File-backed frame ring; cross-process via the OS page cache.
217    pub fn create(
218        path: impl AsRef<Path>, capacity: usize, slot_size: usize, region_bytes: usize,
219    ) -> Result<Self, RingError> {
220        validate_params(capacity, slot_size, region_bytes)?;
221        let total = frame_ring_file_size(capacity, slot_size, region_bytes);
222        let file = OpenOptions::new()
223            .read(true).write(true).create(true).truncate(true)
224            .open(path.as_ref())?;
225        file.set_len(total as u64)?;
226        let mut mmap = unsafe { MmapOptions::new().len(total).map_mut(&file)? };
227        unsafe { init_frame_layout_raw(mmap.as_mut_ptr(), capacity, slot_size, region_bytes) };
228        let raw_ptr = mmap.as_mut_ptr();
229        Ok(Self::from_parts(
230            FrameBacking::File(file, mmap), raw_ptr, capacity, slot_size, region_bytes,
231        ))
232    }
233
234    /// Open an existing file-backed frame ring. Validates the header.
235    pub fn open(
236        path: impl AsRef<Path>, capacity: usize, slot_size: usize, region_bytes: usize,
237    ) -> Result<Self, RingError> {
238        validate_params(capacity, slot_size, region_bytes)?;
239        let total = frame_ring_file_size(capacity, slot_size, region_bytes);
240        let file = OpenOptions::new().read(true).write(true).open(path.as_ref())?;
241        if (file.metadata()?.len() as usize) < total {
242            return Err(RingError::LayoutMismatch);
243        }
244        let mut mmap = unsafe { MmapOptions::new().len(total).map_mut(&file)? };
245        Self::check_header(mmap.as_ptr(), capacity, slot_size, region_bytes)?;
246        let raw_ptr = mmap.as_mut_ptr();
247        Ok(Self::from_parts(
248            FrameBacking::File(file, mmap), raw_ptr, capacity, slot_size, region_bytes,
249        ))
250    }
251
252    /// Build a fresh frame ring on a named RAM-resident shared-memory
253    /// backing (cross-process, never touches the page cache).
254    pub fn create_from_shm(
255        mut shm: crate::shm_file::ShmFile,
256        capacity: usize, slot_size: usize, region_bytes: usize,
257    ) -> Result<Self, RingError> {
258        validate_params(capacity, slot_size, region_bytes)?;
259        let total = frame_ring_file_size(capacity, slot_size, region_bytes);
260        if shm.len() < total {
261            return Err(RingError::LayoutMismatch);
262        }
263        let raw_ptr = shm.as_mut_slice().as_mut_ptr();
264        unsafe { init_frame_layout_raw(raw_ptr, capacity, slot_size, region_bytes) };
265        Ok(Self::from_parts(
266            FrameBacking::Shm(shm), raw_ptr, capacity, slot_size, region_bytes,
267        ))
268    }
269
270    /// Open an existing named ShmFs-backed frame ring (no re-init).
271    pub fn open_from_shm(
272        mut shm: crate::shm_file::ShmFile,
273        capacity: usize, slot_size: usize, region_bytes: usize,
274    ) -> Result<Self, RingError> {
275        validate_params(capacity, slot_size, region_bytes)?;
276        let total = frame_ring_file_size(capacity, slot_size, region_bytes);
277        if shm.len() < total {
278            return Err(RingError::LayoutMismatch);
279        }
280        let raw_ptr = shm.as_mut_slice().as_mut_ptr();
281        Self::check_header(raw_ptr, capacity, slot_size, region_bytes)?;
282        Ok(Self::from_parts(
283            FrameBacking::Shm(shm), raw_ptr, capacity, slot_size, region_bytes,
284        ))
285    }
286
287    fn from_parts(
288        backing: FrameBacking, raw_ptr: *mut u8,
289        capacity: usize, slot_size: usize, region_bytes: usize,
290    ) -> Self {
291        let desc_base = std::mem::size_of::<FrameHeader>();
292        let region_base = desc_base + capacity * slot_size;
293        Self {
294            _backing: backing, raw_ptr, capacity, slot_size, region_bytes,
295            inline_budget: slot_size - DESC_HEADER_BYTES,
296            desc_base, region_base,
297        }
298    }
299
300    fn check_header(
301        ptr: *const u8, capacity: usize, slot_size: usize, region_bytes: usize,
302    ) -> Result<(), RingError> {
303        let header = unsafe { &*(ptr as *const FrameHeader) };
304        if header.magic != FRAME_MAGIC
305            || header.capacity != capacity as u64
306            || header.slot_size != slot_size as u64
307            || header.region_bytes != region_bytes as u64
308        {
309            return Err(RingError::LayoutMismatch);
310        }
311        Ok(())
312    }
313
314    /// Descriptor slot count (power of 2).
315    pub fn capacity(&self) -> usize { self.capacity }
316    /// Descriptor stride in bytes.
317    pub fn slot_size(&self) -> usize { self.slot_size }
318    /// Largest payload stored inline (`slot_size - 8`).
319    pub fn inline_budget(&self) -> usize { self.inline_budget }
320    /// Byte-region size. Region payloads cap at half this.
321    pub fn region_bytes(&self) -> usize { self.region_bytes }
322    /// Largest payload the region accepts (`region_bytes / 2`).
323    pub fn max_payload(&self) -> usize { self.region_bytes / 2 }
324
325    fn header(&self) -> &FrameHeader {
326        unsafe { &*(self.raw_ptr as *const FrameHeader) }
327    }
328
329    fn desc_slot_ptr(&self, idx: u64) -> *mut u8 {
330        let masked = (idx as usize) & (self.capacity - 1);
331        unsafe { self.raw_ptr.add(self.desc_base + masked * self.slot_size) }
332    }
333
334    fn region_ptr(&self) -> *mut u8 {
335        unsafe { self.raw_ptr.add(self.region_base) }
336    }
337
338    /// Items waiting in the descriptor ring (`desc_head - desc_tail`).
339    pub fn approx_len(&self) -> usize {
340        let h = self.header();
341        h.desc_head.load(Ordering::Acquire)
342            .saturating_sub(h.desc_tail.load(Ordering::Acquire)) as usize
343    }
344
345    /// Send a payload, letting the ring pick inline vs region.
346    pub fn send(&self, payload: &[u8]) -> Result<FrameClass, RingError> {
347        self.send_as(payload, LayoutHint::Auto)
348    }
349
350    /// Send a payload with an explicit layout override. **Caller is the
351    /// sole producer.**
352    pub fn send_as(&self, payload: &[u8], hint: LayoutHint)
353        -> Result<FrameClass, RingError>
354    {
355        let h = self.header();
356        let head = h.desc_head.load(Ordering::Relaxed);
357        let tail = h.desc_tail.load(Ordering::Acquire);
358        if head.wrapping_sub(tail) >= self.capacity as u64 {
359            return Err(RingError::Full);
360        }
361
362        let inline = match hint {
363            LayoutHint::ForceInline => {
364                if payload.len() > self.inline_budget {
365                    return Err(RingError::PayloadTooLarge);
366                }
367                true
368            }
369            LayoutHint::ForceOffset => false,
370            LayoutHint::Auto => payload.len() <= self.inline_budget,
371        };
372
373        let slot = self.desc_slot_ptr(head);
374        let len = payload.len() as u32;
375
376        let class = if inline {
377            unsafe {
378                slot.write(FrameClass::Inline as u8);
379                std::ptr::copy_nonoverlapping(
380                    len.to_le_bytes().as_ptr(), slot.add(4), 4,
381                );
382                std::ptr::copy_nonoverlapping(
383                    payload.as_ptr(), slot.add(DESC_HEADER_BYTES), payload.len(),
384                );
385            }
386            FrameClass::Inline
387        } else {
388            if payload.len() > self.max_payload() {
389                return Err(RingError::PayloadTooLarge);
390            }
391            let rh = h.region_head.load(Ordering::Relaxed);
392            let rt = h.region_tail.load(Ordering::Acquire);
393            let rb = self.region_bytes as u64;
394            let phys = rh % rb;
395            // Skip-pad to the next wrap boundary if the record would
396            // straddle the region end.
397            let start = if phys + payload.len() as u64 > rb {
398                rh + (rb - phys)
399            } else {
400                rh
401            };
402            if start.wrapping_add(payload.len() as u64).wrapping_sub(rt) > rb {
403                return Err(RingError::Full);
404            }
405            let pstart = (start % rb) as usize;
406            unsafe {
407                std::ptr::copy_nonoverlapping(
408                    payload.as_ptr(), self.region_ptr().add(pstart), payload.len(),
409                );
410            }
411            // Publish region bytes before the descriptor that points at
412            // them.
413            h.region_head.store(start + payload.len() as u64, Ordering::Release);
414            unsafe {
415                slot.write(FrameClass::Offset as u8);
416                std::ptr::copy_nonoverlapping(
417                    len.to_le_bytes().as_ptr(), slot.add(4), 4,
418                );
419                std::ptr::copy_nonoverlapping(
420                    start.to_le_bytes().as_ptr(), slot.add(DESC_HEADER_BYTES), 8,
421                );
422            }
423            FrameClass::Offset
424        };
425
426        h.desc_head.store(head + 1, Ordering::Release);
427        crate::cache_ops::cldemote(slot as *const u8);
428        Ok(class)
429    }
430
431    /// Receive the next payload into `out` (cleared then filled).
432    /// Returns the [`FrameClass`] the producer used. **Caller is the
433    /// sole consumer.**
434    pub fn recv_into(&self, out: &mut Vec<u8>) -> Result<FrameClass, RingError> {
435        let h = self.header();
436        let tail = h.desc_tail.load(Ordering::Relaxed);
437        let head = h.desc_head.load(Ordering::Acquire);
438        if tail == head {
439            return Err(RingError::Empty);
440        }
441        let slot = self.desc_slot_ptr(tail);
442        let class_byte = unsafe { slot.read() };
443        let len = unsafe {
444            let mut b = [0u8; 4];
445            std::ptr::copy_nonoverlapping(slot.add(4), b.as_mut_ptr(), 4);
446            u32::from_le_bytes(b) as usize
447        };
448
449        out.clear();
450        out.reserve(len);
451        let class = if class_byte == FrameClass::Inline as u8 {
452            unsafe {
453                std::ptr::copy_nonoverlapping(
454                    slot.add(DESC_HEADER_BYTES),
455                    out.spare_capacity_mut().as_mut_ptr() as *mut u8,
456                    len,
457                );
458                out.set_len(len);
459            }
460            FrameClass::Inline
461        } else {
462            let off = unsafe {
463                let mut b = [0u8; 8];
464                std::ptr::copy_nonoverlapping(slot.add(DESC_HEADER_BYTES), b.as_mut_ptr(), 8);
465                u64::from_le_bytes(b)
466            };
467            let pstart = (off % self.region_bytes as u64) as usize;
468            unsafe {
469                std::ptr::copy_nonoverlapping(
470                    self.region_ptr().add(pstart),
471                    out.spare_capacity_mut().as_mut_ptr() as *mut u8,
472                    len,
473                );
474                out.set_len(len);
475            }
476            // Reclaim region space up to the end of this record.
477            h.region_tail.store(off + len as u64, Ordering::Release);
478            FrameClass::Offset
479        };
480
481        h.desc_tail.store(tail + 1, Ordering::Release);
482        crate::cache_ops::cldemote(slot as *const u8);
483        Ok(class)
484    }
485
486    /// Receive the next payload as a fresh `Vec`.
487    pub fn recv(&self) -> Result<Vec<u8>, RingError> {
488        let mut out = Vec::new();
489        self.recv_into(&mut out)?;
490        Ok(out)
491    }
492
493    /// Force any dirty MMF pages to disk (file backing only).
494    pub fn flush(&self) -> Result<(), RingError> {
495        if let FrameBacking::File(_, mmap) = &self._backing {
496            mmap.flush()?;
497        }
498        Ok(())
499    }
500}
501
502#[cfg(test)]
503mod tests {
504    use super::*;
505    use std::sync::Arc;
506    use std::thread;
507
508    fn ring() -> FrameRing {
509        // 64-byte slots (56-byte inline budget), 64 KiB region.
510        FrameRing::create_anon(16, 64, 1 << 16).unwrap()
511    }
512
513    #[test]
514    fn inline_round_trip() {
515        let r = ring();
516        let payload = b"small payload under the inline budget";
517        assert_eq!(r.send(payload).unwrap(), FrameClass::Inline);
518        let got = r.recv().unwrap();
519        assert_eq!(got, payload);
520    }
521
522    #[test]
523    fn offset_round_trip_large() {
524        let r = ring();
525        let payload = vec![0xABu8; 4096]; // far over the 56-byte budget
526        assert_eq!(r.send(&payload).unwrap(), FrameClass::Offset);
527        let got = r.recv().unwrap();
528        assert_eq!(got, payload);
529    }
530
531    #[test]
532    fn boundary_inline_vs_offset() {
533        let r = ring();
534        // Exactly the inline budget stays inline.
535        let at = vec![1u8; r.inline_budget()];
536        assert_eq!(r.send(&at).unwrap(), FrameClass::Inline);
537        assert_eq!(r.recv().unwrap(), at);
538        // One byte over spills to the region.
539        let over = vec![2u8; r.inline_budget() + 1];
540        assert_eq!(r.send(&over).unwrap(), FrameClass::Offset);
541        assert_eq!(r.recv().unwrap(), over);
542    }
543
544    #[test]
545    fn empty_payload_round_trip() {
546        let r = ring();
547        assert_eq!(r.send(&[]).unwrap(), FrameClass::Inline);
548        assert_eq!(r.recv().unwrap(), Vec::<u8>::new());
549    }
550
551    #[test]
552    fn force_offset_overrides_small() {
553        let r = ring();
554        assert_eq!(r.send_as(b"tiny", LayoutHint::ForceOffset).unwrap(),
555                   FrameClass::Offset);
556        assert_eq!(r.recv().unwrap(), b"tiny");
557    }
558
559    #[test]
560    fn force_inline_rejects_oversize() {
561        let r = ring();
562        let big = vec![0u8; r.inline_budget() + 1];
563        assert_eq!(r.send_as(&big, LayoutHint::ForceInline).unwrap_err(),
564                   RingError::PayloadTooLarge);
565    }
566
567    #[test]
568    fn payload_over_region_cap_rejected() {
569        let r = ring();
570        let too_big = vec![0u8; r.max_payload() + 1];
571        assert_eq!(r.send(&too_big).unwrap_err(), RingError::PayloadTooLarge);
572    }
573
574    #[test]
575    fn descriptor_full_then_drains() {
576        let r = FrameRing::create_anon(4, 64, 1 << 16).unwrap();
577        for i in 0..4u8 {
578            r.send(&[i; 8]).unwrap();
579        }
580        assert_eq!(r.send(&[9u8; 8]).unwrap_err(), RingError::Full);
581        assert_eq!(r.recv().unwrap(), &[0u8; 8]);
582        r.send(&[9u8; 8]).unwrap();
583    }
584
585    #[test]
586    fn region_wraps_with_skip_pad() {
587        // Small region forces many wraps; alternate large records so the
588        // region head laps the buffer end repeatedly. Each record is
589        // pushed then immediately drained so the region tail follows.
590        let region = 1usize << 12; // 4 KiB region, max payload 2 KiB
591        let r = FrameRing::create_anon(8, 64, region).unwrap();
592        for i in 0..200u32 {
593            let len = 600 + (i as usize % 700); // 600..1299 bytes, all > budget
594            let payload: Vec<u8> = (0..len).map(|k| (k as u32 ^ i) as u8).collect();
595            assert_eq!(r.send(&payload).unwrap(), FrameClass::Offset);
596            let got = r.recv().unwrap();
597            assert_eq!(got, payload, "record {i} survived the region wrap");
598        }
599    }
600
601    #[test]
602    fn mixed_inline_and_offset_fifo_order() {
603        let r = FrameRing::create_anon(64, 64, 1 << 16).unwrap();
604        let mut expected = Vec::new();
605        for i in 0..40u32 {
606            // Alternate small (inline) and large (offset) records.
607            let len = if i % 2 == 0 { 16 } else { 500 };
608            let p: Vec<u8> = (0..len).map(|k| (k as u32 + i) as u8).collect();
609            r.send(&p).unwrap();
610            expected.push(p);
611        }
612        for want in expected {
613            assert_eq!(r.recv().unwrap(), want);
614        }
615        assert_eq!(r.recv().unwrap_err(), RingError::Empty);
616    }
617
618    #[test]
619    fn two_thread_mixed_size_stream() {
620        let r = Arc::new(FrameRing::create_anon(256, 64, 1 << 20).unwrap());
621        let rp = r.clone();
622        let rc = r.clone();
623        const N: u32 = 50_000;
624
625        let producer = thread::spawn(move || {
626            for i in 0..N {
627                // Sizes sweep the inline/offset boundary. Content is a
628                // per-byte ramp keyed on the item id so a torn or
629                // mis-ordered record is caught at any length (the id
630                // alone would not distinguish two items that alias the
631                // same slot modulo capacity).
632                let len = (i as usize % 300) + 1;
633                let p: Vec<u8> =
634                    (0..len).map(|k| i.wrapping_add(k as u32) as u8).collect();
635                while rp.send(&p).is_err() {
636                    std::hint::spin_loop();
637                }
638            }
639        });
640
641        let consumer = thread::spawn(move || {
642            let mut buf = Vec::new();
643            let mut got = 0u32;
644            while got < N {
645                if rc.recv_into(&mut buf).is_ok() {
646                    let len = (got as usize % 300) + 1;
647                    assert_eq!(buf.len(), len, "item {got} length");
648                    for (k, &b) in buf.iter().enumerate() {
649                        assert_eq!(b, got.wrapping_add(k as u32) as u8,
650                                   "item {got} byte {k}");
651                    }
652                    got += 1;
653                } else {
654                    std::hint::spin_loop();
655                }
656            }
657        });
658
659        producer.join().unwrap();
660        consumer.join().unwrap();
661    }
662
663    #[test]
664    fn shm_cross_handle_visibility() {
665        use crate::shm_file::ShmFile;
666        let nonce = std::time::SystemTime::now()
667            .duration_since(std::time::UNIX_EPOCH)
668            .map(|d| d.as_nanos())
669            .unwrap_or(0);
670        let name = format!("frame_shm_{}_{}", std::process::id(), nonce);
671        let (cap, slot, region) = (16usize, 64usize, 1usize << 16);
672        let size = frame_ring_file_size(cap, slot, region);
673
674        let shm_a = ShmFile::create_or_open_named(&name, size).unwrap();
675        let producer = FrameRing::create_from_shm(shm_a, cap, slot, region).unwrap();
676        let shm_b = ShmFile::create_or_open_named(&name, size).unwrap();
677        let consumer = FrameRing::open_from_shm(shm_b, cap, slot, region).unwrap();
678
679        let small = b"inline across handles";
680        let large = vec![0x5Au8; 2000];
681        producer.send(small).unwrap();
682        producer.send(&large).unwrap();
683        assert_eq!(consumer.recv().unwrap(), small);
684        assert_eq!(consumer.recv().unwrap(), large);
685    }
686
687    #[test]
688    fn file_round_trips() {
689        let p = std::env::temp_dir().join(format!(
690            "subetha-frame-{}.bin", std::process::id(),
691        ));
692        std::fs::remove_file(&p).ok();
693        let (cap, slot, region) = (16usize, 128usize, 1usize << 16);
694        {
695            let r = FrameRing::create(&p, cap, slot, region).unwrap();
696            r.send(b"persisted inline").unwrap();
697            r.send(&vec![7u8; 3000]).unwrap();
698            r.flush().unwrap();
699        }
700        let r2 = FrameRing::open(&p, cap, slot, region).unwrap();
701        assert_eq!(r2.recv().unwrap(), b"persisted inline");
702        assert_eq!(r2.recv().unwrap(), vec![7u8; 3000]);
703        std::fs::remove_file(&p).ok();
704    }
705
706    #[test]
707    fn rejects_bad_params() {
708        assert!(matches!(FrameRing::create_anon(3, 64, 1 << 16),
709                         Err(RingError::LayoutMismatch))); // capacity not pow2
710        assert!(matches!(FrameRing::create_anon(16, 8, 1 << 16),
711                         Err(RingError::LayoutMismatch))); // slot < MIN_SLOT_SIZE
712        assert!(matches!(FrameRing::create_anon(16, 64, 1000),
713                         Err(RingError::LayoutMismatch))); // region not pow2
714    }
715}