Skip to main content

ghostlink_core/
ring.rs

1//! Zero-Copy SPSC Ring Buffer with AF_XDP Integration
2//!
3//! This implementation uses pinned allocations and proper memory ordering
4//! for single-producer/single-consumer DMA-style hand-off.
5//!
6//! Optimizations:
7//! - 4x larger capacity (4096) for better batching
8//! - Prefetching in hot paths for cache-friendly access
9//! - Optimal cache-line padding (128 bytes) to avoid false sharing
10
11use std::cell::UnsafeCell;
12use std::mem::MaybeUninit;
13use std::sync::atomic::{AtomicUsize, Ordering};
14
15/// Ring buffer configuration with backpressure thresholds
16#[derive(Clone, Copy, Debug)]
17pub struct RingConfig {
18    /// Maximum number of elements the ring can hold
19    pub capacity: usize,
20    /// Backpressure threshold at 70% capacity
21    pub backpressure_threshold: usize,
22}
23
24impl Default for RingConfig {
25    fn default() -> Self {
26        Self {
27            capacity: 4095,               // 4x larger for better batching
28            backpressure_threshold: 2867, // ~70% of 4095
29        }
30    }
31}
32
33/// Fixed capacity used for DMA alignment (power of 2 for fast modulo)
34const RING_CAPACITY: usize = 4096;
35const CACHE_LINE: usize = 128; // 128-byte cache lines on modern x86/ARM
36
37/// Prefetch hint for read access (equivalent to `prefetcht0` on x86)
38#[inline(always)]
39#[allow(dead_code)]
40fn prefetch_read<T>(ptr: *const T) {
41    #[cfg(any(target_arch = "x86", target_arch = "x86_64"))]
42    unsafe {
43        std::arch::x86_64::_mm_prefetch(ptr as *const i8, std::arch::x86_64::_MM_HINT_T0);
44    }
45    #[cfg(not(any(target_arch = "x86", target_arch = "x86_64")))]
46    {
47        // On other platforms, hint to compiler/CPU
48        std::hint::black_box(ptr);
49    }
50}
51
52/// Prefetch hint for write access (equivalent to `prefetchw` on x86)
53#[inline(always)]
54#[allow(dead_code)]
55fn prefetch_write<T>(ptr: *mut T) {
56    #[cfg(any(target_arch = "x86", target_arch = "x86_64"))]
57    unsafe {
58        std::arch::x86_64::_mm_prefetch(ptr as *const i8, std::arch::x86_64::_MM_HINT_ET1);
59    }
60    #[cfg(not(any(target_arch = "x86", target_arch = "x86_64")))]
61    {
62        std::hint::black_box(ptr);
63    }
64}
65
66/// Zero-copy SPSC ring buffer with proper memory ordering
67///
68/// # Safety
69/// This type is only safe to use when:
70/// - Only one producer thread exists
71/// - Only one consumer thread exists
72/// - Memory is properly aligned for the element type T
73#[derive(Debug)]
74pub struct SpscRingBuffer<T> {
75    /// Buffer of uninitialized elements (aligned to cache line)
76    buffer: UnsafeCell<[MaybeUninit<T>; RING_CAPACITY]>,
77
78    /// Padding to prevent false sharing between buffer and head
79    _pad1: [u8; CACHE_LINE],
80    /// Current head position (consumer reads here)
81    head: AtomicUsize,
82    /// Cached tail for consumer to avoid reading shared tail
83    cached_tail: AtomicUsize,
84
85    /// Padding to prevent false sharing between head and tail
86    _pad2: [u8; CACHE_LINE],
87    /// Current tail position (producer writes here)
88    tail: AtomicUsize,
89    /// Cached head for producer to avoid reading shared head
90    cached_head: AtomicUsize,
91
92    /// Padding to prevent false sharing between tail and other counters
93    _pad3: [u8; CACHE_LINE],
94    /// Overflow counter for backpressure monitoring
95    overflow_count: AtomicUsize,
96    /// Configuration for this ring buffer
97    config: RingConfig,
98}
99
100impl<T> SpscRingBuffer<T> {
101    const CAPACITY: usize = RING_CAPACITY;
102
103    /// Create a new SPSC ring buffer with the given configuration
104    pub fn new(config: RingConfig) -> Self {
105        assert!(
106            config.capacity < Self::CAPACITY,
107            "capacity must be strictly less than RING_CAPACITY"
108        );
109
110        // Safety: MaybeUninit does not require initialization
111        let buffer = UnsafeCell::new(unsafe {
112            MaybeUninit::<[MaybeUninit<T>; RING_CAPACITY]>::uninit().assume_init()
113        });
114
115        Self {
116            buffer,
117            _pad1: [0; CACHE_LINE],
118            head: AtomicUsize::new(0),
119            cached_tail: AtomicUsize::new(0),
120            _pad2: [0; CACHE_LINE],
121            tail: AtomicUsize::new(0),
122            cached_head: AtomicUsize::new(0),
123            _pad3: [0; CACHE_LINE],
124            overflow_count: AtomicUsize::new(0),
125            config,
126        }
127    }
128
129    /// Push an element into the ring buffer
130    ///
131    /// Returns `Ok(())` on success, or `Err(value)` if the ring is full.
132    /// When full, the producer should wait/backpressure until space is available.
133    pub fn push(&self, value: T) -> Result<(), T> {
134        // Producer loads its own tail with Relaxed.
135        // OPTIMIZATION: Mask loaded tail with CAPACITY - 1 to guarantee tail < RING_CAPACITY,
136        // allowing unsafe unchecked buffer indexing and eliminating bounds checks.
137        let tail = self.tail.load(Ordering::Relaxed) & (Self::CAPACITY - 1);
138
139        // First check against cached head to avoid atomic contention.
140        let mut head = self.cached_head.load(Ordering::Relaxed);
141        let mut current_len = tail.wrapping_sub(head) & (Self::CAPACITY - 1);
142
143        if current_len >= self.config.capacity {
144            // Only if cached head suggests we are full, refresh from shared head.
145            head = self.head.load(Ordering::Acquire);
146            self.cached_head.store(head, Ordering::Relaxed);
147            current_len = tail.wrapping_sub(head) & (Self::CAPACITY - 1);
148
149            if current_len >= self.config.capacity {
150                self.overflow_count.fetch_add(1, Ordering::Relaxed);
151                return Err(value);
152            }
153        }
154
155        let next_tail = (tail + 1) & (Self::CAPACITY - 1);
156
157        // Write the value at tail position
158        unsafe {
159            let buf = &mut *self.buffer.get();
160            buf.get_unchecked_mut(tail).write(value);
161
162            // Release store to make write visible to consumer
163            self.tail.store(next_tail, Ordering::Release);
164        }
165
166        Ok(())
167    }
168
169    /// Pop an element from the ring buffer
170    ///
171    /// Returns `Some(value)` on success, or `None` if the ring is empty.
172    pub fn pop(&self) -> Option<T> {
173        // Consumer loads its own head with Relaxed.
174        // OPTIMIZATION: Mask loaded head with CAPACITY - 1 to guarantee head < RING_CAPACITY,
175        // allowing unsafe unchecked buffer indexing and eliminating bounds checks.
176        let head = self.head.load(Ordering::Relaxed) & (Self::CAPACITY - 1);
177
178        // First check against cached tail to avoid atomic contention.
179        let mut tail = self.cached_tail.load(Ordering::Relaxed);
180
181        if head == tail {
182            // Only if cached tail suggests we are empty, refresh from shared tail.
183            tail = self.tail.load(Ordering::Acquire);
184            self.cached_tail.store(tail, Ordering::Relaxed);
185
186            if head == tail {
187                return None;
188            }
189        }
190
191        // Read the value at head position
192        let value = unsafe {
193            let buf = &mut *self.buffer.get();
194            let val = buf.get_unchecked(head).assume_init_read();
195
196            // Release store to make read visible to producer
197            self.head
198                .store((head + 1) & (Self::CAPACITY - 1), Ordering::Release);
199
200            val
201        };
202
203        Some(value)
204    }
205
206    /// Get the current number of elements in the ring
207    pub fn len(&self) -> usize {
208        let head = self.head.load(Ordering::Acquire);
209        let tail = self.tail.load(Ordering::Acquire);
210
211        tail.wrapping_sub(head) & (Self::CAPACITY - 1)
212    }
213
214    /// Check if the ring is empty
215    pub fn is_empty(&self) -> bool {
216        let head = self.head.load(Ordering::Relaxed);
217        let mut tail = self.cached_tail.load(Ordering::Relaxed);
218
219        if head == tail {
220            tail = self.tail.load(Ordering::Acquire);
221            self.cached_tail.store(tail, Ordering::Relaxed);
222        }
223
224        head == tail
225    }
226
227    /// Get the configured capacity
228    pub fn capacity(&self) -> usize {
229        self.config.capacity
230    }
231
232    /// Get overflow count for backpressure monitoring
233    pub fn overflow_count(&self) -> usize {
234        self.overflow_count.load(Ordering::Relaxed)
235    }
236
237    /// Get empty count for backpressure monitoring
238    pub fn empty_count(&self) -> usize {
239        self.config.capacity.saturating_sub(self.len())
240    }
241
242    /// Check if backpressure should be applied (ring >= threshold)
243    pub fn should_backpressure(&self) -> bool {
244        let tail = self.tail.load(Ordering::Relaxed);
245        let mut head = self.cached_head.load(Ordering::Relaxed);
246        let mut len = tail.wrapping_sub(head) & (Self::CAPACITY - 1);
247
248        if len >= self.config.backpressure_threshold {
249            head = self.head.load(Ordering::Acquire);
250            self.cached_head.store(head, Ordering::Relaxed);
251            len = tail.wrapping_sub(head) & (Self::CAPACITY - 1);
252        }
253
254        len >= self.config.backpressure_threshold
255    }
256
257    /// Number of elements that can currently be pushed without blocking.
258    pub fn write_available(&self) -> usize {
259        let tail = self.tail.load(Ordering::Relaxed);
260        let mut head = self.cached_head.load(Ordering::Relaxed);
261        let mut len = tail.wrapping_sub(head) & (Self::CAPACITY - 1);
262        if len >= self.config.capacity {
263            head = self.head.load(Ordering::Acquire);
264            self.cached_head.store(head, Ordering::Relaxed);
265            len = tail.wrapping_sub(head) & (Self::CAPACITY - 1);
266        }
267        self.config.capacity.saturating_sub(len)
268    }
269
270    /// Number of elements currently available to pop (best-effort, not a snapshot).
271    pub fn read_available(&self) -> usize {
272        let head = self.head.load(Ordering::Relaxed);
273        let mut tail = self.cached_tail.load(Ordering::Relaxed);
274        if head == tail {
275            tail = self.tail.load(Ordering::Acquire);
276            self.cached_tail.store(tail, Ordering::Relaxed);
277        }
278        tail.wrapping_sub(head) & (Self::CAPACITY - 1)
279    }
280
281    /// Wait for space to become available
282    ///
283    /// Uses exponential backoff: spins with PAUSE up to 256 iterations,
284    /// then transitions to OS yield to avoid starving the consumer.
285    pub fn wait_for_space(&self) {
286        let mut backoff = 1u32;
287        loop {
288            if !self.should_backpressure() {
289                return;
290            }
291            // Exponential backoff: 1, 2, 4, 8, … 256
292            let limit = backoff.min(256);
293            for _ in 0..limit {
294                core::hint::spin_loop();
295            }
296            backoff = backoff.saturating_mul(2);
297            if backoff > 256 {
298                std::thread::yield_now();
299                backoff = 1;
300            }
301        }
302    }
303
304    /// Wait for an element to become available
305    ///
306    /// Uses exponential backoff: spins with PAUSE up to 256 iterations,
307    /// then transitions to OS yield to avoid starving the producer.
308    pub fn wait_for_data(&self) {
309        let mut backoff = 1u32;
310        loop {
311            if !self.is_empty() {
312                return;
313            }
314            let limit = backoff.min(256);
315            for _ in 0..limit {
316                core::hint::spin_loop();
317            }
318            backoff = backoff.saturating_mul(2);
319            if backoff > 256 {
320                std::thread::yield_now();
321                backoff = 1;
322            }
323        }
324    }
325
326    /// Push a batch of elements, returning the number successfully pushed.
327    ///
328    /// Stops early if the ring becomes full. This amortizes atomic store
329    /// overhead across multiple elements: only two atomic stores (tail + cached_head)
330    /// for up to `slice.len()` pushes.
331    ///
332    /// Uses prefetching to reduce cache misses on write path.
333    pub fn push_batch(&self, slice: &[T]) -> usize
334    where
335        T: Copy,
336    {
337        let tail = self.tail.load(Ordering::Relaxed);
338        let mut head = self.cached_head.load(Ordering::Relaxed);
339        let mut current_len = tail.wrapping_sub(head) & (Self::CAPACITY - 1);
340
341        if current_len >= self.config.capacity {
342            head = self.head.load(Ordering::Acquire);
343            self.cached_head.store(head, Ordering::Relaxed);
344            current_len = tail.wrapping_sub(head) & (Self::CAPACITY - 1);
345            if current_len >= self.config.capacity {
346                // Ring is full: every requested element is rejected.
347                self.overflow_count
348                    .fetch_add(slice.len(), Ordering::Relaxed);
349                return 0;
350            }
351        }
352
353        let available = self.config.capacity - current_len;
354        let count = available.min(slice.len());
355        if count == 0 {
356            return 0;
357        }
358        if count < slice.len() {
359            // Partial batch: track the elements that didn't fit as overflow,
360            // matching push()'s per-element overflow accounting.
361            self.overflow_count
362                .fetch_add(slice.len() - count, Ordering::Relaxed);
363        }
364
365        let buf = unsafe { &mut *self.buffer.get() };
366        let mask = Self::CAPACITY - 1;
367        let start = tail & mask;
368
369        // Optimize batch write by using direct contiguous memory copying via copy_nonoverlapping
370        // instead of a per-element write loop with modulo and prefetching checks.
371        let buf_ptr = buf.as_mut_ptr() as *mut T;
372        if start + count <= Self::CAPACITY {
373            unsafe {
374                std::ptr::copy_nonoverlapping(slice.as_ptr(), buf_ptr.add(start), count);
375            }
376        } else {
377            let first_chunk = Self::CAPACITY - start;
378            let second_chunk = count - first_chunk;
379            unsafe {
380                std::ptr::copy_nonoverlapping(slice.as_ptr(), buf_ptr.add(start), first_chunk);
381                std::ptr::copy_nonoverlapping(
382                    slice.as_ptr().add(first_chunk),
383                    buf_ptr,
384                    second_chunk,
385                );
386            }
387        }
388        let new_tail = tail.wrapping_add(count) & mask;
389        self.tail.store(new_tail, Ordering::Release);
390        count
391    }
392
393    /// Pop up to `count` elements into the provided mutable slice, returning
394    /// the number of elements actually popped.
395    ///
396    /// Uses `MaybeUninit` to avoid zero-initialisation overhead.
397    /// Uses prefetching to reduce cache misses on read path.
398    pub fn pop_batch(&self, out: &mut [std::mem::MaybeUninit<T>]) -> usize {
399        let head = self.head.load(Ordering::Relaxed);
400        let mut tail = self.cached_tail.load(Ordering::Relaxed);
401
402        if head == tail {
403            tail = self.tail.load(Ordering::Acquire);
404            self.cached_tail.store(tail, Ordering::Relaxed);
405            if head == tail {
406                return 0;
407            }
408        }
409
410        let available = tail.wrapping_sub(head) & (Self::CAPACITY - 1);
411        let count = available.min(out.len());
412        if count == 0 {
413            return 0;
414        }
415
416        let buf = unsafe { &mut *self.buffer.get() };
417        let mask = Self::CAPACITY - 1;
418        let start = head & mask;
419
420        // Optimize batch read by using direct contiguous memory copying via copy_nonoverlapping
421        // instead of a per-element read loop with modulo and prefetching checks.
422        let buf_ptr = buf.as_ptr() as *const T;
423        let out_ptr = out.as_mut_ptr() as *mut T;
424        if start + count <= Self::CAPACITY {
425            unsafe {
426                std::ptr::copy_nonoverlapping(buf_ptr.add(start), out_ptr, count);
427            }
428        } else {
429            let first_chunk = Self::CAPACITY - start;
430            let second_chunk = count - first_chunk;
431            unsafe {
432                std::ptr::copy_nonoverlapping(buf_ptr.add(start), out_ptr, first_chunk);
433                std::ptr::copy_nonoverlapping(buf_ptr, out_ptr.add(first_chunk), second_chunk);
434            }
435        }
436        let new_head = head.wrapping_add(count) & mask;
437        self.head.store(new_head, Ordering::Release);
438        count
439    }
440}
441
442// Safety: SpscRingBuffer is safe to send across threads when used as SPSC.
443// The producer and consumer operate on disjoint memory regions with proper
444// atomic ordering, making this safe for our SPSC use case.
445unsafe impl<T: Send> Send for SpscRingBuffer<T> {}
446unsafe impl<T: Send> Sync for SpscRingBuffer<T> {}
447
448impl<T> Drop for SpscRingBuffer<T> {
449    fn drop(&mut self) {
450        // Drain remaining elements on drop
451        while self.pop().is_some() {}
452    }
453}
454
455#[cfg(test)]
456mod tests {
457    use super::*;
458    use std::sync::Arc;
459    use std::thread;
460
461    #[test]
462    fn supports_fifo_push_and_pop() {
463        let ring = SpscRingBuffer::<i32>::new(RingConfig::default());
464        assert!(ring.push(1).is_ok());
465        assert!(ring.push(2).is_ok());
466        assert_eq!(ring.len(), 2);
467        assert_eq!(ring.pop(), Some(1));
468        assert_eq!(ring.pop(), Some(2));
469        assert!(ring.is_empty());
470    }
471
472    #[test]
473    fn returns_full_when_capacity_is_reached() {
474        let config = RingConfig {
475            capacity: 2,
476            backpressure_threshold: 1,
477        };
478        let ring = SpscRingBuffer::<i32>::new(config);
479        assert!(ring.push(1).is_ok());
480        assert!(ring.push(2).is_ok());
481        assert_eq!(ring.push(3), Err(3));
482    }
483
484    #[test]
485    fn supports_single_producer_single_consumer_threads() {
486        let ring = Arc::new(SpscRingBuffer::<i32>::new(RingConfig::default()));
487        let producer_ring = Arc::clone(&ring);
488        let consumer_ring = Arc::clone(&ring);
489
490        let producer = thread::spawn(move || {
491            for value in 0..1_000 {
492                loop {
493                    if producer_ring.push(value).is_ok() {
494                        break;
495                    }
496                    std::thread::yield_now();
497                }
498            }
499        });
500
501        let consumer = thread::spawn(move || {
502            let mut values = Vec::new();
503            while values.len() < 1_000 {
504                if let Some(value) = consumer_ring.pop() {
505                    values.push(value);
506                } else {
507                    std::thread::yield_now();
508                }
509            }
510            values
511        });
512
513        producer.join().unwrap();
514        let values = consumer.join().unwrap();
515
516        assert_eq!(values.len(), 1_000);
517        assert_eq!(values.first(), Some(&0));
518        assert_eq!(values.last(), Some(&999));
519    }
520
521    #[test]
522    fn handles_wrap_around_correctly() {
523        let ring = SpscRingBuffer::<i32>::new(RingConfig::default());
524
525        // Fill most of the ring
526        for i in 0..1020 {
527            ring.push(i).unwrap();
528        }
529
530        assert_eq!(ring.len(), 1020);
531
532        // Pop all elements in FIFO order
533        for i in 0..1020 {
534            assert_eq!(ring.pop(), Some(i));
535        }
536
537        assert!(ring.is_empty());
538    }
539
540    #[test]
541    fn backpressure_threshold_works() {
542        let config = RingConfig {
543            capacity: 100,
544            backpressure_threshold: 70,
545        };
546        let ring = SpscRingBuffer::<i32>::new(config);
547
548        // Fill to threshold - should not backpressure
549        for i in 0..69 {
550            ring.push(i).unwrap();
551        }
552        assert!(!ring.should_backpressure());
553
554        // Fill one more - should backpressure
555        let result = ring.push(70);
556        assert!(result.is_ok()); // Push succeeds until capacity (100) is reached
557        assert!(ring.should_backpressure());
558    }
559
560    #[test]
561    fn wait_for_space_releases() {
562        let ring = Arc::new(SpscRingBuffer::<i32>::new(RingConfig::default()));
563        let consumer_ring = Arc::clone(&ring);
564
565        // Fill the ring
566        for i in 0..1020 {
567            ring.push(i).unwrap();
568        }
569
570        // Consumer pops one element
571        consumer_ring.pop();
572
573        // Producer should be able to push again
574        let result = ring.push(1021);
575        assert!(result.is_ok());
576    }
577
578    #[test]
579    fn wait_for_data_releases() {
580        let ring = Arc::new(SpscRingBuffer::<i32>::new(RingConfig::default()));
581        let producer_ring = Arc::clone(&ring);
582
583        // Ring is empty
584
585        // Producer pushes one element
586        producer_ring.push(42).unwrap();
587
588        // Consumer should be able to pop now
589        assert_eq!(ring.pop(), Some(42));
590    }
591
592    #[test]
593    fn push_batch_writes_multiple_elements() {
594        let ring = SpscRingBuffer::<u64>::new(RingConfig::default());
595        let data: Vec<u64> = (0..100).collect();
596        let n = ring.push_batch(&data);
597        assert_eq!(n, 100);
598        assert_eq!(ring.len(), 100);
599    }
600
601    #[test]
602    fn push_batch_respects_capacity() {
603        let config = RingConfig {
604            capacity: 10,
605            backpressure_threshold: 8,
606        };
607        let ring = SpscRingBuffer::<u64>::new(config);
608        let data: Vec<u64> = (0..100).collect();
609        let n = ring.push_batch(&data);
610        assert_eq!(n, 10);
611        assert_eq!(ring.len(), 10);
612        // The 90 elements that didn't fit must be observable via overflow_count,
613        // otherwise backpressure through the batch path is invisible to monitoring.
614        assert_eq!(ring.overflow_count(), 90);
615    }
616
617    #[test]
618    fn pop_batch_reads_multiple_elements() {
619        let ring = SpscRingBuffer::<u64>::new(RingConfig::default());
620        for i in 0..50u64 {
621            ring.push(i).unwrap();
622        }
623        let mut out = vec![std::mem::MaybeUninit::uninit(); 20];
624        let n = ring.pop_batch(&mut out);
625        assert_eq!(n, 20);
626        let drained: Vec<u64> = out[..n]
627            .iter()
628            .map(|x| unsafe { x.assume_init_read() })
629            .collect();
630        assert_eq!(drained, (0..20).collect::<Vec<u64>>());
631        assert_eq!(ring.len(), 30);
632    }
633
634    #[test]
635    fn pop_batch_returns_available_count() {
636        let ring = SpscRingBuffer::<u64>::new(RingConfig::default());
637        for i in 0..5u64 {
638            ring.push(i).unwrap();
639        }
640        let mut out = vec![std::mem::MaybeUninit::uninit(); 100];
641        let n = ring.pop_batch(&mut out);
642        assert_eq!(n, 5);
643    }
644
645    #[test]
646    fn push_pop_batch_round_trip_spsc() {
647        let ring = Arc::new(SpscRingBuffer::<u64>::new(RingConfig::default()));
648        let prod = Arc::clone(&ring);
649        let cons = Arc::clone(&ring);
650        let iters = 5_000u64;
651        let batch_size = 64;
652
653        let h_prod = std::thread::spawn(move || {
654            let mut sent = 0u64;
655            while sent < iters {
656                let chunk_size = batch_size.min((iters - sent) as usize);
657                let chunk: Vec<u64> = (sent..sent + chunk_size as u64).collect();
658                let n = prod.push_batch(&chunk);
659                sent += n as u64;
660                if n == 0 {
661                    prod.wait_for_space();
662                }
663            }
664        });
665
666        let h_cons = std::thread::spawn(move || {
667            let mut received = 0u64;
668            let mut buf = vec![std::mem::MaybeUninit::uninit(); batch_size];
669            while received < iters {
670                let n = cons.pop_batch(&mut buf);
671                if n > 0 {
672                    received += n as u64;
673                } else {
674                    cons.wait_for_data();
675                }
676            }
677        });
678
679        h_prod.join().unwrap();
680        h_cons.join().unwrap();
681        assert!(ring.is_empty());
682    }
683
684    #[test]
685    fn write_read_available_tracking() {
686        let config = RingConfig {
687            capacity: 100,
688            backpressure_threshold: 70,
689        };
690        let ring = SpscRingBuffer::<u64>::new(config);
691        assert_eq!(ring.write_available(), 100);
692        assert_eq!(ring.read_available(), 0);
693
694        for i in 0..30u64 {
695            ring.push(i).unwrap();
696        }
697        assert_eq!(ring.write_available(), 70);
698        assert_eq!(ring.read_available(), 30);
699
700        // pop() advances head; write_available() is conservatively stale
701        // (cached_head not refreshed since no push failed), so it may report
702        // slightly less than the true capacity.
703        ring.pop();
704        assert_eq!(ring.read_available(), 29);
705        let wa = ring.write_available();
706        assert!(
707            wa == 70 || wa == 71,
708            "write_available should be 70 (conservative) or 71 (true): got {wa}"
709        );
710    }
711}