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}