Skip to main content

subetha_cxc/
shared_ring.rs

1//! `SharedRing<P>` - cross-thread / cross-process lock-free MPMC ring
2//! backed by a memory-mapped file.
3//!
4//! One mechanism gives you THREE deployment modes:
5//!
6//! 1. **Cross-thread**: multiple threads in one process map the same
7//!    file; lock-free CAS handles concurrency.
8//! 2. **Cross-process**: multiple processes open the same file via
9//!    [`SharedRing::open`]; the OS page-cache aliases them onto the
10//!    same physical pages.
11//! 3. **Disk-persistent**: the MMF is backed by a real file; the
12//!    kernel writes dirty pages to disk on its own schedule, plus
13//!    [`SharedRing::flush`] forces a sync when the caller wants
14//!    durability.
15//!
16//! The same byte layout serves all three.
17//!
18//! # Layout
19//!
20//! ```text
21//! +-----------------------------+
22//! | RingHeader  (64B aligned)   |  producer_seq, consumer_seq,
23//! |                             |  capacity, slot_size, magic
24//! +-----------------------------+
25//! | Slot[0] (64B cache line)    |  state + sequence + payload
26//! | Slot[1]                     |
27//! | ...                         |
28//! | Slot[capacity - 1]          |
29//! +-----------------------------+
30//! ```
31//!
32//! Each slot is exactly one cache line (64 bytes). The state field
33//! advances through EMPTY -> CLAIMED_BY_PRODUCER -> PUBLISHED ->
34//! CLAIMED_BY_CONSUMER -> EMPTY in a closed loop.
35//!
36//! # Concurrency protocol
37//!
38//! Producers:
39//! 1. Read `producer_seq` (atomic).
40//! 2. Compute `slot_idx = producer_seq % capacity`.
41//! 3. Read slot's sequence number; if it doesn't equal `producer_seq`,
42//!    the ring is full (slot still holds an unconsumed value). Retry
43//!    or fail.
44//! 4. CAS `producer_seq` from S to S+1. On success, the slot is ours
45//!    to write; copy payload, then store slot.sequence = S+1 (release).
46//!
47//! Consumers:
48//! 1. Read `consumer_seq`.
49//! 2. `slot_idx = consumer_seq % capacity`.
50//! 3. Acquire-load slot.sequence; must equal `consumer_seq + 1`
51//!    (means producer published). Otherwise empty.
52//! 4. CAS `consumer_seq` from S to S+1. On success, read payload,
53//!    then store slot.sequence = S + capacity (releases the slot
54//!    for the next producer that will use it at producer_seq =
55//!    S + capacity).
56//!
57//! This is the classic Vyukov MPMC bounded-queue protocol.
58
59use std::cell::UnsafeCell;
60use std::fs::{File, OpenOptions};
61use std::path::Path;
62use std::sync::atomic::{AtomicU64, Ordering};
63
64use memmap2::{MmapMut, MmapOptions};
65
66/// Magic number to detect a valid ring header. ASCII 'APMF' + version.
67pub const RING_MAGIC: u64 = 0x4150_4D46_0000_0001;
68
69/// Each slot is exactly one cache line.
70pub const SLOT_SIZE: usize = 64;
71
72/// Payload bytes per slot = SLOT_SIZE - sizeof(sequence: u64).
73pub const PAYLOAD_BYTES: usize = SLOT_SIZE - std::mem::size_of::<u64>();
74
75/// Header layout: three cache lines so the two hot counters never
76/// false-share. Line 0 is read-mostly metadata (plus the
77/// rarely-written `epoch`); `producer_seq` and `consumer_seq` each get
78/// their own line. Every producer CASes `producer_seq` and every
79/// consumer CASes `consumer_seq`; co-locating them on one line made
80/// each side's CAS invalidate the other side's copy, serializing the
81/// producer and consumer coherence traffic under contention. The SPSC
82/// ring separates `head`/`tail` for exactly this reason.
83#[repr(C, align(64))]
84pub struct RingHeader {
85    pub magic: u64,
86    pub capacity: u64,
87    pub slot_size: u64,
88    /// Epoch counter; advanced by the watchdog every scan tick.
89    /// Heartbeats compare against this to detect liveness. Read-mostly
90    /// from the ring's perspective, so it shares the metadata line.
91    pub epoch: AtomicU64,
92    /// Pad the metadata line out to 64 bytes so `producer_seq` starts
93    /// its own cache line.
94    _pad_meta: [u8; 64 - 32],
95    /// Producer-owned enqueue counter; sole occupant of its line.
96    pub producer_seq: AtomicU64,
97    _pad_prod: [u8; 64 - 8],
98    /// Consumer-owned dequeue counter; sole occupant of its line.
99    pub consumer_seq: AtomicU64,
100    _pad_cons: [u8; 64 - 8],
101}
102
103#[repr(C, align(64))]
104pub struct Slot {
105    pub sequence: AtomicU64,
106    pub payload: UnsafeCell<[u8; PAYLOAD_BYTES]>,
107}
108
109unsafe impl Sync for Slot {}
110
111/// Compute the total MMF size for a ring of `capacity` slots.
112pub const fn ring_file_size(capacity: usize) -> usize {
113    std::mem::size_of::<RingHeader>() + capacity * SLOT_SIZE
114}
115
116/// Compile-time-enforced single-producer / single-consumer ring,
117/// backed by the Lamport 1983 SPSC core in
118/// [`crate::spsc_ring::SpscRingCore`].
119///
120/// The [`SharedRing`] type exposes MPMC ops (`try_push` /
121/// `try_pop`) plus SPSC fast-path ops on the same Vyukov-protocol
122/// storage. The fast paths still pay for the per-slot sequence
123/// number that MPMC needs - four cross-thread atomics per push.
124///
125/// `SharedRingSpsc` is the dedicated SPSC primitive. It uses a
126/// different on-disk layout (Lamport: head + tail counters on
127/// separate cache lines, payload-only slots, no per-slot sequence
128/// number) and pays only **one Acquire load + one Release store**
129/// of cross-thread atomics per op. On Zen+ R7 2700 with 100k items
130/// the Lamport core lands roughly 2x the throughput of the Vyukov
131/// SPSC fast path, and ~7x crossbeam_channel.
132///
133/// The constructor returns an owned ([`Producer`], [`Consumer`])
134/// pair; neither half implements `Clone`, both are `Send` and
135/// `!Sync`. The compiler guarantees at most one thread holds the
136/// `Producer` (single producer), at most one thread holds the
137/// `Consumer` (single consumer). The SPSC contract that backs the
138/// no-CAS Lamport pattern is enforced statically.
139///
140/// Internally the pair shares one [`SpscRingCore`](crate::spsc_ring::SpscRingCore)
141/// via [`Arc`](std::sync::Arc). The two halves call the core's `try_push` /
142/// `try_pop` directly; no per-op cost vs the raw core. The only
143/// overhead is the `Arc` clone at construction.
144///
145/// **No stuck-slot recovery needed.** The Lamport protocol does not
146/// have the claimed-but-never-published pathology Vyukov has. The
147/// producer writes payload then Release-stores `head` to publish in
148/// a single observable transition; a producer crash between payload
149/// write and Release-store leaves `head` unchanged and the slot
150/// uncommitted - the consumer never reads it because head was not
151/// advanced.
152pub struct SharedRingSpsc;
153
154/// Sole-producer handle on a [`SharedRingSpsc`] pair. `Send` so it
155/// can be moved to a producer thread; `!Sync` so it cannot be
156/// shared across threads (which would violate the SPSC contract).
157/// Not `Clone`: a second producer is statically impossible.
158pub struct Producer {
159    inner: std::sync::Arc<crate::spsc_ring::SpscRingCore>,
160    _not_sync: std::marker::PhantomData<std::cell::Cell<()>>,
161}
162
163/// Sole-consumer handle on a [`SharedRingSpsc`] pair. Same
164/// `Send + !Sync + !Clone` shape as [`Producer`], mirroring the
165/// SPSC contract on the read side.
166pub struct Consumer {
167    inner: std::sync::Arc<crate::spsc_ring::SpscRingCore>,
168    _not_sync: std::marker::PhantomData<std::cell::Cell<()>>,
169}
170
171impl SharedRingSpsc {
172    /// Anonymous (in-process, no file) SPSC pair. Skips file
173    /// create + ftruncate + first-page-fault cost.
174    pub fn create_anon_pair(capacity: usize) -> Result<(Producer, Consumer), RingError> {
175        let ring = std::sync::Arc::new(
176            crate::spsc_ring::SpscRingCore::create_anon(capacity)?,
177        );
178        Ok((
179            Producer { inner: ring.clone(), _not_sync: std::marker::PhantomData },
180            Consumer { inner: ring,         _not_sync: std::marker::PhantomData },
181        ))
182    }
183
184    /// File-backed SPSC pair. Cross-process visibility available
185    /// via [`SharedRingSpsc::open_pair`] on the same path.
186    pub fn create_pair(
187        path: impl AsRef<Path>,
188        capacity: usize,
189    ) -> Result<(Producer, Consumer), RingError> {
190        let ring = std::sync::Arc::new(
191            crate::spsc_ring::SpscRingCore::create(path, capacity)?,
192        );
193        Ok((
194            Producer { inner: ring.clone(), _not_sync: std::marker::PhantomData },
195            Consumer { inner: ring,         _not_sync: std::marker::PhantomData },
196        ))
197    }
198
199    /// Open an existing file-backed ring and return an SPSC pair.
200    /// Caller's responsibility to ensure only one producer + one
201    /// consumer attach to the underlying file across all processes;
202    /// the type system enforces this within one process, not across.
203    pub fn open_pair(
204        path: impl AsRef<Path>,
205        expected_capacity: usize,
206    ) -> Result<(Producer, Consumer), RingError> {
207        let ring = std::sync::Arc::new(
208            crate::spsc_ring::SpscRingCore::open(path, expected_capacity)?,
209        );
210        Ok((
211            Producer { inner: ring.clone(), _not_sync: std::marker::PhantomData },
212            Consumer { inner: ring,         _not_sync: std::marker::PhantomData },
213        ))
214    }
215}
216
217impl Producer {
218    /// Push one payload. Forwards to
219    /// [`SpscRingCore::try_push`](crate::spsc_ring::SpscRingCore::try_push).
220    /// The SPSC contract is type-system-enforced because there is
221    /// exactly one `Producer` in existence per pair (`!Sync + !Clone`).
222    pub fn try_push(&self, payload: &[u8]) -> Result<(), RingError> {
223        self.inner.try_push(payload)
224    }
225
226    /// Capacity of the underlying ring (always a power of 2).
227    pub fn capacity(&self) -> usize { self.inner.capacity() }
228
229    /// Current head (producer's published position).
230    pub fn head(&self) -> u64 { self.inner.head() }
231}
232
233impl Consumer {
234    /// Pop one payload into `out`. Forwards to
235    /// [`SpscRingCore::try_pop`](crate::spsc_ring::SpscRingCore::try_pop).
236    /// The SPSC contract is type-system-enforced because there is
237    /// exactly one `Consumer` in existence per pair (`!Sync + !Clone`).
238    pub fn try_pop(&self, out: &mut [u8]) -> Result<usize, RingError> {
239        self.inner.try_pop(out)
240    }
241
242    /// Capacity of the underlying ring (always a power of 2).
243    pub fn capacity(&self) -> usize { self.inner.capacity() }
244
245    /// Current tail (consumer's published position).
246    pub fn tail(&self) -> u64 { self.inner.tail() }
247}
248
249/// Defer the file-backed MMF setup until first use.
250///
251/// **When to reach for this:** speculative channel construction
252/// where the consumer may or may not ever send/recv (per-connection
253/// channels that some connections never use, conditional code paths,
254/// option-types that hold a ring "just in case"). Construction is
255/// free; the file create + ftruncate + mmap + init cost is paid
256/// once on the first [`try_push`](LazySharedRing::try_push) or
257/// [`try_pop`](LazySharedRing::try_pop) call.
258///
259/// **When NOT to reach for this:** in-process-only one-shots
260/// (use [`SharedRing::create_anon`] instead, which skips the file
261/// entirely), or hot paths that always send (the lazy branch costs
262/// one extra atomic load per op vs holding `&SharedRing` directly).
263///
264/// **Hot-path tip:** materialise once outside your loop and reuse
265/// the returned `&SharedRing` reference so the lazy branch lives
266/// outside the inner loop.
267pub struct LazySharedRing {
268    path: std::path::PathBuf,
269    capacity: usize,
270    inner: std::sync::OnceLock<SharedRing>,
271}
272
273impl LazySharedRing {
274    /// Construct a lazy ring. No syscalls; just stores the path and
275    /// capacity for the deferred create.
276    pub fn new(path: impl Into<std::path::PathBuf>, capacity: usize) -> Self {
277        assert!(capacity.is_power_of_two() && capacity >= 2,
278                "capacity must be pow2 >= 2");
279        Self {
280            path: path.into(),
281            capacity,
282            inner: std::sync::OnceLock::new(),
283        }
284    }
285
286    /// Materialise the inner ring, paying the setup cost on the
287    /// first call and returning the cached reference thereafter.
288    pub fn get(&self) -> Result<&SharedRing, RingError> {
289        if let Some(ring) = self.inner.get() {
290            return Ok(ring);
291        }
292        let ring = SharedRing::create(&self.path, self.capacity)?;
293        // OnceLock::set returns Err if a concurrent caller already
294        // populated it; either way the subsequent get() returns
295        // whichever instance won the race.
296        match self.inner.set(ring) {
297            Ok(()) => Ok(self.inner.get().expect("OnceLock just populated")),
298            Err(_lost) => Ok(self.inner.get().expect("another thread populated")),
299        }
300    }
301
302    /// Whether the underlying ring has been materialised yet.
303    pub fn is_initialised(&self) -> bool {
304        self.inner.get().is_some()
305    }
306
307    /// Forwarded [`SharedRing::try_push`]; materialises on first call.
308    pub fn try_push(&self, payload: &[u8]) -> Result<(), RingError> {
309        self.get()?.try_push(payload)
310    }
311
312    /// Forwarded [`SharedRing::try_pop`]; materialises on first call.
313    pub fn try_pop(&self, out: &mut [u8]) -> Result<usize, RingError> {
314        self.get()?.try_pop(out)
315    }
316}
317
318/// Initialise the Vyukov ring layout in a freshly-mapped buffer.
319/// Sets the header magic + capacity + counters, then writes each
320/// slot's sequence number to its index (Vyukov: slot[i] is ready
321/// for producer i).
322///
323/// Shared by [`SharedRing::create`] (file-backed),
324/// [`SharedRing::create_anon`] (anonymous), and the lazy
325/// initialiser triggered on first use.
326fn init_ring_layout(mmap: &mut MmapMut, capacity: usize) {
327    unsafe { init_ring_layout_raw(mmap.as_mut_ptr(), capacity) };
328}
329
330/// Backing-agnostic layout init. Writes the Vyukov header and slot
331/// sequence array at the given raw pointer. Caller guarantees that
332/// `ptr` points to at least `ring_file_size(capacity)` bytes of
333/// mutable, suitably-aligned memory.
334unsafe fn init_ring_layout_raw(ptr: *mut u8, capacity: usize) {
335    let header_ptr = ptr as *mut RingHeader;
336    unsafe {
337        std::ptr::write(header_ptr, RingHeader {
338            magic: RING_MAGIC,
339            capacity: capacity as u64,
340            slot_size: SLOT_SIZE as u64,
341            epoch: AtomicU64::new(0),
342            _pad_meta: [0; 64 - 32],
343            producer_seq: AtomicU64::new(0),
344            _pad_prod: [0; 64 - 8],
345            consumer_seq: AtomicU64::new(0),
346            _pad_cons: [0; 64 - 8],
347        });
348    }
349    let slots_base = unsafe { ptr.add(std::mem::size_of::<RingHeader>()) };
350    for i in 0..capacity {
351        let slot_ptr = unsafe { slots_base.add(i * SLOT_SIZE) as *mut Slot };
352        unsafe {
353            std::ptr::write(slot_ptr, Slot {
354                sequence: AtomicU64::new(i as u64),
355                payload: UnsafeCell::new([0; PAYLOAD_BYTES]),
356            });
357        }
358    }
359}
360
361/// Cross-thread / cross-process / disk-persistent MPMC ring.
362///
363/// Payloads must fit in [`PAYLOAD_BYTES`]; larger items must be
364/// chunked by the caller.
365///
366/// `_file` is `None` for rings created via [`SharedRing::create_anon`]
367/// (anonymous in-memory mapping, in-process only) and `Some` for
368/// file-backed rings.
369/// Backing-store discriminator for `SharedRing`. Holds the
370/// underlying memory owner so it stays alive for the lifetime of
371/// the ring; raw byte access goes through `SharedRing::raw_ptr`.
372/// The held values are intentionally never read directly (lifetime
373/// extension only).
374#[allow(dead_code)]
375enum SharedRingBacking {
376    /// Anonymous in-process memory.
377    Anon(MmapMut),
378    /// File-backed (cross-process via page cache).
379    File(File, MmapMut),
380    /// Named RAM-resident shared memory (cross-process, no page cache).
381    Shm(crate::shm_file::ShmFile),
382    /// Caller-owned region (huge / large pages, or any `RegionOwner`).
383    Region(Box<dyn crate::spsc_ring::RegionOwner>),
384}
385
386pub struct SharedRing {
387    _backing: SharedRingBacking,
388    raw_ptr: *mut u8,
389    capacity: usize,
390    header_sidecar: subetha_core::HandshakeHeader,
391    ring_sidecar: Box<subetha_core::ObservationRing>,
392}
393
394unsafe impl Send for SharedRing {}
395unsafe impl Sync for SharedRing {}
396
397impl subetha_sidecar::AdaptiveInstance for SharedRing {
398    fn header(&self) -> &subetha_core::HandshakeHeader { &self.header_sidecar }
399    fn ring(&self) -> &subetha_core::ObservationRing { &self.ring_sidecar }
400    fn make_policy(&self) -> Box<dyn subetha_sidecar::Policy> {
401        Box::new(subetha_sidecar::NoMigrationPolicy)
402    }
403}
404
405#[derive(Debug, Clone, Copy, PartialEq, Eq)]
406pub enum RingError {
407    /// Ring is full; producer cannot insert.
408    Full,
409    /// Ring is empty; consumer cannot drain.
410    Empty,
411    /// File-backed mapping exists but the magic / capacity does not
412    /// match the requested layout.
413    LayoutMismatch,
414    /// Payload exceeds [`PAYLOAD_BYTES`].
415    PayloadTooLarge,
416    /// The operation requires ordering stamps but the ring was not
417    /// constructed with `with_ordering_stamps()`.
418    NotStamped,
419    /// Merge-mode pop on a multi-consumer ring requires the drainer
420    /// lease and another consumer currently holds it. The caller
421    /// backs off and retries; when the holder releases (or its
422    /// heartbeat goes stale past the grace window) a later pop
423    /// acquires the lease automatically.
424    NotDrainer,
425    /// A shape morph was requested while the previous shape's
426    /// backing still holds an undrained backlog. The consumer
427    /// drains it through the normal pop path (the stale walk);
428    /// retry the morph once it has caught up - the sidecar's scan
429    /// loop does exactly that.
430    StaleBacklog,
431    /// I/O error opening or mapping the file.
432    IoError(std::io::ErrorKind),
433}
434
435impl From<std::io::Error> for RingError {
436    fn from(e: std::io::Error) -> Self { Self::IoError(e.kind()) }
437}
438
439impl SharedRing {
440    /// Create or initialise a new ring backed by `path`. `capacity`
441    /// must be a power of two. The file is truncated to the exact
442    /// size needed. Use [`SharedRing::open`] to attach to an
443    /// existing ring without re-initialising.
444    pub fn create(path: impl AsRef<Path>, capacity: usize) -> Result<Self, RingError> {
445        assert!(capacity.is_power_of_two() && capacity >= 2,
446                "capacity must be pow2 >= 2");
447        let total = ring_file_size(capacity);
448        let file = OpenOptions::new()
449            .read(true).write(true).create(true).truncate(true)
450            .open(path.as_ref())?;
451        file.set_len(total as u64)?;
452        let mut mmap = unsafe { MmapOptions::new().len(total).map_mut(&file)? };
453        // No warm-up here: init below writes every slot line, so the
454        // pages get touched either way and the populate syscall is
455        // pure overhead (measured +2.2 ms on a 32 MiB ring).
456        init_ring_layout(&mut mmap, capacity);
457        let raw_ptr = mmap.as_mut_ptr();
458        Ok(Self {
459            _backing: SharedRingBacking::File(file, mmap),
460            raw_ptr, capacity,
461            header_sidecar: subetha_core::HandshakeHeader::new(),
462            ring_sidecar: Box::new(subetha_core::ObservationRing::new()),
463        })
464    }
465
466    /// Create an anonymous in-memory ring with no backing file. Same
467    /// byte layout + concurrency protocol as [`SharedRing::create`],
468    /// but the mapping is private to this process so cross-process
469    /// visibility is not available.
470    ///
471    /// **Use when:** one-shot scripts, in-process pipelines, tests
472    /// that do not need cross-process or disk-persistent semantics.
473    /// Skips the file create + ftruncate + first-page-fault cost
474    /// `create` pays (~600 us on Zen+ R7 2700 / Windows 11), so
475    /// short-lived sessions amortise much faster.
476    ///
477    /// **Do NOT use when:** another process needs to attach to the
478    /// same ring (use [`SharedRing::create`] + [`SharedRing::open`]
479    /// for that path), or when durability across restart matters.
480    pub fn create_anon(capacity: usize) -> Result<Self, RingError> {
481        assert!(capacity.is_power_of_two() && capacity >= 2,
482                "capacity must be pow2 >= 2");
483        let total = ring_file_size(capacity);
484        let mut mmap = MmapOptions::new().len(total).map_anon()?;
485        init_ring_layout(&mut mmap, capacity);
486        let raw_ptr = mmap.as_mut_ptr();
487        Ok(Self {
488            _backing: SharedRingBacking::Anon(mmap),
489            raw_ptr, capacity,
490            header_sidecar: subetha_core::HandshakeHeader::new(),
491            ring_sidecar: Box::new(subetha_core::ObservationRing::new()),
492        })
493    }
494
495    /// Build a fresh ring on top of a named RAM-resident
496    /// shared-memory backing. Cross-process visible via the
497    /// `logical_name` of the underlying `ShmFile`; never touches the
498    /// page cache. The `ShmFile` must be sized to at least
499    /// `ring_file_size(capacity)` bytes.
500    pub fn create_from_shm(
501        mut shm: crate::shm_file::ShmFile,
502        capacity: usize,
503    ) -> Result<Self, RingError> {
504        assert!(capacity.is_power_of_two() && capacity >= 2,
505                "capacity must be pow2 >= 2");
506        let total = ring_file_size(capacity);
507        if shm.len() < total {
508            return Err(RingError::LayoutMismatch);
509        }
510        let slice = shm.as_mut_slice();
511        let raw_ptr = slice.as_mut_ptr();
512        unsafe { init_ring_layout_raw(raw_ptr, capacity) };
513        Ok(Self {
514            _backing: SharedRingBacking::Shm(shm),
515            raw_ptr, capacity,
516            header_sidecar: subetha_core::HandshakeHeader::new(),
517            ring_sidecar: Box::new(subetha_core::ObservationRing::new()),
518        })
519    }
520
521    /// Open an existing named ShmFs-backed ring. Validates magic +
522    /// capacity. Does NOT re-initialize.
523    pub fn open_from_shm(
524        mut shm: crate::shm_file::ShmFile,
525        expected_capacity: usize,
526    ) -> Result<Self, RingError> {
527        let total = ring_file_size(expected_capacity);
528        if shm.len() < total {
529            return Err(RingError::LayoutMismatch);
530        }
531        let slice = shm.as_mut_slice();
532        let raw_ptr = slice.as_mut_ptr();
533        let header = unsafe { &*(raw_ptr as *const RingHeader) };
534        if header.magic != RING_MAGIC
535            || header.capacity != expected_capacity as u64
536            || header.slot_size != SLOT_SIZE as u64
537        {
538            return Err(RingError::LayoutMismatch);
539        }
540        Ok(Self {
541            _backing: SharedRingBacking::Shm(shm),
542            raw_ptr, capacity: expected_capacity,
543            header_sidecar: subetha_core::HandshakeHeader::new(),
544            ring_sidecar: Box::new(subetha_core::ObservationRing::new()),
545        })
546    }
547
548    /// Build a fresh Vyukov MPMC ring laid out in caller-owned memory
549    /// (huge / large pages, or any
550    /// [`RegionOwner`](crate::spsc_ring::RegionOwner)). The region must
551    /// hold at least `ring_file_size(capacity)` bytes; the ring owns it
552    /// for its lifetime so the pages stay mapped. This is the global-
553    /// FIFO MPMC primitive on large pages; the sharded grid
554    /// (`SharedRingMpmc::create_grid_in_region`) is the per-producer-FIFO
555    /// counterpart.
556    pub fn create_in_region<R: crate::spsc_ring::RegionOwner>(
557        mut region: R, capacity: usize,
558    ) -> Result<Self, RingError> {
559        assert!(capacity.is_power_of_two() && capacity >= 2,
560                "capacity must be pow2 >= 2");
561        if region.region_len() < ring_file_size(capacity) {
562            return Err(RingError::LayoutMismatch);
563        }
564        let raw_ptr = region.region_ptr();
565        if !(raw_ptr as usize).is_multiple_of(std::mem::align_of::<RingHeader>()) {
566            return Err(RingError::LayoutMismatch);
567        }
568        unsafe { init_ring_layout_raw(raw_ptr, capacity) };
569        Ok(Self {
570            _backing: SharedRingBacking::Region(Box::new(region)),
571            raw_ptr, capacity,
572            header_sidecar: subetha_core::HandshakeHeader::new(),
573            ring_sidecar: Box::new(subetha_core::ObservationRing::new()),
574        })
575    }
576
577    /// Attach to an existing Vyukov ring already laid out in `region`
578    /// (e.g. a named `LargePageSection` another process created).
579    /// Validates the header; does NOT re-initialise.
580    pub fn open_in_region<R: crate::spsc_ring::RegionOwner>(
581        mut region: R, expected_capacity: usize,
582    ) -> Result<Self, RingError> {
583        if region.region_len() < ring_file_size(expected_capacity) {
584            return Err(RingError::LayoutMismatch);
585        }
586        let raw_ptr = region.region_ptr();
587        if !(raw_ptr as usize).is_multiple_of(std::mem::align_of::<RingHeader>()) {
588            return Err(RingError::LayoutMismatch);
589        }
590        let header = unsafe { &*(raw_ptr as *const RingHeader) };
591        if header.magic != RING_MAGIC
592            || header.capacity != expected_capacity as u64
593            || header.slot_size != SLOT_SIZE as u64
594        {
595            return Err(RingError::LayoutMismatch);
596        }
597        Ok(Self {
598            _backing: SharedRingBacking::Region(Box::new(region)),
599            raw_ptr, capacity: expected_capacity,
600            header_sidecar: subetha_core::HandshakeHeader::new(),
601            ring_sidecar: Box::new(subetha_core::ObservationRing::new()),
602        })
603    }
604
605    /// Wrap this ring in a [`LazySharedRing`] so subsequent attaches
606    /// at the same path can be deferred until first use. The eagerly-
607    /// constructed ring stays valid; this helper just hands you the
608    /// type's lazy constructor for symmetry.
609    pub fn into_lazy(path: impl Into<std::path::PathBuf>, capacity: usize) -> LazySharedRing {
610        LazySharedRing::new(path, capacity)
611    }
612
613    /// Open an existing ring at `path`. Validates magic + capacity.
614    /// Returns [`RingError::LayoutMismatch`] when the file's size
615    /// does not match a ring of `expected_capacity` slots, OR when
616    /// the on-disk header reports different magic / capacity.
617    pub fn open(path: impl AsRef<Path>, expected_capacity: usize) -> Result<Self, RingError> {
618        let file = OpenOptions::new().read(true).write(true).open(path.as_ref())?;
619        let total = ring_file_size(expected_capacity);
620        // File-size pre-check: refuse to map past EOF so callers
621        // get a clean LayoutMismatch instead of the OS's
622        // PermissionDenied / EINVAL.
623        let actual_len = file.metadata()?.len();
624        if (actual_len as usize) < total {
625            return Err(RingError::LayoutMismatch);
626        }
627        let mut mmap = unsafe { MmapOptions::new().len(total).map_mut(&file)? };
628        // The opener's first traffic pass otherwise faults per 4 KiB
629        // across the whole ring; populate in one call instead.
630        crate::mmf_warm::warm_mmap(&mut mmap);
631        let header = unsafe { &*(mmap.as_ptr() as *const RingHeader) };
632        if header.magic != RING_MAGIC
633            || header.capacity != expected_capacity as u64
634            || header.slot_size != SLOT_SIZE as u64
635        {
636            return Err(RingError::LayoutMismatch);
637        }
638        let raw_ptr = mmap.as_mut_ptr();
639        Ok(Self {
640            _backing: SharedRingBacking::File(file, mmap),
641            raw_ptr, capacity: expected_capacity,
642            header_sidecar: subetha_core::HandshakeHeader::new(),
643            ring_sidecar: Box::new(subetha_core::ObservationRing::new()),
644        })
645    }
646
647    #[inline]
648    pub fn capacity(&self) -> usize { self.capacity }
649
650    #[inline]
651    pub fn header(&self) -> &RingHeader {
652        unsafe { &*(self.raw_ptr as *const RingHeader) }
653    }
654
655    #[inline]
656    fn slot(&self, idx: usize) -> &Slot {
657        let slots_base = unsafe {
658            self.raw_ptr.add(std::mem::size_of::<RingHeader>())
659        };
660        unsafe { &*(slots_base.add((idx & (self.capacity - 1)) * SLOT_SIZE) as *const Slot) }
661    }
662
663    /// Try to push `payload` into the ring. Returns `Err(Full)` when
664    /// the ring is full.
665    pub fn try_push(&self, payload: &[u8]) -> Result<(), RingError> {
666        if payload.len() > PAYLOAD_BYTES {
667            return Err(RingError::PayloadTooLarge);
668        }
669        let header = self.header();
670        loop {
671            let pos = header.producer_seq.load(Ordering::Relaxed);
672            let slot = self.slot(pos as usize);
673            let seq = slot.sequence.load(Ordering::Acquire);
674            let diff = seq as i64 - pos as i64;
675            if diff == 0 {
676                // Slot is ours to claim; CAS producer_seq forward.
677                // Write-intent prefetch: the CAS needs the line in
678                // Modified state; requesting it now collapses the
679                // upgrade the RMW pays after the Relaxed load above
680                // brought it in Shared.
681                crate::cache_ops::prefetchw(
682                    &header.producer_seq as *const _ as *const u8,
683                );
684                if header.producer_seq.compare_exchange_weak(
685                    pos, pos + 1, Ordering::Relaxed, Ordering::Relaxed,
686                ).is_ok() {
687                    // Write payload. Plain `ptr::copy_nonoverlapping`
688                    // on purpose: at one-line sizes the inlined
689                    // baseline codegen beats a dispatched wide-
690                    // register kernel (examples/cacheline_probe.rs).
691                    unsafe {
692                        let dst = (*slot.payload.get()).as_mut_ptr();
693                        std::ptr::copy_nonoverlapping(payload.as_ptr(), dst, payload.len());
694                        if payload.len() < PAYLOAD_BYTES {
695                            std::ptr::write_bytes(
696                                dst.add(payload.len()), 0,
697                                PAYLOAD_BYTES - payload.len(),
698                            );
699                        }
700                    }
701                    // Publish: bump sequence so consumer sees it.
702                    slot.sequence.store(pos + 1, Ordering::Release);
703                    // The slot line's next reader is the consumer on
704                    // another core: demote it toward the shared LLC
705                    // (NOP on silicon without CLDEMOTE).
706                    crate::cache_ops::cldemote(slot as *const Slot as *const u8);
707                    self.ring_sidecar
708                        .push_op(crate::sidecar_ops::ring::OP_PUSH, 0);
709                    return Ok(());
710                }
711                // CAS lost; retry.
712            } else if diff < 0 {
713                // Slot still holds an unconsumed value; ring full.
714                self.ring_sidecar
715                    .push_op(crate::sidecar_ops::ring::OP_PUSH, 1); // contention/full
716                return Err(RingError::Full);
717            } else {
718                // Another producer raced ahead; retry.
719                std::hint::spin_loop();
720            }
721        }
722    }
723
724    /// Single-producer fast path: skip the CAS on `producer_seq`.
725    ///
726    /// **Caller contract:** the caller guarantees only one thread / one
727    /// process is calling [`try_push_spsc`](Self::try_push_spsc) on
728    /// this ring at a time. Concurrent producers will corrupt the
729    /// counter; use [`try_push`](Self::try_push) for MPMC.
730    ///
731    /// Saves the `compare_exchange_weak` on `producer_seq` that the
732    /// MPMC path needs to defend against racing producers. Two atomics
733    /// per push (1 Acquire load on the slot's sequence + 1 Release
734    /// store on the slot's sequence) plus one Release store on
735    /// `producer_seq`, vs the MPMC path's 1 load + 1 CAS + 1 load + 1
736    /// store. Net: ~25% less atomic traffic per push.
737    ///
738    /// Also skips the per-op `Observation` push to the sidecar ring.
739    /// Use [`try_push`](Self::try_push) when you want sidecar
740    /// observability on the hot path.
741    pub fn try_push_spsc(&self, payload: &[u8]) -> Result<(), RingError> {
742        if payload.len() > PAYLOAD_BYTES {
743            return Err(RingError::PayloadTooLarge);
744        }
745        let header = self.header();
746        let pos = header.producer_seq.load(Ordering::Relaxed);
747        let slot = self.slot(pos as usize);
748        let seq = slot.sequence.load(Ordering::Acquire);
749        if seq != pos {
750            // Slot still holds an unconsumed value (seq < pos+1 means
751            // we lapped the consumer). Ring is full.
752            return Err(RingError::Full);
753        }
754        unsafe {
755            let dst = (*slot.payload.get()).as_mut_ptr();
756            std::ptr::copy_nonoverlapping(payload.as_ptr(), dst, payload.len());
757            if payload.len() < PAYLOAD_BYTES {
758                std::ptr::write_bytes(
759                    dst.add(payload.len()), 0,
760                    PAYLOAD_BYTES - payload.len(),
761                );
762            }
763        }
764        // Bump producer_seq with a single Relaxed store; we are the
765        // sole producer so no other thread can race against this CAS.
766        // The Release on slot.sequence below carries the happens-before
767        // edge for both the payload write and the producer_seq update.
768        header.producer_seq.store(pos + 1, Ordering::Relaxed);
769        slot.sequence.store(pos + 1, Ordering::Release);
770        // Next reader of this line is the consumer on another core.
771        crate::cache_ops::cldemote(slot as *const Slot as *const u8);
772        Ok(())
773    }
774
775    /// Single-consumer fast path: skip the CAS on `consumer_seq`.
776    ///
777    /// **Caller contract:** the caller guarantees only one thread / one
778    /// process is calling [`try_pop_spsc`](Self::try_pop_spsc) on this
779    /// ring at a time. Concurrent consumers will corrupt the counter;
780    /// use [`try_pop`](Self::try_pop) for MPMC.
781    ///
782    /// Same mirror-image savings as
783    /// [`try_push_spsc`](Self::try_push_spsc): two atomics + one
784    /// Release store per pop, no CAS, no sidecar observation push.
785    pub fn try_pop_spsc(&self, out: &mut [u8]) -> Result<usize, RingError> {
786        if out.len() < PAYLOAD_BYTES {
787            return Err(RingError::PayloadTooLarge);
788        }
789        let header = self.header();
790        let pos = header.consumer_seq.load(Ordering::Relaxed);
791        let slot = self.slot(pos as usize);
792        let seq = slot.sequence.load(Ordering::Acquire);
793        if seq != pos + 1 {
794            // Producer hasn't published this slot yet.
795            return Err(RingError::Empty);
796        }
797        unsafe {
798            let src = (*slot.payload.get()).as_ptr();
799            std::ptr::copy_nonoverlapping(src, out.as_mut_ptr(), PAYLOAD_BYTES);
800        }
801        // Sole consumer: Relaxed store on consumer_seq is fine; the
802        // Release on slot.sequence below carries the happens-before
803        // edge that frees this slot for the next producer.
804        header.consumer_seq.store(pos + 1, Ordering::Relaxed);
805        slot.sequence.store(pos + self.capacity as u64, Ordering::Release);
806        // The freed slot's next toucher is the producer.
807        crate::cache_ops::cldemote(slot as *const Slot as *const u8);
808        Ok(PAYLOAD_BYTES)
809    }
810
811    /// The publish signal for the consumer's NEXT pop: the
812    /// sequence atom of the slot at the current consumer position.
813    /// A producer publishing that slot Release-stores this exact
814    /// atom, so a monitor-wait armed on it wakes on the publish.
815    /// Recompute after every successful pop - the position (and
816    /// therefore the slot) advances.
817    pub fn next_pop_signal(&self) -> &AtomicU64 {
818        let pos = self.header().consumer_seq.load(Ordering::Relaxed);
819        &self.slot(pos as usize).sequence
820    }
821
822    /// Try to pop one payload into `out`. On success, returns the
823    /// number of bytes written. On `Err(Empty)`, the ring is empty.
824    pub fn try_pop(&self, out: &mut [u8]) -> Result<usize, RingError> {
825        if out.len() < PAYLOAD_BYTES {
826            return Err(RingError::PayloadTooLarge);
827        }
828        let header = self.header();
829        loop {
830            let pos = header.consumer_seq.load(Ordering::Relaxed);
831            let slot = self.slot(pos as usize);
832            let seq = slot.sequence.load(Ordering::Acquire);
833            let diff = seq as i64 - (pos + 1) as i64;
834            if diff == 0 {
835                // Slot is ready for us; CAS consumer_seq forward.
836                // Write-intent prefetch ahead of the RMW (see
837                // try_push).
838                crate::cache_ops::prefetchw(
839                    &header.consumer_seq as *const _ as *const u8,
840                );
841                if header.consumer_seq.compare_exchange_weak(
842                    pos, pos + 1, Ordering::Relaxed, Ordering::Relaxed,
843                ).is_ok() {
844                    // Read payload.
845                    unsafe {
846                        let src = (*slot.payload.get()).as_ptr();
847                        std::ptr::copy_nonoverlapping(src, out.as_mut_ptr(), PAYLOAD_BYTES);
848                    }
849                    // Release the slot for the producer who will use
850                    // it at position pos + capacity.
851                    slot.sequence.store(pos + self.capacity as u64, Ordering::Release);
852                    // The freed slot's next toucher is the producer.
853                    crate::cache_ops::cldemote(slot as *const Slot as *const u8);
854                    self.ring_sidecar
855                        .push_op(crate::sidecar_ops::ring::OP_POP, 0);
856                    return Ok(PAYLOAD_BYTES);
857                }
858                // CAS lost; retry.
859            } else if diff < 0 {
860                // No item yet.
861                self.ring_sidecar
862                    .push_op(crate::sidecar_ops::ring::OP_POP, 2); // empty
863                return Err(RingError::Empty);
864            } else {
865                // Producer raced ahead by more than one; retry.
866                std::hint::spin_loop();
867            }
868        }
869    }
870
871    /// Force the underlying file's dirty pages to disk. Only
872    /// meaningful for file-backed rings; no-op for anonymous and
873    /// ShmFs-backed rings (which never touch disk).
874    pub fn flush(&self) -> Result<(), RingError> {
875        match &self._backing {
876            SharedRingBacking::File(_, mmap) => {
877                mmap.flush()?;
878            }
879            SharedRingBacking::Anon(_)
880            | SharedRingBacking::Shm(_)
881            | SharedRingBacking::Region(_) => {}
882        }
883        Ok(())
884    }
885
886    /// Non-blocking flush; lets the OS schedule the writeback. Only
887    /// meaningful for file-backed rings; no-op otherwise.
888    pub fn flush_async(&self) -> Result<(), RingError> {
889        match &self._backing {
890            SharedRingBacking::File(_, mmap) => {
891                mmap.flush_async()?;
892            }
893            SharedRingBacking::Anon(_)
894            | SharedRingBacking::Shm(_)
895            | SharedRingBacking::Region(_) => {}
896        }
897        Ok(())
898    }
899
900    /// Current producer sequence number (monotonic; wraps via
901    /// modulo-capacity on slot index).
902    pub fn producer_seq(&self) -> u64 {
903        self.header().producer_seq.load(Ordering::Acquire)
904    }
905
906    /// Current consumer sequence number.
907    pub fn consumer_seq(&self) -> u64 {
908        self.header().consumer_seq.load(Ordering::Acquire)
909    }
910
911    /// Approximate items waiting to be drained.
912    pub fn approx_len(&self) -> usize {
913        let p = self.producer_seq();
914        let c = self.consumer_seq();
915        p.saturating_sub(c) as usize
916    }
917
918    /// Find the first slot in the claimed-but-undrained window
919    /// `[consumer_seq, producer_seq)` whose sequence number is stuck
920    /// at `pos` instead of having advanced to `pos + 1` (published).
921    /// Returns `Some(pos)` for the first stuck position, `None` if
922    /// every claimed slot has been published.
923    ///
924    /// **Use for:** sidecar-driven recovery from a producer that
925    /// crashed between claiming a slot (CAS on `producer_seq`) and
926    /// publishing it (Release-store on `slot.sequence`). The window
927    /// where a crash leaves a permanent hole is narrow but real for
928    /// any Vyukov MPMC; this is the scan that finds those holes.
929    ///
930    /// **Hot-path cost:** zero. This method is only called by the
931    /// sidecar when its Empty-observation analysis decides a ring is
932    /// stuck. `try_push` and `try_pop` never touch it.
933    ///
934    /// **Scan cost:** O(producer_seq - consumer_seq) in the worst
935    /// case (typically small; if the window is large the ring is
936    /// already saturated and the scan dominates nothing).
937    pub fn next_stuck_slot(&self, from: u64) -> Option<u64> {
938        let producer_seq = self.header().producer_seq.load(Ordering::Acquire);
939        let consumer_seq = self.header().consumer_seq.load(Ordering::Acquire);
940        let start = from.max(consumer_seq);
941        for pos in start..producer_seq {
942            let slot = self.slot(pos as usize);
943            let seq = slot.sequence.load(Ordering::Acquire);
944            // Stuck: producer CAS'd producer_seq forward but never
945            // published the Release-store on slot.sequence.
946            if seq == pos {
947                return Some(pos);
948            }
949        }
950        None
951    }
952
953    /// Heal a slot stuck in the claimed-but-never-published state by
954    /// advancing its sequence number from `pos` to `pos + 1`. The
955    /// next consumer at this position drains the slot in normal
956    /// `try_pop` order; its payload bytes are whatever the dying
957    /// producer happened to write before crashing (or initial zeros
958    /// if the producer crashed before any payload write).
959    ///
960    /// **Caller contract:** the caller must independently confirm
961    /// that the producer which claimed this slot will never publish
962    /// it (process dead, lease expired, application-level timeout
963    /// elapsed). `SharedRing` does not record per-slot producer
964    /// identity, so this method cannot make that determination on
965    /// its own. Calling without dead-producer confirmation will
966    /// data-race a live producer that is about to publish; the
967    /// race is benign for the CAS itself (the producer's Release
968    /// publishes the same value `pos + 1` we are trying to publish,
969    /// so the CAS just returns `Ok(false)`) but the consumer drains
970    /// a slot the producer never finished writing.
971    ///
972    /// **Where the dead-producer signal comes from:** the canonical
973    /// signal is [`HeartbeatTable`](crate::HeartbeatTable) +
974    /// [`FailoverWatchdog`](crate::FailoverWatchdog). Register each
975    /// producer with a heartbeat; the watchdog declares a process
976    /// dead when its heartbeat goes stale beyond the grace period,
977    /// then walks the rings that producer touched and calls
978    /// `heal_stuck_slot(pos)` for each stuck position
979    /// [`next_stuck_slot`](Self::next_stuck_slot) returns.
980    ///
981    /// **Returns:** `Ok(true)` if the slot was stuck and is now
982    /// healed (CAS succeeded; consumer can drain it).
983    /// `Ok(false)` if the slot was not stuck (sequence already at
984    /// `pos + 1` or beyond, or `pos` outside the
985    /// `[consumer_seq, producer_seq)` window). Returns `Err` only
986    /// on `PayloadTooLarge` style protocol misuse.
987    ///
988    /// **Hot-path cost:** zero. Only invoked from sidecar recovery.
989    /// The heal itself is one atomic CAS on the slot's sequence
990    /// number; no payload write, no other state touched.
991    pub fn heal_stuck_slot(&self, pos: u64) -> Result<bool, RingError> {
992        let header = self.header();
993        let producer_seq = header.producer_seq.load(Ordering::Acquire);
994        let consumer_seq = header.consumer_seq.load(Ordering::Acquire);
995        if pos < consumer_seq || pos >= producer_seq {
996            // Outside the claimed-but-undrained window. Either the
997            // slot is already drained, or producer_seq never claimed
998            // pos.
999            return Ok(false);
1000        }
1001        let slot = self.slot(pos as usize);
1002        // CAS from `pos` (claimed, never published) to `pos + 1`
1003        // (published). If a live producer races and publishes
1004        // concurrently, the producer's Release-store wrote `pos + 1`
1005        // first; our CAS sees `pos + 1` (not `pos`) and returns
1006        // Err -> Ok(false). No data loss in either branch.
1007        match slot.sequence.compare_exchange(
1008            pos,
1009            pos + 1,
1010            Ordering::AcqRel,
1011            Ordering::Acquire,
1012        ) {
1013            Ok(_) => {
1014                self.ring_sidecar
1015                    .push_op(crate::sidecar_ops::ring::OP_PUSH, 4); // bit 2 = healed-tombstone marker
1016                Ok(true)
1017            }
1018            Err(_) => Ok(false),
1019        }
1020    }
1021}
1022
1023#[cfg(test)]
1024mod tests {
1025    use super::*;
1026    use std::thread;
1027
1028    fn tmp_path(name: &str) -> std::path::PathBuf {
1029        let mut p = std::env::temp_dir();
1030        let pid = std::process::id();
1031        p.push(format!("subetha-test-{name}-{pid}.bin"));
1032        p
1033    }
1034
1035    #[test]
1036    fn create_open_round_trip() {
1037        let p = tmp_path("create-open");
1038        {
1039            let _r = SharedRing::create(&p, 16).unwrap();
1040        }
1041        // Reopen with same capacity.
1042        let r2 = SharedRing::open(&p, 16).unwrap();
1043        assert_eq!(r2.capacity(), 16);
1044        std::fs::remove_file(&p).ok();
1045    }
1046
1047    /// Simulate a producer crashing between claim and publish: take
1048    /// over the slot manually using direct atomic ops, leaving
1049    /// producer_seq advanced but slot.sequence stuck at `pos`.
1050    fn create_stuck_slot(ring: &SharedRing, pos: u64) {
1051        let header = ring.header();
1052        // Force producer_seq to pos+1 (as if a producer claimed
1053        // the slot and then died).
1054        header.producer_seq.store(pos + 1, Ordering::Release);
1055        // slot.sequence stays at `pos` (its initial Vyukov value
1056        // for the pos-th producer): a producer "claimed" it but
1057        // never published. This is exactly the post-crash state.
1058        assert_eq!(
1059            ring.slot(pos as usize).sequence.load(Ordering::Acquire),
1060            pos,
1061            "test setup: slot.sequence must still be at initial value",
1062        );
1063    }
1064
1065    #[test]
1066    fn stuck_slot_blocks_consumer_then_heal_unblocks() {
1067        // E2E: deliberate stuck slot, consumer hangs, heal unblocks.
1068        let ring = SharedRing::create_anon(8).unwrap();
1069        create_stuck_slot(&ring, 0);
1070
1071        // Consumer at pos=0 sees Empty even though producer_seq says
1072        // there's an item; this is the stuck-slot pathology.
1073        let mut out = [0u8; PAYLOAD_BYTES];
1074        assert_eq!(ring.try_pop(&mut out).unwrap_err(), RingError::Empty);
1075        assert_eq!(ring.try_pop(&mut out).unwrap_err(), RingError::Empty);
1076        assert_eq!(
1077            ring.approx_len(),
1078            1,
1079            "producer_seq advanced past 0 but consumer sees 0",
1080        );
1081
1082        // Sidecar discovers the stuck slot via the scan.
1083        let stuck = ring.next_stuck_slot(0).expect("scan must find pos=0");
1084        assert_eq!(stuck, 0);
1085
1086        // Heal: caller confirmed the producer is dead via its own
1087        // heartbeat machinery (out of band for this test).
1088        assert!(ring.heal_stuck_slot(stuck).unwrap());
1089
1090        // Consumer now drains the healed slot.
1091        let n = ring.try_pop(&mut out).expect("healed slot must drain");
1092        assert_eq!(n, PAYLOAD_BYTES);
1093        assert_eq!(ring.consumer_seq(), 1, "consumer advanced past the heal");
1094
1095        // No more stuck slots.
1096        assert!(ring.next_stuck_slot(0).is_none());
1097
1098        // Ring fully functional after heal: pushes and pops work
1099        // through the rest of the lap.
1100        for i in 1..5u8 {
1101            ring.try_push(&[i; 8]).unwrap();
1102            ring.try_pop(&mut out).unwrap();
1103        }
1104    }
1105
1106    #[test]
1107    fn heal_non_stuck_slot_returns_false() {
1108        let ring = SharedRing::create_anon(4).unwrap();
1109        ring.try_push(&[1u8; 8]).unwrap();
1110        // Slot 0 has been pushed (sequence == 1, not 0).
1111        assert!(!ring.heal_stuck_slot(0).unwrap(),
1112                "heal of an already-published slot must be a no-op");
1113
1114        // Out-of-window position: producer_seq is 1, so pos=5 is
1115        // beyond the claimed window.
1116        assert!(!ring.heal_stuck_slot(5).unwrap(),
1117                "heal of out-of-window position must be a no-op");
1118    }
1119
1120    #[test]
1121    fn heal_loses_race_with_concurrent_producer_publish() {
1122        // Build a scenario where the heal CAS observes the producer
1123        // already published: the slot's sequence is pos+1 when the
1124        // heal tries the CAS pos -> pos+1, so CAS fails and the
1125        // method returns Ok(false). No payload was clobbered.
1126        let ring = SharedRing::create_anon(4).unwrap();
1127        // Producer pushes pos=0 properly (sequence becomes 1).
1128        ring.try_push(&[0xCDu8; 8]).unwrap();
1129
1130        // Heal sees seq=1, not 0, so CAS fails -> Ok(false).
1131        assert!(!ring.heal_stuck_slot(0).unwrap());
1132
1133        // Consumer drains the original published payload, NOT a
1134        // tombstone: heal did not corrupt the slot.
1135        let mut out = [0u8; PAYLOAD_BYTES];
1136        ring.try_pop(&mut out).unwrap();
1137        assert_eq!(&out[..8], &[0xCDu8; 8]);
1138    }
1139
1140    #[test]
1141    fn next_stuck_slot_scans_only_claimed_window() {
1142        let ring = SharedRing::create_anon(8).unwrap();
1143        // Empty window: no stuck slots possible.
1144        assert!(ring.next_stuck_slot(0).is_none());
1145
1146        // Push two normal items. No stuck slots yet.
1147        ring.try_push(&[1u8; 8]).unwrap();
1148        ring.try_push(&[2u8; 8]).unwrap();
1149        assert!(ring.next_stuck_slot(0).is_none());
1150
1151        // Stick slot 2 (producer claimed but never published).
1152        create_stuck_slot(&ring, 2);
1153        // Window is [0, 3); slots 0 and 1 are published (drainable),
1154        // slot 2 is stuck. Scanner should land on 2.
1155        assert_eq!(ring.next_stuck_slot(0), Some(2));
1156    }
1157
1158    #[test]
1159    fn spsc_fast_path_round_trip() {
1160        // Sole producer + sole consumer in two threads; verifies the
1161        // CAS-free fast paths preserve the same byte layout and
1162        // ordering guarantees as the MPMC path.
1163        let ring = std::sync::Arc::new(SharedRing::create_anon(16).unwrap());
1164        let ring_p = ring.clone();
1165        let ring_c = ring.clone();
1166        const N: u32 = 1_000;
1167
1168        let producer = thread::spawn(move || {
1169            for i in 0..N {
1170                let mut buf = [0u8; PAYLOAD_BYTES];
1171                buf[..4].copy_from_slice(&i.to_le_bytes());
1172                while ring_p.try_push_spsc(&buf).is_err() {
1173                    std::hint::spin_loop();
1174                }
1175            }
1176        });
1177
1178        let consumer = thread::spawn(move || {
1179            let mut out = [0u8; PAYLOAD_BYTES];
1180            let mut sum: u64 = 0;
1181            let mut received = 0u32;
1182            while received < N {
1183                if ring_c.try_pop_spsc(&mut out).is_ok() {
1184                    sum += u32::from_le_bytes(out[..4].try_into().unwrap()) as u64;
1185                    received += 1;
1186                } else {
1187                    std::hint::spin_loop();
1188                }
1189            }
1190            sum
1191        });
1192
1193        producer.join().unwrap();
1194        let sum = consumer.join().unwrap();
1195        let expected: u64 = (0..N).map(|i| i as u64).sum();
1196        assert_eq!(sum, expected, "SPSC fast-path lost or duplicated items");
1197    }
1198
1199    #[test]
1200    fn spsc_fast_path_reports_full_on_lap() {
1201        // Sole producer fills the ring without a consumer; the SPSC
1202        // path must return Full once we've published `capacity`
1203        // items and reach the slot the consumer hasn't drained yet.
1204        let ring = SharedRing::create_anon(4).unwrap();
1205        for i in 0..4u8 {
1206            ring.try_push_spsc(&[i; 8]).unwrap();
1207        }
1208        assert_eq!(
1209            ring.try_push_spsc(&[99u8; 8]).unwrap_err(),
1210            RingError::Full,
1211        );
1212        // After consuming one slot via the SPSC pop, push works again.
1213        let mut out = [0u8; PAYLOAD_BYTES];
1214        ring.try_pop_spsc(&mut out).unwrap();
1215        ring.try_push_spsc(&[99u8; 8]).unwrap();
1216    }
1217
1218    #[test]
1219    fn anon_ring_pushes_and_pops() {
1220        // Anon mode does not touch the filesystem; same byte layout
1221        // and concurrency protocol so push/pop round-trips work.
1222        let ring = SharedRing::create_anon(8).unwrap();
1223        assert_eq!(ring.capacity(), 8);
1224        let payload = [0xAB; PAYLOAD_BYTES];
1225        ring.try_push(&payload).unwrap();
1226        let mut out = [0u8; PAYLOAD_BYTES];
1227        let n = ring.try_pop(&mut out).unwrap();
1228        assert_eq!(n, PAYLOAD_BYTES);
1229        assert_eq!(out, payload);
1230        // Second pop on empty ring returns Empty.
1231        assert_eq!(ring.try_pop(&mut out).unwrap_err(), RingError::Empty);
1232    }
1233
1234    #[test]
1235    fn anon_ring_fills_to_capacity() {
1236        let ring = SharedRing::create_anon(4).unwrap();
1237        for i in 0..4u32 {
1238            let mut p = [0u8; PAYLOAD_BYTES];
1239            p[..4].copy_from_slice(&i.to_le_bytes());
1240            ring.try_push(&p).unwrap();
1241        }
1242        // Fifth push must fail with Full, matching file-backed behaviour.
1243        assert_eq!(ring.try_push(&[0u8; PAYLOAD_BYTES]).unwrap_err(), RingError::Full);
1244    }
1245
1246    #[test]
1247    fn lazy_ring_defers_setup_until_first_use() {
1248        let p = tmp_path("lazy-defer");
1249        let lazy = LazySharedRing::new(&p, 8);
1250        // is_initialised stays false until something forces materialisation.
1251        assert!(!lazy.is_initialised());
1252        // First try_push triggers create.
1253        lazy.try_push(&[1u8; 8]).unwrap();
1254        assert!(lazy.is_initialised());
1255        // Subsequent pop reads back the same byte.
1256        let mut out = [0u8; PAYLOAD_BYTES];
1257        let n = lazy.try_pop(&mut out).unwrap();
1258        assert_eq!(n, PAYLOAD_BYTES);
1259        assert_eq!(&out[..1], &[1u8]);
1260        std::fs::remove_file(&p).ok();
1261    }
1262
1263    #[test]
1264    fn lazy_ring_never_materialises_when_unused() {
1265        let p = tmp_path("lazy-never-used");
1266        let lazy = LazySharedRing::new(&p, 8);
1267        // Drop without calling any forwarded method.
1268        drop(lazy);
1269        // The path must not exist - lazy never created the file.
1270        assert!(!p.exists(), "lazy ring touched the filesystem despite no use");
1271    }
1272
1273    #[test]
1274    fn lazy_ring_get_caches_reference() {
1275        let p = tmp_path("lazy-cache");
1276        let lazy = LazySharedRing::new(&p, 8);
1277        let r1 = lazy.get().unwrap() as *const SharedRing;
1278        let r2 = lazy.get().unwrap() as *const SharedRing;
1279        // Second .get() must return the same materialised instance.
1280        assert_eq!(r1, r2, "OnceLock returned different instances across calls");
1281        std::fs::remove_file(&p).ok();
1282    }
1283
1284    #[test]
1285    fn open_rejects_wrong_capacity() {
1286        let p = tmp_path("wrong-cap");
1287        let _r = SharedRing::create(&p, 16).unwrap();
1288        match SharedRing::open(&p, 32) {
1289            Err(RingError::LayoutMismatch) => {}
1290            other => panic!("expected LayoutMismatch, got {:?}",
1291                            other.as_ref().err()),
1292        }
1293        std::fs::remove_file(&p).ok();
1294    }
1295
1296    #[test]
1297    fn single_thread_push_pop_round_trip() {
1298        let p = tmp_path("spsc-rt");
1299        let r = SharedRing::create(&p, 8).unwrap();
1300        for i in 0..8u8 {
1301            let payload = [i, i, i, i];
1302            r.try_push(&payload).unwrap();
1303        }
1304        // Ring should now be full.
1305        assert_eq!(r.try_push(&[42; 4]).unwrap_err(), RingError::Full);
1306
1307        let mut buf = [0u8; PAYLOAD_BYTES];
1308        for i in 0..8u8 {
1309            let n = r.try_pop(&mut buf).unwrap();
1310            assert_eq!(n, PAYLOAD_BYTES);
1311            assert_eq!(&buf[..4], &[i, i, i, i]);
1312        }
1313        // Now empty.
1314        assert_eq!(r.try_pop(&mut buf).unwrap_err(), RingError::Empty);
1315        std::fs::remove_file(&p).ok();
1316    }
1317
1318    #[test]
1319    fn mpmc_concurrent_push_pop_preserves_count() {
1320        let p = tmp_path("mpmc");
1321        let r = std::sync::Arc::new(SharedRing::create(&p, 1024).unwrap());
1322        let producers = 4;
1323        let consumers = 4;
1324        let per_producer = 5_000usize;
1325        let total = producers * per_producer;
1326
1327        let mut handles = vec![];
1328        for pid in 0..producers {
1329            let r = r.clone();
1330            handles.push(thread::spawn(move || {
1331                for i in 0..per_producer {
1332                    let v = ((pid as u32) << 24) | (i as u32);
1333                    let bytes = v.to_le_bytes();
1334                    while r.try_push(&bytes).is_err() {
1335                        std::hint::spin_loop();
1336                    }
1337                }
1338            }));
1339        }
1340
1341        let consumed = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
1342        for _ in 0..consumers {
1343            let r = r.clone();
1344            let consumed = consumed.clone();
1345            handles.push(thread::spawn(move || {
1346                let mut buf = [0u8; PAYLOAD_BYTES];
1347                loop {
1348                    if consumed.load(std::sync::atomic::Ordering::Acquire) >= total {
1349                        return;
1350                    }
1351                    if r.try_pop(&mut buf).is_ok() {
1352                        consumed.fetch_add(1, std::sync::atomic::Ordering::AcqRel);
1353                    }
1354                }
1355            }));
1356        }
1357        for h in handles { h.join().unwrap(); }
1358        assert_eq!(consumed.load(std::sync::atomic::Ordering::Acquire), total);
1359        std::fs::remove_file(&p).ok();
1360    }
1361
1362    #[test]
1363    fn disk_persistence_data_survives_reopen() {
1364        let p = tmp_path("disk-persist");
1365        {
1366            let r = SharedRing::create(&p, 4).unwrap();
1367            r.try_push(&[1, 2, 3, 4]).unwrap();
1368            r.try_push(&[5, 6, 7, 8]).unwrap();
1369            r.flush().unwrap();
1370        }
1371        // Reopen; data should still be there.
1372        let r2 = SharedRing::open(&p, 4).unwrap();
1373        let mut buf = [0u8; PAYLOAD_BYTES];
1374        let _val = r2.try_pop(&mut buf).unwrap();
1375        assert_eq!(&buf[..4], &[1, 2, 3, 4]);
1376        let _val = r2.try_pop(&mut buf).unwrap();
1377        assert_eq!(&buf[..4], &[5, 6, 7, 8]);
1378        std::fs::remove_file(&p).ok();
1379    }
1380
1381    #[test]
1382    fn cross_handle_in_process_sees_writes() {
1383        // Two SharedRing handles to the same file in one process: a
1384        // proxy for cross-process behaviour (they map the same pages).
1385        let p = tmp_path("cross-handle");
1386        let producer = SharedRing::create(&p, 16).unwrap();
1387        let consumer = SharedRing::open(&p, 16).unwrap();
1388        producer.try_push(b"abc").unwrap();
1389        let mut buf = [0u8; PAYLOAD_BYTES];
1390        let _val = consumer.try_pop(&mut buf).unwrap();
1391        assert_eq!(&buf[..3], b"abc");
1392        std::fs::remove_file(&p).ok();
1393    }
1394
1395    #[test]
1396    fn approx_len_tracks_outstanding() {
1397        let p = tmp_path("approx-len");
1398        let r = SharedRing::create(&p, 16).unwrap();
1399        assert_eq!(r.approx_len(), 0);
1400        r.try_push(&[1]).unwrap();
1401        r.try_push(&[2]).unwrap();
1402        r.try_push(&[3]).unwrap();
1403        assert_eq!(r.approx_len(), 3);
1404        let mut buf = [0u8; PAYLOAD_BYTES];
1405        let _val = r.try_pop(&mut buf).unwrap();
1406        assert_eq!(r.approx_len(), 2);
1407        std::fs::remove_file(&p).ok();
1408    }
1409
1410    #[test]
1411    fn payload_too_large_rejected() {
1412        let p = tmp_path("payload-too-large");
1413        let r = SharedRing::create(&p, 4).unwrap();
1414        let oversized = vec![0u8; PAYLOAD_BYTES + 1];
1415        assert_eq!(r.try_push(&oversized).unwrap_err(), RingError::PayloadTooLarge);
1416        std::fs::remove_file(&p).ok();
1417    }
1418
1419    /// 64-byte-aligned heap region for exercising the Vyukov
1420    /// `create_in_region` / `open_in_region` path with no huge-page
1421    /// privilege. The aligned element type matches what page-backed
1422    /// regions give for free.
1423    #[repr(C, align(64))]
1424    #[derive(Clone, Copy)]
1425    struct Block64([u8; 64]);
1426
1427    #[test]
1428    fn create_in_region_round_trips() {
1429        let cap = 16usize;
1430        let bytes = ring_file_size(cap);
1431        let mut blocks = vec![Block64([0u8; 64]); bytes.div_ceil(64)];
1432
1433        struct R { ptr: *mut u8, len: usize }
1434        unsafe impl Send for R {}
1435        unsafe impl Sync for R {}
1436        impl crate::spsc_ring::RegionOwner for R {
1437            fn region_ptr(&mut self) -> *mut u8 { self.ptr }
1438            fn region_len(&self) -> usize { self.len }
1439        }
1440
1441        let ring = SharedRing::create_in_region(
1442            R { ptr: blocks.as_mut_ptr() as *mut u8, len: bytes }, cap,
1443        ).unwrap();
1444        assert_eq!(ring.capacity(), cap);
1445
1446        // Two laps so producer_seq / consumer_seq wrap past capacity and
1447        // the Vyukov slot sequence numbers cycle in the region's bytes.
1448        let mut out = [0u8; PAYLOAD_BYTES];
1449        for round in 0..2u64 {
1450            for i in 0..cap as u64 {
1451                let v = round * cap as u64 + i;
1452                let mut buf = [0u8; PAYLOAD_BYTES];
1453                buf[..8].copy_from_slice(&v.to_le_bytes());
1454                ring.try_push(&buf).unwrap();
1455            }
1456            for i in 0..cap as u64 {
1457                ring.try_pop(&mut out).unwrap();
1458                assert_eq!(
1459                    u64::from_le_bytes(out[..8].try_into().unwrap()),
1460                    round * cap as u64 + i,
1461                );
1462            }
1463        }
1464        // `blocks` declared before `ring`, so scope order drops it last.
1465    }
1466
1467    #[test]
1468    fn open_in_region_attaches_to_initialised_layout() {
1469        // One backing, two views: producer lays the Vyukov ring out and
1470        // pushes; a second handle opens the SAME bytes via
1471        // open_in_region (no re-init) and drains - the cross-process
1472        // LargePageSection attach in miniature.
1473        let cap = 8usize;
1474        let bytes = ring_file_size(cap);
1475        let mut blocks = vec![Block64([0u8; 64]); bytes.div_ceil(64)];
1476        let base = blocks.as_mut_ptr() as *mut u8;
1477        unsafe { init_ring_layout_raw(base, cap) };
1478
1479        struct View { ptr: *mut u8, len: usize }
1480        unsafe impl Send for View {}
1481        unsafe impl Sync for View {}
1482        impl crate::spsc_ring::RegionOwner for View {
1483            fn region_ptr(&mut self) -> *mut u8 { self.ptr }
1484            fn region_len(&self) -> usize { self.len }
1485        }
1486
1487        let producer = SharedRing::open_in_region(
1488            View { ptr: base, len: bytes }, cap,
1489        ).unwrap();
1490        let consumer = SharedRing::open_in_region(
1491            View { ptr: base, len: bytes }, cap,
1492        ).unwrap();
1493
1494        let mut buf = [0u8; PAYLOAD_BYTES];
1495        buf[..4].copy_from_slice(&0xABCD_u32.to_le_bytes());
1496        producer.try_push(&buf).unwrap();
1497        let mut out = [0u8; PAYLOAD_BYTES];
1498        consumer.try_pop(&mut out).unwrap();
1499        assert_eq!(out[..4], buf[..4]);
1500        // `blocks` declared first, so it drops after both views.
1501    }
1502}