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}