subetha_cxc/cross_process_waker.rs
1//! `CrossProcessWaker`: a futex-shaped wait/wake primitive sitting
2//! in shared memory (MMF or named-shm), portable across Linux /
3//! Windows / macOS / FreeBSD.
4//!
5//! # The problem
6//!
7//! SubEtha's bounded rings deliver bytes between threads / processes
8//! without any kernel involvement on the hot path. That's a win
9//! when the consumer can keep up - try_recv either returns an item
10//! or returns `Empty` and the caller decides what to do. The
11//! pattern breaks down when the consumer wants to BLOCK on an empty
12//! ring without spinning: there's no kernel-side handle to wait on,
13//! and a busy-wait burns one CPU per blocked consumer.
14//!
15//! `CrossProcessWaker` closes the gap. It's the userspace `futex`,
16//! ported to the substrate. The producer publishes a monotonic
17//! sequence atom on every push; a blocked consumer parks on a wake
18//! list in shared memory, registering the sequence it wants to be
19//! woken at. When the producer's sequence advances past that
20//! target, the producer's post-publish path fires a single
21//! syscall-level wake and the consumer's `wait` returns.
22//!
23//! # Cross-platform wake
24//!
25//! The primitive calls the platform's wait / wake syscalls
26//! directly (NOT via the `atomic-wait` crate, which hard-codes
27//! `FUTEX_PRIVATE_FLAG` on Linux and so cannot work across
28//! processes):
29//!
30//! - Linux / Android: `futex(FUTEX_WAIT)` / `futex(FUTEX_WAKE)`
31//! without the PRIVATE flag - the kernel hashes by the page's
32//! physical address so any process that mapped the same MMF
33//! page joins the same wait queue.
34//! - FreeBSD: `_umtx_op(UMTX_OP_WAIT_UINT)` /
35//! `_umtx_op(UMTX_OP_WAKE)` - the non-PRIVATE umtx ops, whose
36//! sleep queues the kernel keys by PHYSICAL address exactly so
37//! process-shared synchronization works (per `_umtx_op(2)`).
38//! Same cross-process semantics as the Linux arm.
39//! - Windows: `WaitOnAddress` / `WakeByAddressSingle` for
40//! process-private (anon-backed) wakers - those calls are
41//! INTRA-PROCESS only per Microsoft's docs. Cross-process
42//! (file / named-shm backed) wakers wait on the hardware
43//! MONITOR tier instead (`crate::monitor_wait`): monitors are
44//! physical-address based, so a store from another process to
45//! the shared MMF line wakes the waiter - the platform's only
46//! non-polling cross-process wake. On Windows hosts without
47//! MONITORX/WAITPKG, cross-process waits fall back to the
48//! wait-timeout + re-check recovery the blocking wrappers
49//! already run.
50//! - macOS / other: polling fallback (correct, but wastes CPU
51//! when idle).
52//!
53//! All shipping syscalls operate on a user-space address (no
54//! kernel handle bookkeeping per primitive), so the cross-process
55//! Linux case needs only that the waker's atomic lives in a
56//! mapping both processes have (named-shm or file-backed mmap).
57//! The kernel sees the same physical page from both sides and
58//! the wake reaches the parker.
59//!
60//! # Storage layout
61//!
62//! ```text
63//! +--------------------------------------+ offset 0
64//! | WakerHeader (64 bytes, one cache line)|
65//! | magic: u64 |
66//! | capacity: u32 |
67//! | _pad |
68//! +--------------------------------------+ offset 64
69//! | WakerSlot[0] (64 bytes) |
70//! | state: AtomicU32 (FREE/PARKED/WOKEN)|
71//! | _pad |
72//! | target_seq: AtomicU64 |
73//! | _pad |
74//! +--------------------------------------+
75//! | WakerSlot[1] ... WakerSlot[N-1] |
76//! +--------------------------------------+
77//! ```
78//!
79//! Each slot is one cache line so producer's wake-scan and
80//! parker's state writes don't false-share across slots.
81//!
82//! # Wake protocol
83//!
84//! ## Consumer (parker) side
85//!
86//! 1. Scan slots for one with `state == FREE`.
87//! 2. CAS that slot's state from FREE to a transient RESERVED state.
88//! 3. Write `target_seq` (the sequence we want to be woken at).
89//! 4. Store state from RESERVED to PARKED with Release ordering -
90//! this publishes the slot to producers and is the
91//! happens-before edge for `target_seq`.
92//! 5. Call the platform's wait syscall on `&slot.state` with
93//! expected = PARKED. The kernel verifies `state == PARKED`
94//! before sleeping (Linux's futex_wait semantics; Windows'
95//! WaitOnAddress likewise); if a producer's wake-CAS already
96//! landed (state == WOKEN), wait returns immediately without
97//! entering the kernel sleep path.
98//! 6. On return, store state back to FREE and release the slot.
99//!
100//! ## Producer (waker) side
101//!
102//! On every successful publish, call `wake_up_to(producer_seq)`.
103//! That scans slots:
104//!
105//! 1. Acquire-load `state`. If not PARKED, skip.
106//! 2. Relaxed-load `target_seq`. The Acquire on `state` acquired
107//! the parker's Release-store, so prior writes (incl. target_seq)
108//! are visible.
109//! 3. If `producer_seq >= target_seq`, CAS state from PARKED to
110//! WOKEN. On CAS success, call the platform's wake-one syscall
111//! on `&slot.state` and increment the wake counter.
112//!
113//! The CAS guards against a double-wake when multiple producers
114//! race to wake the same slot.
115//!
116//! # Wake-before-park race
117//!
118//! Between a blocked-recv's "try_recv returned Empty" check and
119//! its `try_park` call, a producer can publish AND call wake_up_to
120//! that finds zero parked slots. The standard recovery is the
121//! double-check in the blocking-recv wrapper: after parking,
122//! re-call try_recv before calling wait. If try_recv succeeds,
123//! release the token and return. Only if it still returns Empty
124//! does the consumer call wait.
125//!
126//! # Linux-futex-raw escape hatch
127//!
128//! The Cargo feature `linux-futex-raw` exposes the direct
129//! `libc::syscall(SYS_futex, ...)` surface to callers that need
130//! `FUTEX_WAIT_BITSET`, `FUTEX_REQUEUE`, or other ops the portable
131//! `atomic-wait` abstraction does not expose. Linux-only.
132
133use std::fs::{File, OpenOptions};
134use std::path::Path;
135use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
136use std::time::{Duration, Instant};
137
138use memmap2::{MmapMut, MmapOptions};
139
140/// Magic bytes identifying a CrossProcessWaker region. Used by
141/// `open` to reject the wrong kind of MMF.
142pub const WAKER_MAGIC: u64 = 0xE7E7_5742_4B45_5201; // "..WBKER.."
143
144/// Default slot capacity. Caller-overridable on construction.
145pub const MAX_WAITERS_DEFAULT: usize = 32;
146
147const STATE_FREE: u32 = 0;
148const STATE_RESERVED: u32 = 1;
149const STATE_PARKED: u32 = 2;
150const STATE_WOKEN: u32 = 3;
151
152#[repr(C, align(64))]
153struct WakerHeader {
154 magic: u64,
155 capacity: u32,
156 _pad0: [u8; 4],
157 /// One bit per slot index (< 64): set while the slot is
158 /// PARKED-ish. Producers' wake scans load this word first; a
159 /// zero mask makes the no-waiters case - the overwhelmingly
160 /// common one on a healthy ring - ONE cache line instead of
161 /// `capacity` slot lines. The bit is advisory: stale-set bits
162 /// are filtered by the per-slot state check, and a not-yet-set
163 /// bit is covered by the parker's pre-wait double-check, the
164 /// same race window the full scan always had. Capacities > 64
165 /// skip the mask and full-scan.
166 parked_mask: AtomicU64,
167 _pad: [u8; 64 - 24],
168}
169
170#[repr(C, align(64))]
171struct WakerSlot {
172 state: AtomicU32,
173 _pad1: [u8; 4],
174 target_seq: AtomicU64,
175 _pad2: [u8; 64 - 16],
176}
177
178const _: () = {
179 assert!(std::mem::size_of::<WakerHeader>() == 64);
180 assert!(std::mem::size_of::<WakerSlot>() == 64);
181};
182
183/// Errors returned by waker operations.
184#[derive(Debug, Clone, Copy, PartialEq, Eq)]
185pub enum WakerError {
186 /// All slots in use. Caller's fallback path: spin until the
187 /// thing they care about is ready (the same path they'd use
188 /// without a waker).
189 Full,
190 /// `wait()` returned because its timeout elapsed before any
191 /// producer fired a wake.
192 Timeout,
193 /// `open()` rejected the MMF because magic / capacity did
194 /// not match.
195 LayoutMismatch,
196 /// I/O error from the underlying mmap.
197 IoError(std::io::ErrorKind),
198}
199
200impl From<std::io::Error> for WakerError {
201 fn from(e: std::io::Error) -> Self { Self::IoError(e.kind()) }
202}
203
204impl std::fmt::Display for WakerError {
205 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
206 match self {
207 Self::Full => write!(f, "no free waker slot"),
208 Self::Timeout => write!(f, "wait timed out"),
209 Self::LayoutMismatch => write!(f, "waker layout mismatch on open"),
210 Self::IoError(k) => write!(f, "waker mmap io error: {k:?}"),
211 }
212 }
213}
214
215impl std::error::Error for WakerError {}
216
217/// Returned by `try_park`; identifies which slot the parker
218/// reserved. Pass back into `wait` and `release`.
219#[derive(Debug, Clone, Copy, PartialEq, Eq)]
220pub struct WakerToken {
221 slot: u32,
222}
223
224impl WakerToken {
225 /// The slot index this token is bound to. Useful for
226 /// debugging / instrumentation.
227 pub fn slot_index(&self) -> u32 { self.slot }
228}
229
230/// Bytes required for a waker region holding `capacity` slots.
231pub const fn waker_region_size(capacity: usize) -> usize {
232 std::mem::size_of::<WakerHeader>() + capacity * std::mem::size_of::<WakerSlot>()
233}
234
235/// Cross-process wake list. See module docs for the protocol.
236pub struct CrossProcessWaker {
237 _backing: WakerBacking,
238 raw_ptr: *mut u8,
239 capacity: usize,
240}
241
242unsafe impl Send for CrossProcessWaker {}
243unsafe impl Sync for CrossProcessWaker {}
244
245#[allow(dead_code)]
246enum WakerBacking {
247 Anon(MmapMut),
248 File(File, MmapMut),
249 Shm(crate::shm_file::ShmFile),
250}
251
252unsafe fn init_waker_layout_raw(ptr: *mut u8, capacity: usize) {
253 let hdr_ptr = ptr as *mut WakerHeader;
254 unsafe {
255 std::ptr::write_bytes(hdr_ptr as *mut u8, 0, std::mem::size_of::<WakerHeader>());
256 (*hdr_ptr).magic = WAKER_MAGIC;
257 (*hdr_ptr).capacity = capacity as u32;
258 }
259 let slots_base = unsafe { ptr.add(std::mem::size_of::<WakerHeader>()) };
260 for i in 0..capacity {
261 let slot_ptr = unsafe {
262 slots_base.add(i * std::mem::size_of::<WakerSlot>())
263 } as *mut WakerSlot;
264 unsafe {
265 std::ptr::write(slot_ptr, WakerSlot {
266 state: AtomicU32::new(STATE_FREE),
267 _pad1: [0; 4],
268 target_seq: AtomicU64::new(0),
269 _pad2: [0; 64 - 16],
270 });
271 }
272 }
273}
274
275impl CrossProcessWaker {
276 /// Anon (in-process) waker. Cross-thread only; for cross-
277 /// process use `create` (file) or `create_from_shm` (named).
278 pub fn create_anon(capacity: usize) -> Result<Self, WakerError> {
279 assert!(capacity >= 1, "capacity must be >= 1");
280 let total = waker_region_size(capacity);
281 let mut mmap = MmapOptions::new().len(total).map_anon()?;
282 let raw_ptr = mmap.as_mut_ptr();
283 unsafe { init_waker_layout_raw(raw_ptr, capacity); }
284 Ok(Self {
285 _backing: WakerBacking::Anon(mmap),
286 raw_ptr,
287 capacity,
288 })
289 }
290
291 /// File-backed waker, cross-process visible via the OS page cache.
292 /// Initialises the region only when `path` does not yet exist; otherwise
293 /// attaches, leaving parked waiters in place. A region built with a
294 /// different capacity is a `LayoutMismatch`. [`reset`](Self::reset)
295 /// reinitialises.
296 pub fn create(path: impl AsRef<Path>, capacity: usize) -> Result<Self, WakerError> {
297 assert!(capacity >= 1, "capacity must be >= 1");
298 let total = waker_region_size(capacity);
299 let (file, mut mmap) = crate::mmf_attach::create_or_attach(
300 path.as_ref(),
301 total,
302 |ptr| unsafe { init_waker_layout_raw(ptr, capacity) },
303 |ptr| unsafe { (*(ptr as *const WakerHeader)).magic == WAKER_MAGIC },
304 )?;
305 let raw_ptr = mmap.as_mut_ptr();
306 let hdr = unsafe { &*(raw_ptr as *const WakerHeader) };
307 if hdr.capacity as usize != capacity {
308 return Err(WakerError::LayoutMismatch);
309 }
310 Ok(Self {
311 _backing: WakerBacking::File(file, mmap),
312 raw_ptr,
313 capacity,
314 })
315 }
316
317 /// Reinitialise the waker at `path`, discarding any parked waiters a live
318 /// peer holds. For a caller that knows it owns the path.
319 pub fn reset(path: impl AsRef<Path>, capacity: usize) -> Result<Self, WakerError> {
320 assert!(capacity >= 1, "capacity must be >= 1");
321 let total = waker_region_size(capacity);
322 let (file, mut mmap) = crate::mmf_attach::reset(path.as_ref(), total, |ptr| unsafe {
323 init_waker_layout_raw(ptr, capacity)
324 })?;
325 let raw_ptr = mmap.as_mut_ptr();
326 Ok(Self {
327 _backing: WakerBacking::File(file, mmap),
328 raw_ptr,
329 capacity,
330 })
331 }
332
333 /// Open an existing file-backed waker. Validates magic +
334 /// capacity.
335 pub fn open(
336 path: impl AsRef<Path>,
337 expected_capacity: usize,
338 ) -> Result<Self, WakerError> {
339 let total = waker_region_size(expected_capacity);
340 let file = OpenOptions::new().read(true).write(true).open(path.as_ref())?;
341 if (file.metadata()?.len() as usize) < total {
342 return Err(WakerError::LayoutMismatch);
343 }
344 let mut mmap = unsafe { MmapOptions::new().len(total).map_mut(&file)? };
345 let raw_ptr = mmap.as_mut_ptr();
346 let hdr = unsafe { &*(raw_ptr as *const WakerHeader) };
347 if hdr.magic != WAKER_MAGIC || hdr.capacity as usize != expected_capacity {
348 return Err(WakerError::LayoutMismatch);
349 }
350 Ok(Self {
351 _backing: WakerBacking::File(file, mmap),
352 raw_ptr,
353 capacity: expected_capacity,
354 })
355 }
356
357 /// Build a fresh waker on top of a named-shm region. Cross-
358 /// process visible via the `logical_name` of the underlying
359 /// [`ShmFile`](crate::shm_file::ShmFile); RAM-resident.
360 pub fn create_from_shm(
361 mut shm: crate::shm_file::ShmFile,
362 capacity: usize,
363 ) -> Result<Self, WakerError> {
364 assert!(capacity >= 1, "capacity must be >= 1");
365 let total = waker_region_size(capacity);
366 if shm.len() < total {
367 return Err(WakerError::LayoutMismatch);
368 }
369 let raw_ptr = shm.as_mut_slice().as_mut_ptr();
370 unsafe { init_waker_layout_raw(raw_ptr, capacity); }
371 Ok(Self {
372 _backing: WakerBacking::Shm(shm),
373 raw_ptr,
374 capacity,
375 })
376 }
377
378 /// Open an existing named-shm waker without re-initializing
379 /// the layout.
380 pub fn open_from_shm(
381 mut shm: crate::shm_file::ShmFile,
382 expected_capacity: usize,
383 ) -> Result<Self, WakerError> {
384 let total = waker_region_size(expected_capacity);
385 if shm.len() < total {
386 return Err(WakerError::LayoutMismatch);
387 }
388 let raw_ptr = shm.as_mut_slice().as_mut_ptr();
389 let hdr = unsafe { &*(raw_ptr as *const WakerHeader) };
390 if hdr.magic != WAKER_MAGIC || hdr.capacity as usize != expected_capacity {
391 return Err(WakerError::LayoutMismatch);
392 }
393 Ok(Self {
394 _backing: WakerBacking::Shm(shm),
395 raw_ptr,
396 capacity: expected_capacity,
397 })
398 }
399
400 /// Slot count fixed at construction.
401 pub fn capacity(&self) -> usize { self.capacity }
402
403 #[inline]
404 fn slot(&self, idx: usize) -> &WakerSlot {
405 let base = unsafe { self.raw_ptr.add(std::mem::size_of::<WakerHeader>()) };
406 unsafe { &*(base.add(idx * std::mem::size_of::<WakerSlot>()) as *const WakerSlot) }
407 }
408
409 /// Reserve a slot and park at `target_seq`. The caller will
410 /// be woken when some producer calls `wake_up_to(seq)` with
411 /// `seq >= target_seq`. Returns the token the caller passes
412 /// to `wait` / `release`.
413 ///
414 /// On `Err(Full)`, every slot is currently in use; the caller
415 /// falls back to spinning. This is the same fallback they'd
416 /// use without a waker at all.
417 pub fn try_park(&self, target_seq: u64) -> Result<WakerToken, WakerError> {
418 for idx in 0..self.capacity {
419 let slot = self.slot(idx);
420 // FREE -> RESERVED CAS. On success, this slot is
421 // ours; on failure, another parker beat us to it,
422 // try the next slot.
423 if slot
424 .state
425 .compare_exchange(
426 STATE_FREE,
427 STATE_RESERVED,
428 Ordering::Acquire,
429 Ordering::Relaxed,
430 )
431 .is_ok()
432 {
433 // Write target_seq before publishing as PARKED.
434 // The Release store on state below is the
435 // happens-before edge for this Relaxed store.
436 slot.target_seq.store(target_seq, Ordering::Relaxed);
437 // Publish the slot to producers.
438 slot.state.store(STATE_PARKED, Ordering::Release);
439 self.mask_set(idx);
440 return Ok(WakerToken { slot: idx as u32 });
441 }
442 }
443 Err(WakerError::Full)
444 }
445
446 /// Block until either some producer wakes this token or the
447 /// optional timeout elapses. After return (Ok or Err), the
448 /// token's slot is released; the caller does NOT need to
449 /// call `release` separately.
450 ///
451 /// If the slot's state was already transitioned to `WOKEN`
452 /// before the wait call entered the kernel, the wait returns
453 /// immediately (no kernel sleep).
454 /// Whether this waker's backing is reachable from other
455 /// processes (file / named-shm) rather than process-private
456 /// anonymous memory. Windows' wait ladder branches on this:
457 /// `WaitOnAddress` never receives a cross-process wake, so
458 /// cross-process-backed waits stay on the hardware monitor
459 /// tier (physical-address based) for their whole duration.
460 fn is_cross_process(&self) -> bool {
461 !matches!(self._backing, WakerBacking::Anon(_))
462 }
463
464 fn header(&self) -> &WakerHeader {
465 unsafe { &*(self.raw_ptr as *const WakerHeader) }
466 }
467
468 #[inline]
469 fn mask_set(&self, idx: usize) {
470 if idx < 64 {
471 self.header()
472 .parked_mask
473 .fetch_or(1u64 << idx, Ordering::Release);
474 }
475 }
476
477 #[inline]
478 fn mask_clear(&self, idx: usize) {
479 if idx < 64 {
480 self.header()
481 .parked_mask
482 .fetch_and(!(1u64 << idx), Ordering::Release);
483 }
484 }
485
486 /// Iterator over candidate slot indices for a wake scan: the
487 /// parked-mask bits when the mask covers every slot, else the
488 /// full range.
489 #[inline]
490 fn wake_candidates(&self) -> WakeCandidates {
491 if self.capacity <= 64 {
492 WakeCandidates::Mask(
493 self.header().parked_mask.load(Ordering::Acquire),
494 )
495 } else {
496 WakeCandidates::Range(0, self.capacity)
497 }
498 }
499
500 pub fn wait(
501 &self,
502 token: WakerToken,
503 timeout: Option<Duration>,
504 ) -> Result<(), WakerError> {
505 let slot = self.slot(token.slot as usize);
506 let cross_process = self.is_cross_process();
507 let result = match timeout {
508 None => {
509 loop {
510 let cur = slot.state.load(Ordering::Acquire);
511 if cur != STATE_PARKED {
512 break Ok(());
513 }
514 platform_wait::wait_forever(
515 &slot.state, STATE_PARKED, cross_process,
516 );
517 }
518 }
519 Some(d) => {
520 // Deadline re-check loop. The wait syscall is only a
521 // hint: futex and the MONITOR/MWAIT tier may both wake
522 // SPURIOUSLY, so `state` - not the syscall's return -
523 // is the authority. A spurious wake re-loops and waits
524 // the remaining time; only a real producer transition
525 // (state != PARKED) returns Ok, and only an elapsed
526 // deadline returns Timeout. Without this loop a single
527 // spurious wake returned Ok, making waits end early and
528 // freeing the slot before a wake_all could see it.
529 let deadline = Instant::now() + d;
530 loop {
531 if slot.state.load(Ordering::Acquire) != STATE_PARKED {
532 break Ok(());
533 }
534 match deadline.checked_duration_since(Instant::now()) {
535 Some(remaining) if !remaining.is_zero() => {
536 platform_wait::wait_with_timeout(
537 &slot.state, STATE_PARKED, remaining, cross_process,
538 );
539 }
540 _ => {
541 // Deadline reached; one last check catches a
542 // wake that landed at the wire.
543 break if slot.state.load(Ordering::Acquire) != STATE_PARKED {
544 Ok(())
545 } else {
546 Err(WakerError::Timeout)
547 };
548 }
549 }
550 }
551 }
552 };
553 slot.state.store(STATE_FREE, Ordering::Release);
554 self.mask_clear(token.slot as usize);
555 result
556 }
557
558 /// Release a parked slot without waiting. Used by the
559 /// blocking-recv wrapper's wake-before-park-race recovery:
560 /// after parking, the wrapper double-checks try_recv; if
561 /// that succeeds, it calls release to give the slot back
562 /// without entering the kernel.
563 pub fn release(&self, token: WakerToken) {
564 let slot = self.slot(token.slot as usize);
565 slot.state.store(STATE_FREE, Ordering::Release);
566 self.mask_clear(token.slot as usize);
567 }
568
569 /// Producer's post-publish wake call. Scans every slot;
570 /// for each PARKED slot whose `target_seq <= seq`, CASes
571 /// state to WOKEN and fires a single-slot wake. Returns the
572 /// number of consumers woken.
573 pub fn wake_up_to(&self, seq: u64) -> usize {
574 let mut count = 0usize;
575 for idx in self.wake_candidates() {
576 let slot = self.slot(idx);
577 let cur_state = slot.state.load(Ordering::Acquire);
578 if cur_state != STATE_PARKED {
579 continue;
580 }
581 // The Acquire on state acquires the parker's Release
582 // store, so target_seq is safe to read Relaxed.
583 let tgt = slot.target_seq.load(Ordering::Relaxed);
584 if seq < tgt {
585 continue;
586 }
587 // Try to claim the wake. If the CAS fails another
588 // producer already woke this slot (or the parker
589 // released it); skip.
590 if slot
591 .state
592 .compare_exchange(
593 STATE_PARKED,
594 STATE_WOKEN,
595 Ordering::AcqRel,
596 Ordering::Relaxed,
597 )
598 .is_ok()
599 {
600 platform_wait::wake_one(&slot.state, self.is_cross_process());
601 count += 1;
602 }
603 }
604 count
605 }
606
607 /// Wake AT MOST ONE PARKED slot whose `target_seq <= seq`. Used
608 /// by Mesa-style condvar `notify_one`: notifier bumps the
609 /// generation, then wakes exactly one waiter (if any) so the
610 /// other parked waiters stay parked.
611 ///
612 /// Returns 1 if a waiter was woken, 0 if none qualified.
613 pub fn wake_one_up_to(&self, seq: u64) -> usize {
614 for idx in self.wake_candidates() {
615 let slot = self.slot(idx);
616 let cur_state = slot.state.load(Ordering::Acquire);
617 if cur_state != STATE_PARKED {
618 continue;
619 }
620 let tgt = slot.target_seq.load(Ordering::Relaxed);
621 if seq < tgt {
622 continue;
623 }
624 if slot
625 .state
626 .compare_exchange(
627 STATE_PARKED,
628 STATE_WOKEN,
629 Ordering::AcqRel,
630 Ordering::Relaxed,
631 )
632 .is_ok()
633 {
634 platform_wait::wake_one(&slot.state, self.is_cross_process());
635 return 1;
636 }
637 }
638 0
639 }
640
641 /// Wake every PARKED slot regardless of `target_seq`. Used
642 /// during shutdown / drain so blocked consumers see the
643 /// terminate signal.
644 pub fn wake_all(&self) -> usize {
645 let mut count = 0usize;
646 for idx in self.wake_candidates() {
647 let slot = self.slot(idx);
648 if slot
649 .state
650 .compare_exchange(
651 STATE_PARKED,
652 STATE_WOKEN,
653 Ordering::AcqRel,
654 Ordering::Relaxed,
655 )
656 .is_ok()
657 {
658 platform_wait::wake_one(&slot.state, self.is_cross_process());
659 count += 1;
660 }
661 }
662 count
663 }
664}
665
666/// Wake-scan candidate indices: set bits of the parked mask, or a
667/// plain range when the capacity outgrows the 64-bit mask.
668enum WakeCandidates {
669 Mask(u64),
670 Range(usize, usize),
671}
672
673impl Iterator for WakeCandidates {
674 type Item = usize;
675 #[inline]
676 fn next(&mut self) -> Option<usize> {
677 match self {
678 WakeCandidates::Mask(m) => {
679 if *m == 0 {
680 return None;
681 }
682 let idx = m.trailing_zeros() as usize;
683 *m &= *m - 1;
684 Some(idx)
685 }
686 WakeCandidates::Range(next, end) => {
687 if next < end {
688 let idx = *next;
689 *next += 1;
690 Some(idx)
691 } else {
692 None
693 }
694 }
695 }
696 }
697}
698
699// ============================================================================
700// Platform wait / wake. We do NOT use the atomic-wait crate
701// because that crate hard-codes FUTEX_PRIVATE_FLAG on Linux,
702// which restricts the futex to a single process and breaks the
703// cross-process wake claim. The waker calls the platform's
704// SHARED futex / WaitOnAddress / wake APIs directly.
705//
706// Every wait first runs the bounded MONITOR-class tier (see
707// crate::monitor_wait): MONITORX/MWAITX or UMONITOR/UMWAIT light
708// sleep on the slot's cache line for ~tens of microseconds, woken
709// for free by the producer's state store - cross-process included,
710// since hardware monitors are physical-address based. Only when
711// that budget expires does the wait escalate to the per-platform
712// kernel park below.
713//
714// Cross-process status per platform:
715// - Linux / Android: SHARED futex (no PRIVATE flag) works
716// across processes when the atomic sits in a SHARED mmap.
717// - FreeBSD: _umtx_op with the non-PRIVATE UMTX_OP_WAIT_UINT /
718// UMTX_OP_WAKE ops; the kernel keys those sleep queues by
719// physical address ("same variable mapped multiple times will
720// give one key value" - _umtx_op(2)), so waiters across
721// processes sharing an MMF page join one queue.
722// - Windows: WaitOnAddress is INTRA-PROCESS only per the docs,
723// so it serves anon-backed wakers; file / shm-backed wakers
724// stay on the monitor tier for their whole wait (the
725// cross_process flag below selects this), which IS
726// cross-process because hardware monitors key on physical
727// addresses. Hosts without MONITORX/WAITPKG fall back to the
728// wait-timeout + re-check recovery in the blocking wrappers.
729// - macOS / others: polling fallback; correct but wastes CPU
730// under heavy idle.
731// ============================================================================
732
733mod platform_wait {
734 use std::sync::atomic::AtomicU32;
735 use std::time::Duration;
736
737 /// macOS 14.4+ `os_sync_*` public-futex symbols resolved at RUNTIME via
738 /// `dlsym`, so the binary LINKS against an older SDK (e.g. 10.15, whose
739 /// libsystem has no `os_sync_*`) and degrades to the polling fallback there,
740 /// while taking the fast path on 14.4+. The flag constants are plain
741 /// integers (no link dependency), so only the three functions are resolved.
742 #[cfg(target_os = "macos")]
743 mod os_sync_dyn {
744 use std::ffi::c_void;
745 use std::sync::atomic::{AtomicUsize, Ordering};
746 pub type WaitFn = unsafe extern "C" fn(*mut c_void, u64, usize, u32) -> i32;
747 pub type WaitTimeoutFn =
748 unsafe extern "C" fn(*mut c_void, u64, usize, u32, u32, u64) -> i32;
749 pub type WakeFn = unsafe extern "C" fn(*mut c_void, usize, u32) -> i32;
750 // 1 = not yet probed, 0 = absent (older macOS), else = resolved fn ptr.
751 static WAIT: AtomicUsize = AtomicUsize::new(1);
752 static WAIT_TO: AtomicUsize = AtomicUsize::new(1);
753 static WAKE: AtomicUsize = AtomicUsize::new(1);
754 fn cached(slot: &AtomicUsize, name: &[u8]) -> usize {
755 let v = slot.load(Ordering::Relaxed);
756 if v != 1 {
757 return v;
758 }
759 // SAFETY: RTLD_DEFAULT lookup of a C symbol by NUL-terminated name.
760 let r = unsafe { libc::dlsym(libc::RTLD_DEFAULT, name.as_ptr() as *const _) } as usize;
761 slot.store(r, Ordering::Relaxed);
762 r
763 }
764 pub fn wait() -> Option<WaitFn> {
765 match cached(&WAIT, b"os_sync_wait_on_address\0") {
766 0 => None,
767 // SAFETY: a non-null resolution of this symbol has this ABI.
768 p => Some(unsafe { std::mem::transmute::<usize, WaitFn>(p) }),
769 }
770 }
771 pub fn wait_timeout() -> Option<WaitTimeoutFn> {
772 match cached(&WAIT_TO, b"os_sync_wait_on_address_with_timeout\0") {
773 0 => None,
774 p => Some(unsafe { std::mem::transmute::<usize, WaitTimeoutFn>(p) }),
775 }
776 }
777 pub fn wake() -> Option<WakeFn> {
778 match cached(&WAKE, b"os_sync_wake_by_address_any\0") {
779 0 => None,
780 p => Some(unsafe { std::mem::transmute::<usize, WakeFn>(p) }),
781 }
782 }
783 }
784
785 /// The monitor tier: MONITORX/MWAITX (AMD) or UMONITOR/UMWAIT
786 /// (WAITPKG) light-sleep waiting for a bounded cycle budget
787 /// BEFORE the kernel park. Two wins when the wait resolves
788 /// inside the budget: the producer's wake is its existing
789 /// state-CAS (no syscall on either side), and - because
790 /// hardware monitors are physical-address based - the wake
791 /// crosses process boundaries on shared MMF pages, which on
792 /// Windows is the only non-polling cross-process wake the
793 /// platform offers (WaitOnAddress is intra-process). Returning
794 /// `false` (budget expired / unsupported CPU /
795 /// SUBETHA_NO_MONITOR_WAIT=1) falls through to the kernel
796 /// park, which re-checks the value itself - so the tier can
797 /// never lose a wake, only hand off.
798 #[inline]
799 fn monitor_tier(atomic: &AtomicU32, expected: u32) -> bool {
800 crate::monitor_wait::monitor_wait_u32(
801 atomic,
802 expected,
803 crate::monitor_wait::monitor_wait_budget_cycles(),
804 )
805 }
806
807 pub fn wait_forever(atomic: &AtomicU32, expected: u32, _cross_process: bool) {
808 if monitor_tier(atomic, expected) {
809 return;
810 }
811 // Windows + cross-process backing: WaitOnAddress never
812 // receives a wake from another process, so the monitor IS
813 // the wait - re-arm in budget-sized chunks until the value
814 // changes. The core holds C0.1 light sleep rather than
815 // releasing to the OS; that is the only non-polling
816 // cross-process wait the platform offers.
817 #[cfg(windows)]
818 if _cross_process
819 && crate::monitor_wait::monitor_wait_kind().is_some()
820 {
821 let budget = crate::monitor_wait::monitor_wait_budget_cycles();
822 while atomic.load(std::sync::atomic::Ordering::Acquire) == expected {
823 crate::monitor_wait::monitor_wait_u32(atomic, expected, budget);
824 }
825 return;
826 }
827 #[cfg(any(target_os = "linux", target_os = "android"))]
828 {
829 unsafe {
830 libc::syscall(
831 libc::SYS_futex,
832 atomic.as_ptr(),
833 libc::FUTEX_WAIT,
834 expected as libc::c_int,
835 std::ptr::null::<libc::timespec>(),
836 );
837 }
838 }
839 #[cfg(target_os = "freebsd")]
840 {
841 // UMTX_OP_WAIT_UINT (the non-PRIVATE op): the kernel
842 // keys the sleep queue by the variable's PHYSICAL
843 // address, so waiters in any process that mapped the
844 // same MMF page share one queue - FreeBSD's native
845 // equivalent of the no-FUTEX_PRIVATE_FLAG Linux call.
846 // Sleeps only while *obj == val, exactly futex_wait.
847 unsafe {
848 libc::_umtx_op(
849 atomic.as_ptr() as *mut libc::c_void,
850 libc::UMTX_OP_WAIT_UINT,
851 expected as libc::c_ulong,
852 std::ptr::null_mut(),
853 std::ptr::null_mut(),
854 );
855 }
856 }
857 #[cfg(windows)]
858 {
859 use windows_sys::Win32::System::Threading::{WaitOnAddress, INFINITE};
860 let expected_local = expected;
861 unsafe {
862 WaitOnAddress(
863 atomic.as_ptr() as *const std::ffi::c_void,
864 &expected_local as *const u32 as *const std::ffi::c_void,
865 std::mem::size_of::<u32>(),
866 INFINITE,
867 );
868 }
869 }
870 #[cfg(target_os = "macos")]
871 {
872 // The public futex (macOS 14.4+): compare-and-wait on the address;
873 // OS_SYNC_WAIT_ON_ADDRESS_SHARED keys the queue for a shared-memory
874 // address, allowing a futex wake from another process (the backing
875 // decides the flag). The symbol is resolved at runtime; on older
876 // macOS it is absent, so poll like the generic fallback below.
877 if let Some(wait_fn) = os_sync_dyn::wait() {
878 let flags = if _cross_process {
879 libc::OS_SYNC_WAIT_ON_ADDRESS_SHARED
880 } else {
881 libc::OS_SYNC_WAIT_ON_ADDRESS_NONE
882 };
883 unsafe {
884 wait_fn(
885 atomic.as_ptr() as *mut libc::c_void,
886 expected as u64,
887 std::mem::size_of::<u32>(),
888 flags,
889 );
890 }
891 } else {
892 std::thread::yield_now();
893 while atomic.load(std::sync::atomic::Ordering::Acquire) == expected {
894 std::thread::sleep(Duration::from_millis(1));
895 }
896 }
897 }
898 #[cfg(not(any(target_os = "linux", target_os = "android",
899 target_os = "freebsd", target_os = "macos", windows)))]
900 {
901 std::thread::yield_now();
902 while atomic.load(std::sync::atomic::Ordering::Acquire) == expected {
903 std::thread::sleep(Duration::from_millis(1));
904 }
905 }
906 }
907
908 pub fn wait_with_timeout(
909 atomic: &AtomicU32,
910 expected: u32,
911 timeout: Duration,
912 _cross_process: bool,
913 ) -> bool {
914 let monitor_start = std::time::Instant::now();
915 if monitor_tier(atomic, expected) {
916 return true;
917 }
918 // The monitor budget counts against the caller's timeout;
919 // the kernel park gets the remainder.
920 let timeout = match timeout.checked_sub(monitor_start.elapsed()) {
921 Some(rest) if !rest.is_zero() => rest,
922 _ => {
923 return atomic.load(std::sync::atomic::Ordering::Acquire)
924 != expected;
925 }
926 };
927 // Windows + cross-process backing: stay on the monitor for
928 // the whole timeout (see wait_forever).
929 #[cfg(windows)]
930 if _cross_process
931 && crate::monitor_wait::monitor_wait_kind().is_some()
932 {
933 let deadline = std::time::Instant::now() + timeout;
934 let budget = crate::monitor_wait::monitor_wait_budget_cycles();
935 loop {
936 if crate::monitor_wait::monitor_wait_u32(atomic, expected, budget) {
937 return true;
938 }
939 if std::time::Instant::now() >= deadline {
940 return atomic.load(std::sync::atomic::Ordering::Acquire)
941 != expected;
942 }
943 }
944 }
945 #[cfg(any(target_os = "linux", target_os = "android"))]
946 {
947 let ts = libc::timespec {
948 tv_sec: timeout.as_secs() as libc::time_t,
949 tv_nsec: timeout.subsec_nanos() as libc::c_long,
950 };
951 unsafe {
952 let rc = libc::syscall(
953 libc::SYS_futex,
954 atomic.as_ptr(),
955 libc::FUTEX_WAIT,
956 expected as libc::c_int,
957 &ts as *const libc::timespec,
958 std::ptr::null::<()>(),
959 0u32,
960 );
961 if rc == -1 {
962 let err = *libc::__errno_location();
963 return err != libc::ETIMEDOUT;
964 }
965 }
966 true
967 }
968 #[cfg(target_os = "freebsd")]
969 {
970 // Relative timeout, monotonic clock by default: uaddr2
971 // points at the timespec and uaddr carries that
972 // structure's size, per _umtx_op(2).
973 let mut ts = libc::timespec {
974 tv_sec: timeout.as_secs() as libc::time_t,
975 tv_nsec: timeout.subsec_nanos() as libc::c_long,
976 };
977 let rc = unsafe {
978 libc::_umtx_op(
979 atomic.as_ptr() as *mut libc::c_void,
980 libc::UMTX_OP_WAIT_UINT,
981 expected as libc::c_ulong,
982 std::mem::size_of::<libc::timespec>() as *mut libc::c_void,
983 &mut ts as *mut libc::timespec as *mut libc::c_void,
984 )
985 };
986 if rc == -1 {
987 let err = unsafe { *libc::__error() };
988 return err != libc::ETIMEDOUT;
989 }
990 true
991 }
992 #[cfg(windows)]
993 {
994 use windows_sys::Win32::System::Threading::WaitOnAddress;
995 let expected_local = expected;
996 let ms = timeout.as_millis().min(u32::MAX as u128) as u32;
997 let rc = unsafe {
998 WaitOnAddress(
999 atomic.as_ptr() as *const std::ffi::c_void,
1000 &expected_local as *const u32 as *const std::ffi::c_void,
1001 std::mem::size_of::<u32>(),
1002 ms,
1003 )
1004 };
1005 rc != 0
1006 }
1007 #[cfg(target_os = "macos")]
1008 {
1009 if let Some(wait_fn) = os_sync_dyn::wait_timeout() {
1010 let flags = if _cross_process {
1011 libc::OS_SYNC_WAIT_ON_ADDRESS_SHARED
1012 } else {
1013 libc::OS_SYNC_WAIT_ON_ADDRESS_NONE
1014 };
1015 let rc = unsafe {
1016 wait_fn(
1017 atomic.as_ptr() as *mut libc::c_void,
1018 expected as u64,
1019 std::mem::size_of::<u32>(),
1020 flags,
1021 libc::OS_CLOCK_MACH_ABSOLUTE_TIME,
1022 timeout.as_nanos().min(u64::MAX as u128) as u64,
1023 )
1024 };
1025 if rc < 0 {
1026 return std::io::Error::last_os_error().raw_os_error()
1027 != Some(libc::ETIMEDOUT);
1028 }
1029 true
1030 } else {
1031 // Older macOS (< 14.4): poll with a deadline, like the generic
1032 // fallback. Returns true if the value changed, false on timeout.
1033 let deadline = std::time::Instant::now() + timeout;
1034 loop {
1035 if atomic.load(std::sync::atomic::Ordering::Acquire) != expected {
1036 return true;
1037 }
1038 if std::time::Instant::now() >= deadline {
1039 return atomic.load(std::sync::atomic::Ordering::Acquire) != expected;
1040 }
1041 std::thread::sleep(Duration::from_millis(2));
1042 }
1043 }
1044 }
1045 #[cfg(not(any(target_os = "linux", target_os = "android",
1046 target_os = "freebsd", target_os = "macos", windows)))]
1047 {
1048 let deadline = std::time::Instant::now() + timeout;
1049 let step = Duration::from_millis(2);
1050 loop {
1051 if atomic.load(std::sync::atomic::Ordering::Acquire) != expected {
1052 return true;
1053 }
1054 let now = std::time::Instant::now();
1055 if now >= deadline {
1056 return false;
1057 }
1058 let remaining = deadline - now;
1059 std::thread::sleep(remaining.min(step));
1060 }
1061 }
1062 }
1063
1064 pub fn wake_one(atomic: &AtomicU32, _cross_process: bool) {
1065 #[cfg(any(target_os = "linux", target_os = "android"))]
1066 {
1067 unsafe {
1068 libc::syscall(
1069 libc::SYS_futex,
1070 atomic.as_ptr(),
1071 libc::FUTEX_WAKE,
1072 1i32,
1073 );
1074 }
1075 }
1076 #[cfg(target_os = "freebsd")]
1077 {
1078 // val = max threads to wake; same shared (non-PRIVATE)
1079 // physical-address-keyed queue the waiters parked on.
1080 unsafe {
1081 libc::_umtx_op(
1082 atomic.as_ptr() as *mut libc::c_void,
1083 libc::UMTX_OP_WAKE,
1084 1 as libc::c_ulong,
1085 std::ptr::null_mut(),
1086 std::ptr::null_mut(),
1087 );
1088 }
1089 }
1090 #[cfg(windows)]
1091 {
1092 use windows_sys::Win32::System::Threading::WakeByAddressSingle;
1093 unsafe {
1094 WakeByAddressSingle(atomic.as_ptr() as *const std::ffi::c_void);
1095 }
1096 }
1097 #[cfg(target_os = "macos")]
1098 {
1099 // Mirror of the wait flag: SHARED wakes waiters in any process that
1100 // mapped the same region. On older macOS the symbol is absent and
1101 // the waiter polls, so no explicit wake is needed.
1102 if let Some(wake_fn) = os_sync_dyn::wake() {
1103 let flags = if _cross_process {
1104 libc::OS_SYNC_WAKE_BY_ADDRESS_SHARED
1105 } else {
1106 libc::OS_SYNC_WAKE_BY_ADDRESS_NONE
1107 };
1108 unsafe {
1109 wake_fn(
1110 atomic.as_ptr() as *mut libc::c_void,
1111 std::mem::size_of::<u32>(),
1112 flags,
1113 );
1114 }
1115 }
1116 }
1117 #[cfg(not(any(target_os = "linux", target_os = "android",
1118 target_os = "freebsd", target_os = "macos", windows)))]
1119 {
1120 drop(atomic);
1121 }
1122 }
1123}
1124
1125#[cfg(test)]
1126mod tests {
1127 use super::*;
1128 use std::sync::Arc;
1129 use std::thread;
1130 use std::time::Instant;
1131
1132 #[test]
1133 fn anon_round_trip() {
1134 let waker = CrossProcessWaker::create_anon(4).expect("create");
1135 let token = waker.try_park(10).expect("park");
1136 let waker_c = Arc::new(waker);
1137 let waker_p = Arc::clone(&waker_c);
1138 let h = thread::spawn(move || {
1139 thread::sleep(Duration::from_millis(20));
1140 let n = waker_p.wake_up_to(15);
1141 assert!(n >= 1);
1142 });
1143 waker_c.wait(token, Some(Duration::from_secs(2))).expect("wake");
1144 h.join().unwrap();
1145 }
1146
1147 #[test]
1148 fn wait_returns_immediately_if_already_woken() {
1149 let waker = CrossProcessWaker::create_anon(2).expect("create");
1150 let token = waker.try_park(5).expect("park");
1151 assert_eq!(waker.wake_up_to(10), 1);
1152 let t0 = Instant::now();
1153 waker.wait(token, Some(Duration::from_secs(1))).expect("ok");
1154 assert!(t0.elapsed() < Duration::from_millis(50),
1155 "wait should return fast since wake fired before wait entered");
1156 }
1157
1158 #[test]
1159 fn timeout_works() {
1160 let waker = CrossProcessWaker::create_anon(2).expect("create");
1161 let token = waker.try_park(100).expect("park");
1162 let t0 = Instant::now();
1163 let err = waker.wait(token, Some(Duration::from_millis(80)));
1164 assert_eq!(err, Err(WakerError::Timeout));
1165 assert!(t0.elapsed() >= Duration::from_millis(70));
1166 }
1167
1168 #[test]
1169 fn full_when_all_slots_taken() {
1170 let waker = CrossProcessWaker::create_anon(2).expect("create");
1171 let _a = waker.try_park(1).expect("park 0");
1172 let _b = waker.try_park(2).expect("park 1");
1173 assert_eq!(waker.try_park(3), Err(WakerError::Full));
1174 }
1175
1176 #[test]
1177 fn release_lets_others_park() {
1178 let waker = CrossProcessWaker::create_anon(2).expect("create");
1179 let a = waker.try_park(1).expect("park 0");
1180 let _b = waker.try_park(2).expect("park 1");
1181 waker.release(a);
1182 let _c = waker.try_park(3).expect("re-park 0");
1183 }
1184
1185 #[test]
1186 fn wake_all_drains_blocked_consumers() {
1187 let waker = Arc::new(CrossProcessWaker::create_anon(4).expect("create"));
1188 let mut handles = Vec::new();
1189 for target in 0..4u64 {
1190 let w = Arc::clone(&waker);
1191 let token = w.try_park(target + 1000).expect("park");
1192 handles.push(thread::spawn(move || {
1193 w.wait(token, Some(Duration::from_secs(2))).expect("woken");
1194 }));
1195 }
1196 thread::sleep(Duration::from_millis(20));
1197 assert_eq!(waker.wake_all(), 4);
1198 for h in handles { h.join().unwrap(); }
1199 }
1200}