guard_core_engine/
scan_budget.rs1use std::cmp::Ordering;
25
26pub const SCAN_TIME_BUDGET_EXHAUSTED_PATTERN: &str = "scan_time_budget_exhausted";
29
30pub const DEFAULT_MAX_SCAN_SECONDS: f64 = 10.0;
32
33#[must_use]
36pub fn budget_exhausted_message() -> String {
37 format!("Pattern exceeded scan time budget: '{SCAN_TIME_BUDGET_EXHAUSTED_PATTERN}'")
38}
39
40#[cfg(any(target_os = "linux", target_os = "android", target_os = "macos"))]
41fn thread_cpu_seconds() -> f64 {
42 let ts = rustix::time::clock_gettime(rustix::time::ClockId::ThreadCPUTime);
43 ts.tv_sec as f64 + ts.tv_nsec as f64 / 1e9
44}
45
46#[cfg(not(any(target_os = "linux", target_os = "android", target_os = "macos")))]
47fn thread_cpu_seconds() -> f64 {
48 use std::sync::OnceLock;
49 use std::time::Instant;
50 static START: OnceLock<Instant> = OnceLock::new();
51 START.get_or_init(Instant::now).elapsed().as_secs_f64()
52}
53
54#[derive(Debug, Clone, Copy)]
56pub struct ScanTimeBudget {
57 cap: Option<f64>,
58 spent: f64,
59 clock: fn() -> f64,
60}
61
62impl ScanTimeBudget {
63 #[must_use]
65 pub fn new(cap_seconds: f64) -> Self {
66 Self::with_clock(Some(cap_seconds), thread_cpu_seconds)
67 }
68
69 #[must_use]
71 pub fn unbounded() -> Self {
72 Self::with_clock(None, thread_cpu_seconds)
73 }
74
75 #[must_use]
77 pub const fn with_clock(cap: Option<f64>, clock: fn() -> f64) -> Self {
78 Self {
79 cap,
80 spent: 0.0,
81 clock,
82 }
83 }
84
85 #[must_use]
87 pub fn exhausted(&self) -> bool {
88 self.cap
91 .is_some_and(|cap| self.spent.partial_cmp(&cap) != Some(Ordering::Less))
92 }
93
94 pub fn measure<T>(&mut self, scan: impl FnOnce() -> T) -> T {
96 let start = (self.clock)();
97 let result = scan();
98 self.spent += ((self.clock)() - start).max(0.0);
99 result
100 }
101}
102
103#[cfg(test)]
104mod tests {
105 use super::*;
106 use std::cell::Cell;
107
108 thread_local! {
109 static NOW: Cell<f64> = const { Cell::new(0.0) };
110 }
111
112 fn stepping_clock() -> f64 {
113 NOW.with(|now| {
114 now.set(now.get() + 1.0);
115 now.get()
116 })
117 }
118
119 #[test]
120 fn unbounded_budget_is_never_exhausted() {
121 let mut budget = ScanTimeBudget::unbounded();
122 budget.measure(|| ());
123 assert!(!budget.exhausted());
124 }
125
126 #[test]
127 fn measured_time_exhausts_the_cap() {
128 let mut budget = ScanTimeBudget::with_clock(Some(2.5), stepping_clock);
129 assert!(!budget.exhausted());
130 assert_eq!(budget.measure(|| 7), 7);
132 assert!(!budget.exhausted());
133 budget.measure(|| ());
134 assert!(!budget.exhausted());
135 budget.measure(|| ());
136 assert!(budget.exhausted());
137 }
138
139 #[test]
140 fn a_backwards_clock_never_refunds_time() {
141 fn backwards() -> f64 {
142 NOW.with(|now| {
143 now.set(now.get() - 1.0);
144 now.get()
145 })
146 }
147 let mut budget = ScanTimeBudget::with_clock(Some(0.1), backwards);
148 budget.measure(|| ());
149 assert!(!budget.exhausted());
150 }
151
152 #[test]
153 fn the_real_thread_clock_advances_with_work() {
154 let first = thread_cpu_seconds();
155 let mut acc = 0_u64;
156 for i in 0..5_000_000_u64 {
157 acc = acc.wrapping_add(std::hint::black_box(i));
158 }
159 std::hint::black_box(acc);
160 assert!(thread_cpu_seconds() > first);
161 }
162
163 #[test]
164 fn a_nan_cap_fails_closed() {
165 assert!(ScanTimeBudget::new(f64::NAN).exhausted());
166 }
167
168 #[test]
169 fn the_message_names_the_budget() {
170 assert_eq!(
171 budget_exhausted_message(),
172 "Pattern exceeded scan time budget: 'scan_time_budget_exhausted'"
173 );
174 }
175}