// Copyright (c) Microsoft Corporation. // Licensed under the MIT License. /* ExecutionTimer provides cooperative wall-clock enforcement for long-running policy evaluations. The timer tracks three pieces of state: - ExecutionTimerConfig, which holds the optional wall-clock budget and the interval (in work units) between time checks. - The monotonic start instant recorded via start(now), expressed as a Duration from whatever time source the engine uses. - An accumulator that counts work units so callers can amortize expensive time queries; once the counter reaches the configured interval, tick() performs a check and preserves any remainder. The timer never calls into a clock directly. Instead, callers pass the current monotonic Duration to start(), tick(), check_now(), or elapsed(). Helper monotonic_now() returns that Duration by selecting a TimeSource implementation: - On std builds we use StdTimeSource, which anchors a std::time::Instant via OnceLock and reports elapsed() for stable, monotonic measurements. - In tests and truly no_std builds we allow integrators to inject a global &'static dyn TimeSource using set_time_source(). This override lives behind a spin::Mutex> so the critical section stays small (just a pointer read) while remaining usable in bare-metal environments. With this design the interpreter can cheaply interleave work with periodic limit checks. Std builds automatically use the Instant-backed source, while embedded users configure both their ExecutionTimerConfig and a single global time source without paying for per-interpreter callbacks or unsafe code. */ use core::num::NonZeroU32; use core::time::Duration; use spin::Mutex; use super::LimitError; #[cfg(test)] use std::sync::{Mutex as StdMutex, MutexGuard as StdMutexGuard}; /// Public configuration for the cooperative execution time limiter. /// /// The limiter reads this struct to determine how often it should check for wall-clock overruns and /// what deadline to enforce. Engines without a configuration skip time checks; when a configuration /// is present, it normally pairs a concrete deadline with a small [`NonZeroU32`] interval so /// interpreter loops amortize their clock reads without skipping checks for long stretches of /// repetitive work. The process-wide fallback installed via [`set_fallback_execution_timer_config`] /// supplies this configuration when an engine lacks its own override. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct ExecutionTimerConfig { /// Maximum allowed wall-clock duration. pub limit: Duration, /// Number of work units between time checks (minimum 1). pub check_interval: NonZeroU32, } /// Cooperative time-limit tracker shared across interpreter and VM loops. #[derive(Debug)] pub struct ExecutionTimer { config: Option, start: Option, accumulated_units: u32, last_elapsed: Duration, } /// Monotonic time provider. pub trait TimeSource: Send + Sync { /// Returns a non-decreasing duration since an arbitrary anchor. fn now(&self) -> Option; } #[cfg(feature = "std")] #[derive(Debug)] struct StdTimeSource; #[cfg(feature = "std")] impl StdTimeSource { const fn new() -> Self { Self } } #[cfg(feature = "std")] impl TimeSource for StdTimeSource { fn now(&self) -> Option { use std::sync::OnceLock; static ANCHOR: OnceLock = OnceLock::new(); let anchor = ANCHOR.get_or_init(std::time::Instant::now); Some(anchor.elapsed()) } } #[cfg(feature = "std")] static STD_TIME_SOURCE: StdTimeSource = StdTimeSource::new(); #[cfg(any(test, not(feature = "std")))] static TIME_SOURCE_OVERRIDE: Mutex> = Mutex::new(None); #[cfg(any(test, not(feature = "std")))] #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum TimeSourceRegistrationError { AlreadySet, } #[cfg(any(test, not(feature = "std")))] impl core::fmt::Display for TimeSourceRegistrationError { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { match self { Self::AlreadySet => f.write_str("time source already configured"), } } } #[cfg(any(test, not(feature = "std")))] impl core::error::Error for TimeSourceRegistrationError {} static FALLBACK_EXECUTION_TIMER_CONFIG: Mutex> = Mutex::new(None); #[cfg(test)] static LIMITS_TEST_LOCK: StdMutex<()> = StdMutex::new(()); #[cfg(test)] pub fn acquire_limits_test_lock() -> StdMutexGuard<'static, ()> { LIMITS_TEST_LOCK .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) } /// Returns the duration supplied by the chosen source for this build. pub fn monotonic_now() -> Option { #[cfg(any(test, not(feature = "std")))] // Spin mutex acquisition incurs only a few atomic ops; the critical section // is a single pointer read, so uncontended overhead stays tiny. if let Some(source) = { let guard = TIME_SOURCE_OVERRIDE.lock(); *guard } { if let Some(duration) = source.now() { return Some(duration); } } #[cfg(feature = "std")] { STD_TIME_SOURCE.now() } #[cfg(not(feature = "std"))] { None } } #[cfg(any(test, not(feature = "std")))] pub fn set_time_source(source: &'static dyn TimeSource) -> Result<(), TimeSourceRegistrationError> { let mut slot = TIME_SOURCE_OVERRIDE.lock(); if slot.is_some() { Err(TimeSourceRegistrationError::AlreadySet) } else { *slot = Some(source); Ok(()) } } /// Sets the process-wide fallback configuration for the execution time limiter. Engine instances can /// override this fallback via [`Engine::set_execution_timer_config`](crate::Engine::set_execution_timer_config). /// /// # Examples /// /// ``` /// use std::num::NonZeroU32; /// use std::time::Duration; /// use regorus::utils::limits::{ /// fallback_execution_timer_config, /// set_fallback_execution_timer_config, /// ExecutionTimerConfig, /// }; /// /// let config = ExecutionTimerConfig { /// limit: Duration::from_secs(1), /// check_interval: NonZeroU32::new(10).unwrap(), /// }; /// set_fallback_execution_timer_config(Some(config)); /// assert_eq!(fallback_execution_timer_config(), Some(config)); /// ``` pub fn set_fallback_execution_timer_config(config: Option) { *FALLBACK_EXECUTION_TIMER_CONFIG.lock() = config; } /// Returns the process-wide fallback configuration for the execution time limiter, if any. /// /// # Examples /// /// ``` /// use regorus::utils::limits::fallback_execution_timer_config; /// /// // By default no fallback execution timer is configured. /// assert!(fallback_execution_timer_config().is_none()); /// ``` pub fn fallback_execution_timer_config() -> Option { let guard = FALLBACK_EXECUTION_TIMER_CONFIG.lock(); guard.as_ref().copied() } impl ExecutionTimer { /// Construct a new timer with the provided configuration. pub const fn new(config: Option) -> Self { Self { config, start: None, accumulated_units: 0, last_elapsed: Duration::ZERO, } } /// Reset the timer state to its initial configuration without recording a start instant. pub const fn reset(&mut self) { self.start = None; self.accumulated_units = 0; self.last_elapsed = Duration::ZERO; } /// Reset any prior state and record the start instant. pub const fn start(&mut self, now: Duration) { self.start = Some(now); self.accumulated_units = 0; self.last_elapsed = Duration::ZERO; } /// Returns the timer configuration. pub const fn config(&self) -> Option { self.config } /// Returns the configured limit. pub const fn limit(&self) -> Option { match self.config { Some(config) => Some(config.limit), None => None, } } /// Returns the last elapsed duration recorded by a check. pub const fn last_elapsed(&self) -> Duration { self.last_elapsed } /// Increment work units and return whether a time check is due. /// /// This method only updates the internal counter — it never reads a clock. /// Callers should obtain the current time and call [`check_now`](Self::check_now) /// only when this returns `true`. pub const fn accumulate(&mut self, work_units: u32) -> bool { let Some(config) = self.config else { return false; }; self.accumulated_units = self.accumulated_units.saturating_add(work_units); if self.accumulated_units < config.check_interval.get() { return false; } // Preserve the remainder so that callers do not lose fractional work. let interval = config.check_interval.get(); self.accumulated_units %= interval; true } /// Increment work units and run the periodic limit check when necessary. pub fn tick(&mut self, work_units: u32, now: Duration) -> Result<(), LimitError> { let Some(config) = self.config else { return Ok(()); }; self.accumulated_units = self.accumulated_units.saturating_add(work_units); if self.accumulated_units < config.check_interval.get() { return Ok(()); } // Preserve the remainder so that callers do not lose fractional work. let interval = config.check_interval.get(); self.accumulated_units %= interval; self.check_now(now) } /// Force an immediate check against the configured deadline. pub fn check_now(&mut self, now: Duration) -> Result<(), LimitError> { let Some(config) = self.config else { return Ok(()); }; let Some(start) = self.start else { return Ok(()); }; let elapsed = now.checked_sub(start).unwrap_or(Duration::ZERO); self.last_elapsed = elapsed; if elapsed > config.limit { return Err(LimitError::TimeLimitExceeded { elapsed, limit: config.limit, }); } Ok(()) } /// Compute elapsed time relative to the recorded start, if available. pub fn elapsed(&self, now: Duration) -> Option { let start = self.start?; Some(now.checked_sub(start).unwrap_or(Duration::ZERO)) } /// Realign the timer start so that a previously consumed `elapsed` duration is preserved while /// ignoring any wall-clock time that passed during a suspension window. pub const fn resume_from_elapsed(&mut self, now: Duration, elapsed: Duration) { if self.config.is_none() { return; } self.start = Some(now.saturating_sub(elapsed)); self.last_elapsed = elapsed; } } #[cfg(test)] mod tests { use super::*; use core::num::NonZeroU32; use core::sync::atomic::{AtomicU64, Ordering}; use core::time::Duration; fn nz(value: u32) -> NonZeroU32 { NonZeroU32::new(value).unwrap_or(NonZeroU32::MIN) } #[test] fn tick_defers_checks_until_interval_is_reached() { let mut timer = ExecutionTimer::new(Some(ExecutionTimerConfig { limit: Duration::from_millis(100), check_interval: nz(4), })); timer.start(Duration::from_millis(0)); for step in 1..4 { let now = Duration::from_millis((step * 10) as u64); let result = timer.tick(1, now); assert_eq!(result, Ok(()), "tick before reaching interval must succeed"); assert_eq!(timer.last_elapsed(), Duration::ZERO); } let result = timer.tick(1, Duration::from_millis(40)); assert_eq!(result, Ok(()), "tick at interval boundary must succeed"); assert_eq!(timer.last_elapsed(), Duration::from_millis(40)); } #[test] fn check_now_reports_limit_exceeded() { let mut timer = ExecutionTimer::new(Some(ExecutionTimerConfig { limit: Duration::from_millis(25), check_interval: nz(1), })); timer.start(Duration::from_millis(0)); assert_eq!( timer.tick(1, Duration::from_millis(10)), Ok(()), "tick before limit breach must succeed" ); let result = timer.check_now(Duration::from_millis(30)); assert!(matches!(&result, Err(LimitError::TimeLimitExceeded { .. }))); if let Err(LimitError::TimeLimitExceeded { elapsed, limit }) = result { assert!(elapsed > limit); assert_eq!(limit, Duration::from_millis(25)); } } #[test] fn tick_reports_limit_exceeded() { let mut timer = ExecutionTimer::new(Some(ExecutionTimerConfig { limit: Duration::from_millis(30), check_interval: nz(2), })); timer.start(Duration::from_millis(0)); assert_eq!( timer.tick(1, Duration::from_millis(10)), Ok(()), "initial tick must succeed" ); let result = timer.tick(1, Duration::from_millis(35)); assert!(matches!(&result, Err(LimitError::TimeLimitExceeded { .. }))); if let Err(LimitError::TimeLimitExceeded { elapsed, limit }) = result { assert!(elapsed > limit); assert_eq!(limit, Duration::from_millis(30)); assert_eq!(timer.last_elapsed(), elapsed); } } #[test] fn tick_before_start_is_noop() { let mut timer = ExecutionTimer::new(Some(ExecutionTimerConfig { limit: Duration::from_secs(1), check_interval: nz(1), })); let result = timer.tick(1, Duration::from_millis(100)); assert_eq!(result, Ok(()), "tick before start should be ignored"); assert_eq!(timer.last_elapsed(), Duration::ZERO); assert!(timer.elapsed(Duration::from_millis(200)).is_none()); } #[test] fn check_now_allows_elapsed_equal_to_limit() { let mut timer = ExecutionTimer::new(Some(ExecutionTimerConfig { limit: Duration::from_millis(50), check_interval: nz(1), })); timer.start(Duration::from_millis(0)); assert_eq!( timer.tick(1, Duration::from_millis(30)), Ok(()), "tick prior to equality check must succeed" ); let result = timer.check_now(Duration::from_millis(50)); assert_eq!(result, Ok(()), "elapsed equal to limit must not fail"); assert_eq!(timer.last_elapsed(), Duration::from_millis(50)); } #[test] fn tick_is_noop_when_limit_disabled() { let mut timer = ExecutionTimer::new(None); timer.start(Duration::from_millis(0)); for step in 0..8 { let now = Duration::from_millis((step + 1) as u64); assert_eq!( timer.tick(1, now), Ok(()), "ticks with disabled limit must succeed" ); } assert_eq!(timer.last_elapsed(), Duration::ZERO); } #[test] fn check_now_is_noop_before_start() { let mut timer = ExecutionTimer::new(None); let result = timer.check_now(Duration::from_secs(1)); assert_eq!(result, Ok(()), "check before start must be ignored"); assert!(timer.elapsed(Duration::from_secs(2)).is_none()); } #[test] fn elapsed_reports_offset_from_start() { let mut timer = ExecutionTimer::new(None); timer.start(Duration::from_millis(5)); let elapsed = timer.elapsed(Duration::from_millis(20)); assert_eq!(elapsed, Some(Duration::from_millis(15))); } #[test] fn monotonic_now_uses_override_when_present() { static TEST_TIME: AtomicU64 = AtomicU64::new(0); struct TestSource; impl TimeSource for TestSource { fn now(&self) -> Option { Some(Duration::from_nanos(TEST_TIME.load(Ordering::Relaxed))) } } static SOURCE: TestSource = TestSource; let _suite_guard = super::acquire_limits_test_lock(); let mut slot = super::TIME_SOURCE_OVERRIDE.lock(); let previous = (*slot).replace(&SOURCE); drop(slot); TEST_TIME.store(123_000_000, Ordering::Relaxed); assert_eq!(monotonic_now(), Some(Duration::from_nanos(123_000_000))); let mut slot = super::TIME_SOURCE_OVERRIDE.lock(); *slot = previous; } #[test] fn accumulate_defers_clock_reads() { let mut timer = ExecutionTimer::new(Some(ExecutionTimerConfig { limit: Duration::from_secs(1), check_interval: nz(4), })); timer.start(Duration::from_millis(0)); // First 3 work units should not require a clock read. for _ in 0..3 { assert!( !timer.accumulate(1), "accumulate before interval must return false" ); } // The 4th unit crosses the interval — caller should read the clock now. assert!( timer.accumulate(1), "accumulate at interval must return true" ); // After the boundary, the counter resets — next 3 units are cheap again. for _ in 0..3 { assert!( !timer.accumulate(1), "accumulate after reset must return false" ); } assert!( timer.accumulate(1), "second interval crossing must return true" ); } #[test] fn accumulate_returns_false_when_disabled() { let mut timer = ExecutionTimer::new(None); for _ in 0..10 { assert!( !timer.accumulate(1), "disabled timer must never request a clock read" ); } } }