Skip to main content

ph_eventing/
counted_signal.rs

1//! A saturating count for payload-free events.
2//!
3//! [`CountedSignal`] is an SPSC primitive for events whose
4//! multiplicity matters but whose payload and ordering do not. Its producer
5//! commits each increment with a sole-producer–bounded path; its consumer
6//! atomically takes the accumulated count.
7//!
8//! The single-producer handle is load-bearing. Below [`u32::MAX`] the producer
9//! uses a Relaxed `fetch_add`, which cannot wrap because only the consumer may
10//! write between the load and the RMW and it can only reset to zero. An
11//! observed `u32::MAX` is treated as maybe-stale and re-read through a no-op
12//! RMW (`fetch_or(0)`), which — unlike a load — observes the latest value in
13//! modification order: `MAX` confirms true saturation (skip), anything else
14//! means a completed take reset the counter and the producer `fetch_add`s into
15//! the new epoch. Every path is a fixed sequence of at most three source-level
16//! atomic operations — no compare-exchange and no algorithmic retry. On
17//! exclusive-monitor Arm each single RMW is realised as an LDREX/STREX pair
18//! that repeats only when an intervening event claims the word; contract B1
19//! carries the full per-ISA disclosure. Multiple
20//! producers would invalidate the proof.
21//!
22//! The counter carries no payload-publication semantics. These Relaxed
23//! operations order the count itself, but do not publish unrelated application
24//! memory. Use a separate synchronization mechanism when an occurrence makes
25//! payload data available.
26
27use core::cell::Cell;
28use core::marker::PhantomData;
29
30use crate::sync::{AtomicBool, AtomicU32, Ordering};
31
32/// A saturating SPSC counter for payload-free events.
33///
34/// Exactly one [`Producer`] and one [`Consumer`] may be active at a time.
35/// Handles are `Send + !Sync`: each can move to another execution context,
36/// but cannot be shared between contexts. Dropping a handle releases its slot.
37///
38/// `u32::MAX` is the saturation sentinel. Counts below it are exact; a
39/// saturated snapshot means that at least `u32::MAX` increments occurred
40/// since the preceding take.
41pub struct CountedSignal {
42    count: AtomicU32,
43    producer_taken: AtomicBool,
44    consumer_taken: AtomicBool,
45}
46
47impl CountedSignal {
48    /// Create an empty counted signal.
49    #[cfg(not(loom))]
50    #[must_use]
51    pub const fn new() -> Self {
52        Self {
53            count: AtomicU32::new(0),
54            producer_taken: AtomicBool::new(false),
55            consumer_taken: AtomicBool::new(false),
56        }
57    }
58
59    /// Create an empty counted signal under Loom.
60    #[cfg(loom)]
61    #[must_use]
62    pub fn new() -> Self {
63        Self {
64            count: AtomicU32::new(0),
65            producer_taken: AtomicBool::new(false),
66            consumer_taken: AtomicBool::new(false),
67        }
68    }
69
70    #[cfg(all(loom, test))]
71    pub(crate) fn with_count_for_model(count: u32) -> Self {
72        Self {
73            count: AtomicU32::new(count),
74            producer_taken: AtomicBool::new(false),
75            consumer_taken: AtomicBool::new(false),
76        }
77    }
78
79    /// Probe-only seeding constructor. Not part of the public contract.
80    ///
81    /// The saturated `increment` arm is unreachable through the public API in
82    /// bounded time — it needs the counter at `u32::MAX`, which is
83    /// `u32::MAX` increments away — yet its cost is a measured claim
84    /// (contract B1/B2). The QEMU cycle probe enables the hidden
85    /// `_cycles-probe` feature to construct a saturated signal directly, the
86    /// same way the Loom models seed epochs via `with_count_for_model`.
87    #[cfg(feature = "_cycles-probe")]
88    #[doc(hidden)]
89    #[must_use]
90    pub const fn with_count_for_probe(count: u32) -> Self {
91        Self {
92            count: AtomicU32::new(count),
93            producer_taken: AtomicBool::new(false),
94            consumer_taken: AtomicBool::new(false),
95        }
96    }
97
98    /// Try to acquire the sole producer handle.
99    ///
100    /// Returns `None` while another producer handle is active.
101    #[inline]
102    pub fn try_producer(&self) -> Option<Producer<'_>> {
103        if self.producer_taken.swap(true, Ordering::AcqRel) {
104            None
105        } else {
106            Some(Producer {
107                signal: self,
108                _not_sync: PhantomData,
109            })
110        }
111    }
112
113    /// Try to acquire the sole consumer handle.
114    ///
115    /// Returns `None` while another consumer handle is active.
116    #[inline]
117    pub fn try_consumer(&self) -> Option<Consumer<'_>> {
118        if self.consumer_taken.swap(true, Ordering::AcqRel) {
119            None
120        } else {
121            Some(Consumer {
122                signal: self,
123                _not_sync: PhantomData,
124            })
125        }
126    }
127}
128
129impl Default for CountedSignal {
130    fn default() -> Self {
131        Self::new()
132    }
133}
134
135// Deliberately opaque: printing `count` would be a non-clearing peek —
136// the advisory observation the API deliberately lacks (destructive
137// `take_count` is the only read) — without the take's snapshot semantics.
138// Debug is required by convention; it reports the type, not the state.
139// Same rationale as EventFlags' opaque Debug.
140impl core::fmt::Debug for CountedSignal {
141    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
142        f.debug_struct("CountedSignal").finish_non_exhaustive()
143    }
144}
145
146/// The sole incrementing handle for a [`CountedSignal`].
147///
148/// This handle is `Send + !Sync`. Its exclusivity is what keeps exact
149/// saturation wrap-free with a fixed source-level sequence on every path —
150/// the sentinel re-read is a no-op RMW, never an algorithmic retry loop.
151///
152/// The load-bearing `!Sync` property is pinned at compile time:
153///
154/// ```compile_fail,E0277
155/// use ph_eventing::counted_signal::Producer;
156///
157/// fn assert_sync<T: Sync>() {}
158/// assert_sync::<Producer<'static>>();
159/// ```
160pub struct Producer<'a> {
161    signal: &'a CountedSignal,
162    _not_sync: PhantomData<Cell<()>>,
163}
164
165impl Producer<'_> {
166    /// Record one occurrence.
167    ///
168    /// Below `u32::MAX` this is one Relaxed load and one Relaxed `fetch_add`.
169    /// An observed `u32::MAX` is re-read through a no-op RMW (`fetch_or(0)`):
170    /// `MAX` confirms saturation (skip), anything else is a post-take epoch
171    /// and one `fetch_add` records the occurrence. Under sole-producer
172    /// ownership the consumer's `swap(0)` is the only competing write, so
173    /// every path is a fixed sequence of source-level atomics with no
174    /// algorithmic retry, and the counter never wraps. On exclusive-monitor
175    /// Arm each single RMW is an LDREX/STREX pair that repeats only if an
176    /// intervening event (an interrupt, or that `swap`) claims the word —
177    /// contention-bounded hardware retry, disclosed in contract B1; the
178    /// measured rows are the uncontended realisations.
179    #[inline]
180    pub fn increment(&self) {
181        // Only this handle may increase `count`; the consumer can only reset it
182        // to zero. A plain skip on MAX can be stale after take returns, so the
183        // sentinel path re-reads through an RMW: `fetch_or(0)` writes nothing
184        // back but, unlike a load or a failed compare_exchange, is guaranteed
185        // to observe the latest value in modification order — and it stays a
186        // single atomic op on LR/SC ISAs, where a strong compare_exchange
187        // lowers to a retry loop with no static bound (contract T3 / A1 / B1).
188        let observed = self.signal.count.load(Ordering::Relaxed);
189        if observed == u32::MAX && self.signal.count.fetch_or(0, Ordering::Relaxed) == u32::MAX {
190            return;
191        }
192        // Wrap-free under H1: intervening writes can only lower the value.
193        // Reached for every non-MAX observe, and for a stale MAX after take.
194        self.signal.count.fetch_add(1, Ordering::Relaxed);
195    }
196}
197
198impl Drop for Producer<'_> {
199    fn drop(&mut self) {
200        self.signal.producer_taken.store(false, Ordering::Release);
201    }
202}
203
204impl core::fmt::Debug for Producer<'_> {
205    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
206        f.debug_struct("counted_signal::Producer").finish()
207    }
208}
209
210/// The sole taking handle for a [`CountedSignal`].
211///
212/// This handle is `Send + !Sync` and may atomically take counts while its
213/// paired producer increments from another context.
214///
215/// ```compile_fail,E0277
216/// use ph_eventing::counted_signal::Consumer;
217///
218/// fn assert_sync<T: Sync>() {}
219/// assert_sync::<Consumer<'static>>();
220/// ```
221pub struct Consumer<'a> {
222    signal: &'a CountedSignal,
223    _not_sync: PhantomData<Cell<()>>,
224}
225
226impl Consumer<'_> {
227    /// Atomically take the count accumulated since the preceding take.
228    ///
229    /// A concurrent increment belongs wholly to this snapshot or wholly to
230    /// the next one. [`CountSnapshot::is_saturated`] distinguishes the
231    /// saturation sentinel from an exact count.
232    #[inline]
233    pub fn take_count(&self) -> CountSnapshot {
234        CountSnapshot::from_raw(self.signal.count.swap(0, Ordering::Relaxed))
235    }
236}
237
238impl Drop for Consumer<'_> {
239    fn drop(&mut self) {
240        self.signal.consumer_taken.store(false, Ordering::Release);
241    }
242}
243
244impl core::fmt::Debug for Consumer<'_> {
245    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
246        f.debug_struct("counted_signal::Consumer").finish()
247    }
248}
249
250/// The result of atomically taking a [`CountedSignal`] count.
251#[derive(Clone, Copy, Debug, Eq, PartialEq)]
252#[must_use]
253pub struct CountSnapshot {
254    count: u32,
255}
256
257impl CountSnapshot {
258    #[inline(always)]
259    const fn from_raw(raw: u32) -> Self {
260        Self { count: raw }
261    }
262
263    /// Return the exact count, or `u32::MAX` when saturated.
264    #[inline(always)]
265    #[must_use]
266    pub const fn count(self) -> u32 {
267        self.count
268    }
269
270    /// Whether at least `u32::MAX` increments accumulated before the take.
271    #[inline(always)]
272    #[must_use]
273    pub const fn is_saturated(self) -> bool {
274        self.count == u32::MAX
275    }
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281
282    #[test]
283    fn increments_accumulate_and_take_clears() {
284        // Contract I1, T1, T4, and A1.
285        let signal = CountedSignal::new();
286        let producer = signal.try_producer().unwrap();
287        let consumer = signal.try_consumer().unwrap();
288
289        producer.increment();
290        producer.increment();
291
292        assert_eq!(consumer.take_count(), CountSnapshot { count: 2 });
293        assert_eq!(consumer.take_count().count(), 0);
294    }
295
296    #[test]
297    fn saturates_instead_of_wrapping() {
298        // Contract I2-I3, T2, and A2-A3.
299        let signal = CountedSignal::new();
300        signal.count.store(u32::MAX - 1, Ordering::Relaxed);
301        let producer = signal.try_producer().unwrap();
302        let consumer = signal.try_consumer().unwrap();
303
304        producer.increment();
305        producer.increment();
306
307        let snapshot = consumer.take_count();
308        assert_eq!(snapshot.count(), u32::MAX);
309        assert!(snapshot.is_saturated());
310        assert_eq!(consumer.take_count().count(), 0);
311    }
312
313    #[test]
314    fn handles_are_exclusive_and_reusable_after_drop() {
315        // Contract H1 and H3: reacquisition continues existing state.
316        let signal = CountedSignal::new();
317        let producer = signal.try_producer().unwrap();
318        let consumer = signal.try_consumer().unwrap();
319        assert!(signal.try_producer().is_none());
320        assert!(signal.try_consumer().is_none());
321
322        producer.increment();
323
324        drop(producer);
325        drop(consumer);
326        let producer = signal.try_producer().expect("producer role released");
327        let consumer = signal.try_consumer().expect("consumer role released");
328        assert_eq!(consumer.take_count().count(), 1);
329        producer.increment();
330        assert_eq!(consumer.take_count().count(), 1);
331    }
332
333    #[test]
334    fn handles_are_send() {
335        // Contract H2. The compile-fail examples above pin `!Sync`.
336        fn assert_send<T: Send>() {}
337        assert_send::<Producer<'static>>();
338        assert_send::<Consumer<'static>>();
339    }
340
341    #[cfg(not(loom))]
342    #[test]
343    fn const_new_works_in_static_context() {
344        // Contract H4.
345        static SIGNAL: CountedSignal = CountedSignal::new();
346        let producer = SIGNAL.try_producer().unwrap();
347        let consumer = SIGNAL.try_consumer().unwrap();
348        producer.increment();
349        assert_eq!(consumer.take_count().count(), 1);
350    }
351
352    #[cfg(not(loom))]
353    #[test]
354    fn concurrent_takes_do_not_lose_increments() {
355        // Contract T3 and A1 at stress-test scale.
356        use core::sync::atomic::{AtomicBool, Ordering as CoreOrdering};
357
358        let signal = CountedSignal::new();
359        let producer = signal.try_producer().unwrap();
360        let consumer = signal.try_consumer().unwrap();
361        let done = AtomicBool::new(false);
362
363        let total = std::thread::scope(|scope| {
364            let done_for_producer = &done;
365            scope.spawn(move || {
366                for _ in 0..crate::test_support::iterations(100_000) {
367                    producer.increment();
368                }
369                done_for_producer.store(true, CoreOrdering::Release);
370            });
371
372            let done_for_consumer = &done;
373            let taker = scope.spawn(move || {
374                let mut total = 0u64;
375                while !done_for_consumer.load(CoreOrdering::Acquire) {
376                    total += u64::from(consumer.take_count().count());
377                    std::thread::yield_now();
378                }
379                total + u64::from(consumer.take_count().count())
380            });
381
382            taker.join().unwrap()
383        });
384
385        assert_eq!(total, u64::from(crate::test_support::iterations(100_000)));
386    }
387}