Skip to main content

latexml_core/
cycle_guard.rs

1//! Windowed cycle-detection infinite-loop guard.
2//!
3//! A general defense layer that complements the coarse size/count limits
4//! (`Gullet::token_limit` / `pushback_limit`) and the outermost RSS soft cap
5//! (`stomach::check_timeout`). Where those catch a runaway only after it has
6//! consumed millions of tokens or gigabytes of RSS, this guard spots the
7//! *structure* of a loop directly: a short window of items that repeats many
8//! times back-to-back.
9//!
10//! Two instances run at different kernel levels (the project's layered-guard
11//! philosophy): one over the gullet's expansion stream (token fingerprints),
12//! one over the stomach's accumulated digest list (box fingerprints). Either
13//! can terminate a runaway with a clean `Fatal` long before the RSS cap.
14//!
15//! Algorithm (per the design directive): record a stream of `u64`
16//! fingerprints in a fixed ring buffer. Periodically check whether the most
17//! recent items are periodic with some period `W` in `1..=MAX_WINDOW`,
18//! repeated at least `REPEAT` times. The check is pure periodicity over the
19//! last `W*REPEAT` items (`item[i] == item[i-W]`), so it is **phase/offset
20//! independent** — the cycle need not align to any buffer boundary. The
21//! smallest matching period is reported.
22//!
23//! Cost: detection is throttled to once per `CHECK_EVERY` pushes and is
24//! `O(MAX_WINDOW^2 * REPEAT)` per check (~5.4k u64 compares for the defaults),
25//! i.e. a few amortized compares per push. Callers further gate activation on
26//! an already-high item count so normal conversions pay nothing.
27
28/// Largest cycle period (in items) we look for.
29pub const MAX_WINDOW: usize = 10;
30/// How many consecutive repetitions of a window constitute "infinite".
31pub const REPEAT: usize = 100;
32/// Ring-buffer capacity: enough to hold `MAX_WINDOW` repeated `REPEAT` times.
33const CAP: usize = MAX_WINDOW * REPEAT;
34/// Run the (cheap but non-trivial) periodicity scan only this often.
35const CHECK_EVERY: usize = 256;
36
37/// A windowed cycle detector over a stream of `u64` fingerprints.
38pub struct CycleGuard {
39  buf:   Box<[u64; CAP]>,
40  /// next write position (ring)
41  head:  usize,
42  /// number of valid entries (saturates at CAP)
43  len:   usize,
44  /// throttle counter
45  since: usize,
46}
47
48impl Default for CycleGuard {
49  fn default() -> Self { Self::new() }
50}
51
52impl std::fmt::Debug for CycleGuard {
53  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54    // The ring buffer is large and uninteresting; show only the state.
55    f.debug_struct("CycleGuard")
56      .field("len", &self.len)
57      .field("head", &self.head)
58      .finish()
59  }
60}
61
62impl CycleGuard {
63  pub fn new() -> Self {
64    CycleGuard {
65      buf:   Box::new([0u64; CAP]),
66      head:  0,
67      len:   0,
68      since: 0,
69    }
70  }
71
72  /// Drop all recorded history (call at the start of each conversion).
73  pub fn reset(&mut self) {
74    self.head = 0;
75    self.len = 0;
76    self.since = 0;
77  }
78
79  /// Record one fingerprint. Returns `Some(period)` if the recent stream is a
80  /// window of `period` items repeated at least [`REPEAT`] times.
81  #[inline]
82  pub fn push(&mut self, fp: u64) -> Option<usize> {
83    self.buf[self.head] = fp;
84    self.head = if self.head + 1 == CAP {
85      0
86    } else {
87      self.head + 1
88    };
89    if self.len < CAP {
90      self.len += 1;
91    }
92    self.since += 1;
93    if self.since >= CHECK_EVERY {
94      self.since = 0;
95      return self.detect();
96    }
97    None
98  }
99
100  /// The `k`-th most recently pushed item (`k = 0` is newest).
101  #[inline]
102  fn at_from_end(&self, k: usize) -> u64 {
103    // head points one past the newest, modulo CAP.
104    let idx = (self.head + CAP - 1 - k) % CAP;
105    self.buf[idx]
106  }
107
108  fn detect(&self) -> Option<usize> {
109    // Smallest period first, so a 2-cycle reports period 2, not 4/6/…
110    for w in 1..=MAX_WINDOW {
111      let need = w * REPEAT;
112      if self.len < need {
113        // Larger windows need even more history; the loop is monotone in `w`.
114        break;
115      }
116      // The last `need` items are periodic with period `w` iff
117      // item[i] == item[i-w] for every i in the most-recent `need - w`.
118      let mut periodic = true;
119      for k in 0..(need - w) {
120        if self.at_from_end(k) != self.at_from_end(k + w) {
121          periodic = false;
122          break;
123        }
124      }
125      if periodic {
126        // UNIFORM-run suppression (PR #249 review P1-3): a window whose
127        // items are all IDENTICAL describes a long run of one repeated
128        // fingerprint — textually legitimate input (a verbatim `====…`
129        // separator row, dot leaders, repeated rule glyphs), not a loop.
130        // Real macro loops re-read ≥2 distinct tokens per cycle
131        // (`\def\x{a\x}` → a,\x,a,\x…), and the pure single-token
132        // self-expansion `\def\x{\x}` is caught by the Expandable
133        // self-recursion error before any guard. Note a uniform stream
134        // matches EVERY w, so rejecting uniform windows here rejects the
135        // whole stream (each larger window is uniform too) — by design.
136        let first = self.at_from_end(0);
137        let has_distinct = (1..w).any(|k| self.at_from_end(k) != first);
138        if has_distinct {
139          return Some(w);
140        }
141      }
142    }
143    None
144  }
145}
146
147#[cfg(test)]
148mod tests {
149  use super::*;
150
151  fn run(stream: &[u64]) -> Option<usize> {
152    let mut g = CycleGuard::new();
153    let mut hit = None;
154    for &x in stream {
155      if let Some(p) = g.push(x) {
156        hit = Some(p);
157        break;
158      }
159    }
160    hit
161  }
162
163  #[test]
164  fn uniform_run_does_not_fire() {
165    // A long run of IDENTICAL fingerprints is textually legitimate input
166    // (a verbatim `====…` separator row, a dot-leader, a repeated rule
167    // glyph) — NOT a loop. Real macro loops re-read at least two distinct
168    // tokens (`\def\x{a\x}` → a, \x, a, \x …), and a pure single-token
169    // self-expansion is caught by the Expandable self-recursion error
170    // before any guard. PR #249 review P1-3.
171    let s: Vec<u64> = std::iter::repeat_n(7, 2000).collect();
172    assert_eq!(run(&s), None);
173  }
174
175  #[test]
176  fn alternating_pair_still_fires() {
177    // `\def\x{a\x}` shape: two distinct fingerprints alternating — the
178    // canonical real loop must still be detected (as period 2).
179    let s: Vec<u64> = (0..900).map(|i| (i % 2) as u64).collect();
180    assert_eq!(run(&s), Some(2));
181  }
182
183  #[test]
184  fn detects_period_three() {
185    // Stream must outlast the first throttled scan that sees >= 3*REPEAT items.
186    let s: Vec<u64> = (0..900).map(|i| (i % 3) as u64).collect();
187    assert_eq!(run(&s), Some(3));
188  }
189
190  #[test]
191  fn detects_period_ten() {
192    let s: Vec<u64> = (0..2000).map(|i| (i % 10) as u64).collect();
193    assert_eq!(run(&s), Some(10));
194  }
195
196  #[test]
197  fn ignores_non_cycle() {
198    // Strictly increasing — never periodic.
199    let s: Vec<u64> = (0..5000).collect();
200    assert_eq!(run(&s), None);
201  }
202
203  #[test]
204  fn ignores_short_repeat() {
205    // A window repeated only 50 times (< REPEAT) must NOT fire.
206    let s: Vec<u64> = (0..50 * 4).map(|i| (i % 4) as u64).collect();
207    assert_eq!(run(&s), None);
208  }
209
210  #[test]
211  fn ignores_period_over_max_window() {
212    // Period 11 (> MAX_WINDOW) repeated many times must NOT fire.
213    let s: Vec<u64> = (0..11 * 300).map(|i| (i % 11) as u64).collect();
214    assert_eq!(run(&s), None);
215  }
216
217  #[test]
218  fn reset_clears_history() {
219    let mut g = CycleGuard::new();
220    for _ in 0..150 {
221      g.push(1);
222    }
223    g.reset();
224    // After reset, a fresh non-cyclic stream must not immediately fire.
225    let mut hit = None;
226    for i in 0..50 {
227      if let Some(p) = g.push(i) {
228        hit = Some(p);
229      }
230    }
231    assert_eq!(hit, None);
232  }
233}