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}