use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant};
pub const DEFAULT_RULE_TIMEOUT: Duration = Duration::from_secs(1);
pub const DEFAULT_GLOBAL_TIMEOUT: Duration = Duration::from_secs(15);
pub const DEFAULT_MEMORY_BYTES: usize = 64 * 1024 * 1024;
pub const DEFAULT_ANALYSIS_TIMEOUT: Duration = Duration::from_mins(1);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Limits {
pub rule_timeout: Duration,
pub global_timeout: Duration,
pub analysis_timeout: Duration,
pub memory_bytes: usize,
}
impl Default for Limits {
fn default() -> Self {
Self {
rule_timeout: DEFAULT_RULE_TIMEOUT,
global_timeout: DEFAULT_GLOBAL_TIMEOUT,
analysis_timeout: DEFAULT_ANALYSIS_TIMEOUT,
memory_bytes: DEFAULT_MEMORY_BYTES,
}
}
}
impl Limits {
#[must_use]
pub const fn with_rule_timeout(mut self, timeout: Duration) -> Self {
self.rule_timeout = timeout;
self
}
#[must_use]
pub const fn with_global_timeout(mut self, timeout: Duration) -> Self {
self.global_timeout = timeout;
self
}
#[must_use]
pub const fn with_memory_bytes(mut self, bytes: usize) -> Self {
self.memory_bytes = bytes;
self
}
#[must_use]
pub const fn with_analysis_timeout(mut self, timeout: Duration) -> Self {
self.analysis_timeout = timeout;
self
}
}
#[derive(Debug)]
pub struct RunClock {
start: Instant,
global_timeout: Duration,
}
impl RunClock {
#[must_use]
pub fn start(global_timeout: Duration) -> Arc<Self> {
Arc::new(Self {
start: Instant::now(),
global_timeout,
})
}
#[must_use]
pub fn elapsed(&self) -> Duration {
self.start.elapsed()
}
#[must_use]
pub const fn global_timeout(&self) -> Duration {
self.global_timeout
}
#[must_use]
pub fn is_expired(&self) -> bool {
self.elapsed() >= self.global_timeout
}
fn elapsed_nanos(&self) -> u64 {
u64::try_from(self.start.elapsed().as_nanos()).unwrap_or(u64::MAX)
}
}
#[derive(Debug)]
pub struct Paused<'a> {
budget: &'a Budget,
remaining: u64,
was_armed: bool,
}
impl Drop for Paused<'_> {
fn drop(&mut self) {
if !self.was_armed {
return;
}
let now = self.budget.clock.elapsed_nanos();
self.budget
.invocation_deadline_nanos
.store(now.saturating_add(self.remaining).max(1), Ordering::Relaxed);
}
}
#[derive(Debug, Clone)]
pub struct AnalysisBudget {
budget: Duration,
spent: Arc<AtomicU64>,
}
impl AnalysisBudget {
#[must_use]
pub fn start(budget: Duration) -> Self {
Self {
budget,
spent: Arc::new(AtomicU64::new(0)),
}
}
#[must_use]
pub const fn budget(&self) -> Duration {
self.budget
}
#[must_use]
pub fn spent(&self) -> Duration {
Duration::from_nanos(self.spent.load(Ordering::Relaxed))
}
#[must_use]
pub fn charge(&self) -> Charge<'_> {
Charge {
budget: self,
started: Instant::now(),
}
}
#[must_use]
pub fn remaining(&self) -> Option<Duration> {
remaining_after(self.spent(), self.budget)
}
#[must_use]
pub fn overrun(&self) -> Option<String> {
analysis_overrun(self.spent(), self.budget)
}
}
#[derive(Debug)]
pub struct Charge<'a> {
budget: &'a AnalysisBudget,
started: Instant,
}
impl Drop for Charge<'_> {
fn drop(&mut self) {
let nanos = u64::try_from(self.started.elapsed().as_nanos()).unwrap_or(u64::MAX);
self.budget
.spent
.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |spent| {
Some(spent.saturating_add(nanos))
})
.ok();
}
}
#[must_use]
pub fn analysis_overrun_fallback(budget: Duration) -> String {
format!(
"type analysis did not answer within the {budget:.1?} allowed\n \
this is the cost of building the project's own TypeScript program and not of running \
any rule, so narrowing what is checked will not help much\n \
raise it with `timeouts.analysis`, or set `types.provider` to `builtin`"
)
}
pub(crate) fn remaining_after(spent: Duration, budget: Duration) -> Option<Duration> {
budget.checked_sub(spent)
}
#[must_use]
pub fn analysis_overrun(spent: Duration, budget: Duration) -> Option<String> {
if spent <= budget {
return None;
}
let over = spent.saturating_sub(budget);
Some(format!(
"type analysis took {spent:.1?}, past the {budget:.1?} allowed (by {over:.3?})\n \
this is the cost of building the project's own TypeScript program and not of running \
any rule, so narrowing what is checked will not help much\n \
raise it with `timeouts.analysis`, or set `types.provider` to `builtin`"
))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Trip {
Rule,
Run,
}
const TRIP_NONE: u64 = 0;
const TRIP_RULE: u64 = 1;
const TRIP_RUN: u64 = 2;
#[derive(Debug)]
pub struct Budget {
clock: Arc<RunClock>,
global_nanos: u64,
invocation_deadline_nanos: AtomicU64,
tripped: AtomicU64,
}
impl Budget {
pub fn new(clock: Arc<RunClock>) -> Arc<Self> {
let global_nanos = u64::try_from(clock.global_timeout.as_nanos()).unwrap_or(u64::MAX);
Arc::new(Self {
clock,
global_nanos,
invocation_deadline_nanos: AtomicU64::new(0),
tripped: AtomicU64::new(TRIP_NONE),
})
}
pub fn arm(&self, rule_timeout: Duration) {
let now = self.clock.elapsed_nanos();
let budget = u64::try_from(rule_timeout.as_nanos()).unwrap_or(u64::MAX);
self.invocation_deadline_nanos
.store(now.saturating_add(budget).max(1), Ordering::Relaxed);
self.tripped.store(TRIP_NONE, Ordering::Relaxed);
}
pub fn disarm(&self) {
self.invocation_deadline_nanos.store(0, Ordering::Relaxed);
}
#[must_use]
pub fn pause(&self) -> Paused<'_> {
let deadline = self.invocation_deadline_nanos.swap(0, Ordering::Relaxed);
let now = self.clock.elapsed_nanos();
Paused {
budget: self,
remaining: deadline.saturating_sub(now),
was_armed: deadline != 0,
}
}
pub fn should_interrupt(&self) -> bool {
let elapsed = self.clock.elapsed_nanos();
if elapsed >= self.global_nanos {
self.tripped.store(TRIP_RUN, Ordering::Relaxed);
return true;
}
let deadline = self.invocation_deadline_nanos.load(Ordering::Relaxed);
if deadline != 0 && elapsed >= deadline {
self.tripped.store(TRIP_RULE, Ordering::Relaxed);
return true;
}
false
}
pub fn take_trip(&self) -> Option<Trip> {
match self.tripped.swap(TRIP_NONE, Ordering::Relaxed) {
TRIP_RULE => Some(Trip::Rule),
TRIP_RUN => Some(Trip::Run),
_ => None,
}
}
pub fn clock(&self) -> &RunClock {
&self.clock
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn defaults_match_the_documented_budgets() {
let limits = Limits::default();
assert_eq!(limits.rule_timeout, Duration::from_secs(1));
assert_eq!(limits.global_timeout, Duration::from_secs(15));
assert_eq!(limits.memory_bytes, 64 * 1024 * 1024);
}
#[test]
fn the_rule_budget_is_well_under_the_global_one() {
let limits = Limits::default();
assert!(
limits.rule_timeout * 5 < limits.global_timeout,
"the per-invocation budget must leave room for the global limit to be a backstop"
);
}
#[test]
fn a_rule_cannot_raise_the_global_budget() {
let limits = Limits::default().with_rule_timeout(Duration::from_mins(1));
assert_eq!(limits.rule_timeout, Duration::from_mins(1));
assert_eq!(
limits.global_timeout, DEFAULT_GLOBAL_TIMEOUT,
"raising a rule's own budget must not extend the run"
);
}
#[test]
fn an_unarmed_budget_never_interrupts() {
let budget = Budget::new(RunClock::start(Duration::from_hours(1)));
assert!(!budget.should_interrupt());
assert_eq!(budget.take_trip(), None);
}
#[test]
fn an_expired_invocation_budget_interrupts_and_records_why() {
let budget = Budget::new(RunClock::start(Duration::from_hours(1)));
budget.arm(Duration::ZERO);
assert!(budget.should_interrupt());
assert_eq!(budget.take_trip(), Some(Trip::Rule));
}
#[test]
fn an_expired_run_budget_interrupts_and_records_why() {
let budget = Budget::new(RunClock::start(Duration::ZERO));
budget.arm(Duration::from_hours(1));
assert!(budget.should_interrupt());
assert_eq!(budget.take_trip(), Some(Trip::Run));
}
#[test]
fn the_run_budget_wins_when_both_are_spent() {
let budget = Budget::new(RunClock::start(Duration::ZERO));
budget.arm(Duration::ZERO);
assert!(budget.should_interrupt());
assert_eq!(budget.take_trip(), Some(Trip::Run));
}
#[test]
fn disarming_stops_invocation_enforcement() {
let budget = Budget::new(RunClock::start(Duration::from_hours(1)));
budget.arm(Duration::ZERO);
budget.disarm();
assert!(!budget.should_interrupt(), "no invocation is in flight");
}
#[test]
fn taking_the_trip_clears_it() {
let budget = Budget::new(RunClock::start(Duration::from_hours(1)));
budget.arm(Duration::ZERO);
assert!(budget.should_interrupt());
assert_eq!(budget.take_trip(), Some(Trip::Rule));
assert_eq!(
budget.take_trip(),
None,
"a trip must not be reported twice"
);
}
#[test]
fn arming_clears_a_previous_trip() {
let budget = Budget::new(RunClock::start(Duration::from_hours(1)));
budget.arm(Duration::ZERO);
assert!(budget.should_interrupt());
budget.arm(Duration::from_hours(1));
assert!(!budget.should_interrupt());
assert_eq!(budget.take_trip(), None);
}
#[test]
fn an_overflowing_budget_does_not_wrap_into_disarmed() {
let budget = Budget::new(RunClock::start(Duration::from_hours(1)));
budget.arm(Duration::MAX);
assert_ne!(
budget.invocation_deadline_nanos.load(Ordering::Relaxed),
0,
"an overflowing budget must not read as disarmed"
);
}
#[test]
fn the_clock_measures_from_one_origin() {
let clock = RunClock::start(Duration::from_hours(1));
let a = Arc::clone(&clock);
let b = Arc::clone(&clock);
assert!(!a.is_expired());
assert!(!b.is_expired());
assert_eq!(a.global_timeout(), Duration::from_hours(1));
}
#[test]
fn a_zero_global_budget_is_immediately_expired() {
assert!(RunClock::start(Duration::ZERO).is_expired());
}
#[test]
fn the_analysis_budget_defaults_to_a_minute() {
assert_eq!(Limits::default().analysis_timeout, Duration::from_mins(1));
assert_eq!(DEFAULT_ANALYSIS_TIMEOUT, Duration::from_mins(1));
}
#[test]
fn the_analysis_budget_is_settable_without_moving_the_others() {
let limits = Limits::default().with_analysis_timeout(Duration::from_secs(5));
assert_eq!(limits.analysis_timeout, Duration::from_secs(5));
assert_eq!(limits.rule_timeout, DEFAULT_RULE_TIMEOUT);
assert_eq!(limits.global_timeout, DEFAULT_GLOBAL_TIMEOUT);
}
const ANALYSIS: Duration = Duration::from_mins(1);
#[test]
fn analysis_within_budget_is_not_an_overrun() {
assert_eq!(analysis_overrun(Duration::ZERO, ANALYSIS), None);
assert_eq!(analysis_overrun(ANALYSIS, ANALYSIS), None);
}
#[test]
fn analysis_one_microsecond_past_the_budget_is_an_overrun() {
assert!(analysis_overrun(ANALYSIS + Duration::from_micros(1), ANALYSIS).is_some());
}
#[test]
fn the_analysis_overrun_names_analysis_and_the_setting_that_raises_it() {
let detail =
analysis_overrun(ANALYSIS * 3, ANALYSIS).expect("three times the budget is an overrun");
assert!(detail.contains("type analysis"), "got: {detail}");
assert!(detail.contains("timeouts.analysis"), "got: {detail}");
assert!(!detail.contains("--timeout"), "got: {detail}");
}
#[test]
fn an_overrun_too_small_for_the_rounding_is_still_printed() {
let budget = Duration::from_secs(3);
let detail = analysis_overrun(budget + Duration::from_millis(40), budget)
.expect("forty milliseconds past the budget is an overrun");
assert!(
detail.contains("took 3.0s, past the 3.0s allowed"),
"{detail}"
);
assert!(
detail.contains("(by 40.000ms)"),
"the two rounded figures are equal, so the difference has to be printed: {detail}"
);
}
#[test]
fn remaining_after_at_zero_elapsed_and_zero_budget_is_zero_not_none() {
assert_eq!(
remaining_after(Duration::ZERO, Duration::ZERO),
Some(Duration::ZERO)
);
}
#[test]
fn remaining_after_one_nanosecond_past_a_zero_budget_is_none() {
assert_eq!(
remaining_after(Duration::from_nanos(1), Duration::ZERO),
None
);
}
#[test]
fn remaining_after_short_of_the_budget_is_the_gap() {
assert_eq!(
remaining_after(Duration::from_secs(59), Duration::from_mins(1)),
Some(Duration::from_secs(1))
);
}
#[test]
fn remaining_after_exactly_the_budget_is_zero_not_none() {
assert_eq!(
remaining_after(Duration::from_mins(1), Duration::from_mins(1)),
Some(Duration::ZERO)
);
}
#[test]
fn remaining_after_one_nanosecond_past_the_budget_is_none() {
assert_eq!(
remaining_after(
Duration::from_mins(1) + Duration::from_nanos(1),
Duration::from_mins(1)
),
None
);
}
#[test]
fn a_generous_budget_has_remaining_time_no_greater_than_the_budget() {
let generous = AnalysisBudget::start(Duration::from_hours(1));
let remaining = generous.remaining().expect("an hour is not spent yet");
assert!(remaining <= Duration::from_hours(1));
assert!(generous.overrun().is_none());
}
#[test]
fn a_charge_adds_the_time_it_measured() {
let budget = AnalysisBudget::start(Duration::from_mins(1));
assert_eq!(budget.spent(), Duration::ZERO, "nothing has been charged");
{
let _charge = budget.charge();
std::thread::sleep(Duration::from_millis(20));
}
assert!(
budget.spent() >= Duration::from_millis(20),
"a charge adds what it measured, got {:?}",
budget.spent()
);
}
#[test]
fn two_charges_add() {
let budget = AnalysisBudget::start(Duration::from_mins(1));
for _ in 0..2 {
let _charge = budget.charge();
std::thread::sleep(Duration::from_millis(20));
}
assert!(
budget.spent() >= Duration::from_millis(40),
"two charges accumulate rather than replacing one another, got {:?}",
budget.spent()
);
}
#[test]
fn nothing_is_spent_while_no_charge_is_open() {
let budget = AnalysisBudget::start(Duration::from_millis(1));
std::thread::sleep(Duration::from_millis(50));
assert_eq!(budget.spent(), Duration::ZERO);
assert_eq!(budget.remaining(), Some(Duration::from_millis(1)));
assert_eq!(budget.overrun(), None);
}
#[test]
fn remaining_and_overrun_read_the_accumulator() {
let budget = AnalysisBudget::start(Duration::from_millis(1));
{
let _charge = budget.charge();
std::thread::sleep(Duration::from_millis(20));
}
assert_eq!(budget.remaining(), None, "a millisecond is long gone");
let detail = budget.overrun().expect("the budget is spent");
assert!(detail.contains("timeouts.analysis"), "got: {detail}");
}
#[test]
fn a_clone_is_the_same_accumulator_rather_than_a_second_one() {
let budget = AnalysisBudget::start(Duration::from_mins(1));
let copy = budget.clone();
{
let _charge = copy.charge();
std::thread::sleep(Duration::from_millis(20));
}
assert!(budget.spent() >= Duration::from_millis(20));
assert_eq!(budget.spent(), copy.spent());
}
#[test]
fn a_paused_invocation_is_not_charged_for_the_host_work_it_waited_on() {
let clock = RunClock::start(Duration::from_mins(1));
let budget = Budget::new(clock);
budget.arm(Duration::from_millis(300));
assert!(!budget.should_interrupt(), "nothing has run yet");
{
let _paused = budget.pause();
std::thread::sleep(Duration::from_millis(400));
assert!(
!budget.should_interrupt(),
"host work while paused is not the rule's"
);
}
assert!(!budget.should_interrupt(), "the rule has its budget back");
std::thread::sleep(Duration::from_millis(450));
assert!(
budget.should_interrupt(),
"and it is still bounded - a second pause would not have refilled it"
);
assert_eq!(budget.take_trip(), Some(Trip::Rule));
}
#[test]
fn a_pause_taken_late_resumes_with_what_was_left() {
let clock = RunClock::start(Duration::from_mins(1));
let budget = Budget::new(clock);
budget.arm(Duration::from_secs(2));
std::thread::sleep(Duration::from_secs(1));
{
let _paused = budget.pause();
std::thread::sleep(Duration::from_millis(1500));
}
assert!(
!budget.should_interrupt(),
"the remainder has not run out yet"
);
std::thread::sleep(Duration::from_millis(1500));
assert!(
budget.should_interrupt(),
"the remainder it resumed with is now spent"
);
assert_eq!(budget.take_trip(), Some(Trip::Rule));
}
#[test]
fn pausing_a_disarmed_budget_leaves_it_disarmed() {
let clock = RunClock::start(Duration::from_mins(1));
let budget = Budget::new(clock);
drop(budget.pause());
std::thread::sleep(Duration::from_millis(5));
assert!(!budget.should_interrupt());
}
#[test]
fn a_pause_does_not_clear_a_recorded_trip() {
let clock = RunClock::start(Duration::from_millis(1));
let budget = Budget::new(clock);
std::thread::sleep(Duration::from_millis(5));
assert!(budget.should_interrupt());
drop(budget.pause());
assert_eq!(budget.take_trip(), Some(Trip::Run));
}
#[test]
fn a_pause_does_not_suspend_the_run_clock() {
let clock = RunClock::start(Duration::from_millis(5));
let budget = Budget::new(clock);
budget.arm(Duration::from_hours(1));
let _paused = budget.pause();
std::thread::sleep(Duration::from_millis(10));
assert!(budget.should_interrupt(), "the run budget is spent");
assert_eq!(budget.take_trip(), Some(Trip::Run));
}
#[test]
fn nested_pauses_compose_and_the_outermost_one_decides() {
let clock = RunClock::start(Duration::from_mins(1));
let budget = Budget::new(clock);
budget.arm(Duration::from_millis(30));
let outer = budget.pause();
{
let _inner = budget.pause();
std::thread::sleep(Duration::from_millis(40));
}
assert!(
!budget.should_interrupt(),
"the inner guard must not resume the clock"
);
std::thread::sleep(Duration::from_millis(20));
assert!(
!budget.should_interrupt(),
"and it must not have re-armed a stale deadline either"
);
drop(outer);
assert!(!budget.should_interrupt(), "resumed with its 30 ms");
std::thread::sleep(Duration::from_millis(45));
assert!(budget.should_interrupt(), "and still bounded by them");
assert_eq!(budget.take_trip(), Some(Trip::Rule));
}
}