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 /// Prediction requires a sustained run of empty-ring waits. A
719 /// consumer descheduled on a loaded host arrives to a backlog
720 /// instead, which is the mixed regime the estimator refuses to
721 /// predict in, so the cadence gets several attempts and the
722 /// assertion rests on the predictor rather than on the host's
723 /// scheduler. FIFO is checked on every attempt.
724 #[test]
725 fn phase_locked_recv_preserves_order_and_engages() {
726 use crate::phase_estimator::{PhaseConfig, PhaseEstimator};
727
728 let mut engaged_once = false;
729 for _ in 0..5 {
730 let ring = Arc::new(BlockingSpscRing::create_anon(256).expect("create"));
731 let n = 4_000u64;
732
733 // Producer: a regular ~15us cadence so the estimator engages.
734 let r2 = Arc::clone(&ring);
735 let producer = thread::spawn(move || {
736 for i in 0..n {
737 let mut payload = [0u8; 56];
738 payload[..8].copy_from_slice(&i.to_le_bytes());
739 while r2.try_push(&payload).is_err() {
740 std::hint::spin_loop();
741 }
742 let t = Instant::now();
743 while t.elapsed() < Duration::from_micros(15) {
744 std::hint::spin_loop();
745 }
746 }
747 });
748
749 let mut est = PhaseEstimator::new(PhaseConfig::default());
750 let mut stats = PhaseRecvStats::default();
751 let mut buf = [0u8; 64];
752 for expected in 0..n {
753 ring.recv_phase_locked(
754 &mut buf,
755 &mut est,
756 Duration::from_micros(3),
757 Some(Duration::from_secs(5)),
758 &mut stats,
759 ).expect("recv");
760 let got = u64::from_le_bytes(buf[..8].try_into().unwrap());
761 assert_eq!(got, expected, "phase-locked recv must preserve FIFO");
762 }
763 producer.join().unwrap();
764
765 // The estimator must have engaged and caught a meaningful
766 // share of items via the syscall-free guard-band spin.
767 if est.engaged() || stats.spin_catches > 0 {
768 assert!(stats.spin_catches > 0,
769 "engaged mode must catch items via the guard-band spin");
770 engaged_once = true;
771 break;
772 }
773 }
774 assert!(engaged_once, "a regular cadence must engage the predictor");
775 }
776}