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    fn create_file(path: &Path) -> Result<Self, CondvarError> {
137        let file = OpenOptions::new()
138            .read(true).write(true).create(true).truncate(true)
139            .open(path)?;
140        file.set_len(GEN_REGION_SIZE as u64)?;
141        let mut mmap = unsafe { MmapOptions::new().len(GEN_REGION_SIZE).map_mut(&file)? };
142        let base = mmap.as_mut_ptr();
143        unsafe {
144            (base as *mut u64).write(CONDVAR_GEN_MAGIC);
145            (base.add(GEN_OFFSET) as *mut AtomicU64).write(AtomicU64::new(0));
146        }
147        let ptr = unsafe { base.add(GEN_OFFSET) as *const AtomicU64 };
148        Ok(Self { backing: GenBacking::File(file, mmap), ptr })
149    }
150
151    fn open_file(path: &Path) -> Result<Self, CondvarError> {
152        let file = OpenOptions::new().read(true).write(true).open(path)?;
153        let meta = file.metadata()?;
154        if (meta.len() as usize) < GEN_REGION_SIZE {
155            return Err(CondvarError::LayoutMismatch);
156        }
157        let mut mmap = unsafe { MmapOptions::new().len(GEN_REGION_SIZE).map_mut(&file)? };
158        let base = mmap.as_mut_ptr();
159        let magic = unsafe { (base as *const u64).read() };
160        if magic != CONDVAR_GEN_MAGIC {
161            return Err(CondvarError::LayoutMismatch);
162        }
163        let ptr = unsafe { base.add(GEN_OFFSET) as *const AtomicU64 };
164        Ok(Self { backing: GenBacking::File(file, mmap), ptr })
165    }
166
167    #[inline]
168    fn atom(&self) -> &AtomicU64 {
169        // SAFETY: ptr points GEN_OFFSET bytes into an mmap owned by
170        // this struct; AtomicU64 was initialised in create_*
171        // (or read from a peer's create_* on open_file).
172        unsafe { &*self.ptr }
173    }
174}
175
176/// Cross-process condition variable. Mesa-style: callers re-check
177/// the predicate after each wake. Internally backed by one
178/// [`CrossProcessWaker`] plus a generation counter in mmap.
179pub struct SharedCondvar {
180    waker: Arc<CrossProcessWaker>,
181    gen_atom: Arc<GenAtom>,
182}
183
184impl SharedCondvar {
185    /// In-process condvar (anonymous waker + anonymous mmap for the
186    /// generation atom).
187    pub fn create_anon() -> Result<Self, CondvarError> {
188        Self::create_anon_with_capacity(MAX_WAITERS_DEFAULT)
189    }
190
191    /// In-process condvar with a custom max-waiters capacity.
192    pub fn create_anon_with_capacity(max_waiters: usize) -> Result<Self, CondvarError> {
193        let waker = Arc::new(CrossProcessWaker::create_anon(max_waiters)?);
194        let gen_atom = Arc::new(GenAtom::create_anon()?);
195        Ok(Self { waker, gen_atom })
196    }
197
198    /// File-backed condvar. Path layout:
199    ///   `<base>.waker.bin`  - waker slot array
200    ///   `<base>.gen.bin`    - magic + generation counter
201    pub fn create(base_path: impl AsRef<Path>) -> Result<Self, CondvarError> {
202        Self::create_with_capacity(base_path, MAX_WAITERS_DEFAULT)
203    }
204
205    pub fn create_with_capacity(
206        base_path: impl AsRef<Path>,
207        max_waiters: usize,
208    ) -> Result<Self, CondvarError> {
209        let (waker_path, gen_path) = side_paths(base_path.as_ref());
210        let waker = Arc::new(CrossProcessWaker::create(waker_path, max_waiters)?);
211        let gen_atom = Arc::new(GenAtom::create_file(&gen_path)?);
212        Ok(Self { waker, gen_atom })
213    }
214
215    /// Open an existing file-backed condvar. Both processes that
216    /// share the condvar pass the same `base_path`; one calls
217    /// `create`, the other (and any later joiners) call `open`.
218    pub fn open(base_path: impl AsRef<Path>) -> Result<Self, CondvarError> {
219        Self::open_with_capacity(base_path, MAX_WAITERS_DEFAULT)
220    }
221
222    pub fn open_with_capacity(
223        base_path: impl AsRef<Path>,
224        expected_max_waiters: usize,
225    ) -> Result<Self, CondvarError> {
226        let (waker_path, gen_path) = side_paths(base_path.as_ref());
227        let waker = Arc::new(CrossProcessWaker::open(waker_path, expected_max_waiters)?);
228        let gen_atom = Arc::new(GenAtom::open_file(&gen_path)?);
229        Ok(Self { waker, gen_atom })
230    }
231
232    /// Park until `predicate()` returns true. Re-evaluates the
233    /// predicate after every wake (Mesa-style). Spurious wakes
234    /// re-loop without surfacing to the caller.
235    pub fn wait<F: FnMut() -> bool>(&self, mut predicate: F) -> Result<(), CondvarError> {
236        loop {
237            if predicate() {
238                return Ok(());
239            }
240            // Snapshot generation BEFORE re-checking. If a notify
241            // slips in between predicate() and try_park, the
242            // snapshot is older than the bumped generation, so the
243            // wake call's wake_*_up_to(new_gen) matches our slot's
244            // target_seq = snapshot + 1 <= new_gen.
245            let snapshot = self.gen_atom.atom().load(Ordering::Acquire);
246            let token = self.waker.try_park(snapshot + 1)?;
247            // Wake-before-park recovery.
248            if predicate() {
249                self.waker.release(token);
250                return Ok(());
251            }
252            self.waker.wait(token, None)?;
253        }
254    }
255
256    /// Park until `predicate()` returns true OR `timeout` elapses.
257    /// On `Err(Timeout)` the predicate is guaranteed to have been
258    /// false at the point of return.
259    pub fn wait_timeout<F: FnMut() -> bool>(
260        &self,
261        mut predicate: F,
262        timeout: Duration,
263    ) -> Result<(), CondvarError> {
264        let deadline = Instant::now() + timeout;
265        loop {
266            if predicate() {
267                return Ok(());
268            }
269            let snapshot = self.gen_atom.atom().load(Ordering::Acquire);
270            let token = self.waker.try_park(snapshot + 1)?;
271            if predicate() {
272                self.waker.release(token);
273                return Ok(());
274            }
275            let now = Instant::now();
276            if now >= deadline {
277                self.waker.release(token);
278                return Err(CondvarError::Timeout);
279            }
280            let remaining = deadline - now;
281            match self.waker.wait(token, Some(remaining)) {
282                Ok(()) => continue,
283                Err(WakerError::Timeout) => {
284                    if predicate() {
285                        return Ok(());
286                    }
287                    return Err(CondvarError::Timeout);
288                }
289                Err(e) => return Err(CondvarError::from(e)),
290            }
291        }
292    }
293
294    /// Wake at most one parked waiter. Caller is responsible for
295    /// having advanced the predicate before calling. Returns 1 if a
296    /// waiter was woken, 0 if none were parked.
297    pub fn notify_one(&self) -> usize {
298        let new_gen = self.gen_atom.atom().fetch_add(1, Ordering::Release) + 1;
299        self.waker.wake_one_up_to(new_gen)
300    }
301
302    /// Wake every parked waiter. Returns the count actually woken.
303    pub fn notify_all(&self) -> usize {
304        let new_gen = self.gen_atom.atom().fetch_add(1, Ordering::Release) + 1;
305        self.waker.wake_up_to(new_gen)
306    }
307
308    /// Current generation snapshot (observational; advances on
309    /// every notify).
310    pub fn generation(&self) -> u64 {
311        self.gen_atom.atom().load(Ordering::Acquire)
312    }
313
314    /// Underlying waker handle, for callers who want to peek wake
315    /// state directly.
316    pub fn waker(&self) -> &Arc<CrossProcessWaker> { &self.waker }
317}
318
319fn side_paths(base: &Path) -> (PathBuf, PathBuf) {
320    let mut w = base.as_os_str().to_owned();
321    w.push(".waker.bin");
322    let mut g = base.as_os_str().to_owned();
323    g.push(".gen.bin");
324    (PathBuf::from(w), PathBuf::from(g))
325}
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330    use std::sync::atomic::AtomicBool;
331    use std::thread;
332
333    #[test]
334    fn notify_one_wakes_exactly_one_waiter() {
335        let cv = Arc::new(SharedCondvar::create_anon().expect("create"));
336        let pred = Arc::new(AtomicBool::new(false));
337        let waiters: Vec<_> = (0..3)
338            .map(|_| {
339                let cv2 = Arc::clone(&cv);
340                let pred2 = Arc::clone(&pred);
341                thread::spawn(move || {
342                    cv2.wait(|| pred2.load(Ordering::Acquire)).unwrap();
343                })
344            })
345            .collect();
346        thread::sleep(Duration::from_millis(50));
347
348        // First notify: pred still false, the woken waiter
349        // re-checks, re-parks. We're checking that notify_one
350        // returns 1 (saw a parked slot).
351        assert_eq!(cv.notify_one(), 1);
352        // Now flip the predicate and wake all so the test exits.
353        thread::sleep(Duration::from_millis(20));
354        pred.store(true, Ordering::Release);
355        cv.notify_all();
356        for h in waiters {
357            h.join().unwrap();
358        }
359    }
360
361    #[test]
362    fn notify_all_wakes_every_waiter() {
363        let cv = Arc::new(SharedCondvar::create_anon().expect("create"));
364        let pred = Arc::new(AtomicBool::new(false));
365        let waiters: Vec<_> = (0..4)
366            .map(|_| {
367                let cv2 = Arc::clone(&cv);
368                let pred2 = Arc::clone(&pred);
369                thread::spawn(move || {
370                    cv2.wait(|| pred2.load(Ordering::Acquire)).unwrap();
371                })
372            })
373            .collect();
374        thread::sleep(Duration::from_millis(30));
375        pred.store(true, Ordering::Release);
376        let woken = cv.notify_all();
377        assert!(woken >= 1, "at least one waiter woken (got {woken})");
378        for h in waiters {
379            h.join().unwrap();
380        }
381    }
382
383    #[test]
384    fn wait_timeout_returns_timeout() {
385        let cv = SharedCondvar::create_anon().expect("create");
386        let t0 = Instant::now();
387        let err = cv.wait_timeout(|| false, Duration::from_millis(60));
388        assert_eq!(err, Err(CondvarError::Timeout));
389        assert!(t0.elapsed() >= Duration::from_millis(50));
390    }
391
392    #[test]
393    fn wait_returns_immediately_if_predicate_already_true() {
394        let cv = SharedCondvar::create_anon().expect("create");
395        let pred = AtomicBool::new(true);
396        let t0 = Instant::now();
397        cv.wait(|| pred.load(Ordering::Acquire)).unwrap();
398        assert!(t0.elapsed() < Duration::from_millis(10));
399    }
400
401    /// Intra-process file-backed sharing uses Arc::clone (NOT
402    /// create+open). The `open` constructor is for callers in
403    /// SEPARATE processes joining a file the creator already
404    /// initialised; calling `open` in the SAME process as `create`
405    /// produces a second mmap with a different virtual-address
406    /// range aliased to the same file pages. On Windows that
407    /// breaks the wake path because `WaitOnAddress` /
408    /// `WakeByAddressSingle` are keyed by virtual address, not by
409    /// the underlying file page. Cross-process Linux works via
410    /// SHARED `futex` (keyed by inode-offset); see
411    /// `examples/condvar_xproc_*.rs` + the matching sweep script
412    /// for that path.
413    #[test]
414    fn file_backed_create_then_arc_clone_round_trip() {
415        let dir = std::env::temp_dir();
416        let path = dir.join(format!("subetha_condvar_test_{}", std::process::id()));
417        // Cleanup leftover files from a prior aborted run.
418        for suffix in [".waker.bin", ".gen.bin"] {
419            let mut p = path.as_os_str().to_owned();
420            p.push(suffix);
421            drop(std::fs::remove_file(PathBuf::from(p)));
422        }
423        let cv = Arc::new(SharedCondvar::create(&path).expect("create"));
424        let pred = Arc::new(AtomicBool::new(false));
425        let cv2 = Arc::clone(&cv);
426        let pred2 = Arc::clone(&pred);
427        let waiter = thread::spawn(move || {
428            cv2.wait(|| pred2.load(Ordering::Acquire)).unwrap();
429        });
430        thread::sleep(Duration::from_millis(30));
431        pred.store(true, Ordering::Release);
432        cv.notify_all();
433        waiter.join().unwrap();
434
435        // Cleanup.
436        for suffix in [".waker.bin", ".gen.bin"] {
437            let mut p = path.as_os_str().to_owned();
438            p.push(suffix);
439            drop(std::fs::remove_file(PathBuf::from(p)));
440        }
441    }
442}