Skip to main content

subetha_cxc/
shared_condvar.rs

1//! `SharedCondvar`: cross-process condition variable built on top
2//! of [`CrossProcessWaker`](crate::cross_process_waker).
3//!
4//! Classic Mesa-style condvar interface: waiters check a user-owned
5//! predicate, park if not satisfied, and resume when a notifier
6//! advances the predicate AND calls `notify_*`. The substrate uses
7//! a monotonic generation counter so each `wait` parks at
8//! `target = current_gen + 1`; every `notify_*` bumps the generation
9//! and fires `wake_(one_)up_to(new_gen)`, which wakes parked waiters
10//! whose `target <= new_gen`.
11//!
12//! # Cross-process semantics
13//!
14//! Two processes mmap the same condvar base; both call `wait` /
15//! `notify_*` directly. On Linux the wake call crosses the process
16//! boundary via SHARED `futex` (keyed by inode + offset, so two
17//! different mmaps of the same file page DO match). On Windows /
18//! macOS the primitive runs intra-process via `WaitOnAddress` /
19//! spin fallback.
20//!
21//! # Intra-process sharing: use Arc::clone, NOT create+open
22//!
23//! Within ONE process, share a single `SharedCondvar` through
24//! `Arc<SharedCondvar>` + `Arc::clone`. Calling `create` and then
25//! `open` on the same path in the same process produces two
26//! independent mmaps with different virtual-address ranges aliased
27//! to the same file pages. Windows `WaitOnAddress` is keyed by
28//! virtual address, so a `notify_*` on the second handle does NOT
29//! reach a `wait` on the first handle - the wake hashtable lookup
30//! misses on the differing virtual address. Linux SHARED `futex`
31//! keys by the underlying file page, which works across separate
32//! mmaps, but the rule "use one `Arc<SharedCondvar>` per process"
33//! is cross-platform safe.
34//!
35//! The `open` constructor is exclusively for joiners in SEPARATE
36//! processes that need to find the file the creator already
37//! initialised.
38//!
39//! # Predicate ownership
40//!
41//! The condvar does NOT own the predicate atom; the caller passes
42//! a closure that returns the current predicate value. This matches
43//! `parking_lot::Condvar::wait_while` semantics and lets the same
44//! condvar guard predicates held in any cross-process atom
45//! (`SharedAtomicU32`, a field in a `SharedCell`, an offset into
46//! an MMF struct, etc.).
47
48use std::fs::OpenOptions;
49use std::io;
50use std::path::{Path, PathBuf};
51use std::sync::Arc;
52use std::sync::atomic::{AtomicU64, Ordering};
53use std::time::{Duration, Instant};
54
55use memmap2::{MmapMut, MmapOptions};
56
57use crate::cross_process_waker::{
58    CrossProcessWaker, MAX_WAITERS_DEFAULT, WakerError,
59};
60
61/// Magic header byte so `open` validates that the file at the gen
62/// path was actually written by this primitive.
63const CONDVAR_GEN_MAGIC: u64 = 0x434F_4E44_5641_5230; // "CONDVAR0"
64const GEN_REGION_SIZE: usize = 64; // one cache line: [magic u64][gen AtomicU64]
65const GEN_OFFSET: usize = 8;
66
67/// Errors returned by [`SharedCondvar`] operations.
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub enum CondvarError {
70    /// All waker slots in use; caller's fallback is to spin on the
71    /// predicate via the underlying atom.
72    WakerFull,
73    /// `wait_timeout` returned because the caller-supplied timeout
74    /// elapsed before the predicate became true.
75    Timeout,
76    /// Backing file (waker or gen) layout did not match expectations
77    /// on `open`.
78    LayoutMismatch,
79    /// I/O error from the underlying mmap.
80    Io(io::ErrorKind),
81}
82
83impl From<WakerError> for CondvarError {
84    fn from(e: WakerError) -> Self {
85        match e {
86            WakerError::Full => Self::WakerFull,
87            WakerError::Timeout => Self::Timeout,
88            WakerError::LayoutMismatch => Self::LayoutMismatch,
89            WakerError::IoError(k) => Self::Io(k),
90        }
91    }
92}
93
94impl From<io::Error> for CondvarError {
95    fn from(e: io::Error) -> Self { Self::Io(e.kind()) }
96}
97
98/// Generation-counter backing. Owns either an anon mmap (in-process)
99/// or a file-backed mmap (cross-process); exposes a stable
100/// `&AtomicU64` view into the first 8 bytes after a magic header.
101///
102/// Variant payloads are held purely for their `Drop` side effects:
103/// dropping the `MmapMut` unmaps, dropping the `File` releases the
104/// fd. The `GenAtom::ptr` field reads through them, so they ARE
105/// load-bearing despite never being named.
106#[allow(dead_code)]
107enum GenBacking {
108    Anon(MmapMut),
109    File(std::fs::File, MmapMut),
110}
111
112struct GenAtom {
113    /// Owns the underlying mmap so `ptr` stays valid until Drop.
114    #[allow(dead_code)]
115    backing: GenBacking,
116    ptr: *const AtomicU64,
117}
118
119// SAFETY: the AtomicU64 ptr lives inside the mmap we own; mmap
120// pages are valid for the lifetime of GenAtom. AtomicU64 is Sync.
121unsafe impl Send for GenAtom {}
122unsafe impl Sync for GenAtom {}
123
124impl GenAtom {
125    fn create_anon() -> Result<Self, CondvarError> {
126        let mut mmap = MmapOptions::new().len(GEN_REGION_SIZE).map_anon()?;
127        let base = mmap.as_mut_ptr();
128        unsafe {
129            (base as *mut u64).write(CONDVAR_GEN_MAGIC);
130            (base.add(GEN_OFFSET) as *mut AtomicU64).write(AtomicU64::new(0));
131        }
132        let ptr = unsafe { base.add(GEN_OFFSET) as *const AtomicU64 };
133        Ok(Self { backing: GenBacking::Anon(mmap), ptr })
134    }
135
136    /// Obtain the generation region at `path`, initializing it only when the
137    /// path does not yet exist. Attaching leaves a live generation counter in
138    /// place.
139    fn create_file(path: &Path) -> Result<Self, CondvarError> {
140        let (file, mut mmap) = crate::mmf_attach::create_or_attach(
141            path,
142            GEN_REGION_SIZE,
143            |base| unsafe {
144                (base.add(GEN_OFFSET) as *mut AtomicU64).write(AtomicU64::new(0));
145                std::ptr::write_volatile(base as *mut u64, CONDVAR_GEN_MAGIC);
146            },
147            |base| unsafe { (base as *const u64).read() == CONDVAR_GEN_MAGIC },
148        )?;
149        let base = mmap.as_mut_ptr();
150        let ptr = unsafe { base.add(GEN_OFFSET) as *const AtomicU64 };
151        Ok(Self { backing: GenBacking::File(file, mmap), ptr })
152    }
153
154    fn open_file(path: &Path) -> Result<Self, CondvarError> {
155        let file = OpenOptions::new().read(true).write(true).open(path)?;
156        let meta = file.metadata()?;
157        if (meta.len() as usize) < GEN_REGION_SIZE {
158            return Err(CondvarError::LayoutMismatch);
159        }
160        let mut mmap = unsafe { MmapOptions::new().len(GEN_REGION_SIZE).map_mut(&file)? };
161        let base = mmap.as_mut_ptr();
162        let magic = unsafe { (base as *const u64).read() };
163        if magic != CONDVAR_GEN_MAGIC {
164            return Err(CondvarError::LayoutMismatch);
165        }
166        let ptr = unsafe { base.add(GEN_OFFSET) as *const AtomicU64 };
167        Ok(Self { backing: GenBacking::File(file, mmap), ptr })
168    }
169
170    #[inline]
171    fn atom(&self) -> &AtomicU64 {
172        // SAFETY: ptr points GEN_OFFSET bytes into an mmap owned by
173        // this struct; AtomicU64 was initialised in create_*
174        // (or read from a peer's create_* on open_file).
175        unsafe { &*self.ptr }
176    }
177}
178
179/// Cross-process condition variable. Mesa-style: callers re-check
180/// the predicate after each wake. Internally backed by one
181/// [`CrossProcessWaker`] plus a generation counter in mmap.
182pub struct SharedCondvar {
183    waker: Arc<CrossProcessWaker>,
184    gen_atom: Arc<GenAtom>,
185}
186
187impl SharedCondvar {
188    /// In-process condvar (anonymous waker + anonymous mmap for the
189    /// generation atom).
190    pub fn create_anon() -> Result<Self, CondvarError> {
191        Self::create_anon_with_capacity(MAX_WAITERS_DEFAULT)
192    }
193
194    /// In-process condvar with a custom max-waiters capacity.
195    pub fn create_anon_with_capacity(max_waiters: usize) -> Result<Self, CondvarError> {
196        let waker = Arc::new(CrossProcessWaker::create_anon(max_waiters)?);
197        let gen_atom = Arc::new(GenAtom::create_anon()?);
198        Ok(Self { waker, gen_atom })
199    }
200
201    /// File-backed condvar. Path layout:
202    ///   `<base>.waker.bin`  - waker slot array
203    ///   `<base>.gen.bin`    - magic + generation counter
204    pub fn create(base_path: impl AsRef<Path>) -> Result<Self, CondvarError> {
205        Self::create_with_capacity(base_path, MAX_WAITERS_DEFAULT)
206    }
207
208    pub fn create_with_capacity(
209        base_path: impl AsRef<Path>,
210        max_waiters: usize,
211    ) -> Result<Self, CondvarError> {
212        let (waker_path, gen_path) = side_paths(base_path.as_ref());
213        let waker = Arc::new(CrossProcessWaker::create(waker_path, max_waiters)?);
214        let gen_atom = Arc::new(GenAtom::create_file(&gen_path)?);
215        Ok(Self { waker, gen_atom })
216    }
217
218    /// Open an existing file-backed condvar. Both processes that
219    /// share the condvar pass the same `base_path`; one calls
220    /// `create`, the other (and any later joiners) call `open`.
221    pub fn open(base_path: impl AsRef<Path>) -> Result<Self, CondvarError> {
222        Self::open_with_capacity(base_path, MAX_WAITERS_DEFAULT)
223    }
224
225    pub fn open_with_capacity(
226        base_path: impl AsRef<Path>,
227        expected_max_waiters: usize,
228    ) -> Result<Self, CondvarError> {
229        let (waker_path, gen_path) = side_paths(base_path.as_ref());
230        let waker = Arc::new(CrossProcessWaker::open(waker_path, expected_max_waiters)?);
231        let gen_atom = Arc::new(GenAtom::open_file(&gen_path)?);
232        Ok(Self { waker, gen_atom })
233    }
234
235    /// Park until `predicate()` returns true. Re-evaluates the
236    /// predicate after every wake (Mesa-style). Spurious wakes
237    /// re-loop without surfacing to the caller.
238    pub fn wait<F: FnMut() -> bool>(&self, mut predicate: F) -> Result<(), CondvarError> {
239        loop {
240            if predicate() {
241                return Ok(());
242            }
243            // Snapshot generation BEFORE re-checking. If a notify
244            // slips in between predicate() and try_park, the
245            // snapshot is older than the bumped generation, so the
246            // wake call's wake_*_up_to(new_gen) matches our slot's
247            // target_seq = snapshot + 1 <= new_gen.
248            let snapshot = self.gen_atom.atom().load(Ordering::Acquire);
249            let token = self.waker.try_park(snapshot + 1)?;
250            // Wake-before-park recovery.
251            if predicate() {
252                self.waker.release(token);
253                return Ok(());
254            }
255            self.waker.wait(token, None)?;
256        }
257    }
258
259    /// Park until `predicate()` returns true OR `timeout` elapses.
260    /// On `Err(Timeout)` the predicate is guaranteed to have been
261    /// false at the point of return.
262    pub fn wait_timeout<F: FnMut() -> bool>(
263        &self,
264        mut predicate: F,
265        timeout: Duration,
266    ) -> Result<(), CondvarError> {
267        let deadline = Instant::now() + timeout;
268        loop {
269            if predicate() {
270                return Ok(());
271            }
272            let snapshot = self.gen_atom.atom().load(Ordering::Acquire);
273            let token = self.waker.try_park(snapshot + 1)?;
274            if predicate() {
275                self.waker.release(token);
276                return Ok(());
277            }
278            let now = Instant::now();
279            if now >= deadline {
280                self.waker.release(token);
281                return Err(CondvarError::Timeout);
282            }
283            let remaining = deadline - now;
284            match self.waker.wait(token, Some(remaining)) {
285                Ok(()) => continue,
286                Err(WakerError::Timeout) => {
287                    if predicate() {
288                        return Ok(());
289                    }
290                    return Err(CondvarError::Timeout);
291                }
292                Err(e) => return Err(CondvarError::from(e)),
293            }
294        }
295    }
296
297    /// Wake at most one parked waiter. Caller is responsible for
298    /// having advanced the predicate before calling. Returns 1 if a
299    /// waiter was woken, 0 if none were parked.
300    pub fn notify_one(&self) -> usize {
301        let new_gen = self.gen_atom.atom().fetch_add(1, Ordering::Release) + 1;
302        self.waker.wake_one_up_to(new_gen)
303    }
304
305    /// Wake every parked waiter. Returns the count actually woken.
306    pub fn notify_all(&self) -> usize {
307        let new_gen = self.gen_atom.atom().fetch_add(1, Ordering::Release) + 1;
308        self.waker.wake_up_to(new_gen)
309    }
310
311    /// Current generation snapshot (observational; advances on
312    /// every notify).
313    pub fn generation(&self) -> u64 {
314        self.gen_atom.atom().load(Ordering::Acquire)
315    }
316
317    /// Underlying waker handle, for callers who want to peek wake
318    /// state directly.
319    pub fn waker(&self) -> &Arc<CrossProcessWaker> { &self.waker }
320}
321
322fn side_paths(base: &Path) -> (PathBuf, PathBuf) {
323    let mut w = base.as_os_str().to_owned();
324    w.push(".waker.bin");
325    let mut g = base.as_os_str().to_owned();
326    g.push(".gen.bin");
327    (PathBuf::from(w), PathBuf::from(g))
328}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333    use std::sync::atomic::AtomicBool;
334    use std::thread;
335
336    #[test]
337    fn notify_one_wakes_exactly_one_waiter() {
338        let cv = Arc::new(SharedCondvar::create_anon().expect("create"));
339        let pred = Arc::new(AtomicBool::new(false));
340        let waiters: Vec<_> = (0..3)
341            .map(|_| {
342                let cv2 = Arc::clone(&cv);
343                let pred2 = Arc::clone(&pred);
344                thread::spawn(move || {
345                    cv2.wait(|| pred2.load(Ordering::Acquire)).unwrap();
346                })
347            })
348            .collect();
349        thread::sleep(Duration::from_millis(50));
350
351        // First notify: pred still false, the woken waiter
352        // re-checks, re-parks. We're checking that notify_one
353        // returns 1 (saw a parked slot).
354        assert_eq!(cv.notify_one(), 1);
355        // Now flip the predicate and wake all so the test exits.
356        thread::sleep(Duration::from_millis(20));
357        pred.store(true, Ordering::Release);
358        cv.notify_all();
359        for h in waiters {
360            h.join().unwrap();
361        }
362    }
363
364    #[test]
365    fn notify_all_wakes_every_waiter() {
366        let cv = Arc::new(SharedCondvar::create_anon().expect("create"));
367        let pred = Arc::new(AtomicBool::new(false));
368        let waiters: Vec<_> = (0..4)
369            .map(|_| {
370                let cv2 = Arc::clone(&cv);
371                let pred2 = Arc::clone(&pred);
372                thread::spawn(move || {
373                    cv2.wait(|| pred2.load(Ordering::Acquire)).unwrap();
374                })
375            })
376            .collect();
377        thread::sleep(Duration::from_millis(30));
378        pred.store(true, Ordering::Release);
379        let woken = cv.notify_all();
380        assert!(woken >= 1, "at least one waiter woken (got {woken})");
381        for h in waiters {
382            h.join().unwrap();
383        }
384    }
385
386    #[test]
387    fn wait_timeout_returns_timeout() {
388        let cv = SharedCondvar::create_anon().expect("create");
389        let t0 = Instant::now();
390        let err = cv.wait_timeout(|| false, Duration::from_millis(60));
391        assert_eq!(err, Err(CondvarError::Timeout));
392        assert!(t0.elapsed() >= Duration::from_millis(50));
393    }
394
395    #[test]
396    fn wait_returns_immediately_if_predicate_already_true() {
397        let cv = SharedCondvar::create_anon().expect("create");
398        let pred = AtomicBool::new(true);
399        let t0 = Instant::now();
400        cv.wait(|| pred.load(Ordering::Acquire)).unwrap();
401        assert!(t0.elapsed() < Duration::from_millis(10));
402    }
403
404    /// Intra-process file-backed sharing uses Arc::clone (NOT
405    /// create+open). The `open` constructor is for callers in
406    /// SEPARATE processes joining a file the creator already
407    /// initialised; calling `open` in the SAME process as `create`
408    /// produces a second mmap with a different virtual-address
409    /// range aliased to the same file pages. On Windows that
410    /// breaks the wake path because `WaitOnAddress` /
411    /// `WakeByAddressSingle` are keyed by virtual address, not by
412    /// the underlying file page. Cross-process Linux works via
413    /// SHARED `futex` (keyed by inode-offset); see
414    /// `examples/condvar_xproc_*.rs` + the matching sweep script
415    /// for that path.
416    #[test]
417    fn file_backed_create_then_arc_clone_round_trip() {
418        let dir = std::env::temp_dir();
419        let path = dir.join(format!("subetha_condvar_test_{}", std::process::id()));
420        // Cleanup leftover files from a prior aborted run.
421        for suffix in [".waker.bin", ".gen.bin"] {
422            let mut p = path.as_os_str().to_owned();
423            p.push(suffix);
424            drop(std::fs::remove_file(PathBuf::from(p)));
425        }
426        let cv = Arc::new(SharedCondvar::create(&path).expect("create"));
427        let pred = Arc::new(AtomicBool::new(false));
428        let cv2 = Arc::clone(&cv);
429        let pred2 = Arc::clone(&pred);
430        let waiter = thread::spawn(move || {
431            cv2.wait(|| pred2.load(Ordering::Acquire)).unwrap();
432        });
433        thread::sleep(Duration::from_millis(30));
434        pred.store(true, Ordering::Release);
435        cv.notify_all();
436        waiter.join().unwrap();
437
438        // Cleanup.
439        for suffix in [".waker.bin", ".gen.bin"] {
440            let mut p = path.as_os_str().to_owned();
441            p.push(suffix);
442            drop(std::fs::remove_file(PathBuf::from(p)));
443        }
444    }
445}