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}