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/// Default wall-clock budget for host-side type-provider work across a whole run.
38///
39/// A minute rather than the fifteen seconds `DEFAULT_GLOBAL_TIMEOUT` gives guest execution,
40/// because what this bounds is the user's own TypeScript program being built — a cost that
41/// belongs to their project and scales with it, not with anything a rule did. That is also
42/// why it is configurable where `COMPILE_BUDGET_PER_COMPONENT` is not: lanekeep's own
43/// artifacts are lanekeep's problem, and a monorepo's `tsc` is not.
44pub const DEFAULT_ANALYSIS_TIMEOUT: Duration = Duration::from_mins(1);
45
46/// The three budgets.
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub struct Limits {
49    /// Budget for one handler invocation — a single `check` or `reduce` call.
50    ///
51    /// This is the limit that fires fast and names the culprit: which rule, which file,
52    /// which phase. Keeping it well under the global budget means the diagnostic usually
53    /// comes from the level that can identify the cause.
54    pub rule_timeout: Duration,
55
56    /// Wall-clock budget for the whole run.
57    ///
58    /// The backstop for when no single invocation is pathological but the aggregate is —
59    /// a thousand rules each taking twenty milliseconds.
60    pub global_timeout: Duration,
61
62    /// Budget for host-side type-provider work across the whole run.
63    ///
64    /// **Analysis time, not elapsed time**: the sum of what the provider spends building
65    /// programs and answering requests, as [`AnalysisBudget`] accumulates it, and not the
66    /// wall clock since the run started. A run's own reading, parsing, matching and rule
67    /// execution are `global_timeout`'s business and are charged nowhere here.
68    ///
69    /// Separate from `global_timeout` because it bounds host work rather than guest
70    /// execution, and the two must not subsidize each other: a program build charged to the
71    /// run budget would make a cold `tsc` run and a warm one take different exits over
72    /// identical input, which is exactly the reasoning architecture §6.8 gives for taking
73    /// component compilation off the run clock.
74    pub analysis_timeout: Duration,
75
76    /// Memory ceiling per runtime.
77    pub memory_bytes: usize,
78}
79
80impl Default for Limits {
81    fn default() -> Self {
82        Self {
83            rule_timeout: DEFAULT_RULE_TIMEOUT,
84            global_timeout: DEFAULT_GLOBAL_TIMEOUT,
85            analysis_timeout: DEFAULT_ANALYSIS_TIMEOUT,
86            memory_bytes: DEFAULT_MEMORY_BYTES,
87        }
88    }
89}
90
91impl Limits {
92    /// Raise the per-invocation budget, for a rule that legitimately does heavy work.
93    ///
94    /// Cannot raise the global budget: a single rule must not be able to extend the run's
95    /// total. That is the whole point of having two levels rather than one.
96    #[must_use]
97    pub const fn with_rule_timeout(mut self, timeout: Duration) -> Self {
98        self.rule_timeout = timeout;
99        self
100    }
101
102    /// Set the global wall-clock budget.
103    #[must_use]
104    pub const fn with_global_timeout(mut self, timeout: Duration) -> Self {
105        self.global_timeout = timeout;
106        self
107    }
108
109    /// Set the per-runtime memory ceiling.
110    #[must_use]
111    pub const fn with_memory_bytes(mut self, bytes: usize) -> Self {
112        self.memory_bytes = bytes;
113        self
114    }
115
116    /// Set the analysis budget.
117    #[must_use]
118    pub const fn with_analysis_timeout(mut self, timeout: Duration) -> Self {
119        self.analysis_timeout = timeout;
120        self
121    }
122}
123
124/// When the run started, shared by every worker.
125///
126/// The global budget has to be measured from one origin across all workers, or each would
127/// enforce its own fifteen seconds and the run's total would scale with the worker count.
128#[derive(Debug)]
129pub struct RunClock {
130    start: Instant,
131    global_timeout: Duration,
132}
133
134impl RunClock {
135    /// Start the clock now.
136    #[must_use]
137    pub fn start(global_timeout: Duration) -> Arc<Self> {
138        Arc::new(Self {
139            start: Instant::now(),
140            global_timeout,
141        })
142    }
143
144    /// How long the run has been going.
145    #[must_use]
146    pub fn elapsed(&self) -> Duration {
147        self.start.elapsed()
148    }
149
150    /// The configured global budget.
151    #[must_use]
152    pub const fn global_timeout(&self) -> Duration {
153        self.global_timeout
154    }
155
156    /// Whether the global budget is spent.
157    #[must_use]
158    pub fn is_expired(&self) -> bool {
159        self.elapsed() >= self.global_timeout
160    }
161
162    fn elapsed_nanos(&self) -> u64 {
163        u64::try_from(self.start.elapsed().as_nanos()).unwrap_or(u64::MAX)
164    }
165}
166
167/// An invocation whose clock is stopped, resumed when this drops.
168///
169/// A guard rather than a matched pair of calls, because the call it wraps can return early on
170/// an error: a `disarm` whose `arm` sits after a `?` is a rule that runs unbounded from then
171/// on, and nothing about the code would look wrong.
172#[derive(Debug)]
173pub struct Paused<'a> {
174    budget: &'a Budget,
175    remaining: u64,
176    was_armed: bool,
177}
178
179impl Drop for Paused<'_> {
180    fn drop(&mut self) {
181        if !self.was_armed {
182            return;
183        }
184        let now = self.budget.clock.elapsed_nanos();
185        // `.max(1)` for the reason `arm` uses it: zero means disarmed, so a resumed deadline
186        // must never land on it.
187        self.budget
188            .invocation_deadline_nanos
189            .store(now.saturating_add(self.remaining).max(1), Ordering::Relaxed);
190    }
191}
192
193/// The run's budget for host-side type-provider work.
194///
195/// # Why this is a second clock rather than a share of [`RunClock`]
196///
197/// [`RunClock`] bounds guest execution and is polled from inside a handler by both engines.
198/// A `tsc` provider's cost is neither: it is the user's own TypeScript program being built,
199/// in a process lanekeep spawned, while no rule is running. Charging it to the run budget
200/// would make a cold provider run and a warm one take different exits over identical input,
201/// which is the determinism argument architecture §6.8 already makes for taking component
202/// compilation off the run clock.
203///
204/// # Why an accumulator rather than a wall clock
205///
206/// **This budget is analysis time, not elapsed time.** An `Instant` taken at prepare charges
207/// discovery, hashing, parsing, matching and every rule that ran to a budget whose own
208/// breach message says it is "the cost of building the project's own TypeScript program and
209/// not of running any rule" — so the message would be a lie, and a large corpus whose program
210/// build takes half a minute would be cancelled for spending the rest of the minute doing the
211/// work the run exists to do. Only what [`AnalysisBudget::charge`] brackets is charged.
212///
213/// It is the mirror image of [`Budget::pause`] in intent: the rule clock stops where the
214/// analysis clock runs, so no instant is charged to both.
215///
216/// The converse does not hold, and that is deliberate. What a provider charges is *service*
217/// time — the window in which its sidecar is working on one request — so an instant a worker
218/// spends queued behind another worker's request is charged to neither clock. Charging the
219/// queue wait instead would make the accumulator grow with the number of rayon workers rather
220/// than with the work: measured through the `tsc` provider, fourteen workers each waiting
221/// about 200 ms charged 2.866 s against 205 ms of wall clock, so a 60 s budget bounded roughly
222/// 60/P seconds of real analysis and the breach message quoted a duration nobody could
223/// observe. One sidecar serves the run, so the sum of its service times is the wall time it
224/// was busy — which is exactly what "type analysis took …" claims to name.
225///
226/// `Clone` over a shared accumulator rather than `Copy` over an `Instant`: the engine holds
227/// one and the provider holds another, and a charge on either has to be visible to the check
228/// the other makes. Two clones are the same budget, not two budgets.
229#[derive(Debug, Clone)]
230pub struct AnalysisBudget {
231    budget: Duration,
232    /// Nanoseconds of analysis time charged so far, shared by every clone.
233    spent: Arc<AtomicU64>,
234}
235
236impl AnalysisBudget {
237    /// A budget with nothing spent yet.
238    #[must_use]
239    pub fn start(budget: Duration) -> Self {
240        Self {
241            budget,
242            spent: Arc::new(AtomicU64::new(0)),
243        }
244    }
245
246    /// The configured budget, for a diagnostic.
247    #[must_use]
248    pub const fn budget(&self) -> Duration {
249        self.budget
250    }
251
252    /// How much analysis time has been charged.
253    #[must_use]
254    pub fn spent(&self) -> Duration {
255        Duration::from_nanos(self.spent.load(Ordering::Relaxed))
256    }
257
258    /// Charge everything until the returned guard drops to this budget.
259    ///
260    /// A guard rather than a matched pair for [`Paused`]'s reason: the call it brackets can
261    /// return early on `?`, and a stop whose start sits after a `?` charges nothing for the
262    /// one request that actually ran long.
263    ///
264    /// Nesting composes by over-charging rather than by being forbidden — an inner charge's
265    /// time lands in the accumulator twice — so the caller brackets the outermost call it
266    /// owns and nothing inside it.
267    ///
268    /// Bracket the *service*, never the wait for it. A provider whose sidecar serves one
269    /// request at a time must take this guard after it holds whatever serializes access, or
270    /// every worker queued behind the one being served charges the queue and the accumulator
271    /// counts the same instants once per waiting thread. See the type's own documentation.
272    #[must_use]
273    pub fn charge(&self) -> Charge<'_> {
274        Charge {
275            budget: self,
276            started: Instant::now(),
277        }
278    }
279
280    /// How much of it is left, or `None` once it is breached.
281    ///
282    /// `Some(Duration::ZERO)` is a real answer and is not the same as `None`: a request handed
283    /// a zero I/O timeout fails immediately and reports as a timeout, where `None` means the
284    /// run is already over and nothing further should be attempted. The boundary is the same
285    /// one [`analysis_overrun`] uses, so the two can never disagree about a single instant.
286    #[must_use]
287    pub fn remaining(&self) -> Option<Duration> {
288        remaining_after(self.spent(), self.budget)
289    }
290
291    /// The diagnostic, if the budget is spent.
292    #[must_use]
293    pub fn overrun(&self) -> Option<String> {
294        analysis_overrun(self.spent(), self.budget)
295    }
296}
297
298/// Analysis work whose time is being charged, added to the budget when this drops.
299///
300/// See [`AnalysisBudget::charge`].
301#[derive(Debug)]
302pub struct Charge<'a> {
303    budget: &'a AnalysisBudget,
304    started: Instant,
305}
306
307impl Drop for Charge<'_> {
308    fn drop(&mut self) {
309        let nanos = u64::try_from(self.started.elapsed().as_nanos()).unwrap_or(u64::MAX);
310        // Saturating: a budget that has somehow accumulated most of a u64 of nanoseconds is
311        // already over by nearly six centuries, and wrapping it back to nothing would be the
312        // one arithmetic outcome that reads as a fresh budget.
313        self.budget
314            .spent
315            .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |spent| {
316                Some(spent.saturating_add(nanos))
317            })
318            .ok();
319    }
320}
321
322/// The fallback detail for an analysis overrun that has no measurement to quote.
323///
324/// One function rather than a string written at each of the three sites that need it — the
325/// provider's `begin_run` and `failure`, and the engine's `provider_for` — because
326/// the engine's `RunError` carries a rendered message and nothing else, so three
327/// spellings of one refusal would be three different pieces of advice for one fault.
328///
329/// Reached only when a request reported a timeout while the accumulator still reads short of
330/// the budget: a request handed the last microsecond of it can outlive that microsecond
331/// without the sum passing the total.
332#[must_use]
333pub fn analysis_overrun_fallback(budget: Duration) -> String {
334    format!(
335        "type analysis did not answer within the {budget:.1?} allowed\n  \
336         this is the cost of building the project's own TypeScript program and not of running \
337         any rule, so narrowing what is checked will not help much\n  \
338         raise it with `timeouts.analysis`, or set `types.provider` to `builtin`"
339    )
340}
341
342/// The arithmetic behind [`AnalysisBudget::remaining`], separated from the accumulator.
343///
344/// `budget.checked_sub(spent)` rather than `if spent > budget { None } else { Some(budget -
345/// spent) }`: the two are equivalent everywhere except the exact point `spent == budget`,
346/// where subtraction gives `Some(Duration::ZERO)` — spending precisely the budget still leaves a
347/// (zero) answer, and only stepping past it turns the answer into `None`. Synthetic durations
348/// drive that boundary directly; through [`AnalysisBudget`] only the `(0, 0)` corner is
349/// reachable in practice, since a real [`Charge`] never measures exactly the budget.
350pub(crate) fn remaining_after(spent: Duration, budget: Duration) -> Option<Duration> {
351    budget.checked_sub(spent)
352}
353
354/// The arithmetic and the wording, separated from the accumulator.
355///
356/// A pure function for the reason `compile_overrun` is one: a test drives the comparison and
357/// the message with synthetic microsecond `Duration`s, where an end-to-end test would have to
358/// spend the whole budget to reach the branch. What no test asserts is that sixty seconds is
359/// the right number of seconds — that is a judgment, and it is the user's to change, which is
360/// exactly why this budget is configurable and `COMPILE_BUDGET_PER_COMPONENT` is not.
361///
362/// `spent` is analysis time — the sum of what [`AnalysisBudget::charge`] bracketed — and not
363/// the time since the run started, which is what makes the message's second line true.
364///
365/// The message names `timeouts.analysis` and not `--timeout`. `--timeout` moves the *run*
366/// budget, and printing advice that cannot work is the original `--timeout` bug in a new
367/// phase — `AGENTS.md` records both instances.
368#[must_use]
369pub fn analysis_overrun(spent: Duration, budget: Duration) -> Option<String> {
370    if spent <= budget {
371        return None;
372    }
373
374    // The overrun printed beside the two figures, because `{:.1?}` rounds and two roundings
375    // that land on the same tenth read as "took 3.0s, past the 3.0s allowed" — a message that
376    // says a budget was breached and shows two identical numbers. The difference is computed
377    // from the unrounded durations, so it is never zero here.
378    let over = spent.saturating_sub(budget);
379    Some(format!(
380        "type analysis took {spent:.1?}, past the {budget:.1?} allowed (by {over:.3?})\n  \
381         this is the cost of building the project's own TypeScript program and not of running \
382         any rule, so narrowing what is checked will not help much\n  \
383         raise it with `timeouts.analysis`, or set `types.provider` to `builtin`"
384    ))
385}
386
387/// Which budget was breached.
388#[derive(Debug, Clone, Copy, PartialEq, Eq)]
389pub enum Trip {
390    /// A single invocation ran too long.
391    Rule,
392    /// The run as a whole ran too long.
393    Run,
394}
395
396const TRIP_NONE: u64 = 0;
397const TRIP_RULE: u64 = 1;
398const TRIP_RUN: u64 = 2;
399
400/// Shared between an engine's runtime and its interrupt handler.
401///
402/// Records *why* execution was interrupted rather than leaving it to be inferred from the
403/// engine's own exception or trap text. QuickJS, for instance, reports an interrupt as an
404/// ordinary `Error` whose message happens to be "interrupted"; keying behavior off that
405/// string would make the difference between "your rule looped forever" and "your rule
406/// threw" depend on wording this project does not control. A different engine's own
407/// interrupted-execution signal would be exactly as unreliable to string-match, for the
408/// same reason.
409#[derive(Debug)]
410pub struct Budget {
411    clock: Arc<RunClock>,
412    global_nanos: u64,
413    /// Deadline for the current invocation, in nanoseconds since the run started.
414    /// Zero means no invocation is in flight.
415    invocation_deadline_nanos: AtomicU64,
416    tripped: AtomicU64,
417}
418
419impl Budget {
420    /// Build a budget enforcer sharing the run's clock.
421    pub fn new(clock: Arc<RunClock>) -> Arc<Self> {
422        let global_nanos = u64::try_from(clock.global_timeout.as_nanos()).unwrap_or(u64::MAX);
423        Arc::new(Self {
424            clock,
425            global_nanos,
426            invocation_deadline_nanos: AtomicU64::new(0),
427            tripped: AtomicU64::new(TRIP_NONE),
428        })
429    }
430
431    /// Start the clock on one invocation.
432    pub fn arm(&self, rule_timeout: Duration) {
433        let now = self.clock.elapsed_nanos();
434        let budget = u64::try_from(rule_timeout.as_nanos()).unwrap_or(u64::MAX);
435        // Saturating: a deadline of zero means disarmed, so an overflowing budget must not
436        // wrap around into it and silently switch the limit off.
437        self.invocation_deadline_nanos
438            .store(now.saturating_add(budget).max(1), Ordering::Relaxed);
439        self.tripped.store(TRIP_NONE, Ordering::Relaxed);
440    }
441
442    /// Stop enforcing an invocation budget.
443    pub fn disarm(&self) {
444        self.invocation_deadline_nanos.store(0, Ordering::Relaxed);
445    }
446
447    /// Stop charging the current invocation while the host does work on its behalf.
448    ///
449    /// Returns a guard; the invocation resumes with exactly the time it had when the guard
450    /// was taken, measured from wherever the run clock is when the guard drops.
451    ///
452    /// # Why not `disarm` followed by `arm`
453    ///
454    /// [`Budget::arm`] takes a fresh `rule_timeout` and also clears [`Budget::take_trip`]'s
455    /// record. Re-arming after a provider call would therefore hand the rule a whole new
456    /// allowance on every question it asks — a rule asking a hundred type questions would be
457    /// bounded by nothing — and would erase a global-budget trip that had already been
458    /// recorded, turning a run timeout into silence. This carries the remainder instead, so a
459    /// rule's own budget still bounds a rule's own code and nothing else.
460    ///
461    /// The global check at [`Budget::should_interrupt`] is untouched: a paused invocation is
462    /// still inside a run, and a run that has overrun must still stop.
463    ///
464    /// Nesting composes rather than being forbidden, which costs no runtime check: an inner
465    /// pause reads an already stopped clock as disarmed and so restores nothing on drop,
466    /// leaving the outermost guard — the only one holding a real remainder — to resume.
467    #[must_use]
468    pub fn pause(&self) -> Paused<'_> {
469        let deadline = self.invocation_deadline_nanos.swap(0, Ordering::Relaxed);
470        let now = self.clock.elapsed_nanos();
471        Paused {
472            budget: self,
473            // Saturating, so a deadline already in the past resumes with nothing left rather
474            // than wrapping into a very large allowance.
475            remaining: deadline.saturating_sub(now),
476            was_armed: deadline != 0,
477        }
478    }
479
480    /// Whether execution should stop now, recording why. Called by the engine's interrupt
481    /// handler, so it runs often and must stay cheap.
482    pub fn should_interrupt(&self) -> bool {
483        let elapsed = self.clock.elapsed_nanos();
484
485        if elapsed >= self.global_nanos {
486            self.tripped.store(TRIP_RUN, Ordering::Relaxed);
487            return true;
488        }
489
490        let deadline = self.invocation_deadline_nanos.load(Ordering::Relaxed);
491        if deadline != 0 && elapsed >= deadline {
492            self.tripped.store(TRIP_RULE, Ordering::Relaxed);
493            return true;
494        }
495
496        false
497    }
498
499    /// Which budget was breached, if any. Clears the record.
500    pub fn take_trip(&self) -> Option<Trip> {
501        match self.tripped.swap(TRIP_NONE, Ordering::Relaxed) {
502            TRIP_RULE => Some(Trip::Rule),
503            TRIP_RUN => Some(Trip::Run),
504            _ => None,
505        }
506    }
507
508    /// The run clock this budget was built from.
509    pub fn clock(&self) -> &RunClock {
510        &self.clock
511    }
512}
513
514#[cfg(test)]
515mod tests {
516    use super::*;
517
518    #[test]
519    fn defaults_match_the_documented_budgets() {
520        let limits = Limits::default();
521        assert_eq!(limits.rule_timeout, Duration::from_secs(1));
522        assert_eq!(limits.global_timeout, Duration::from_secs(15));
523        assert_eq!(limits.memory_bytes, 64 * 1024 * 1024);
524    }
525
526    #[test]
527    fn the_rule_budget_is_well_under_the_global_one() {
528        // Not arithmetic for its own sake. If a single invocation could consume the whole
529        // run, the global limit would be the one that fires, and its diagnostic cannot say
530        // which rule or file was responsible.
531        let limits = Limits::default();
532        assert!(
533            limits.rule_timeout * 5 < limits.global_timeout,
534            "the per-invocation budget must leave room for the global limit to be a backstop"
535        );
536    }
537
538    #[test]
539    fn a_rule_cannot_raise_the_global_budget() {
540        let limits = Limits::default().with_rule_timeout(Duration::from_mins(1));
541        assert_eq!(limits.rule_timeout, Duration::from_mins(1));
542        assert_eq!(
543            limits.global_timeout, DEFAULT_GLOBAL_TIMEOUT,
544            "raising a rule's own budget must not extend the run"
545        );
546    }
547
548    #[test]
549    fn an_unarmed_budget_never_interrupts() {
550        let budget = Budget::new(RunClock::start(Duration::from_hours(1)));
551        assert!(!budget.should_interrupt());
552        assert_eq!(budget.take_trip(), None);
553    }
554
555    #[test]
556    fn an_expired_invocation_budget_interrupts_and_records_why() {
557        let budget = Budget::new(RunClock::start(Duration::from_hours(1)));
558        budget.arm(Duration::ZERO);
559        assert!(budget.should_interrupt());
560        assert_eq!(budget.take_trip(), Some(Trip::Rule));
561    }
562
563    #[test]
564    fn an_expired_run_budget_interrupts_and_records_why() {
565        let budget = Budget::new(RunClock::start(Duration::ZERO));
566        budget.arm(Duration::from_hours(1));
567        assert!(budget.should_interrupt());
568        assert_eq!(budget.take_trip(), Some(Trip::Run));
569    }
570
571    #[test]
572    fn the_run_budget_wins_when_both_are_spent() {
573        // The run being over is the more consequential fact: every subsequent invocation
574        // will breach too, so reporting the rule budget would name an arbitrary victim.
575        let budget = Budget::new(RunClock::start(Duration::ZERO));
576        budget.arm(Duration::ZERO);
577        assert!(budget.should_interrupt());
578        assert_eq!(budget.take_trip(), Some(Trip::Run));
579    }
580
581    #[test]
582    fn disarming_stops_invocation_enforcement() {
583        let budget = Budget::new(RunClock::start(Duration::from_hours(1)));
584        budget.arm(Duration::ZERO);
585        budget.disarm();
586        assert!(!budget.should_interrupt(), "no invocation is in flight");
587    }
588
589    #[test]
590    fn taking_the_trip_clears_it() {
591        let budget = Budget::new(RunClock::start(Duration::from_hours(1)));
592        budget.arm(Duration::ZERO);
593        assert!(budget.should_interrupt());
594        assert_eq!(budget.take_trip(), Some(Trip::Rule));
595        assert_eq!(
596            budget.take_trip(),
597            None,
598            "a trip must not be reported twice"
599        );
600    }
601
602    #[test]
603    fn arming_clears_a_previous_trip() {
604        // Otherwise the next invocation would inherit the last one's verdict and be
605        // reported as timing out without ever running.
606        let budget = Budget::new(RunClock::start(Duration::from_hours(1)));
607        budget.arm(Duration::ZERO);
608        assert!(budget.should_interrupt());
609
610        budget.arm(Duration::from_hours(1));
611        assert!(!budget.should_interrupt());
612        assert_eq!(budget.take_trip(), None);
613    }
614
615    #[test]
616    fn an_overflowing_budget_does_not_wrap_into_disarmed() {
617        // A deadline of zero means "no invocation in flight". An enormous budget must
618        // saturate rather than wrap around to zero and switch the limit off entirely.
619        let budget = Budget::new(RunClock::start(Duration::from_hours(1)));
620        budget.arm(Duration::MAX);
621        assert_ne!(
622            budget.invocation_deadline_nanos.load(Ordering::Relaxed),
623            0,
624            "an overflowing budget must not read as disarmed"
625        );
626    }
627
628    #[test]
629    fn the_clock_measures_from_one_origin() {
630        let clock = RunClock::start(Duration::from_hours(1));
631        let a = Arc::clone(&clock);
632        let b = Arc::clone(&clock);
633        assert!(!a.is_expired());
634        assert!(!b.is_expired());
635        assert_eq!(a.global_timeout(), Duration::from_hours(1));
636    }
637
638    #[test]
639    fn a_zero_global_budget_is_immediately_expired() {
640        assert!(RunClock::start(Duration::ZERO).is_expired());
641    }
642
643    #[test]
644    fn the_analysis_budget_defaults_to_a_minute() {
645        assert_eq!(Limits::default().analysis_timeout, Duration::from_mins(1));
646        assert_eq!(DEFAULT_ANALYSIS_TIMEOUT, Duration::from_mins(1));
647    }
648
649    #[test]
650    fn the_analysis_budget_is_settable_without_moving_the_others() {
651        let limits = Limits::default().with_analysis_timeout(Duration::from_secs(5));
652        assert_eq!(limits.analysis_timeout, Duration::from_secs(5));
653        assert_eq!(limits.rule_timeout, DEFAULT_RULE_TIMEOUT);
654        assert_eq!(limits.global_timeout, DEFAULT_GLOBAL_TIMEOUT);
655    }
656
657    const ANALYSIS: Duration = Duration::from_mins(1);
658
659    #[test]
660    fn analysis_within_budget_is_not_an_overrun() {
661        assert_eq!(analysis_overrun(Duration::ZERO, ANALYSIS), None);
662        // The boundary is inclusive, matching `compile_overrun`: spending exactly the budget
663        // is spending the budget, not exceeding it.
664        assert_eq!(analysis_overrun(ANALYSIS, ANALYSIS), None);
665    }
666
667    #[test]
668    fn analysis_one_microsecond_past_the_budget_is_an_overrun() {
669        assert!(analysis_overrun(ANALYSIS + Duration::from_micros(1), ANALYSIS).is_some());
670    }
671
672    #[test]
673    fn the_analysis_overrun_names_analysis_and_the_setting_that_raises_it() {
674        let detail =
675            analysis_overrun(ANALYSIS * 3, ANALYSIS).expect("three times the budget is an overrun");
676        assert!(detail.contains("type analysis"), "got: {detail}");
677        assert!(detail.contains("timeouts.analysis"), "got: {detail}");
678        // Not `--timeout`: that raises the *run* budget, and advice that cannot work is the
679        // exact failure `AGENTS.md` records for the original `--timeout` bug.
680        assert!(!detail.contains("--timeout"), "got: {detail}");
681    }
682
683    /// A breach whose two figures round to the same tenth still reads as a breach.
684    ///
685    /// `{:.1?}` rounds, so `3.04s` past `3.0s` printed "took 3.0s, past the 3.0s allowed" — a
686    /// message asserting a budget was exceeded and showing two identical numbers, which reads
687    /// as a lanekeep bug rather than as a slow project. The overrun is computed from the
688    /// unrounded durations, so it is never zero when this branch is reached at all.
689    #[test]
690    fn an_overrun_too_small_for_the_rounding_is_still_printed() {
691        let budget = Duration::from_secs(3);
692        let detail = analysis_overrun(budget + Duration::from_millis(40), budget)
693            .expect("forty milliseconds past the budget is an overrun");
694        assert!(
695            detail.contains("took 3.0s, past the 3.0s allowed"),
696            "{detail}"
697        );
698        assert!(
699            detail.contains("(by 40.000ms)"),
700            "the two rounded figures are equal, so the difference has to be printed: {detail}"
701        );
702    }
703
704    // `remaining_after` is `AnalysisBudget::remaining`'s arithmetic with the clock removed, so
705    // the boundary can be driven with synthetic durations: two real `Instant::now()` reads are
706    // never equal (see `remaining_after`'s doc comment), so no test built on `AnalysisBudget`
707    // itself can land on `elapsed == budget` to exercise it.
708    #[test]
709    fn remaining_after_at_zero_elapsed_and_zero_budget_is_zero_not_none() {
710        assert_eq!(
711            remaining_after(Duration::ZERO, Duration::ZERO),
712            Some(Duration::ZERO)
713        );
714    }
715
716    #[test]
717    fn remaining_after_one_nanosecond_past_a_zero_budget_is_none() {
718        assert_eq!(
719            remaining_after(Duration::from_nanos(1), Duration::ZERO),
720            None
721        );
722    }
723
724    #[test]
725    fn remaining_after_short_of_the_budget_is_the_gap() {
726        assert_eq!(
727            remaining_after(Duration::from_secs(59), Duration::from_mins(1)),
728            Some(Duration::from_secs(1))
729        );
730    }
731
732    #[test]
733    fn remaining_after_exactly_the_budget_is_zero_not_none() {
734        assert_eq!(
735            remaining_after(Duration::from_mins(1), Duration::from_mins(1)),
736            Some(Duration::ZERO)
737        );
738    }
739
740    #[test]
741    fn remaining_after_one_nanosecond_past_the_budget_is_none() {
742        assert_eq!(
743            remaining_after(
744                Duration::from_mins(1) + Duration::from_nanos(1),
745                Duration::from_mins(1)
746            ),
747            None
748        );
749    }
750
751    // These drive `AnalysisBudget`'s accumulator. Wide margins throughout: `sleep` guarantees
752    // a floor and nothing else, so every assertion is one-sided.
753    #[test]
754    fn a_generous_budget_has_remaining_time_no_greater_than_the_budget() {
755        let generous = AnalysisBudget::start(Duration::from_hours(1));
756        let remaining = generous.remaining().expect("an hour is not spent yet");
757        assert!(remaining <= Duration::from_hours(1));
758        assert!(generous.overrun().is_none());
759    }
760
761    #[test]
762    fn a_charge_adds_the_time_it_measured() {
763        let budget = AnalysisBudget::start(Duration::from_mins(1));
764        assert_eq!(budget.spent(), Duration::ZERO, "nothing has been charged");
765        {
766            let _charge = budget.charge();
767            std::thread::sleep(Duration::from_millis(20));
768        }
769        assert!(
770            budget.spent() >= Duration::from_millis(20),
771            "a charge adds what it measured, got {:?}",
772            budget.spent()
773        );
774    }
775
776    #[test]
777    fn two_charges_add() {
778        let budget = AnalysisBudget::start(Duration::from_mins(1));
779        for _ in 0..2 {
780            let _charge = budget.charge();
781            std::thread::sleep(Duration::from_millis(20));
782        }
783        assert!(
784            budget.spent() >= Duration::from_millis(40),
785            "two charges accumulate rather than replacing one another, got {:?}",
786            budget.spent()
787        );
788    }
789
790    #[test]
791    fn nothing_is_spent_while_no_charge_is_open() {
792        // The whole ruling in one test: the budget is analysis time, so a run that spends
793        // fifty milliseconds discovering, hashing, parsing and matching has spent none of it.
794        let budget = AnalysisBudget::start(Duration::from_millis(1));
795        std::thread::sleep(Duration::from_millis(50));
796        assert_eq!(budget.spent(), Duration::ZERO);
797        assert_eq!(budget.remaining(), Some(Duration::from_millis(1)));
798        assert_eq!(budget.overrun(), None);
799    }
800
801    #[test]
802    fn remaining_and_overrun_read_the_accumulator() {
803        let budget = AnalysisBudget::start(Duration::from_millis(1));
804        {
805            let _charge = budget.charge();
806            std::thread::sleep(Duration::from_millis(20));
807        }
808        assert_eq!(budget.remaining(), None, "a millisecond is long gone");
809        let detail = budget.overrun().expect("the budget is spent");
810        assert!(detail.contains("timeouts.analysis"), "got: {detail}");
811    }
812
813    #[test]
814    fn a_clone_is_the_same_accumulator_rather_than_a_second_one() {
815        // The engine holds one and the provider holds another; a charge on either has to be
816        // visible to the check the other makes, or the engine would bound a budget nothing
817        // spends.
818        let budget = AnalysisBudget::start(Duration::from_mins(1));
819        let copy = budget.clone();
820        {
821            let _charge = copy.charge();
822            std::thread::sleep(Duration::from_millis(20));
823        }
824        assert!(budget.spent() >= Duration::from_millis(20));
825        assert_eq!(budget.spent(), copy.spent());
826    }
827
828    #[test]
829    fn a_paused_invocation_is_not_charged_for_the_host_work_it_waited_on() {
830        // A rule with a 30 ms budget asks a provider a question that takes 40 ms of host
831        // time; that host time must not count against the rule's own budget.
832        let clock = RunClock::start(Duration::from_mins(1));
833        let budget = Budget::new(clock);
834        budget.arm(Duration::from_millis(300));
835        assert!(!budget.should_interrupt(), "nothing has run yet");
836
837        {
838            let _paused = budget.pause();
839            std::thread::sleep(Duration::from_millis(400));
840            assert!(
841                !budget.should_interrupt(),
842                "host work while paused is not the rule's"
843            );
844        }
845
846        // Resumed with what it had, not with a fresh allowance.
847        assert!(!budget.should_interrupt(), "the rule has its budget back");
848        std::thread::sleep(Duration::from_millis(450));
849        assert!(
850            budget.should_interrupt(),
851            "and it is still bounded - a second pause would not have refilled it"
852        );
853        assert_eq!(budget.take_trip(), Some(Trip::Rule));
854    }
855
856    #[test]
857    fn a_pause_taken_late_resumes_with_what_was_left() {
858        // Every other pause test arms and pauses back-to-back, so `remaining == deadline`
859        // passes them too - the subtraction never actually has anything to subtract. Spend
860        // part of the budget before pausing, so a `remaining: deadline` mutant (dropping the
861        // `.saturating_sub(now)`) hands the rule back its whole original allowance instead of
862        // what was left, and is caught here instead of shipping unnoticed.
863        let clock = RunClock::start(Duration::from_mins(1));
864        let budget = Budget::new(clock);
865        // Seconds rather than tens of milliseconds: a hosted CI runner overshoots a sleep by
866        // a hundred milliseconds or more under load, and the first assertion below holds only
867        // while the overshoot stays under what was left when the pause began.
868        budget.arm(Duration::from_secs(2));
869        std::thread::sleep(Duration::from_secs(1));
870
871        {
872            let _paused = budget.pause();
873            std::thread::sleep(Duration::from_millis(1500));
874        }
875
876        // ~1 s of the original 2 s was left when it paused; none of the 1.5 s of host work
877        // while paused counts against it.
878        assert!(
879            !budget.should_interrupt(),
880            "the remainder has not run out yet"
881        );
882        std::thread::sleep(Duration::from_millis(1500));
883        assert!(
884            budget.should_interrupt(),
885            "the remainder it resumed with is now spent"
886        );
887        assert_eq!(budget.take_trip(), Some(Trip::Rule));
888    }
889
890    #[test]
891    fn pausing_a_disarmed_budget_leaves_it_disarmed() {
892        // Config load and the reduce phase both run with nothing armed. A guard that re-armed
893        // on drop regardless would invent a per-invocation deadline where there was none.
894        let clock = RunClock::start(Duration::from_mins(1));
895        let budget = Budget::new(clock);
896        drop(budget.pause());
897        std::thread::sleep(Duration::from_millis(5));
898        assert!(!budget.should_interrupt());
899    }
900
901    #[test]
902    fn a_pause_does_not_clear_a_recorded_trip() {
903        // `arm` resets `tripped`; this must not, or the trip that decides whether a breach is
904        // reported as a rule timeout or a run timeout would be erased by a provider call.
905        let clock = RunClock::start(Duration::from_millis(1));
906        let budget = Budget::new(clock);
907        std::thread::sleep(Duration::from_millis(5));
908        assert!(budget.should_interrupt());
909        drop(budget.pause());
910        assert_eq!(budget.take_trip(), Some(Trip::Run));
911    }
912
913    #[test]
914    fn a_pause_does_not_suspend_the_run_clock() {
915        // "Limits cancel the run; they never degrade it." A paused rule clock stops charging
916        // the *rule*; the run as a whole is still running, and a run that overruns while the
917        // host is building a program must still stop. Otherwise a provider call would be a
918        // hole in the global budget large enough to drive a whole analysis through.
919        let clock = RunClock::start(Duration::from_millis(5));
920        let budget = Budget::new(clock);
921        budget.arm(Duration::from_hours(1));
922        let _paused = budget.pause();
923        std::thread::sleep(Duration::from_millis(10));
924        assert!(budget.should_interrupt(), "the run budget is spent");
925        assert_eq!(budget.take_trip(), Some(Trip::Run));
926    }
927
928    #[test]
929    fn nested_pauses_compose_and_the_outermost_one_decides() {
930        // Nesting is not forbidden, because forbidding it would mean a runtime check on a
931        // path that has none today. It composes instead: the inner pause reads an already
932        // stopped clock as disarmed, so its drop restores nothing and the outer guard - the
933        // one holding the real remainder - is what puts the deadline back.
934        let clock = RunClock::start(Duration::from_mins(1));
935        let budget = Budget::new(clock);
936        budget.arm(Duration::from_millis(30));
937
938        let outer = budget.pause();
939        {
940            let _inner = budget.pause();
941            std::thread::sleep(Duration::from_millis(40));
942        }
943        assert!(
944            !budget.should_interrupt(),
945            "the inner guard must not resume the clock"
946        );
947        std::thread::sleep(Duration::from_millis(20));
948        assert!(
949            !budget.should_interrupt(),
950            "and it must not have re-armed a stale deadline either"
951        );
952        drop(outer);
953
954        assert!(!budget.should_interrupt(), "resumed with its 30 ms");
955        std::thread::sleep(Duration::from_millis(45));
956        assert!(budget.should_interrupt(), "and still bounded by them");
957        assert_eq!(budget.take_trip(), Some(Trip::Rule));
958    }
959}