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}