Skip to main content

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