Skip to main content

proxima_primitives/sync/
notify.rs

1//! `proxima::sync::Notify` — single-permit edge-triggered notifier,
2//! shape-compatible with `tokio::sync::Notify`. Backed by
3//! `event_listener::Event`; permit semantics implemented via a
4//! single-bit `AtomicBool` so `notify_one` saves a permit when no
5//! waiter is listening (just like tokio).
6//!
7//! Semantics summary:
8//! - At most ONE permit is ever stored. Repeated `notify_one` calls
9//!   while a permit is already pending are no-ops.
10//! - `notify_waiters` wakes all currently-listening waiters but does
11//!   NOT save a permit. Future `notified().await` calls will park.
12//! - `notified()` returns a [`Notified`] future. `notified().await`
13//!   consumes a pending permit or parks until the next `notify_one`
14//!   or `notify_waiters`.
15//! - `Notified::enable(self: Pin<&mut Self>)` ensures the listener is
16//!   registered *without* polling — used to close lost-wakeup races
17//!   when the caller wants to re-check state between registration and
18//!   parking.
19//!
20//! # Non-coverage
21//!
22//! - `notify_one` only — no `notify_last` / `notify_first` ordering
23//!   variants. tokio's `Notify` doesn't offer these either, but other
24//!   notifier crates do; documenting it for migration clarity.
25//! - `notify_waiters` wakes ALL current waiters and does NOT save a
26//!   permit. That matches tokio. If you need "wake all + save a
27//!   permit for the next entrant", chain `notify_waiters()` and
28//!   `notify_one()`.
29//!
30//! # Loom coverage
31//!
32//! `--cfg loom` loom-tests this exact `Notify` (not a hand-written
33//! model): [`crate::sync::loom_atomic`] swaps `permit`'s `AtomicBool` for
34//! loom's instrumented one, and `event_listener::Event` becomes
35//! loom-instrumented too (its `loom` Cargo feature is enabled under
36//! `[target.'cfg(loom)'.dependencies]`), so the model checker sees
37//! every atomic op in the permit/listener-registration race, inside
38//! and outside `Event`. See the `loom_tests` module below.
39
40use alloc::boxed::Box;
41use core::future::Future;
42use core::pin::Pin;
43use core::task::{Context, Poll};
44
45use event_listener::{Event, EventListener};
46
47use crate::sync::loom_atomic::{AtomicBool, Ordering};
48
49#[derive(Debug, Default)]
50pub struct Notify {
51    event: Event,
52    /// True when `notify_one` stored a permit that has not yet been
53    /// consumed by a `notified().await`.
54    permit: AtomicBool,
55}
56
57impl Notify {
58    /// Under `--cfg loom`, `event_listener::Event::new` (and loom's own
59    /// `AtomicBool::new`) are deliberately non-`const` — loom's model
60    /// needs a runtime execution context to track the value. Only the
61    /// `const` qualifier is affected; the constructed `Notify` is
62    /// identical either way.
63    #[cfg(not(loom))]
64    #[must_use]
65    pub const fn const_new() -> Self {
66        Self {
67            event: Event::new(),
68            permit: AtomicBool::new(false),
69        }
70    }
71
72    #[cfg(loom)]
73    #[must_use]
74    pub fn const_new() -> Self {
75        Self {
76            event: Event::new(),
77            permit: AtomicBool::new(false),
78        }
79    }
80
81    #[must_use]
82    pub fn new() -> Self {
83        Self::const_new()
84    }
85
86    /// Wake one waiter, or store a permit for the next waiter if none
87    /// are currently listening. Multiple calls before a wake do not
88    /// stack — at most one permit is held at a time.
89    pub fn notify_one(&self) {
90        if self
91            .permit
92            .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
93            .is_ok()
94        {
95            self.event.notify(1);
96        }
97    }
98
99    /// Wake every currently-listening waiter. Does NOT store a permit.
100    pub fn notify_waiters(&self) {
101        self.event.notify(usize::MAX);
102    }
103
104    /// Returns a future that resolves when a permit is available or a
105    /// waiter wake fires.
106    #[must_use = "futures are lazy and do nothing unless awaited"]
107    pub fn notified(&self) -> Notified<'_> {
108        Notified {
109            notify: self,
110            listener: None,
111        }
112    }
113}
114
115/// Future returned by [`Notify::notified`]. Can be explicitly
116/// registered without polling via [`Notified::enable`] to close
117/// lost-wakeup races between state check and park.
118pub struct Notified<'lifetime> {
119    notify: &'lifetime Notify,
120    /// `Pin<Box<EventListener>>` is `Unpin` (Box is always Unpin)
121    /// even though EventListener itself is not, so the whole
122    /// `Notified` is `Unpin` and supports the `enable(self: Pin<&mut
123    /// Self>)` signature without `unsafe`.
124    listener: Option<Pin<Box<EventListener>>>,
125}
126
127impl Notified<'_> {
128    /// Register as a listener and consume any pending permit. Returns
129    /// `true` if a permit was already pending (the listener will not
130    /// park on subsequent `await`); `false` otherwise. Idempotent.
131    pub fn enable(self: Pin<&mut Self>) -> bool {
132        let this = self.get_mut();
133        this.ensure_listener();
134        if this.notify.permit.swap(false, Ordering::AcqRel) {
135            this.listener = None;
136            true
137        } else {
138            false
139        }
140    }
141
142    fn ensure_listener(&mut self) {
143        if self.listener.is_none() {
144            self.listener = Some(Box::pin(self.notify.event.listen()));
145        }
146    }
147}
148
149impl Future for Notified<'_> {
150    type Output = ();
151    fn poll(self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll<Self::Output> {
152        let this = self.get_mut();
153        if this.notify.permit.swap(false, Ordering::AcqRel) {
154            this.listener = None;
155            return Poll::Ready(());
156        }
157        this.ensure_listener();
158        if this.notify.permit.swap(false, Ordering::AcqRel) {
159            this.listener = None;
160            return Poll::Ready(());
161        }
162        let Some(listener) = this.listener.as_mut() else {
163            return Poll::Pending;
164        };
165        match listener.as_mut().poll(context) {
166            Poll::Ready(()) => {
167                this.listener = None;
168                let _ = this.notify.permit.swap(false, Ordering::AcqRel);
169                Poll::Ready(())
170            }
171            Poll::Pending => Poll::Pending,
172        }
173    }
174}
175
176// under `--cfg loom`, `event_listener::Event` and loom's atomics require
177// every call to happen inside a `loom::model()`; these plain, non-model
178// tests exist for the normal (non-loom) build only — `loom_tests` below
179// is the loom-model equivalent coverage.
180#[cfg(all(test, not(loom)))]
181#[allow(clippy::unwrap_used, clippy::expect_used)]
182mod tests {
183    use super::*;
184    use futures::executor::block_on;
185    use std::sync::Arc;
186
187    #[test]
188    fn notify_one_then_notified_returns_immediately() {
189        let notify = Notify::new();
190        notify.notify_one();
191        block_on(notify.notified());
192    }
193
194    #[test]
195    fn second_notified_parks_until_next_notify() {
196        let notify = Arc::new(Notify::new());
197        notify.notify_one();
198        block_on(notify.notified());
199        let started = std::sync::atomic::AtomicBool::new(false);
200        let started_ref = &started;
201        let notify_ref = notify.clone();
202        std::thread::scope(|scope| {
203            scope.spawn(move || {
204                while !started_ref.load(Ordering::Acquire) {
205                    std::hint::spin_loop();
206                }
207                notify_ref.notify_one();
208            });
209            started.store(true, Ordering::Release);
210            block_on(notify.notified());
211        });
212    }
213
214    #[test]
215    fn notify_waiters_does_not_save_permit() {
216        let notify = Notify::new();
217        notify.notify_waiters();
218        // no listener was registered when notify_waiters fired, so the
219        // permit is NOT saved. A subsequent notified() must park; we
220        // can't easily check "parks forever" so instead verify via
221        // notify_one re-set after.
222        notify.notify_one();
223        block_on(notify.notified());
224    }
225
226    #[test]
227    fn enable_returns_true_when_permit_already_pending() {
228        let notify = Notify::new();
229        notify.notify_one();
230        let waiter = notify.notified();
231        futures::pin_mut!(waiter);
232        assert!(waiter.as_mut().enable());
233    }
234
235    #[test]
236    fn enable_returns_false_when_no_permit() {
237        let notify = Notify::new();
238        let waiter = notify.notified();
239        futures::pin_mut!(waiter);
240        assert!(!waiter.as_mut().enable());
241    }
242}
243
244/// Loom interleaving coverage for the REAL `Notify` above (not a
245/// hand-written model). Ported 1:1 from the former
246/// `proxima-loom/tests/loom_sync.rs`. `preemption_bound` is set inline
247/// so the search is bounded without relying on an operator remembering
248/// `LOOM_MAX_PREEMPTIONS`.
249///
250/// Run with:
251///   RUSTFLAGS="--cfg loom" cargo test -p proxima-primitives --release notify::loom_tests
252#[cfg(all(test, loom))]
253#[allow(clippy::unwrap_used, clippy::expect_used)]
254mod loom_tests {
255    use super::Notify;
256    use loom::sync::Arc;
257
258    fn check(model: impl Fn() + Sync + Send + 'static) {
259        let mut builder = loom::model::Builder::new();
260        builder.preemption_bound = Some(3);
261        builder.check(model);
262    }
263
264    #[test]
265    fn notify_one_then_notified_returns_immediately() {
266        check(|| {
267            let notify = Arc::new(Notify::new());
268            notify.notify_one();
269            loom::future::block_on(notify.notified());
270        });
271    }
272
273    #[test]
274    fn notified_then_notify_one_resolves() {
275        check(|| {
276            let notify = Arc::new(Notify::new());
277            let notify_producer = notify.clone();
278
279            let producer = loom::thread::spawn(move || {
280                notify_producer.notify_one();
281            });
282
283            loom::future::block_on(notify.notified());
284            producer.join().expect("producer thread panicked");
285        });
286    }
287
288    #[test]
289    fn concurrent_notify_one_and_notified() {
290        check(|| {
291            let notify = Arc::new(Notify::new());
292            let notify_producer = notify.clone();
293
294            let producer = loom::thread::spawn(move || {
295                notify_producer.notify_one();
296            });
297
298            loom::future::block_on(notify.notified());
299            producer.join().expect("producer thread panicked");
300        });
301    }
302
303    /// A second `notify_one` while a permit is already pending must NOT
304    /// stack additional permits — the CAS in `notify_one` is the guard.
305    #[test]
306    fn multiple_notify_one_does_not_stack() {
307        check(|| {
308            let notify = Arc::new(Notify::new());
309
310            notify.notify_one();
311            notify.notify_one(); // permit already true — must be a no-op
312
313            loom::future::block_on(notify.notified()); // consumes the one permit
314
315            // no stacked permit, so notified() must park until notify_one fires
316            let notify_for_producer = notify.clone();
317            let producer = loom::thread::spawn(move || {
318                notify_for_producer.notify_one(); // third notify — unblocks second waiter
319            });
320
321            loom::future::block_on(notify.notified());
322            producer.join().expect("producer thread panicked");
323        });
324    }
325}