Skip to main content

orbit_core/
sync.rs

1//! Waiting on a shared 32-bit word, the primitive under every "wake me when
2//! this changes" in Orbit: ring readiness, cell changes.
3//!
4//! [`wait_word`] parks the caller until the word no longer holds `expected`
5//! (or spuriously; callers loop). [`wake_word_one`] wakes one waiter and
6//! [`wake_word`] wakes every waiter on it. The
7//! word may live in shared memory: Linux futex, FreeBSD umtx and macOS
8//! `os_sync_wait_on_address` all key waiters by the physical location, so a
9//! wake in one process reaches a waiter in another with nothing carried
10//! between them. No descriptor, no channel: the memory is the signal.
11//!
12//! macOS needs 14.4 for the shared form. Below that there is no wait to
13//! be had, and [`supported`] says so: a crate that parks on words refuses
14//! to open rather than pretending, because a sleep loop wearing the shape
15//! of a wait is worse than a clear no.
16
17#![cfg(any(target_os = "linux", target_os = "freebsd", target_os = "macos"))]
18
19use std::io;
20#[cfg(target_os = "macos")]
21use std::mem::size_of;
22use std::sync::atomic::AtomicU32;
23use std::time::Duration;
24
25/// Whether this build can park on a shared word at all.
26///
27/// Linux and FreeBSD always can. macOS can from 14.4; below it, and on
28/// any other target, nothing here works and callers should refuse at
29/// their own front door rather than degrade quietly.
30pub fn supported() -> bool {
31    #[cfg(any(target_os = "linux", target_os = "freebsd"))]
32    {
33        true
34    }
35    #[cfg(target_os = "macos")]
36    {
37        macos::api().is_some()
38    }
39}
40
41#[cfg(target_os = "linux")]
42pub fn wait_word(
43    word: &AtomicU32,
44    expected: u32,
45) -> io::Result<()> {
46    let result = unsafe {
47        libc::syscall(
48            libc::SYS_futex,
49            word.as_ptr(),
50            libc::FUTEX_WAIT,
51            expected,
52            std::ptr::null::<libc::timespec>(),
53            std::ptr::null::<u32>(),
54            0,
55        )
56    };
57    if result == 0 {
58        return Ok(());
59    }
60
61    let error = io::Error::last_os_error();
62    match error.raw_os_error() {
63        // The generation changed before the kernel parked us, or the driver
64        // was interrupted. The outer loop re-checks both generation and stop.
65        Some(libc::EAGAIN) | Some(libc::EINTR) => Ok(()),
66        _ => Err(error),
67    }
68}
69
70/// Park until the word no longer holds `expected`, or `timeout` passes.
71///
72/// `Ok(true)` means something may have changed — a wake, a value that
73/// moved before the kernel parked us, or a signal — and the caller
74/// re-checks as it does after [`wait_word`]. `Ok(false)` means the
75/// timeout passed and nothing else. The timeout is relative and measured
76/// on a monotonic clock, so a caller holding a deadline recomputes what
77/// is left on each turn of its loop.
78#[cfg(target_os = "linux")]
79pub fn wait_word_timeout(
80    word: &AtomicU32,
81    expected: u32,
82    timeout: Duration,
83) -> io::Result<bool> {
84    // FUTEX_WAIT reads this as relative, on CLOCK_MONOTONIC.
85    let left = libc::timespec {
86        tv_sec: timeout.as_secs().min(i64::MAX as u64) as libc::time_t,
87        tv_nsec: timeout.subsec_nanos() as libc::c_long,
88    };
89    let result = unsafe {
90        libc::syscall(
91            libc::SYS_futex,
92            word.as_ptr(),
93            libc::FUTEX_WAIT,
94            expected,
95            &left as *const libc::timespec,
96            std::ptr::null::<u32>(),
97            0,
98        )
99    };
100    if result == 0 {
101        return Ok(true);
102    }
103
104    let error = io::Error::last_os_error();
105    match error.raw_os_error() {
106        Some(libc::EAGAIN) | Some(libc::EINTR) => Ok(true),
107        Some(libc::ETIMEDOUT) => Ok(false),
108        _ => Err(error),
109    }
110}
111
112#[cfg(target_os = "linux")]
113pub fn wake_word_one(word: &AtomicU32) -> io::Result<()> {
114    let result = unsafe {
115        libc::syscall(
116            libc::SYS_futex,
117            word.as_ptr(),
118            libc::FUTEX_WAKE,
119            1,
120            std::ptr::null::<libc::timespec>(),
121            std::ptr::null::<u32>(),
122            0,
123        )
124    };
125    if result >= 0 { Ok(()) } else { Err(io::Error::last_os_error()) }
126}
127
128#[cfg(target_os = "linux")]
129pub fn wake_word(word: &AtomicU32) -> io::Result<()> {
130    let result = unsafe {
131        libc::syscall(
132            libc::SYS_futex,
133            word.as_ptr(),
134            libc::FUTEX_WAKE,
135            i32::MAX,
136            std::ptr::null::<libc::timespec>(),
137            std::ptr::null::<u32>(),
138            0,
139        )
140    };
141    if result >= 0 { Ok(()) } else { Err(io::Error::last_os_error()) }
142}
143
144#[cfg(target_os = "freebsd")]
145pub fn wait_word(
146    word: &AtomicU32,
147    expected: u32,
148) -> io::Result<()> {
149    let result = unsafe {
150        libc::_umtx_op(
151            word.as_ptr().cast(),
152            libc::UMTX_OP_WAIT_UINT,
153            expected as libc::c_ulong,
154            std::ptr::null_mut(),
155            std::ptr::null_mut(),
156        )
157    };
158    if result == 0 {
159        return Ok(());
160    }
161
162    let error = io::Error::last_os_error();
163    match error.raw_os_error() {
164        // The generation changed before the kernel parked us, or the driver
165        // was interrupted. The outer loop re-checks generation and stop.
166        Some(libc::EINTR) => Ok(()),
167        _ => Err(error),
168    }
169}
170
171/// Park until the word no longer holds `expected`, or `timeout` passes.
172///
173/// `Ok(true)` means something may have changed — a wake, a value that
174/// moved before the kernel parked us, or a signal — and the caller
175/// re-checks as it does after [`wait_word`]. `Ok(false)` means the
176/// timeout passed and nothing else. The timeout is relative and measured
177/// on a monotonic clock, so a caller holding a deadline recomputes what
178/// is left on each turn of its loop.
179#[cfg(target_os = "freebsd")]
180pub fn wait_word_timeout(
181    word: &AtomicU32,
182    expected: u32,
183    timeout: Duration,
184) -> io::Result<bool> {
185    // For the UMTX_OP_WAIT family the fourth argument is the size of the
186    // timeout structure and the fifth points at it; a bare `timespec` is
187    // read as relative.
188    let left = libc::timespec {
189        tv_sec: timeout.as_secs().min(i64::MAX as u64) as libc::time_t,
190        tv_nsec: timeout.subsec_nanos() as libc::c_long,
191    };
192    let result = unsafe {
193        libc::_umtx_op(
194            word.as_ptr().cast(),
195            libc::UMTX_OP_WAIT_UINT,
196            expected as libc::c_ulong,
197            size_of::<libc::timespec>() as *mut libc::c_void,
198            &left as *const libc::timespec as *mut libc::c_void,
199        )
200    };
201    if result == 0 {
202        return Ok(true);
203    }
204
205    let error = io::Error::last_os_error();
206    match error.raw_os_error() {
207        Some(libc::EINTR) => Ok(true),
208        Some(libc::ETIMEDOUT) => Ok(false),
209        _ => Err(error),
210    }
211}
212
213#[cfg(target_os = "freebsd")]
214pub fn wake_word_one(word: &AtomicU32) -> io::Result<()> {
215    let result = unsafe {
216        libc::_umtx_op(
217            word.as_ptr().cast(),
218            libc::UMTX_OP_WAKE,
219            1,
220            std::ptr::null_mut(),
221            std::ptr::null_mut(),
222        )
223    };
224    if result == 0 { Ok(()) } else { Err(io::Error::last_os_error()) }
225}
226
227#[cfg(target_os = "freebsd")]
228pub fn wake_word(word: &AtomicU32) -> io::Result<()> {
229    let result = unsafe {
230        libc::_umtx_op(
231            word.as_ptr().cast(),
232            libc::UMTX_OP_WAKE,
233            i32::MAX as libc::c_ulong,
234            std::ptr::null_mut(),
235            std::ptr::null_mut(),
236        )
237    };
238    if result == 0 { Ok(()) } else { Err(io::Error::last_os_error()) }
239}
240
241#[cfg(target_os = "macos")]
242pub fn wait_word(
243    word: &AtomicU32,
244    expected: u32,
245) -> io::Result<()> {
246    let api = macos::api().ok_or_else(|| {
247        io::Error::new(
248            io::ErrorKind::Unsupported,
249            "macOS shared address waits unavailable",
250        )
251    })?;
252    debug_assert_eq!(
253        (word.as_ptr() as usize) % size_of::<u32>(),
254        0,
255        "shared wait word must be naturally aligned"
256    );
257    // The SHM word is AtomicU32, not u64. Wait and wake must agree on size
258    // and shared mode. Apple returns a nonnegative waiter count on success.
259    let result = unsafe {
260        (api.wait)(
261            word.as_ptr().cast(),
262            u64::from(expected),
263            size_of::<u32>(),
264            macos::SHARED,
265        )
266    };
267    if result >= 0 {
268        return Ok(());
269    }
270    let error = io::Error::last_os_error();
271    match error.raw_os_error() {
272        Some(libc::EINTR) => Ok(()),
273        _ => Err(error),
274    }
275}
276
277/// Park until the word no longer holds `expected`, or `timeout` passes.
278///
279/// `Ok(true)` means something may have changed — a wake, a value that
280/// moved before the kernel parked us, or a signal — and the caller
281/// re-checks as it does after [`wait_word`]. `Ok(false)` means the
282/// timeout passed and nothing else. The timeout is relative and measured
283/// on a monotonic clock, so a caller holding a deadline recomputes what
284/// is left on each turn of its loop.
285#[cfg(target_os = "macos")]
286pub fn wait_word_timeout(
287    word: &AtomicU32,
288    expected: u32,
289    timeout: Duration,
290) -> io::Result<bool> {
291    let api = macos::api().ok_or_else(|| {
292        io::Error::new(
293            io::ErrorKind::Unsupported,
294            "macOS shared address waits unavailable",
295        )
296    })?;
297    let nanos = timeout.as_nanos().min(u128::from(u64::MAX)) as u64;
298    // SAFETY: the same word, size and shared flag as the untimed wait.
299    let result = unsafe {
300        (api.wait_timeout)(
301            word.as_ptr().cast(),
302            u64::from(expected),
303            size_of::<u32>(),
304            macos::SHARED,
305            macos::MACH_ABSOLUTE_TIME,
306            nanos,
307        )
308    };
309    if result >= 0 {
310        return Ok(true);
311    }
312    let error = io::Error::last_os_error();
313    match error.raw_os_error() {
314        Some(libc::EINTR) => Ok(true),
315        Some(libc::ETIMEDOUT) => Ok(false),
316        _ => Err(error),
317    }
318}
319
320#[cfg(target_os = "macos")]
321pub fn wake_word_one(word: &AtomicU32) -> io::Result<()> {
322    wake_macos(word, false)
323}
324
325#[cfg(target_os = "macos")]
326pub fn wake_word(word: &AtomicU32) -> io::Result<()> {
327    wake_macos(word, true)
328}
329
330#[cfg(target_os = "macos")]
331fn wake_macos(
332    word: &AtomicU32,
333    all: bool,
334) -> io::Result<()> {
335    debug_assert_eq!(
336        (word.as_ptr() as usize) % size_of::<u32>(),
337        0,
338        "shared wake word must be naturally aligned"
339    );
340    // Older macOS has no native subscribers; publication still succeeds and
341    // polling readers see the committed frames.
342    let Some(api) = macos::api() else {
343        return Ok(());
344    };
345    loop {
346        let wake = if all { api.wake_all } else { api.wake_one };
347        let result = unsafe {
348            wake(word.as_ptr().cast(), size_of::<u32>(), macos::SHARED)
349        };
350        if result >= 0 {
351            return Ok(());
352        }
353        let error = io::Error::last_os_error();
354        match error.raw_os_error() {
355            // No waiter is normal: publication can precede subscription or
356            // race the driver's compare-and-wait. The generation persists.
357            Some(libc::ENOENT) => return Ok(()),
358            Some(libc::EINTR) => continue,
359            _ => return Err(error),
360        }
361    }
362}
363
364#[cfg(target_os = "macos")]
365pub(crate) mod macos {
366    use std::sync::atomic::{AtomicU8, AtomicUsize, Ordering};
367
368    // OS_SYNC_WAIT_ON_ADDRESS_SHARED and OS_SYNC_WAKE_BY_ADDRESS_SHARED
369    // have the same ABI value in <os/os_sync_wait_on_address.h>.
370    pub(super) const SHARED: u32 = 1;
371
372    /// `os_clockid_t` in <os/clock.h>: the only clock the timed wait
373    /// takes, and the one a relative timeout is measured on.
374    pub(super) const MACH_ABSOLUTE_TIME: u32 = 32;
375
376    type Wait =
377        unsafe extern "C" fn(*mut libc::c_void, u64, usize, u32) -> libc::c_int;
378    type WaitTimeout = unsafe extern "C" fn(
379        *mut libc::c_void,
380        u64,
381        usize,
382        u32,
383        u32,
384        u64,
385    ) -> libc::c_int;
386    type Wake =
387        unsafe extern "C" fn(*mut libc::c_void, usize, u32) -> libc::c_int;
388
389    #[derive(Clone, Copy)]
390    pub(crate) struct Api {
391        pub(super) wait: Wait,
392        pub(super) wait_timeout: WaitTimeout,
393        pub(super) wake_one: Wake,
394        pub(super) wake_all: Wake,
395    }
396
397    const UNRESOLVED: u8 = 0;
398    const UNAVAILABLE: u8 = 1;
399    const READY: u8 = 2;
400
401    static STATE: AtomicU8 = AtomicU8::new(UNRESOLVED);
402    static WAIT: AtomicUsize = AtomicUsize::new(0);
403    static WAIT_TIMEOUT: AtomicUsize = AtomicUsize::new(0);
404    static WAKE_ONE: AtomicUsize = AtomicUsize::new(0);
405    static WAKE_ALL: AtomicUsize = AtomicUsize::new(0);
406
407    /// Resolve the 14.4 entry points, once per process but never by waiting.
408    ///
409    /// Deliberately not a `OnceLock`. A publisher reaches this from
410    /// `wake_all_generation_waiters`, and a publisher may be a process that
411    /// was just forked while another thread of its parent was inside the
412    /// resolution -- the test harness runs tests on parallel threads, and a
413    /// supervisor forks workers with readiness threads alive. The child
414    /// inherits the "initializing" state and none of the thread that would
415    /// finish it, so a blocking once-cell parks forever. Resolution here is
416    /// idempotent: racing callers each `dlsym` the same two symbols and store
417    /// the same values, and nobody waits for anybody.
418    ///
419    /// Resolving lazily rather than linking avoids hard references to
420    /// 14.4-only symbols on older deployment targets. libSystem stays loaded
421    /// for the life of the process, so the pointers never dangle.
422    pub(crate) fn api() -> Option<Api> {
423        match STATE.load(Ordering::Acquire) {
424            READY => Some(load()),
425            UNAVAILABLE => None,
426            _ => resolve(),
427        }
428    }
429
430    fn load() -> Api {
431        unsafe {
432            Api {
433                wait: std::mem::transmute::<usize, Wait>(
434                    WAIT.load(Ordering::Acquire),
435                ),
436                wait_timeout: std::mem::transmute::<usize, WaitTimeout>(
437                    WAIT_TIMEOUT.load(Ordering::Acquire),
438                ),
439                wake_one: std::mem::transmute::<usize, Wake>(
440                    WAKE_ONE.load(Ordering::Acquire),
441                ),
442                wake_all: std::mem::transmute::<usize, Wake>(
443                    WAKE_ALL.load(Ordering::Acquire),
444                ),
445            }
446        }
447    }
448
449    fn resolve() -> Option<Api> {
450        let wait = unsafe {
451            libc::dlsym(libc::RTLD_DEFAULT, c"os_sync_wait_on_address".as_ptr())
452        };
453        // Shipped in the same release as the other two; all or nothing.
454        let wait_timeout = unsafe {
455            libc::dlsym(
456                libc::RTLD_DEFAULT,
457                c"os_sync_wait_on_address_with_timeout".as_ptr(),
458            )
459        };
460        let wake_one = unsafe {
461            libc::dlsym(
462                libc::RTLD_DEFAULT,
463                c"os_sync_wake_by_address_any".as_ptr(),
464            )
465        };
466        let wake_all = unsafe {
467            libc::dlsym(
468                libc::RTLD_DEFAULT,
469                c"os_sync_wake_by_address_all".as_ptr(),
470            )
471        };
472        if wait.is_null()
473            || wait_timeout.is_null()
474            || wake_one.is_null()
475            || wake_all.is_null()
476        {
477            STATE.store(UNAVAILABLE, Ordering::Release);
478            return None;
479        }
480        // Pointers first, then the state that publishes them.
481        WAIT.store(wait as usize, Ordering::Release);
482        WAIT_TIMEOUT.store(wait_timeout as usize, Ordering::Release);
483        WAKE_ONE.store(wake_one as usize, Ordering::Release);
484        WAKE_ALL.store(wake_all as usize, Ordering::Release);
485        STATE.store(READY, Ordering::Release);
486        Some(load())
487    }
488}
489
490#[cfg(test)]
491mod timeout_tests {
492    use std::sync::Arc;
493    use std::sync::atomic::Ordering;
494    use std::time::Instant;
495
496    use super::*;
497
498    /// Also pins the unit the platform reads the timeout in: a wrong one
499    /// shows up here as a wait that is orders of magnitude off, not as a
500    /// wrong answer.
501    #[test]
502    fn a_timeout_is_a_timeout() {
503        if !supported() {
504            return;
505        }
506        let word = AtomicU32::new(7);
507        let started = Instant::now();
508        assert!(
509            !wait_word_timeout(&word, 7, Duration::from_millis(200))
510                .expect("wait")
511        );
512        let waited = started.elapsed();
513        assert!(
514            waited >= Duration::from_millis(150),
515            "returned after {waited:?}"
516        );
517        assert!(waited < Duration::from_secs(2), "returned after {waited:?}");
518    }
519
520    #[test]
521    fn a_wake_beats_the_timeout() {
522        if !supported() {
523            return;
524        }
525        let word = Arc::new(AtomicU32::new(0));
526        let waker = Arc::clone(&word);
527        std::thread::spawn(move || {
528            std::thread::sleep(Duration::from_millis(50));
529            waker.store(1, Ordering::SeqCst);
530            let _ = wake_word(&waker);
531        });
532        let started = Instant::now();
533        assert!(
534            wait_word_timeout(&word, 0, Duration::from_secs(10)).expect("wait")
535        );
536        assert!(started.elapsed() < Duration::from_secs(5));
537    }
538
539    #[test]
540    fn wake_one_releases_only_one_parked_waiter() {
541        if !supported() {
542            return;
543        }
544        let word = Arc::new(AtomicU32::new(0));
545        let (sent, received) = std::sync::mpsc::channel();
546        let waiters = (0..2)
547            .map(|_| {
548                let word = Arc::clone(&word);
549                let sent = sent.clone();
550                std::thread::spawn(move || {
551                    let outcome =
552                        wait_word_timeout(&word, 0, Duration::from_millis(500))
553                            .expect("wait");
554                    sent.send(outcome).unwrap();
555                })
556            })
557            .collect::<Vec<_>>();
558
559        std::thread::sleep(Duration::from_millis(100));
560        word.store(1, Ordering::SeqCst);
561        wake_word_one(&word).expect("wake one");
562
563        assert!(received.recv_timeout(Duration::from_millis(200)).unwrap());
564        assert!(received.recv_timeout(Duration::from_millis(100)).is_err());
565        assert!(!received.recv_timeout(Duration::from_millis(400)).unwrap());
566        for waiter in waiters {
567            waiter.join().unwrap();
568        }
569    }
570
571    #[test]
572    fn a_word_that_already_moved_does_not_park_at_all() {
573        if !supported() {
574            return;
575        }
576        let word = AtomicU32::new(3);
577        let started = Instant::now();
578        assert!(
579            wait_word_timeout(&word, 9, Duration::from_secs(30)).expect("wait")
580        );
581        assert!(started.elapsed() < Duration::from_secs(1));
582    }
583}