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
292 /// cache.
293 pub fn create(path: impl AsRef<Path>, capacity: usize) -> Result<Self, WakerError> {
294 assert!(capacity >= 1, "capacity must be >= 1");
295 let total = waker_region_size(capacity);
296 let file = OpenOptions::new()
297 .read(true).write(true).create(true).truncate(true)
298 .open(path.as_ref())?;
299 file.set_len(total as u64)?;
300 let mut mmap = unsafe { MmapOptions::new().len(total).map_mut(&file)? };
301 let raw_ptr = mmap.as_mut_ptr();
302 unsafe { init_waker_layout_raw(raw_ptr, capacity); }
303 Ok(Self {
304 _backing: WakerBacking::File(file, mmap),
305 raw_ptr,
306 capacity,
307 })
308 }
309
310 /// Open an existing file-backed waker. Validates magic +
311 /// capacity.
312 pub fn open(
313 path: impl AsRef<Path>,
314 expected_capacity: usize,
315 ) -> Result<Self, WakerError> {
316 let total = waker_region_size(expected_capacity);
317 let file = OpenOptions::new().read(true).write(true).open(path.as_ref())?;
318 if (file.metadata()?.len() as usize) < total {
319 return Err(WakerError::LayoutMismatch);
320 }
321 let mut mmap = unsafe { MmapOptions::new().len(total).map_mut(&file)? };
322 let raw_ptr = mmap.as_mut_ptr();
323 let hdr = unsafe { &*(raw_ptr as *const WakerHeader) };
324 if hdr.magic != WAKER_MAGIC || hdr.capacity as usize != expected_capacity {
325 return Err(WakerError::LayoutMismatch);
326 }
327 Ok(Self {
328 _backing: WakerBacking::File(file, mmap),
329 raw_ptr,
330 capacity: expected_capacity,
331 })
332 }
333
334 /// Build a fresh waker on top of a named-shm region. Cross-
335 /// process visible via the `logical_name` of the underlying
336 /// [`ShmFile`](crate::shm_file::ShmFile); RAM-resident.
337 pub fn create_from_shm(
338 mut shm: crate::shm_file::ShmFile,
339 capacity: usize,
340 ) -> Result<Self, WakerError> {
341 assert!(capacity >= 1, "capacity must be >= 1");
342 let total = waker_region_size(capacity);
343 if shm.len() < total {
344 return Err(WakerError::LayoutMismatch);
345 }
346 let raw_ptr = shm.as_mut_slice().as_mut_ptr();
347 unsafe { init_waker_layout_raw(raw_ptr, capacity); }
348 Ok(Self {
349 _backing: WakerBacking::Shm(shm),
350 raw_ptr,
351 capacity,
352 })
353 }
354
355 /// Open an existing named-shm waker without re-initialising
356 /// the layout.
357 pub fn open_from_shm(
358 mut shm: crate::shm_file::ShmFile,
359 expected_capacity: usize,
360 ) -> Result<Self, WakerError> {
361 let total = waker_region_size(expected_capacity);
362 if shm.len() < total {
363 return Err(WakerError::LayoutMismatch);
364 }
365 let raw_ptr = shm.as_mut_slice().as_mut_ptr();
366 let hdr = unsafe { &*(raw_ptr as *const WakerHeader) };
367 if hdr.magic != WAKER_MAGIC || hdr.capacity as usize != expected_capacity {
368 return Err(WakerError::LayoutMismatch);
369 }
370 Ok(Self {
371 _backing: WakerBacking::Shm(shm),
372 raw_ptr,
373 capacity: expected_capacity,
374 })
375 }
376
377 /// Slot count fixed at construction.
378 pub fn capacity(&self) -> usize { self.capacity }
379
380 #[inline]
381 fn slot(&self, idx: usize) -> &WakerSlot {
382 let base = unsafe { self.raw_ptr.add(std::mem::size_of::<WakerHeader>()) };
383 unsafe { &*(base.add(idx * std::mem::size_of::<WakerSlot>()) as *const WakerSlot) }
384 }
385
386 /// Reserve a slot and park at `target_seq`. The caller will
387 /// be woken when some producer calls `wake_up_to(seq)` with
388 /// `seq >= target_seq`. Returns the token the caller passes
389 /// to `wait` / `release`.
390 ///
391 /// On `Err(Full)`, every slot is currently in use; the caller
392 /// falls back to spinning. This is the same fallback they'd
393 /// use without a waker at all.
394 pub fn try_park(&self, target_seq: u64) -> Result<WakerToken, WakerError> {
395 for idx in 0..self.capacity {
396 let slot = self.slot(idx);
397 // FREE -> RESERVED CAS. On success, this slot is
398 // ours; on failure, another parker beat us to it,
399 // try the next slot.
400 if slot
401 .state
402 .compare_exchange(
403 STATE_FREE,
404 STATE_RESERVED,
405 Ordering::Acquire,
406 Ordering::Relaxed,
407 )
408 .is_ok()
409 {
410 // Write target_seq before publishing as PARKED.
411 // The Release store on state below is the
412 // happens-before edge for this Relaxed store.
413 slot.target_seq.store(target_seq, Ordering::Relaxed);
414 // Publish the slot to producers.
415 slot.state.store(STATE_PARKED, Ordering::Release);
416 self.mask_set(idx);
417 return Ok(WakerToken { slot: idx as u32 });
418 }
419 }
420 Err(WakerError::Full)
421 }
422
423 /// Block until either some producer wakes this token or the
424 /// optional timeout elapses. After return (Ok or Err), the
425 /// token's slot is released; the caller does NOT need to
426 /// call `release` separately.
427 ///
428 /// If the slot's state was already transitioned to `WOKEN`
429 /// before the wait call entered the kernel, the wait returns
430 /// immediately (no kernel sleep).
431 /// Whether this waker's backing is reachable from other
432 /// processes (file / named-shm) rather than process-private
433 /// anonymous memory. Windows' wait ladder branches on this:
434 /// `WaitOnAddress` never receives a cross-process wake, so
435 /// cross-process-backed waits stay on the hardware monitor
436 /// tier (physical-address based) for their whole duration.
437 fn is_cross_process(&self) -> bool {
438 !matches!(self._backing, WakerBacking::Anon(_))
439 }
440
441 fn header(&self) -> &WakerHeader {
442 unsafe { &*(self.raw_ptr as *const WakerHeader) }
443 }
444
445 #[inline]
446 fn mask_set(&self, idx: usize) {
447 if idx < 64 {
448 self.header()
449 .parked_mask
450 .fetch_or(1u64 << idx, Ordering::Release);
451 }
452 }
453
454 #[inline]
455 fn mask_clear(&self, idx: usize) {
456 if idx < 64 {
457 self.header()
458 .parked_mask
459 .fetch_and(!(1u64 << idx), Ordering::Release);
460 }
461 }
462
463 /// Iterator over candidate slot indices for a wake scan: the
464 /// parked-mask bits when the mask covers every slot, else the
465 /// full range.
466 #[inline]
467 fn wake_candidates(&self) -> WakeCandidates {
468 if self.capacity <= 64 {
469 WakeCandidates::Mask(
470 self.header().parked_mask.load(Ordering::Acquire),
471 )
472 } else {
473 WakeCandidates::Range(0, self.capacity)
474 }
475 }
476
477 pub fn wait(
478 &self,
479 token: WakerToken,
480 timeout: Option<Duration>,
481 ) -> Result<(), WakerError> {
482 let slot = self.slot(token.slot as usize);
483 let cross_process = self.is_cross_process();
484 let result = match timeout {
485 None => {
486 loop {
487 let cur = slot.state.load(Ordering::Acquire);
488 if cur != STATE_PARKED {
489 break Ok(());
490 }
491 platform_wait::wait_forever(
492 &slot.state, STATE_PARKED, cross_process,
493 );
494 }
495 }
496 Some(d) => {
497 // Deadline re-check loop. The wait syscall is only a
498 // hint: futex and the MONITOR/MWAIT tier may both wake
499 // SPURIOUSLY, so `state` - not the syscall's return -
500 // is the authority. A spurious wake re-loops and waits
501 // the remaining time; only a real producer transition
502 // (state != PARKED) returns Ok, and only an elapsed
503 // deadline returns Timeout. Without this loop a single
504 // spurious wake returned Ok, making waits end early and
505 // freeing the slot before a wake_all could see it.
506 let deadline = Instant::now() + d;
507 loop {
508 if slot.state.load(Ordering::Acquire) != STATE_PARKED {
509 break Ok(());
510 }
511 match deadline.checked_duration_since(Instant::now()) {
512 Some(remaining) if !remaining.is_zero() => {
513 platform_wait::wait_with_timeout(
514 &slot.state, STATE_PARKED, remaining, cross_process,
515 );
516 }
517 _ => {
518 // Deadline reached; one last check catches a
519 // wake that landed at the wire.
520 break if slot.state.load(Ordering::Acquire) != STATE_PARKED {
521 Ok(())
522 } else {
523 Err(WakerError::Timeout)
524 };
525 }
526 }
527 }
528 }
529 };
530 slot.state.store(STATE_FREE, Ordering::Release);
531 self.mask_clear(token.slot as usize);
532 result
533 }
534
535 /// Release a parked slot without waiting. Used by the
536 /// blocking-recv wrapper's wake-before-park-race recovery:
537 /// after parking, the wrapper double-checks try_recv; if
538 /// that succeeds, it calls release to give the slot back
539 /// without entering the kernel.
540 pub fn release(&self, token: WakerToken) {
541 let slot = self.slot(token.slot as usize);
542 slot.state.store(STATE_FREE, Ordering::Release);
543 self.mask_clear(token.slot as usize);
544 }
545
546 /// Producer's post-publish wake call. Scans every slot;
547 /// for each PARKED slot whose `target_seq <= seq`, CASes
548 /// state to WOKEN and fires a single-slot wake. Returns the
549 /// number of consumers woken.
550 pub fn wake_up_to(&self, seq: u64) -> usize {
551 let mut count = 0usize;
552 for idx in self.wake_candidates() {
553 let slot = self.slot(idx);
554 let cur_state = slot.state.load(Ordering::Acquire);
555 if cur_state != STATE_PARKED {
556 continue;
557 }
558 // The Acquire on state acquires the parker's Release
559 // store, so target_seq is safe to read Relaxed.
560 let tgt = slot.target_seq.load(Ordering::Relaxed);
561 if seq < tgt {
562 continue;
563 }
564 // Try to claim the wake. If the CAS fails another
565 // producer already woke this slot (or the parker
566 // released it); skip.
567 if slot
568 .state
569 .compare_exchange(
570 STATE_PARKED,
571 STATE_WOKEN,
572 Ordering::AcqRel,
573 Ordering::Relaxed,
574 )
575 .is_ok()
576 {
577 platform_wait::wake_one(&slot.state, self.is_cross_process());
578 count += 1;
579 }
580 }
581 count
582 }
583
584 /// Wake AT MOST ONE PARKED slot whose `target_seq <= seq`. Used
585 /// by Mesa-style condvar `notify_one`: notifier bumps the
586 /// generation, then wakes exactly one waiter (if any) so the
587 /// other parked waiters stay parked.
588 ///
589 /// Returns 1 if a waiter was woken, 0 if none qualified.
590 pub fn wake_one_up_to(&self, seq: u64) -> usize {
591 for idx in self.wake_candidates() {
592 let slot = self.slot(idx);
593 let cur_state = slot.state.load(Ordering::Acquire);
594 if cur_state != STATE_PARKED {
595 continue;
596 }
597 let tgt = slot.target_seq.load(Ordering::Relaxed);
598 if seq < tgt {
599 continue;
600 }
601 if slot
602 .state
603 .compare_exchange(
604 STATE_PARKED,
605 STATE_WOKEN,
606 Ordering::AcqRel,
607 Ordering::Relaxed,
608 )
609 .is_ok()
610 {
611 platform_wait::wake_one(&slot.state, self.is_cross_process());
612 return 1;
613 }
614 }
615 0
616 }
617
618 /// Wake every PARKED slot regardless of `target_seq`. Used
619 /// during shutdown / drain so blocked consumers see the
620 /// terminate signal.
621 pub fn wake_all(&self) -> usize {
622 let mut count = 0usize;
623 for idx in self.wake_candidates() {
624 let slot = self.slot(idx);
625 if slot
626 .state
627 .compare_exchange(
628 STATE_PARKED,
629 STATE_WOKEN,
630 Ordering::AcqRel,
631 Ordering::Relaxed,
632 )
633 .is_ok()
634 {
635 platform_wait::wake_one(&slot.state, self.is_cross_process());
636 count += 1;
637 }
638 }
639 count
640 }
641}
642
643/// Wake-scan candidate indices: set bits of the parked mask, or a
644/// plain range when the capacity outgrows the 64-bit mask.
645enum WakeCandidates {
646 Mask(u64),
647 Range(usize, usize),
648}
649
650impl Iterator for WakeCandidates {
651 type Item = usize;
652 #[inline]
653 fn next(&mut self) -> Option<usize> {
654 match self {
655 WakeCandidates::Mask(m) => {
656 if *m == 0 {
657 return None;
658 }
659 let idx = m.trailing_zeros() as usize;
660 *m &= *m - 1;
661 Some(idx)
662 }
663 WakeCandidates::Range(next, end) => {
664 if next < end {
665 let idx = *next;
666 *next += 1;
667 Some(idx)
668 } else {
669 None
670 }
671 }
672 }
673 }
674}
675
676// ============================================================================
677// Platform wait / wake. We do NOT use the atomic-wait crate
678// because that crate hard-codes FUTEX_PRIVATE_FLAG on Linux,
679// which restricts the futex to a single process and breaks the
680// cross-process wake claim. The waker calls the platform's
681// SHARED futex / WaitOnAddress / wake APIs directly.
682//
683// Every wait first runs the bounded MONITOR-class tier (see
684// crate::monitor_wait): MONITORX/MWAITX or UMONITOR/UMWAIT light
685// sleep on the slot's cache line for ~tens of microseconds, woken
686// for free by the producer's state store - cross-process included,
687// since hardware monitors are physical-address based. Only when
688// that budget expires does the wait escalate to the per-platform
689// kernel park below.
690//
691// Cross-process status per platform:
692// - Linux / Android: SHARED futex (no PRIVATE flag) works
693// across processes when the atomic sits in a SHARED mmap.
694// - FreeBSD: _umtx_op with the non-PRIVATE UMTX_OP_WAIT_UINT /
695// UMTX_OP_WAKE ops; the kernel keys those sleep queues by
696// physical address ("same variable mapped multiple times will
697// give one key value" - _umtx_op(2)), so waiters across
698// processes sharing an MMF page join one queue.
699// - Windows: WaitOnAddress is INTRA-PROCESS only per the docs,
700// so it serves anon-backed wakers; file / shm-backed wakers
701// stay on the monitor tier for their whole wait (the
702// cross_process flag below selects this), which IS
703// cross-process because hardware monitors key on physical
704// addresses. Hosts without MONITORX/WAITPKG fall back to the
705// wait-timeout + re-check recovery in the blocking wrappers.
706// - macOS / others: polling fallback; correct but wastes CPU
707// under heavy idle.
708// ============================================================================
709
710mod platform_wait {
711 use std::sync::atomic::AtomicU32;
712 use std::time::Duration;
713
714 /// macOS 14.4+ `os_sync_*` public-futex symbols resolved at RUNTIME via
715 /// `dlsym`, so the binary LINKS against an older SDK (e.g. 10.15, whose
716 /// libsystem has no `os_sync_*`) and degrades to the polling fallback there,
717 /// while taking the fast path on 14.4+. The flag constants are plain
718 /// integers (no link dependency), so only the three functions are resolved.
719 #[cfg(target_os = "macos")]
720 mod os_sync_dyn {
721 use std::ffi::c_void;
722 use std::sync::atomic::{AtomicUsize, Ordering};
723 pub type WaitFn = unsafe extern "C" fn(*mut c_void, u64, usize, u32) -> i32;
724 pub type WaitTimeoutFn =
725 unsafe extern "C" fn(*mut c_void, u64, usize, u32, u32, u64) -> i32;
726 pub type WakeFn = unsafe extern "C" fn(*mut c_void, usize, u32) -> i32;
727 // 1 = not yet probed, 0 = absent (older macOS), else = resolved fn ptr.
728 static WAIT: AtomicUsize = AtomicUsize::new(1);
729 static WAIT_TO: AtomicUsize = AtomicUsize::new(1);
730 static WAKE: AtomicUsize = AtomicUsize::new(1);
731 fn cached(slot: &AtomicUsize, name: &[u8]) -> usize {
732 let v = slot.load(Ordering::Relaxed);
733 if v != 1 {
734 return v;
735 }
736 // SAFETY: RTLD_DEFAULT lookup of a C symbol by NUL-terminated name.
737 let r = unsafe { libc::dlsym(libc::RTLD_DEFAULT, name.as_ptr() as *const _) } as usize;
738 slot.store(r, Ordering::Relaxed);
739 r
740 }
741 pub fn wait() -> Option<WaitFn> {
742 match cached(&WAIT, b"os_sync_wait_on_address\0") {
743 0 => None,
744 // SAFETY: a non-null resolution of this symbol has this ABI.
745 p => Some(unsafe { std::mem::transmute::<usize, WaitFn>(p) }),
746 }
747 }
748 pub fn wait_timeout() -> Option<WaitTimeoutFn> {
749 match cached(&WAIT_TO, b"os_sync_wait_on_address_with_timeout\0") {
750 0 => None,
751 p => Some(unsafe { std::mem::transmute::<usize, WaitTimeoutFn>(p) }),
752 }
753 }
754 pub fn wake() -> Option<WakeFn> {
755 match cached(&WAKE, b"os_sync_wake_by_address_any\0") {
756 0 => None,
757 p => Some(unsafe { std::mem::transmute::<usize, WakeFn>(p) }),
758 }
759 }
760 }
761
762 /// The monitor tier: MONITORX/MWAITX (AMD) or UMONITOR/UMWAIT
763 /// (WAITPKG) light-sleep waiting for a bounded cycle budget
764 /// BEFORE the kernel park. Two wins when the wait resolves
765 /// inside the budget: the producer's wake is its existing
766 /// state-CAS (no syscall on either side), and - because
767 /// hardware monitors are physical-address based - the wake
768 /// crosses process boundaries on shared MMF pages, which on
769 /// Windows is the only non-polling cross-process wake the
770 /// platform offers (WaitOnAddress is intra-process). Returning
771 /// `false` (budget expired / unsupported CPU /
772 /// SUBETHA_NO_MONITOR_WAIT=1) falls through to the kernel
773 /// park, which re-checks the value itself - so the tier can
774 /// never lose a wake, only hand off.
775 #[inline]
776 fn monitor_tier(atomic: &AtomicU32, expected: u32) -> bool {
777 crate::monitor_wait::monitor_wait_u32(
778 atomic,
779 expected,
780 crate::monitor_wait::monitor_wait_budget_cycles(),
781 )
782 }
783
784 pub fn wait_forever(atomic: &AtomicU32, expected: u32, _cross_process: bool) {
785 if monitor_tier(atomic, expected) {
786 return;
787 }
788 // Windows + cross-process backing: WaitOnAddress never
789 // receives a wake from another process, so the monitor IS
790 // the wait - re-arm in budget-sized chunks until the value
791 // changes. The core holds C0.1 light sleep rather than
792 // releasing to the OS; that is the only non-polling
793 // cross-process wait the platform offers.
794 #[cfg(windows)]
795 if _cross_process
796 && crate::monitor_wait::monitor_wait_kind().is_some()
797 {
798 let budget = crate::monitor_wait::monitor_wait_budget_cycles();
799 while atomic.load(std::sync::atomic::Ordering::Acquire) == expected {
800 crate::monitor_wait::monitor_wait_u32(atomic, expected, budget);
801 }
802 return;
803 }
804 #[cfg(any(target_os = "linux", target_os = "android"))]
805 {
806 unsafe {
807 libc::syscall(
808 libc::SYS_futex,
809 atomic.as_ptr(),
810 libc::FUTEX_WAIT,
811 expected as libc::c_int,
812 std::ptr::null::<libc::timespec>(),
813 );
814 }
815 }
816 #[cfg(target_os = "freebsd")]
817 {
818 // UMTX_OP_WAIT_UINT (the non-PRIVATE op): the kernel
819 // keys the sleep queue by the variable's PHYSICAL
820 // address, so waiters in any process that mapped the
821 // same MMF page share one queue - FreeBSD's native
822 // equivalent of the no-FUTEX_PRIVATE_FLAG Linux call.
823 // Sleeps only while *obj == val, exactly futex_wait.
824 unsafe {
825 libc::_umtx_op(
826 atomic.as_ptr() as *mut libc::c_void,
827 libc::UMTX_OP_WAIT_UINT,
828 expected as libc::c_ulong,
829 std::ptr::null_mut(),
830 std::ptr::null_mut(),
831 );
832 }
833 }
834 #[cfg(windows)]
835 {
836 use windows_sys::Win32::System::Threading::{WaitOnAddress, INFINITE};
837 let expected_local = expected;
838 unsafe {
839 WaitOnAddress(
840 atomic.as_ptr() as *const std::ffi::c_void,
841 &expected_local as *const u32 as *const std::ffi::c_void,
842 std::mem::size_of::<u32>(),
843 INFINITE,
844 );
845 }
846 }
847 #[cfg(target_os = "macos")]
848 {
849 // The public futex (macOS 14.4+): compare-and-wait on the address;
850 // OS_SYNC_WAIT_ON_ADDRESS_SHARED keys the queue for a shared-memory
851 // address, allowing a futex wake from another process (the backing
852 // decides the flag). The symbol is resolved at runtime; on older
853 // macOS it is absent, so poll like the generic fallback below.
854 if let Some(wait_fn) = os_sync_dyn::wait() {
855 let flags = if _cross_process {
856 libc::OS_SYNC_WAIT_ON_ADDRESS_SHARED
857 } else {
858 libc::OS_SYNC_WAIT_ON_ADDRESS_NONE
859 };
860 unsafe {
861 wait_fn(
862 atomic.as_ptr() as *mut libc::c_void,
863 expected as u64,
864 std::mem::size_of::<u32>(),
865 flags,
866 );
867 }
868 } else {
869 std::thread::yield_now();
870 while atomic.load(std::sync::atomic::Ordering::Acquire) == expected {
871 std::thread::sleep(Duration::from_millis(1));
872 }
873 }
874 }
875 #[cfg(not(any(target_os = "linux", target_os = "android",
876 target_os = "freebsd", target_os = "macos", windows)))]
877 {
878 std::thread::yield_now();
879 while atomic.load(std::sync::atomic::Ordering::Acquire) == expected {
880 std::thread::sleep(Duration::from_millis(1));
881 }
882 }
883 }
884
885 pub fn wait_with_timeout(
886 atomic: &AtomicU32,
887 expected: u32,
888 timeout: Duration,
889 _cross_process: bool,
890 ) -> bool {
891 let monitor_start = std::time::Instant::now();
892 if monitor_tier(atomic, expected) {
893 return true;
894 }
895 // The monitor budget counts against the caller's timeout;
896 // the kernel park gets the remainder.
897 let timeout = match timeout.checked_sub(monitor_start.elapsed()) {
898 Some(rest) if !rest.is_zero() => rest,
899 _ => {
900 return atomic.load(std::sync::atomic::Ordering::Acquire)
901 != expected;
902 }
903 };
904 // Windows + cross-process backing: stay on the monitor for
905 // the whole timeout (see wait_forever).
906 #[cfg(windows)]
907 if _cross_process
908 && crate::monitor_wait::monitor_wait_kind().is_some()
909 {
910 let deadline = std::time::Instant::now() + timeout;
911 let budget = crate::monitor_wait::monitor_wait_budget_cycles();
912 loop {
913 if crate::monitor_wait::monitor_wait_u32(atomic, expected, budget) {
914 return true;
915 }
916 if std::time::Instant::now() >= deadline {
917 return atomic.load(std::sync::atomic::Ordering::Acquire)
918 != expected;
919 }
920 }
921 }
922 #[cfg(any(target_os = "linux", target_os = "android"))]
923 {
924 let ts = libc::timespec {
925 tv_sec: timeout.as_secs() as libc::time_t,
926 tv_nsec: timeout.subsec_nanos() as libc::c_long,
927 };
928 unsafe {
929 let rc = libc::syscall(
930 libc::SYS_futex,
931 atomic.as_ptr(),
932 libc::FUTEX_WAIT,
933 expected as libc::c_int,
934 &ts as *const libc::timespec,
935 std::ptr::null::<()>(),
936 0u32,
937 );
938 if rc == -1 {
939 let err = *libc::__errno_location();
940 return err != libc::ETIMEDOUT;
941 }
942 }
943 true
944 }
945 #[cfg(target_os = "freebsd")]
946 {
947 // Relative timeout, monotonic clock by default: uaddr2
948 // points at the timespec and uaddr carries that
949 // structure's size, per _umtx_op(2).
950 let mut ts = libc::timespec {
951 tv_sec: timeout.as_secs() as libc::time_t,
952 tv_nsec: timeout.subsec_nanos() as libc::c_long,
953 };
954 let rc = unsafe {
955 libc::_umtx_op(
956 atomic.as_ptr() as *mut libc::c_void,
957 libc::UMTX_OP_WAIT_UINT,
958 expected as libc::c_ulong,
959 std::mem::size_of::<libc::timespec>() as *mut libc::c_void,
960 &mut ts as *mut libc::timespec as *mut libc::c_void,
961 )
962 };
963 if rc == -1 {
964 let err = unsafe { *libc::__error() };
965 return err != libc::ETIMEDOUT;
966 }
967 true
968 }
969 #[cfg(windows)]
970 {
971 use windows_sys::Win32::System::Threading::WaitOnAddress;
972 let expected_local = expected;
973 let ms = timeout.as_millis().min(u32::MAX as u128) as u32;
974 let rc = unsafe {
975 WaitOnAddress(
976 atomic.as_ptr() as *const std::ffi::c_void,
977 &expected_local as *const u32 as *const std::ffi::c_void,
978 std::mem::size_of::<u32>(),
979 ms,
980 )
981 };
982 rc != 0
983 }
984 #[cfg(target_os = "macos")]
985 {
986 if let Some(wait_fn) = os_sync_dyn::wait_timeout() {
987 let flags = if _cross_process {
988 libc::OS_SYNC_WAIT_ON_ADDRESS_SHARED
989 } else {
990 libc::OS_SYNC_WAIT_ON_ADDRESS_NONE
991 };
992 let rc = unsafe {
993 wait_fn(
994 atomic.as_ptr() as *mut libc::c_void,
995 expected as u64,
996 std::mem::size_of::<u32>(),
997 flags,
998 libc::OS_CLOCK_MACH_ABSOLUTE_TIME,
999 timeout.as_nanos().min(u64::MAX as u128) as u64,
1000 )
1001 };
1002 if rc < 0 {
1003 return std::io::Error::last_os_error().raw_os_error()
1004 != Some(libc::ETIMEDOUT);
1005 }
1006 true
1007 } else {
1008 // Older macOS (< 14.4): poll with a deadline, like the generic
1009 // fallback. Returns true if the value changed, false on timeout.
1010 let deadline = std::time::Instant::now() + timeout;
1011 loop {
1012 if atomic.load(std::sync::atomic::Ordering::Acquire) != expected {
1013 return true;
1014 }
1015 if std::time::Instant::now() >= deadline {
1016 return atomic.load(std::sync::atomic::Ordering::Acquire) != expected;
1017 }
1018 std::thread::sleep(Duration::from_millis(2));
1019 }
1020 }
1021 }
1022 #[cfg(not(any(target_os = "linux", target_os = "android",
1023 target_os = "freebsd", target_os = "macos", windows)))]
1024 {
1025 let deadline = std::time::Instant::now() + timeout;
1026 let step = Duration::from_millis(2);
1027 loop {
1028 if atomic.load(std::sync::atomic::Ordering::Acquire) != expected {
1029 return true;
1030 }
1031 let now = std::time::Instant::now();
1032 if now >= deadline {
1033 return false;
1034 }
1035 let remaining = deadline - now;
1036 std::thread::sleep(remaining.min(step));
1037 }
1038 }
1039 }
1040
1041 pub fn wake_one(atomic: &AtomicU32, _cross_process: bool) {
1042 #[cfg(any(target_os = "linux", target_os = "android"))]
1043 {
1044 unsafe {
1045 libc::syscall(
1046 libc::SYS_futex,
1047 atomic.as_ptr(),
1048 libc::FUTEX_WAKE,
1049 1i32,
1050 );
1051 }
1052 }
1053 #[cfg(target_os = "freebsd")]
1054 {
1055 // val = max threads to wake; same shared (non-PRIVATE)
1056 // physical-address-keyed queue the waiters parked on.
1057 unsafe {
1058 libc::_umtx_op(
1059 atomic.as_ptr() as *mut libc::c_void,
1060 libc::UMTX_OP_WAKE,
1061 1 as libc::c_ulong,
1062 std::ptr::null_mut(),
1063 std::ptr::null_mut(),
1064 );
1065 }
1066 }
1067 #[cfg(windows)]
1068 {
1069 use windows_sys::Win32::System::Threading::WakeByAddressSingle;
1070 unsafe {
1071 WakeByAddressSingle(atomic.as_ptr() as *const std::ffi::c_void);
1072 }
1073 }
1074 #[cfg(target_os = "macos")]
1075 {
1076 // Mirror of the wait flag: SHARED wakes waiters in any process that
1077 // mapped the same region. On older macOS the symbol is absent and
1078 // the waiter polls, so no explicit wake is needed.
1079 if let Some(wake_fn) = os_sync_dyn::wake() {
1080 let flags = if _cross_process {
1081 libc::OS_SYNC_WAKE_BY_ADDRESS_SHARED
1082 } else {
1083 libc::OS_SYNC_WAKE_BY_ADDRESS_NONE
1084 };
1085 unsafe {
1086 wake_fn(
1087 atomic.as_ptr() as *mut libc::c_void,
1088 std::mem::size_of::<u32>(),
1089 flags,
1090 );
1091 }
1092 }
1093 }
1094 #[cfg(not(any(target_os = "linux", target_os = "android",
1095 target_os = "freebsd", target_os = "macos", windows)))]
1096 {
1097 drop(atomic);
1098 }
1099 }
1100}
1101
1102#[cfg(test)]
1103mod tests {
1104 use super::*;
1105 use std::sync::Arc;
1106 use std::thread;
1107 use std::time::Instant;
1108
1109 #[test]
1110 fn anon_round_trip() {
1111 let waker = CrossProcessWaker::create_anon(4).expect("create");
1112 let token = waker.try_park(10).expect("park");
1113 let waker_c = Arc::new(waker);
1114 let waker_p = Arc::clone(&waker_c);
1115 let h = thread::spawn(move || {
1116 thread::sleep(Duration::from_millis(20));
1117 let n = waker_p.wake_up_to(15);
1118 assert!(n >= 1);
1119 });
1120 waker_c.wait(token, Some(Duration::from_secs(2))).expect("wake");
1121 h.join().unwrap();
1122 }
1123
1124 #[test]
1125 fn wait_returns_immediately_if_already_woken() {
1126 let waker = CrossProcessWaker::create_anon(2).expect("create");
1127 let token = waker.try_park(5).expect("park");
1128 assert_eq!(waker.wake_up_to(10), 1);
1129 let t0 = Instant::now();
1130 waker.wait(token, Some(Duration::from_secs(1))).expect("ok");
1131 assert!(t0.elapsed() < Duration::from_millis(50),
1132 "wait should return fast since wake fired before wait entered");
1133 }
1134
1135 #[test]
1136 fn timeout_works() {
1137 let waker = CrossProcessWaker::create_anon(2).expect("create");
1138 let token = waker.try_park(100).expect("park");
1139 let t0 = Instant::now();
1140 let err = waker.wait(token, Some(Duration::from_millis(80)));
1141 assert_eq!(err, Err(WakerError::Timeout));
1142 assert!(t0.elapsed() >= Duration::from_millis(70));
1143 }
1144
1145 #[test]
1146 fn full_when_all_slots_taken() {
1147 let waker = CrossProcessWaker::create_anon(2).expect("create");
1148 let _a = waker.try_park(1).expect("park 0");
1149 let _b = waker.try_park(2).expect("park 1");
1150 assert_eq!(waker.try_park(3), Err(WakerError::Full));
1151 }
1152
1153 #[test]
1154 fn release_lets_others_park() {
1155 let waker = CrossProcessWaker::create_anon(2).expect("create");
1156 let a = waker.try_park(1).expect("park 0");
1157 let _b = waker.try_park(2).expect("park 1");
1158 waker.release(a);
1159 let _c = waker.try_park(3).expect("re-park 0");
1160 }
1161
1162 #[test]
1163 fn wake_all_drains_blocked_consumers() {
1164 let waker = Arc::new(CrossProcessWaker::create_anon(4).expect("create"));
1165 let mut handles = Vec::new();
1166 for target in 0..4u64 {
1167 let w = Arc::clone(&waker);
1168 let token = w.try_park(target + 1000).expect("park");
1169 handles.push(thread::spawn(move || {
1170 w.wait(token, Some(Duration::from_secs(2))).expect("woken");
1171 }));
1172 }
1173 thread::sleep(Duration::from_millis(20));
1174 assert_eq!(waker.wake_all(), 4);
1175 for h in handles { h.join().unwrap(); }
1176 }
1177}