Skip to main content

lanekeep_core/
limits.rs

1//! Execution budgets.
2//!
3//! Turing-complete rules can fail to terminate. Three limits bound that, none of which can
4//! be disabled: a per-invocation timeout, a global wall-clock budget for the whole run, and
5//! a memory ceiling per runtime.
6//!
7//! Breaching any of them cancels the run — see `docs/architecture.md` §6.8 for why
8//! continuing would be worse. Turning a breach into an error is each engine's own concern;
9//! `lanekeep-js`'s `SandboxError` is one such type.
10//!
11//! # Why this lives in `lanekeep-core` rather than in one engine
12//!
13//! There is one global run budget, not one per engine: `docs/architecture.md`'s resource-limits
14//! invariant is that breaching it cancels the *run*, and a run can call into more than one
15//! engine (`lanekeep-js`'s QuickJS sandbox today, `lanekeep-wasm`'s component runtime once it
16//! dispatches rules). [`RunClock`] is the shared origin that makes "the run" a single wall-clock
17//! deadline rather than a per-engine one. Two independent clocks would each enforce their own
18//! share of the budget correctly in isolation while the run as a whole overran both — a
19//! quantitative failure, not a maintenance one, since it needs no drift to manifest: two honest
20//! clocks that were never told about each other already sum past the one promise the run makes.
21//! Defining `RunClock` once, here, is what keeps a second instance from being constructible at
22//! all for a single run.
23
24use std::sync::Arc;
25use std::sync::atomic::{AtomicU64, Ordering};
26use std::time::{Duration, Instant};
27
28/// Default budget for a single handler invocation.
29pub const DEFAULT_RULE_TIMEOUT: Duration = Duration::from_secs(1);
30
31/// Default wall-clock budget for an entire run.
32pub const DEFAULT_GLOBAL_TIMEOUT: Duration = Duration::from_secs(15);
33
34/// Default memory ceiling per JavaScript runtime, which means per worker.
35pub const DEFAULT_MEMORY_BYTES: usize = 64 * 1024 * 1024;
36
37/// The three budgets.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub struct Limits {
40    /// Budget for one handler invocation — a single `check` or `reduce` call.
41    ///
42    /// This is the limit that fires fast and names the culprit: which rule, which file,
43    /// which phase. Keeping it well under the global budget means the diagnostic usually
44    /// comes from the level that can identify the cause.
45    pub rule_timeout: Duration,
46
47    /// Wall-clock budget for the whole run.
48    ///
49    /// The backstop for when no single invocation is pathological but the aggregate is —
50    /// a thousand rules each taking twenty milliseconds.
51    pub global_timeout: Duration,
52
53    /// Memory ceiling per runtime.
54    pub memory_bytes: usize,
55}
56
57impl Default for Limits {
58    fn default() -> Self {
59        Self {
60            rule_timeout: DEFAULT_RULE_TIMEOUT,
61            global_timeout: DEFAULT_GLOBAL_TIMEOUT,
62            memory_bytes: DEFAULT_MEMORY_BYTES,
63        }
64    }
65}
66
67impl Limits {
68    /// Raise the per-invocation budget, for a rule that legitimately does heavy work.
69    ///
70    /// Cannot raise the global budget: a single rule must not be able to extend the run's
71    /// total. That is the whole point of having two levels rather than one.
72    #[must_use]
73    pub const fn with_rule_timeout(mut self, timeout: Duration) -> Self {
74        self.rule_timeout = timeout;
75        self
76    }
77
78    /// Set the global wall-clock budget.
79    #[must_use]
80    pub const fn with_global_timeout(mut self, timeout: Duration) -> Self {
81        self.global_timeout = timeout;
82        self
83    }
84
85    /// Set the per-runtime memory ceiling.
86    #[must_use]
87    pub const fn with_memory_bytes(mut self, bytes: usize) -> Self {
88        self.memory_bytes = bytes;
89        self
90    }
91}
92
93/// When the run started, shared by every worker.
94///
95/// The global budget has to be measured from one origin across all workers, or each would
96/// enforce its own fifteen seconds and the run's total would scale with the worker count.
97#[derive(Debug)]
98pub struct RunClock {
99    start: Instant,
100    global_timeout: Duration,
101}
102
103impl RunClock {
104    /// Start the clock now.
105    #[must_use]
106    pub fn start(global_timeout: Duration) -> Arc<Self> {
107        Arc::new(Self {
108            start: Instant::now(),
109            global_timeout,
110        })
111    }
112
113    /// How long the run has been going.
114    #[must_use]
115    pub fn elapsed(&self) -> Duration {
116        self.start.elapsed()
117    }
118
119    /// The configured global budget.
120    #[must_use]
121    pub const fn global_timeout(&self) -> Duration {
122        self.global_timeout
123    }
124
125    /// Whether the global budget is spent.
126    #[must_use]
127    pub fn is_expired(&self) -> bool {
128        self.elapsed() >= self.global_timeout
129    }
130
131    fn elapsed_nanos(&self) -> u64 {
132        u64::try_from(self.start.elapsed().as_nanos()).unwrap_or(u64::MAX)
133    }
134}
135
136/// Which budget was breached.
137#[derive(Debug, Clone, Copy, PartialEq, Eq)]
138pub enum Trip {
139    /// A single invocation ran too long.
140    Rule,
141    /// The run as a whole ran too long.
142    Run,
143}
144
145const TRIP_NONE: u64 = 0;
146const TRIP_RULE: u64 = 1;
147const TRIP_RUN: u64 = 2;
148
149/// Shared between an engine's runtime and its interrupt handler.
150///
151/// Records *why* execution was interrupted rather than leaving it to be inferred from the
152/// engine's own exception or trap text. QuickJS, for instance, reports an interrupt as an
153/// ordinary `Error` whose message happens to be "interrupted"; keying behavior off that
154/// string would make the difference between "your rule looped forever" and "your rule
155/// threw" depend on wording this project does not control. A different engine's own
156/// interrupted-execution signal would be exactly as unreliable to string-match, for the
157/// same reason.
158#[derive(Debug)]
159pub struct Budget {
160    clock: Arc<RunClock>,
161    global_nanos: u64,
162    /// Deadline for the current invocation, in nanoseconds since the run started.
163    /// Zero means no invocation is in flight.
164    invocation_deadline_nanos: AtomicU64,
165    tripped: AtomicU64,
166}
167
168impl Budget {
169    /// Build a budget enforcer sharing the run's clock.
170    pub fn new(clock: Arc<RunClock>) -> Arc<Self> {
171        let global_nanos = u64::try_from(clock.global_timeout.as_nanos()).unwrap_or(u64::MAX);
172        Arc::new(Self {
173            clock,
174            global_nanos,
175            invocation_deadline_nanos: AtomicU64::new(0),
176            tripped: AtomicU64::new(TRIP_NONE),
177        })
178    }
179
180    /// Start the clock on one invocation.
181    pub fn arm(&self, rule_timeout: Duration) {
182        let now = self.clock.elapsed_nanos();
183        let budget = u64::try_from(rule_timeout.as_nanos()).unwrap_or(u64::MAX);
184        // Saturating: a deadline of zero means disarmed, so an overflowing budget must not
185        // wrap around into it and silently switch the limit off.
186        self.invocation_deadline_nanos
187            .store(now.saturating_add(budget).max(1), Ordering::Relaxed);
188        self.tripped.store(TRIP_NONE, Ordering::Relaxed);
189    }
190
191    /// Stop enforcing an invocation budget.
192    pub fn disarm(&self) {
193        self.invocation_deadline_nanos.store(0, Ordering::Relaxed);
194    }
195
196    /// Whether execution should stop now, recording why. Called by the engine's interrupt
197    /// handler, so it runs often and must stay cheap.
198    pub fn should_interrupt(&self) -> bool {
199        let elapsed = self.clock.elapsed_nanos();
200
201        if elapsed >= self.global_nanos {
202            self.tripped.store(TRIP_RUN, Ordering::Relaxed);
203            return true;
204        }
205
206        let deadline = self.invocation_deadline_nanos.load(Ordering::Relaxed);
207        if deadline != 0 && elapsed >= deadline {
208            self.tripped.store(TRIP_RULE, Ordering::Relaxed);
209            return true;
210        }
211
212        false
213    }
214
215    /// Which budget was breached, if any. Clears the record.
216    pub fn take_trip(&self) -> Option<Trip> {
217        match self.tripped.swap(TRIP_NONE, Ordering::Relaxed) {
218            TRIP_RULE => Some(Trip::Rule),
219            TRIP_RUN => Some(Trip::Run),
220            _ => None,
221        }
222    }
223
224    /// The run clock this budget was built from.
225    pub fn clock(&self) -> &RunClock {
226        &self.clock
227    }
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233
234    #[test]
235    fn defaults_match_the_documented_budgets() {
236        let limits = Limits::default();
237        assert_eq!(limits.rule_timeout, Duration::from_secs(1));
238        assert_eq!(limits.global_timeout, Duration::from_secs(15));
239        assert_eq!(limits.memory_bytes, 64 * 1024 * 1024);
240    }
241
242    #[test]
243    fn the_rule_budget_is_well_under_the_global_one() {
244        // Not arithmetic for its own sake. If a single invocation could consume the whole
245        // run, the global limit would be the one that fires, and its diagnostic cannot say
246        // which rule or file was responsible.
247        let limits = Limits::default();
248        assert!(
249            limits.rule_timeout * 5 < limits.global_timeout,
250            "the per-invocation budget must leave room for the global limit to be a backstop"
251        );
252    }
253
254    #[test]
255    fn a_rule_cannot_raise_the_global_budget() {
256        let limits = Limits::default().with_rule_timeout(Duration::from_mins(1));
257        assert_eq!(limits.rule_timeout, Duration::from_mins(1));
258        assert_eq!(
259            limits.global_timeout, DEFAULT_GLOBAL_TIMEOUT,
260            "raising a rule's own budget must not extend the run"
261        );
262    }
263
264    #[test]
265    fn an_unarmed_budget_never_interrupts() {
266        let budget = Budget::new(RunClock::start(Duration::from_hours(1)));
267        assert!(!budget.should_interrupt());
268        assert_eq!(budget.take_trip(), None);
269    }
270
271    #[test]
272    fn an_expired_invocation_budget_interrupts_and_records_why() {
273        let budget = Budget::new(RunClock::start(Duration::from_hours(1)));
274        budget.arm(Duration::ZERO);
275        assert!(budget.should_interrupt());
276        assert_eq!(budget.take_trip(), Some(Trip::Rule));
277    }
278
279    #[test]
280    fn an_expired_run_budget_interrupts_and_records_why() {
281        let budget = Budget::new(RunClock::start(Duration::ZERO));
282        budget.arm(Duration::from_hours(1));
283        assert!(budget.should_interrupt());
284        assert_eq!(budget.take_trip(), Some(Trip::Run));
285    }
286
287    #[test]
288    fn the_run_budget_wins_when_both_are_spent() {
289        // The run being over is the more consequential fact: every subsequent invocation
290        // will breach too, so reporting the rule budget would name an arbitrary victim.
291        let budget = Budget::new(RunClock::start(Duration::ZERO));
292        budget.arm(Duration::ZERO);
293        assert!(budget.should_interrupt());
294        assert_eq!(budget.take_trip(), Some(Trip::Run));
295    }
296
297    #[test]
298    fn disarming_stops_invocation_enforcement() {
299        let budget = Budget::new(RunClock::start(Duration::from_hours(1)));
300        budget.arm(Duration::ZERO);
301        budget.disarm();
302        assert!(!budget.should_interrupt(), "no invocation is in flight");
303    }
304
305    #[test]
306    fn taking_the_trip_clears_it() {
307        let budget = Budget::new(RunClock::start(Duration::from_hours(1)));
308        budget.arm(Duration::ZERO);
309        assert!(budget.should_interrupt());
310        assert_eq!(budget.take_trip(), Some(Trip::Rule));
311        assert_eq!(
312            budget.take_trip(),
313            None,
314            "a trip must not be reported twice"
315        );
316    }
317
318    #[test]
319    fn arming_clears_a_previous_trip() {
320        // Otherwise the next invocation would inherit the last one's verdict and be
321        // reported as timing out without ever running.
322        let budget = Budget::new(RunClock::start(Duration::from_hours(1)));
323        budget.arm(Duration::ZERO);
324        assert!(budget.should_interrupt());
325
326        budget.arm(Duration::from_hours(1));
327        assert!(!budget.should_interrupt());
328        assert_eq!(budget.take_trip(), None);
329    }
330
331    #[test]
332    fn an_overflowing_budget_does_not_wrap_into_disarmed() {
333        // A deadline of zero means "no invocation in flight". An enormous budget must
334        // saturate rather than wrap around to zero and switch the limit off entirely.
335        let budget = Budget::new(RunClock::start(Duration::from_hours(1)));
336        budget.arm(Duration::MAX);
337        assert_ne!(
338            budget.invocation_deadline_nanos.load(Ordering::Relaxed),
339            0,
340            "an overflowing budget must not read as disarmed"
341        );
342    }
343
344    #[test]
345    fn the_clock_measures_from_one_origin() {
346        let clock = RunClock::start(Duration::from_hours(1));
347        let a = Arc::clone(&clock);
348        let b = Arc::clone(&clock);
349        assert!(!a.is_expired());
350        assert!(!b.is_expired());
351        assert_eq!(a.global_timeout(), Duration::from_hours(1));
352    }
353
354    #[test]
355    fn a_zero_global_budget_is_immediately_expired() {
356        assert!(RunClock::start(Duration::ZERO).is_expired());
357    }
358}