Skip to main content

basalt_api/
budget.rs

1//! Cooperative CPU budget for tick-based systems.
2//!
3//! Systems receive a [`TickBudget`] via [`SystemContext::budget()`] that
4//! tracks elapsed time against a configured limit. Budget-aware systems
5//! check [`is_expired()`](TickBudget::is_expired) and yield early when
6//! time runs out. Systems that ignore the budget run to completion —
7//! enforcement is cooperative, not preemptive.
8
9use std::time::{Duration, Instant};
10
11/// A cooperative CPU budget for one system invocation.
12///
13/// Created by the dispatcher before each system runs. The system can
14/// query remaining time to decide whether to continue processing
15/// (e.g., a pathfinding system stops after its budget expires and
16/// re-queues remaining requests for the next tick).
17///
18/// # Example
19///
20/// ```ignore
21/// fn my_system(ctx: &mut dyn SystemContext) {
22///     for request in pending_requests() {
23///         if ctx.budget().is_expired() {
24///             break; // yield, continue next tick
25///         }
26///         process(request, ctx);
27///     }
28/// }
29/// ```
30pub struct TickBudget {
31    /// When this budget started (system dispatch time).
32    start: Instant,
33    /// Maximum allowed duration for this system.
34    limit: Duration,
35}
36
37impl TickBudget {
38    /// Creates a budget that starts now with the given time limit.
39    pub fn new(limit: Duration) -> Self {
40        Self {
41            start: Instant::now(),
42            limit,
43        }
44    }
45
46    /// Creates an unlimited budget (never expires).
47    ///
48    /// Used for systems that have no configured budget and for
49    /// backward compatibility with systems that don't check budgets.
50    pub fn unlimited() -> Self {
51        Self {
52            start: Instant::now(),
53            limit: Duration::MAX,
54        }
55    }
56
57    /// Returns the time remaining before the budget expires.
58    ///
59    /// Returns [`Duration::ZERO`] if the budget is already expired.
60    pub fn remaining(&self) -> Duration {
61        self.limit.saturating_sub(self.start.elapsed())
62    }
63
64    /// Returns whether the budget has expired.
65    pub fn is_expired(&self) -> bool {
66        self.start.elapsed() >= self.limit
67    }
68
69    /// Returns the time elapsed since the budget was created.
70    pub fn elapsed(&self) -> Duration {
71        self.start.elapsed()
72    }
73
74    /// Returns the configured time limit.
75    pub fn limit(&self) -> Duration {
76        self.limit
77    }
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83
84    #[test]
85    fn unlimited_budget_never_expires() {
86        let budget = TickBudget::unlimited();
87        assert!(!budget.is_expired());
88        assert!(budget.remaining() > Duration::from_secs(1000));
89        assert_eq!(budget.limit(), Duration::MAX);
90    }
91
92    #[test]
93    fn new_budget_starts_not_expired() {
94        let budget = TickBudget::new(Duration::from_millis(100));
95        assert!(!budget.is_expired());
96        assert!(budget.remaining() > Duration::ZERO);
97        assert_eq!(budget.limit(), Duration::from_millis(100));
98    }
99
100    #[test]
101    fn zero_budget_expires_immediately() {
102        let budget = TickBudget::new(Duration::ZERO);
103        assert!(budget.is_expired());
104        assert_eq!(budget.remaining(), Duration::ZERO);
105    }
106
107    #[test]
108    fn elapsed_increases() {
109        let budget = TickBudget::new(Duration::from_secs(10));
110        let e1 = budget.elapsed();
111        // Spin briefly
112        std::hint::spin_loop();
113        let e2 = budget.elapsed();
114        assert!(e2 >= e1);
115    }
116}