renew_fixed/saturation.rs
1//! The saturation counter: how an overflow that is handled stays visible.
2
3use core::cell::Cell;
4
5thread_local! {
6 /// Saturations on this thread since it started.
7 ///
8 /// Thread-local rather than a global atomic, and that is the design
9 /// rather than a concession. Simulation is single-threaded, so
10 /// per-thread is per-simulation: a count attributable to the run that
11 /// produced it, where one global atomic would have merged unrelated
12 /// threads into a number nobody could act on.
13 ///
14 /// It is also the shape this engine's threading rules already permit —
15 /// thread-local storage for diagnostics only, drained through an
16 /// explicit call on the owning thread — so it needs no exception to the
17 /// rule against global mutable state, which a global atomic would have.
18 static SATURATIONS: Cell<u64> = const { Cell::new(0) };
19}
20
21/// How many times arithmetic on this thread saturated.
22///
23/// **Diagnostic only.** Never simulation state, never digested, never
24/// consulted for control flow — a simulation that branched on this would
25/// have made the count part of its own behaviour, and two machines whose
26/// counts differ would then diverge for a reason no digest could explain.
27#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
28pub struct Saturations(pub u64);
29
30impl Saturations {
31 /// Nothing saturated, which is what a test asserts.
32 #[must_use]
33 pub const fn is_clean(self) -> bool {
34 self.0 == 0
35 }
36}
37
38/// Read this thread's saturation count.
39///
40/// The explicit snapshot call `threading.md` requires: the counter is never
41/// read from another thread and never published except through here.
42///
43/// # Example
44///
45/// The shape a test uses — the counter is the alarm, and saturation is what
46/// it is an alarm about:
47///
48/// ```
49/// # use renew_fixed::{Fixed, saturations};
50/// let before = saturations();
51/// let _ = Fixed::MAX + Fixed::ONE;
52/// assert_eq!(saturations().0, before.0 + 1);
53/// ```
54#[must_use]
55pub fn saturations() -> Saturations {
56 Saturations(SATURATIONS.with(Cell::get))
57}
58
59/// Record one saturation. Called only from the arithmetic that saturated.
60pub(crate) fn record() {
61 SATURATIONS.with(|count| count.set(count.get().saturating_add(1)));
62}
63
64#[cfg(test)]
65mod tests {
66 use super::{record, saturations};
67
68 #[test]
69 fn the_counter_counts_and_is_readable_only_through_the_snapshot() {
70 let before = saturations();
71 record();
72 record();
73 assert_eq!(saturations().0, before.0 + 2);
74 assert!(!saturations().is_clean());
75 }
76
77 /// The counter must not itself overflow into wrapping, which would be a
78 /// diagnostic silently claiming fewer failures than occurred.
79 #[test]
80 fn the_counter_saturates_rather_than_wrapping() {
81 super::SATURATIONS.with(|count| count.set(u64::MAX));
82 record();
83 assert_eq!(saturations().0, u64::MAX);
84 super::SATURATIONS.with(|count| count.set(0));
85 }
86
87 /// Per-thread, which is the property that makes it attributable. A
88 /// count raised on another thread must not appear on this one.
89 #[test]
90 // Spawning is disallowed in this crate for good reason: arithmetic has
91 // nothing to parallelise. Proving the counter is *per-thread* is the one
92 // thing that cannot be done without a second thread, so the exemption is
93 // taken here, narrowly, in the test that exists to establish the
94 // property the ban's neighbour depends on.
95 #[expect(
96 clippy::disallowed_methods,
97 reason = "the property under test is thread-locality, which needs a thread"
98 )]
99 fn a_count_on_another_thread_is_not_visible_here() {
100 let before = saturations();
101 std::thread::spawn(|| {
102 record();
103 record();
104 record();
105 })
106 .join()
107 .expect("the counting thread finished");
108 assert_eq!(saturations(), before, "another thread's count leaked here");
109 }
110}