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}