Skip to main content

subetha_cxc/
blocking_spsc_ring.rs

1//! `BlockingSpscRing`: SPSC ring with cross-process futex-shaped
2//! `recv_blocking(timeout)` / `send_blocking(timeout)`.
3//!
4//! Wraps `SpscRingCore` + `CrossProcessWaker`. The hot path
5//! (`try_send` / `try_recv`) stays the same as the bare SPSC
6//! primitive. The blocking calls park the caller on the waker
7//! when the ring is empty (recv) or full (send), and the
8//! counterparty's post-publish path fires a single-slot wake to
9//! release them.
10//!
11//! See [`crate::cross_process_waker`] for the wake protocol +
12//! storage layout. See `examples/blocking_spsc_e2e.rs` for the
13//! intra-process worked example and
14//! `examples/blocking_spsc_xproc_producer.rs` +
15//! `..._consumer.rs` for the cross-process pair.
16
17use std::path::Path;
18use std::sync::Arc;
19use std::sync::Mutex;
20use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
21use std::time::{Duration, Instant};
22
23use crate::cross_process_waker::{
24    CrossProcessWaker, WakerError, MAX_WAITERS_DEFAULT,
25};
26use crate::phase_estimator::{PhaseConfig, PhaseEstimator};
27use crate::shared_ring::RingError;
28use crate::spsc_ring::SpscRingCore;
29
30/// Instrumentation for [`BlockingSpscRing::recv_phase_locked`]: how
31/// each item was caught. The headline ratio is `spin_catches` (no
32/// wake syscall) vs `doorbell_catches` (a park/wake round-trip).
33#[derive(Debug, Default, Clone, Copy)]
34pub struct PhaseRecvStats {
35    /// Item already present at entry (no wait at all).
36    pub fast_catches: u64,
37    /// Predictive parks: a budgeted wait until just before the
38    /// predicted arrival, in engaged mode.
39    pub predictive_parks: u64,
40    /// Item caught by the guard-band spin after a predictive park -
41    /// the syscall-free path the experiment is about.
42    pub spin_catches: u64,
43    /// Doorbell parks: a park in the fallback (disengaged or
44    /// missed-prediction) path.
45    pub doorbell_parks: u64,
46    /// Item caught in the fallback path.
47    pub doorbell_catches: u64,
48}
49
50/// Errors returned by the blocking variant.
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum BlockingError {
53    /// Wrapped ring error from the underlying SPSC primitive.
54    Ring(RingError),
55    /// All waker slots in use; caller's fallback is to spin via
56    /// `try_send` / `try_recv` directly.
57    WakerFull,
58    /// `recv_blocking` / `send_blocking` returned because the
59    /// caller-supplied timeout elapsed without the counterparty
60    /// firing a wake.
61    Timeout,
62    /// Waker mmap layout did not match expectations on open.
63    WakerLayout,
64    /// I/O error from the underlying mmap of either the ring or
65    /// one of the wakers.
66    Io(std::io::ErrorKind),
67}
68
69impl From<RingError> for BlockingError {
70    fn from(e: RingError) -> Self { Self::Ring(e) }
71}
72
73impl From<WakerError> for BlockingError {
74    fn from(e: WakerError) -> Self {
75        match e {
76            WakerError::Full => Self::WakerFull,
77            WakerError::Timeout => Self::Timeout,
78            WakerError::LayoutMismatch => Self::WakerLayout,
79            WakerError::IoError(k) => Self::Io(k),
80        }
81    }
82}
83
84/// Consumer-local adaptive phase-locked-waiting state. SPSC has
85/// exactly one consumer, so the ring owns it.
86///
87/// DEFAULT OFF. The cross-process bench (`phase_lock_xproc`) showed
88/// that across the OS process boundary - SubEtha's primary use case -
89/// the doorbell wake is already ~400-500 ns and predictive waiting is
90/// a LOSS (worse p50 and much worse p99 from prediction jitter). The
91/// in-process win it shows (10-50x) came from thread-scheduling
92/// contention inflating the in-process doorbell to ~10 us, which does
93/// not occur cross-process. So predictive waiting is opt-in for the
94/// narrow in-process-contended case, enabled via
95/// [`set_phase_locking`](BlockingSpscRing::set_phase_locking).
96///
97/// When enabled, two nested gates keep it cheap: a **wait-mode gate**
98/// (`in_wait_mode`) runs the estimator only while the consumer waits
99/// on an empty ring (the fast path reads one relaxed atomic and skips
100/// it otherwise), and a **sustained-wait + CV engage gate** inside
101/// the estimator predicts only on a regular cadence after consecutive
102/// empty-ring waits.
103struct PhaseControl {
104    enabled: AtomicBool,
105    in_wait_mode: AtomicBool,
106    /// Consecutive fast-path catches while in wait mode; a long run
107    /// means the consumer has caught up and prediction is moot -
108    /// leave wait mode. Atomic so the fast path touches it without
109    /// the estimator lock or an `Instant::now`.
110    consecutive_fast: AtomicU32,
111    /// Consecutive empty-ring waits. Prediction fires only after a
112    /// sustained run, so a MIXED regime (small backlog, only
113    /// occasional empties) never predicts - predicting there mistimes
114    /// the park against queued items and adds latency. Reset by any
115    /// fast catch.
116    consecutive_waits: AtomicU32,
117    guard_band: Duration,
118    /// Sticky count of items caught by the predictive guard-band spin
119    /// (the syscall-free path). Observability - proves the mechanism
120    /// fired, surviving the tail-drain estimator reset.
121    predictive_catches: AtomicU64,
122    /// The arrival estimator. Locked ONLY on the wait path (already
123    /// slow), never on the fast path.
124    est: Mutex<PhaseEstimator>,
125}
126
127impl PhaseControl {
128    fn new() -> Self {
129        Self {
130            // OFF by default: predictive waiting loses cross-process
131            // (see the type doc); opt-in via set_phase_locking.
132            enabled: AtomicBool::new(false),
133            in_wait_mode: AtomicBool::new(false),
134            consecutive_fast: AtomicU32::new(0),
135            consecutive_waits: AtomicU32::new(0),
136            guard_band: Duration::from_micros(3),
137            predictive_catches: AtomicU64::new(0),
138            est: Mutex::new(PhaseEstimator::new(PhaseConfig::default())),
139        }
140    }
141}
142
143/// SPSC ring with cross-process blocking recv / send.
144pub struct BlockingSpscRing {
145    inner: Arc<SpscRingCore>,
146    /// Wakes a parked CONSUMER when the producer pushes (consumer
147    /// is waiting on a non-empty ring).
148    consumer_waker: Arc<CrossProcessWaker>,
149    /// Wakes a parked PRODUCER when the consumer pops (producer
150    /// is waiting on a non-full ring).
151    producer_waker: Arc<CrossProcessWaker>,
152    /// Adaptive phase-locked waiting, automatic and atomically
153    /// toggleable. See [`PhaseControl`].
154    phase: PhaseControl,
155}
156
157const PRE_PARK_SPIN: u32 = 32;
158/// Consecutive fast-path catches that take the consumer out of wait
159/// mode (it has caught up; prediction is moot until it waits again).
160const PHASE_EXIT_FAST_RUN: u32 = 64;
161/// Consecutive empty-ring waits required before prediction fires.
162/// Below this the regime is mixed (queued items, not clean waiting)
163/// and predicting mistimes the park - so the consumer just doorbells,
164/// staying at parity instead of regressing.
165const PHASE_MIN_SUSTAINED_WAITS: u32 = 8;
166
167impl BlockingSpscRing {
168    /// Anon (in-process) ring + both wakers anon.
169    pub fn create_anon(capacity: usize) -> Result<Self, BlockingError> {
170        let inner = SpscRingCore::create_anon(capacity).map_err(BlockingError::from)?;
171        let consumer_waker = CrossProcessWaker::create_anon(MAX_WAITERS_DEFAULT)
172            .map_err(BlockingError::from)?;
173        let producer_waker = CrossProcessWaker::create_anon(MAX_WAITERS_DEFAULT)
174            .map_err(BlockingError::from)?;
175        Ok(Self {
176            inner: Arc::new(inner),
177            consumer_waker: Arc::new(consumer_waker),
178            producer_waker: Arc::new(producer_waker),
179            phase: PhaseControl::new(),
180        })
181    }
182
183    /// File-backed ring + both wakers in adjacent files.
184    /// Suffixes: `.ring.bin`, `.cw.bin`, `.pw.bin`.
185    pub fn create(
186        base_path: impl AsRef<Path>,
187        capacity: usize,
188    ) -> Result<Self, BlockingError> {
189        let base = base_path.as_ref();
190        let mut ring_path = base.as_os_str().to_owned();
191        ring_path.push(".ring.bin");
192        let mut cw_path = base.as_os_str().to_owned();
193        cw_path.push(".cw.bin");
194        let mut pw_path = base.as_os_str().to_owned();
195        pw_path.push(".pw.bin");
196        let inner = SpscRingCore::create(std::path::PathBuf::from(ring_path), capacity)
197            .map_err(BlockingError::from)?;
198        let consumer_waker = CrossProcessWaker::create(
199            std::path::PathBuf::from(cw_path),
200            MAX_WAITERS_DEFAULT,
201        ).map_err(BlockingError::from)?;
202        let producer_waker = CrossProcessWaker::create(
203            std::path::PathBuf::from(pw_path),
204            MAX_WAITERS_DEFAULT,
205        ).map_err(BlockingError::from)?;
206        Ok(Self {
207            inner: Arc::new(inner),
208            consumer_waker: Arc::new(consumer_waker),
209            producer_waker: Arc::new(producer_waker),
210            phase: PhaseControl::new(),
211        })
212    }
213
214    /// Open an existing file-backed ring + wakers.
215    pub fn open(
216        base_path: impl AsRef<Path>,
217        expected_capacity: usize,
218    ) -> Result<Self, BlockingError> {
219        let base = base_path.as_ref();
220        let mut ring_path = base.as_os_str().to_owned();
221        ring_path.push(".ring.bin");
222        let mut cw_path = base.as_os_str().to_owned();
223        cw_path.push(".cw.bin");
224        let mut pw_path = base.as_os_str().to_owned();
225        pw_path.push(".pw.bin");
226        let inner = SpscRingCore::open(std::path::PathBuf::from(ring_path), expected_capacity)
227            .map_err(BlockingError::from)?;
228        let consumer_waker = CrossProcessWaker::open(
229            std::path::PathBuf::from(cw_path),
230            MAX_WAITERS_DEFAULT,
231        ).map_err(BlockingError::from)?;
232        let producer_waker = CrossProcessWaker::open(
233            std::path::PathBuf::from(pw_path),
234            MAX_WAITERS_DEFAULT,
235        ).map_err(BlockingError::from)?;
236        Ok(Self {
237            inner: Arc::new(inner),
238            consumer_waker: Arc::new(consumer_waker),
239            producer_waker: Arc::new(producer_waker),
240            phase: PhaseControl::new(),
241        })
242    }
243
244    /// Direct access to the underlying SPSC ring for callers that
245    /// want the non-blocking surface.
246    pub fn inner(&self) -> &Arc<SpscRingCore> { &self.inner }
247
248    /// Wakers (in case the caller wants to peek wake counts for
249    /// instrumentation).
250    pub fn consumer_waker(&self) -> &Arc<CrossProcessWaker> { &self.consumer_waker }
251    pub fn producer_waker(&self) -> &Arc<CrossProcessWaker> { &self.producer_waker }
252
253    /// Hot-path non-blocking push. On success, fires a single-slot
254    /// wake at the consumer_waker so any blocked recv runs.
255    #[inline]
256    pub fn try_push(&self, payload: &[u8]) -> Result<(), RingError> {
257        let r = self.inner.try_push(payload);
258        if r.is_ok() {
259            self.consumer_waker.wake_up_to(self.inner.head());
260        }
261        r
262    }
263
264    /// Hot-path non-blocking pop. On success, fires a wake at the
265    /// producer_waker so any blocked send runs.
266    #[inline]
267    pub fn try_pop(&self, out: &mut [u8]) -> Result<usize, RingError> {
268        let r = self.inner.try_pop(out);
269        if r.is_ok() {
270            self.producer_waker.wake_up_to(self.inner.tail());
271        }
272        r
273    }
274
275    /// Block until either a push succeeds or `timeout` elapses.
276    /// On `Err(Timeout)` the caller's payload is NOT in the ring.
277    pub fn send_blocking(
278        &self,
279        payload: &[u8],
280        timeout: Option<Duration>,
281    ) -> Result<(), BlockingError> {
282        let deadline = timeout.map(|d| Instant::now() + d);
283        loop {
284            match self.try_push(payload) {
285                Ok(()) => return Ok(()),
286                Err(RingError::Full) => {}
287                Err(e) => return Err(BlockingError::Ring(e)),
288            }
289            for _ in 0..PRE_PARK_SPIN {
290                if self.inner.try_push(payload).is_ok() {
291                    self.consumer_waker.wake_up_to(self.inner.head());
292                    return Ok(());
293                }
294                std::hint::spin_loop();
295            }
296            let current_tail = self.inner.tail();
297            let token = self.producer_waker.try_park(current_tail + 1)?;
298            // Wake-before-park recovery.
299            if self.inner.try_push(payload).is_ok() {
300                self.producer_waker.release(token);
301                self.consumer_waker.wake_up_to(self.inner.head());
302                return Ok(());
303            }
304            let remaining = match deadline {
305                None => None,
306                Some(d) => {
307                    let now = Instant::now();
308                    if now >= d {
309                        self.producer_waker.release(token);
310                        return Err(BlockingError::Timeout);
311                    }
312                    Some(d - now)
313                }
314            };
315            match self.producer_waker.wait(token, remaining) {
316                Ok(()) => continue,
317                Err(WakerError::Timeout) => return Err(BlockingError::Timeout),
318                Err(e) => return Err(BlockingError::from(e)),
319            }
320        }
321    }
322
323    /// Enable or disable predictive (phase-locked) waiting on this
324    /// ring's consumer at runtime. Atomic; takes effect on the next
325    /// `recv_blocking` call. Default is DISABLED - the bare doorbell
326    /// wins cross-process (the primary use case). Enable it only for
327    /// an IN-PROCESS consumer whose producer contends for cores
328    /// (where the doorbell wake inflates and predictive spinning
329    /// shaves it); see the `phase_lock_probe` (in-process win) and
330    /// `phase_lock_xproc` (cross-process loss) benches.
331    pub fn set_phase_locking(&self, enabled: bool) {
332        self.phase.enabled.store(enabled, Ordering::Relaxed);
333    }
334
335    /// Whether automatic phase-locked waiting is currently enabled.
336    pub fn phase_locking_enabled(&self) -> bool {
337        self.phase.enabled.load(Ordering::Relaxed)
338    }
339
340    /// Whether the consumer is currently in wait mode (the estimator
341    /// is live). Observability; false in steady high-throughput.
342    pub fn phase_in_wait_mode(&self) -> bool {
343        self.phase.in_wait_mode.load(Ordering::Relaxed)
344    }
345
346    /// Whether the arrival predictor is currently engaged (regular
347    /// cadence, enough samples). Observability accessor - locks the
348    /// estimator, so not for the hot path. Note: the estimator resets
349    /// when the consumer leaves wait mode, so this can read false at
350    /// the end of a run even after heavy engagement - use
351    /// [`phase_predictive_catches`](Self::phase_predictive_catches)
352    /// for a sticky "did it fire" signal.
353    pub fn phase_engaged(&self) -> bool {
354        self.phase.est.lock().unwrap().engaged()
355    }
356
357    /// Sticky count of items caught by the predictive guard-band spin
358    /// since construction - the syscall-free path. Nonzero proves the
359    /// adaptive mechanism engaged and fired.
360    pub fn phase_predictive_catches(&self) -> u64 {
361        self.phase.predictive_catches.load(Ordering::Relaxed)
362    }
363
364    /// Fast-path catch while in wait mode: cheap, lock-free,
365    /// `Instant`-free. The consumer caught up; count toward leaving
366    /// wait mode. No estimator update - only WAIT arrivals feed the
367    /// period estimate.
368    #[inline]
369    fn phase_on_fast(&self) {
370        self.phase.consecutive_waits.store(0, Ordering::Relaxed);
371        let cf = self.phase.consecutive_fast.fetch_add(1, Ordering::Relaxed) + 1;
372        if cf >= PHASE_EXIT_FAST_RUN {
373            // Caught up: leave wait mode and reset the estimator so
374            // the next wait re-learns a fresh cadence.
375            self.phase.in_wait_mode.store(false, Ordering::Relaxed);
376            self.phase.consecutive_fast.store(0, Ordering::Relaxed);
377            *self.phase.est.lock().unwrap() =
378                PhaseEstimator::new(PhaseConfig::default());
379        }
380    }
381
382    /// Wait-path catch: feed the estimator (this is already the slow
383    /// path, so the lock + `Instant` are free relative to the park).
384    fn phase_on_wait(&self, now: Instant) {
385        self.phase.consecutive_fast.store(0, Ordering::Relaxed);
386        self.phase.consecutive_waits.fetch_add(1, Ordering::Relaxed);
387        self.phase.est.lock().unwrap().record(now);
388    }
389
390    /// The engaged predictive path: park to just before the predicted
391    /// arrival, then spin the guard band. Returns `Ok(Some(n))` on a
392    /// catch, `Ok(None)` when disengaged or the prediction missed
393    /// (fall through to the doorbell), `Err` on timeout. Never holds
394    /// the estimator lock across the park.
395    fn phase_predict_and_spin(
396        &self,
397        out: &mut [u8],
398        deadline: Option<Instant>,
399    ) -> Result<Option<usize>, BlockingError> {
400        // Predict only on a regular cadence AND sustained empty-ring
401        // waiting - a mixed regime stays on the doorbell.
402        if self.phase.consecutive_waits.load(Ordering::Relaxed)
403            < PHASE_MIN_SUSTAINED_WAITS
404        {
405            return Ok(None);
406        }
407        let (engaged, predicted) = {
408            let est = self.phase.est.lock().unwrap();
409            (est.engaged(), est.predict_next())
410        };
411        let Some(predicted) = predicted.filter(|_| engaged) else {
412            return Ok(None);
413        };
414        let now = Instant::now();
415        if let Some(wake_at) = predicted.checked_sub(self.phase.guard_band)
416            && wake_at > now
417        {
418            let mut budget = wake_at - now;
419            if let Some(d) = deadline {
420                budget = budget.min(d.saturating_duration_since(now));
421            }
422            if !budget.is_zero() {
423                let token = self.consumer_waker.try_park(self.inner.head() + 1)?;
424                if let Ok(n) = self.try_pop(out) {
425                    self.consumer_waker.release(token);
426                    return Ok(Some(n));
427                }
428                self.consumer_waker.wait(token, Some(budget)).ok();
429            }
430        }
431        let spin_end = predicted + self.phase.guard_band;
432        loop {
433            if let Ok(n) = self.try_pop(out) {
434                return Ok(Some(n));
435            }
436            let now = Instant::now();
437            if let Some(d) = deadline
438                && now >= d
439            {
440                return Err(BlockingError::Timeout);
441            }
442            if now > spin_end {
443                return Ok(None); // missed prediction -> doorbell
444            }
445            std::hint::spin_loop();
446        }
447    }
448
449    /// Block until either a pop succeeds or `timeout` elapses.
450    /// On `Err(Timeout)` `out` is unchanged.
451    ///
452    /// By default this is the bare doorbell park (the consumer parks
453    /// on the cross-process waker until the producer's push wakes it).
454    /// Predictive (phase-locked) waiting is OPT-IN via
455    /// [`Self::set_phase_locking`] - it wins only for an in-process consumer
456    /// whose producer contends for cores, and LOSES cross-process
457    /// where the doorbell is already fast. When enabled and the
458    /// consumer waits on a regular-cadence producer, it predicts the
459    /// arrival and spins a short guard band instead of paying the
460    /// wake propagation; the wait-mode gate keeps the fast path at one
461    /// relaxed atomic load. Correctness (exactly-once, FIFO) is
462    /// identical in every mode.
463    pub fn recv_blocking(
464        &self,
465        out: &mut [u8],
466        timeout: Option<Duration>,
467    ) -> Result<usize, BlockingError> {
468        let deadline = timeout.map(|d| Instant::now() + d);
469        let adaptive = self.phase.enabled.load(Ordering::Relaxed);
470        // Per-call: did this recv park/spin-wait before catching? A
471        // catch after a park is a WAIT, even though it surfaces via
472        // the fast-path try_pop on the loop-back - classifying it as
473        // "fast" would reset the sustained-wait counter and prediction
474        // would never accumulate.
475        let mut waited = false;
476
477        loop {
478            // Fast path: item already present.
479            match self.try_pop(out) {
480                Ok(n) => {
481                    if adaptive && self.phase.in_wait_mode.load(Ordering::Relaxed) {
482                        if waited {
483                            self.phase_on_wait(Instant::now());
484                        } else {
485                            self.phase_on_fast();
486                        }
487                    }
488                    return Ok(n);
489                }
490                Err(RingError::Empty) => {}
491                Err(e) => return Err(BlockingError::Ring(e)),
492            }
493            for _ in 0..PRE_PARK_SPIN {
494                if let Ok(n) = self.inner.try_pop(out) {
495                    self.producer_waker.wake_up_to(self.inner.tail());
496                    if adaptive && self.phase.in_wait_mode.load(Ordering::Relaxed) {
497                        if waited {
498                            self.phase_on_wait(Instant::now());
499                        } else {
500                            self.phase_on_fast();
501                        }
502                    }
503                    return Ok(n);
504                }
505                std::hint::spin_loop();
506            }
507
508            // About to wait: enter wait mode and try the predictive
509            // path before falling back to the doorbell park.
510            waited = true;
511            if adaptive {
512                self.phase.in_wait_mode.store(true, Ordering::Relaxed);
513                if let Some(n) = self.phase_predict_and_spin(out, deadline)? {
514                    self.phase.predictive_catches.fetch_add(1, Ordering::Relaxed);
515                    self.phase_on_wait(Instant::now());
516                    return Ok(n);
517                }
518            }
519
520            // Doorbell park (today's behavior).
521            let current_head = self.inner.head();
522            let token = self.consumer_waker.try_park(current_head + 1)?;
523            if let Ok(n) = self.inner.try_pop(out) {
524                self.consumer_waker.release(token);
525                self.producer_waker.wake_up_to(self.inner.tail());
526                if adaptive && self.phase.in_wait_mode.load(Ordering::Relaxed) {
527                    self.phase_on_wait(Instant::now());
528                }
529                return Ok(n);
530            }
531            let remaining = match deadline {
532                None => None,
533                Some(d) => {
534                    let now = Instant::now();
535                    if now >= d {
536                        self.consumer_waker.release(token);
537                        return Err(BlockingError::Timeout);
538                    }
539                    Some(d - now)
540                }
541            };
542            match self.consumer_waker.wait(token, remaining) {
543                Ok(()) => continue,
544                Err(WakerError::Timeout) => return Err(BlockingError::Timeout),
545                Err(e) => return Err(BlockingError::from(e)),
546            }
547        }
548    }
549
550    /// Predictive blocking pop. When the `estimator` is engaged (the
551    /// producer's cadence is regular enough), this parks only until
552    /// `guard_band` before the predicted next arrival, then spins
553    /// through the guard band catching the item by polling - skipping
554    /// the park/wake syscall round-trip the doorbell pays. When the
555    /// estimator is disengaged (irregular cadence) or the prediction
556    /// is missed, it falls back to the same doorbell park as
557    /// [`Self::recv_blocking`], so correctness is identical in every mode.
558    ///
559    /// The estimator is consumer-local state the caller owns; pass the
560    /// same `&mut` instance across calls so it accumulates cadence.
561    /// `stats` accumulates how each item was caught.
562    pub fn recv_phase_locked(
563        &self,
564        out: &mut [u8],
565        estimator: &mut PhaseEstimator,
566        guard_band: Duration,
567        timeout: Option<Duration>,
568        stats: &mut PhaseRecvStats,
569    ) -> Result<usize, BlockingError> {
570        let deadline = timeout.map(|d| Instant::now() + d);
571
572        // Fast path: an item is already waiting.
573        if let Ok(n) = self.try_pop(out) {
574            estimator.record(Instant::now());
575            stats.fast_catches += 1;
576            return Ok(n);
577        }
578
579        // Engaged predictive path: park to just before the predicted
580        // arrival, then spin through the guard band.
581        if estimator.engaged()
582            && let Some(predicted) = estimator.predict_next()
583        {
584            let now = Instant::now();
585            if let Some(wake_at) = predicted.checked_sub(guard_band)
586                && wake_at > now
587            {
588                let mut budget = wake_at - now;
589                if let Some(d) = deadline {
590                    budget = budget.min(d.saturating_duration_since(now));
591                }
592                if !budget.is_zero() {
593                    let token = self.consumer_waker.try_park(self.inner.head() + 1)?;
594                    // Wake-before-park recovery.
595                    if let Ok(n) = self.try_pop(out) {
596                        self.consumer_waker.release(token);
597                        estimator.record(Instant::now());
598                        stats.fast_catches += 1;
599                        return Ok(n);
600                    }
601                    stats.predictive_parks += 1;
602                    // Woken by the doorbell or the budget elapsed -
603                    // either way, spin the guard band next.
604                    self.consumer_waker.wait(token, Some(budget)).ok();
605                }
606            }
607
608            // Guard-band spin: poll until the item lands or the window
609            // past the prediction closes (a missed prediction).
610            let spin_end = predicted + guard_band;
611            loop {
612                if let Ok(n) = self.try_pop(out) {
613                    estimator.record(Instant::now());
614                    stats.spin_catches += 1;
615                    return Ok(n);
616                }
617                let now = Instant::now();
618                if let Some(d) = deadline
619                    && now >= d
620                {
621                    return Err(BlockingError::Timeout);
622                }
623                if now > spin_end {
624                    break; // prediction missed; fall through to the doorbell
625                }
626                std::hint::spin_loop();
627            }
628        }
629
630        // Fallback: the doorbell park loop (identical to
631        // recv_blocking), recording arrivals so the estimator keeps
632        // learning even while disengaged.
633        loop {
634            if let Ok(n) = self.try_pop(out) {
635                estimator.record(Instant::now());
636                stats.doorbell_catches += 1;
637                return Ok(n);
638            }
639            for _ in 0..PRE_PARK_SPIN {
640                if let Ok(n) = self.try_pop(out) {
641                    estimator.record(Instant::now());
642                    stats.doorbell_catches += 1;
643                    return Ok(n);
644                }
645                std::hint::spin_loop();
646            }
647            let current_head = self.inner.head();
648            let token = self.consumer_waker.try_park(current_head + 1)?;
649            if let Ok(n) = self.try_pop(out) {
650                self.consumer_waker.release(token);
651                estimator.record(Instant::now());
652                stats.doorbell_catches += 1;
653                return Ok(n);
654            }
655            let remaining = match deadline {
656                None => None,
657                Some(d) => {
658                    let now = Instant::now();
659                    if now >= d {
660                        self.consumer_waker.release(token);
661                        return Err(BlockingError::Timeout);
662                    }
663                    Some(d - now)
664                }
665            };
666            stats.doorbell_parks += 1;
667            match self.consumer_waker.wait(token, remaining) {
668                Ok(()) => continue,
669                Err(WakerError::Timeout) => return Err(BlockingError::Timeout),
670                Err(e) => return Err(BlockingError::from(e)),
671            }
672        }
673    }
674}
675
676#[cfg(test)]
677mod tests {
678    use super::*;
679    use std::sync::Arc;
680    use std::thread;
681
682    #[test]
683    fn round_trip_blocking_anon() {
684        let ring = Arc::new(BlockingSpscRing::create_anon(4).expect("create"));
685        let r2 = Arc::clone(&ring);
686        let producer = thread::spawn(move || {
687            for i in 0..10u64 {
688                let mut payload = [0u8; 56];
689                payload[..8].copy_from_slice(&i.to_le_bytes());
690                r2.send_blocking(&payload, Some(Duration::from_secs(2)))
691                    .expect("send");
692            }
693        });
694        let r3 = Arc::clone(&ring);
695        let consumer = thread::spawn(move || {
696            let mut buf = [0u8; 64];
697            for expected in 0..10u64 {
698                r3.recv_blocking(&mut buf, Some(Duration::from_secs(2)))
699                    .expect("recv");
700                let got = u64::from_le_bytes(buf[..8].try_into().unwrap());
701                assert_eq!(got, expected);
702            }
703        });
704        producer.join().unwrap();
705        consumer.join().unwrap();
706    }
707
708    #[test]
709    fn recv_blocking_returns_timeout() {
710        let ring = BlockingSpscRing::create_anon(4).expect("create");
711        let mut buf = [0u8; 64];
712        let t0 = Instant::now();
713        let err = ring.recv_blocking(&mut buf, Some(Duration::from_millis(60)));
714        assert_eq!(err, Err(BlockingError::Timeout));
715        assert!(t0.elapsed() >= Duration::from_millis(50));
716    }
717
718    #[test]
719    fn phase_locked_recv_preserves_order_and_engages() {
720        use crate::phase_estimator::{PhaseConfig, PhaseEstimator};
721
722        let ring = Arc::new(BlockingSpscRing::create_anon(256).expect("create"));
723        let n = 4_000u64;
724
725        // Producer: a regular ~15us cadence so the estimator engages.
726        let r2 = Arc::clone(&ring);
727        let producer = thread::spawn(move || {
728            for i in 0..n {
729                let mut payload = [0u8; 56];
730                payload[..8].copy_from_slice(&i.to_le_bytes());
731                while r2.try_push(&payload).is_err() {
732                    std::hint::spin_loop();
733                }
734                let t = Instant::now();
735                while t.elapsed() < Duration::from_micros(15) {
736                    std::hint::spin_loop();
737                }
738            }
739        });
740
741        let mut est = PhaseEstimator::new(PhaseConfig::default());
742        let mut stats = PhaseRecvStats::default();
743        let mut buf = [0u8; 64];
744        for expected in 0..n {
745            ring.recv_phase_locked(
746                &mut buf,
747                &mut est,
748                Duration::from_micros(3),
749                Some(Duration::from_secs(5)),
750                &mut stats,
751            ).expect("recv");
752            let got = u64::from_le_bytes(buf[..8].try_into().unwrap());
753            assert_eq!(got, expected, "phase-locked recv must preserve FIFO");
754        }
755        producer.join().unwrap();
756
757        // The estimator must have engaged and caught a meaningful
758        // share of items via the syscall-free guard-band spin.
759        assert!(est.engaged() || stats.spin_catches > 0,
760                "a regular cadence must engage the predictor");
761        assert!(stats.spin_catches > 0,
762                "engaged mode must catch items via the guard-band spin");
763    }
764}