latexml_core/stomach.rs
1use std::{
2 borrow::Cow,
3 cell::{Cell, RefCell},
4 collections::VecDeque,
5 rc::Rc,
6 time::Instant,
7};
8
9use once_cell::sync::Lazy;
10
11use crate::{digested::DigestedData, pin};
12
13/// Cached snapshot of `LXML_TRACE_BOUND_MODE` env var. Like the
14/// `TRACE_GROUP_END` cache in gullet.rs, this avoids per-digest
15/// `getenv` calls — glibc's `getenv` is unsafe under high-volume
16/// concurrent reads from many test threads, manifesting as SIGSEGV
17/// in `__GI_getenv` when running `cargo test --release --tests`.
18/// Sample once at static-init; subsequent reads are an atomic load.
19static TRACE_BOUND_MODE: Lazy<bool> = Lazy::new(|| std::env::var("LXML_TRACE_BOUND_MODE").is_ok());
20
21// Conversion timeout: thread-local deadline. When set, digest loops check it.
22thread_local! {
23 static CONVERSION_DEADLINE: Cell<Option<Instant>> = const { Cell::new(None) };
24}
25
26/// Set a conversion timeout (seconds from now). 0 = no timeout.
27pub fn set_timeout(seconds: u64) {
28 if seconds > 0 {
29 CONVERSION_DEADLINE.with(|d| {
30 d.set(Some(
31 Instant::now() + std::time::Duration::from_secs(seconds),
32 ))
33 });
34 } else {
35 CONVERSION_DEADLINE.with(|d| d.set(None));
36 }
37}
38
39// Explicit override for the cooperative soft-RSS budget (bytes). When set it
40// takes precedence over the `LATEXML_RSS_CAP_BYTES` env; when `None` the env /
41// built-in default applies. The binary sets it from the single `--max-memory`
42// knob (via [`soft_cap_from_ceiling`]) so that ONE flag governs the whole
43// memory limit — this cooperative fuse rides a fixed fraction below the hard
44// Watchdog ceiling rather than being an independent number, and `--max-memory=0`
45// disables both (this fuse via a `0` cap → `None`, the Watchdog via
46// `max_rss_kb == 0`).
47thread_local! {
48 static RSS_CAP_OVERRIDE: Cell<Option<u64>> = const { Cell::new(None) };
49}
50
51/// Override the cooperative soft-RSS memory budget, in bytes. `Some(0)`
52/// disables the budget; `Some(n)` caps at `n` bytes; `None` restores the
53/// `LATEXML_RSS_CAP_BYTES` env / built-in default. Mirrors the `--max-memory`
54/// CLI convention where `0` means "no limit". See `resolve_rss_cap` (private)
55/// for the precedence order.
56pub fn set_memory_cap(bytes: Option<u64>) { RSS_CAP_OVERRIDE.with(|c| c.set(bytes)); }
57
58/// Derive the cooperative soft-RSS budget (bytes) from the hard `--max-memory`
59/// ceiling (MiB). The soft fuse sits at 75% of the ceiling, leaving ~25%
60/// headroom for the post-processing phase (libxml DOM + XSLT) that runs above
61/// digestion and which this cooperative guard cannot see. `0` in → `0` out
62/// (disabled), so `--max-memory=0` disables the whole memory limit. This keeps
63/// `--max-memory` the single knob: the hard Watchdog rides the ceiling, this
64/// fuse rides a fixed fraction below it — no independent second number.
65///
66/// The 75% factor reproduces the historical ~4.5 GB-under-6 GiB relationship at
67/// the 6144 MiB default (→ 4608 MiB) while scaling with any user-chosen ceiling
68/// (so a tight `--max-memory` also gets the graceful cooperative failure first,
69/// and a generous one raises both guards together).
70pub fn soft_cap_from_ceiling(max_memory_mib: u64) -> u64 {
71 (max_memory_mib.saturating_mul(3) / 4).saturating_mul(1024 * 1024)
72}
73
74/// Minimum boxes that must accumulate before the **soft-RSS** yield branch may
75/// fire (the box-budget branch is unaffected and still yields on its own).
76///
77/// The soft-RSS test is a LEVEL test with no hysteresis: `rss > watermark`. A
78/// document whose irreducible resident floor sits above the watermark therefore
79/// latches it on permanently and yields at every legal seam, accumulating
80/// almost nothing between yields. Measured on the 131 MB witness at
81/// `--max-memory 48000` (watermark 12 GB, pass-1 RSS 13.3-14.9 GB — above it
82/// for the entire run): **24,051,712 yields** producing **459,579 segments
83/// averaging 5.5 KB**, against a box budget of ~2.0 M boxes that would on its
84/// own have yielded ~12 times. The same binary on a witness that never crosses
85/// its watermark yields **8** times.
86///
87/// A floor restores the trigger's intent — "respond to memory pressure sooner
88/// than the box budget would" — without the degenerate per-seam case. 1024
89/// boxes is ~2.5 MB of box memory at the measured 2416 B/box, i.e. negligible
90/// against any watermark large enough to matter, so the pressure response stays
91/// effectively immediate while the yield count drops by ~3 orders of magnitude.
92///
93/// **The floor is waived under real pressure** — see `soft_yield_is_urgent`.
94/// The soft-RSS branch exists because "the box budget alone assumes a per-box
95/// footprint; on content whose real cost per box is higher (math-dense trees),
96/// RSS crosses the ceiling long before the box count does". A floor that
97/// applied unconditionally would blunt that valve for exactly the pathological
98/// input it was added for: 1024 boxes of ordinary content is ~2.5 MB, but 1024
99/// boxes of something pathological is unbounded, and the fuse could fire inside
100/// one un-yielded window. So above a higher RSS mark the floor is ignored.
101///
102/// Env-overridable for calibration only (`LATEXML_SOFT_YIELD_MIN_BOXES`),
103/// deliberately not a CLI flag — same reasoning as `LATEXML_SPILL_AT_MIB`.
104/// Override the soft-RSS floor directly, bypassing the env lookup. For tests
105/// that need to drive the degenerate (floor = 1) and fixed (floor = N) regimes
106/// in one process — see `115_soft_yield_floor`.
107pub fn set_soft_yield_min_boxes(boxes: usize) { SOFT_YIELD_MIN_BOXES.set(Some(boxes)); }
108
109/// Is memory pressure URGENT enough to waive the soft-yield floor?
110///
111/// Halfway from the spill watermark to the cooperative fuse. Below this the
112/// floor applies and yields stay coarse; above it every legal seam yields, as
113/// before this floor existed — so a document whose per-box footprint is wildly
114/// larger than the 2416 B the box budget assumes still gets the immediate
115/// response the soft-RSS branch was introduced to provide, instead of Fatal-ing
116/// inside a 1024-box window.
117fn soft_yield_is_urgent(rss_kb: u64) -> bool {
118 soft_yield_urgency(rss_kb, spill_watermark_bytes(), resolve_rss_cap())
119}
120
121/// The pure predicate behind [`soft_yield_is_urgent`], split out so the
122/// arithmetic is unit-testable (an integration test would need a real RSS cap
123/// near the test process's actual footprint — flaky by construction on the
124/// 16 GB CI runner). `watermark`/`fuse` in bytes, `rss_kb` in KiB.
125fn soft_yield_urgency(rss_kb: u64, watermark: Option<u64>, fuse: Option<u64>) -> bool {
126 match (watermark, fuse) {
127 (Some(watermark), Some(fuse)) if fuse > watermark => {
128 rss_kb.saturating_mul(1024) >= watermark + (fuse - watermark) / 2
129 },
130 // No fuse to divide (`--max-memory=0`): the floor always applies, matching
131 // the watermark fallback's own "no ceiling to race" reasoning.
132 _ => false,
133 }
134}
135
136/// Cached: this sits on the per-seam yield predicate, which the 131 MB witness
137/// evaluates tens of millions of times — an `std::env::var` there would be its
138/// own hotspot.
139pub fn soft_yield_min_boxes() -> usize {
140 const DEFAULT: usize = 1024;
141 if let Some(cached) = SOFT_YIELD_MIN_BOXES.get() {
142 return cached;
143 }
144 let resolved = std::env::var("LATEXML_SOFT_YIELD_MIN_BOXES")
145 .ok()
146 .and_then(|v| v.parse::<usize>().ok())
147 .unwrap_or(DEFAULT);
148 SOFT_YIELD_MIN_BOXES.set(Some(resolved));
149 resolved
150}
151
152/// The RAM watermark, in bytes, at which streaming pass 1 begins spilling
153/// closed subtrees to disk — the second derived quantity of the single
154/// `--max-memory` knob, and deliberately NOT a flag of its own (a watermark a
155/// user could raise above the fuse would Fatal before it ever spilled).
156///
157/// **A third of the cooperative fuse.** Not a half: the yields fire only at
158/// legal seams (a large alignment digests straight through any threshold), the
159/// RSS sample lags by up to 1024 guard ticks, and per-run bookkeeping creeps
160/// monotonically — measured on the 131 MB witness, a half-of-fuse watermark
161/// steadied pass 1 around 33 GB and the creep then walked it into the 37.7 GB
162/// fuse and died, where a third completed at 28.1 GB peak.
163///
164/// **With `--max-memory=0` there is no fuse to divide, and the watermark must
165/// still exist**: disabling the death ceiling says "do not kill me", not "let
166/// the machine run out". Fall back to an eighth of physical RAM, which lands
167/// the same 12 GiB on a 96 GB host that the validated 48 GiB ceiling derives.
168pub fn spill_watermark_bytes() -> Option<u64> {
169 // Calibration override (`LATEXML_SPILL_AT_MIB`), deliberately env-only and
170 // NOT a CLI flag: a user-settable watermark could be raised above the fuse,
171 // producing a run that Fatals before it ever spills. It exists so the
172 // fuse-fraction below can be re-derived by measurement rather than argued.
173 if let Some(mib) = std::env::var("LATEXML_SPILL_AT_MIB")
174 .ok()
175 .and_then(|v| v.parse::<u64>().ok())
176 .filter(|mib| *mib > 0)
177 {
178 return Some(mib.saturating_mul(1024 * 1024));
179 }
180 match resolve_rss_cap() {
181 Some(fuse) => Some(fuse / 3),
182 None => crate::watchdog::total_memory_bytes().map(|ram| ram / 8),
183 }
184}
185
186/// Apply the single `--max-memory` ceiling (MiB) to this thread's cooperative
187/// soft fuse, so the one knob means the same thing on every conversion path.
188///
189/// **`--max-memory` wins over `LATEXML_RSS_CAP_BYTES`, unconditionally.** The
190/// flag is the single knob; an env var must not silently override what the user
191/// typed. This deliberately overwrites the env, which is why the env keeps its
192/// meaning exactly where no flag exists to contradict it: embedders that never
193/// parse CLI arguments and so never reach this function — the library test
194/// harness (`util::test`, which pins 9 GB) and the `cortex_worker` fleet (which
195/// pins each child to its `--max-rss-mb`). Both are unaffected.
196///
197/// Callers must be EVERY conversion path: the plain one, the `--server` forked
198/// body child, and the in-process fallback. When only the first called it,
199/// `--server --max-memory=0` still ran against a live 4.5 GB fuse while the help
200/// text promised the limit was off.
201pub fn apply_memory_ceiling(max_memory_mib: u64) {
202 set_memory_cap(Some(soft_cap_from_ceiling(max_memory_mib)));
203}
204
205/// Resolve the effective soft-RSS budget: `None` = disabled (no ceiling),
206/// `Some(n)` = abort above `n` bytes. Precedence: the explicit
207/// [`set_memory_cap`] override, else `LATEXML_RSS_CAP_BYTES`, else the 4.5 GB
208/// default. A cap of `0` from EITHER source resolves to `None`, so
209/// `--max-memory=0` / `LATEXML_RSS_CAP_BYTES=0` mean "no limit" — not "abort
210/// immediately" (a literal `0` compared as `rss_bytes > 0` is always true).
211///
212/// Note the env is consulted only when nothing set the override — i.e. only for
213/// embedders that never call [`apply_memory_ceiling`]. Every path in the
214/// `latexml_oxide` binary calls it, so there `--max-memory` always wins.
215pub fn resolve_rss_cap() -> Option<u64> {
216 let cap = RSS_CAP_OVERRIDE.with(|c| c.get()).unwrap_or_else(|| {
217 std::env::var("LATEXML_RSS_CAP_BYTES")
218 .ok()
219 .and_then(|v| v.parse::<u64>().ok())
220 .unwrap_or(4_500_000_000)
221 });
222 (cap > 0).then_some(cap)
223}
224
225/// Check if conversion has timed out. Returns Err if deadline exceeded.
226///
227/// Also samples RSS via /proc/self/status every ~1024 calls and raises
228/// `Fatal:oom:memory_budget` if the process is approaching the worker
229/// memory cap. R35.A witnesses (plain-TeX `$$\displaylines{ … \picture
230/// … }$$`, 7 sandbox papers from 1999–2006) trigger a runaway where
231/// `set_alloc_error_hook` fires AFTER the process has already allocated
232/// ~5+ GB; that hook can't easily walk back the call site under
233/// `panic="unwind"`. Sampling RSS here at well below the OS ulimit
234/// gives us a clean diagnostic and a unwound stack via `fatal!`.
235pub fn check_timeout() -> Result<()> {
236 // Box-list cycle guard fired in `push_box_list` (which cannot unwind) —
237 // surface it here, the regular Result-returning digestion checkpoint.
238 // (The `stomach_mut!` macro is defined textually below; use STOMACH
239 // directly, with a try-borrow so a transient borrow just defers to the
240 // next tick.)
241 let pending = STOMACH
242 .try_borrow_mut()
243 .ok()
244 .and_then(|mut s| s.pending_cycle_fatal.take());
245 if let Some((category, msg)) = pending {
246 use crate::common::error::{Error as LatexmlError, ErrorTarget};
247 return Err(LatexmlError {
248 target: ErrorTarget::Stomach,
249 category,
250 message: msg,
251 });
252 }
253 CONVERSION_DEADLINE.with(|d| {
254 if let Some(deadline) = d.get()
255 && Instant::now() > deadline
256 {
257 fatal!(Timeout, Convert, "Conversion timed out!");
258 }
259 Ok(())
260 })?;
261 // Soft memory budget: every ~1024 calls, peek at our own RSS.
262 // 1024-call cadence keeps overhead negligible on the hot path
263 // (each call reads /proc/self/statm — a single syscall).
264 std::thread_local! {
265 static MEM_TICK: Cell<usize> = const { Cell::new(0) };
266 }
267 let tick = MEM_TICK.with(|t| {
268 let v = t.get().wrapping_add(1);
269 t.set(v);
270 v
271 });
272 if tick & 0x3FF == 0 {
273 // Single RSS-reading seam: `watchdog::process_rss_kb` (this was a second
274 // hand-rolled /proc parser; PR #249 review P3-12). When the watchdog
275 // grows macOS/Windows backends, this cap follows for free.
276 {
277 {
278 if let Some(rss_kb) = crate::watchdog::process_rss_kb() {
279 LAST_SAMPLED_RSS_KB.set(rss_kb);
280 let rss_bytes = rss_kb * 1024;
281 // R35.A safety cap: 4.5 GB RSS. Real documents in the wp5 /
282 // canvas3 corpus stay below 1 GB peak RSS, so this is well
283 // into pathological territory while leaving headroom for
284 // post-processing (XSLT, MathML chain).
285 // Override via LATEXML_RSS_CAP_BYTES env or `set_memory_cap`; a
286 // value of 0 (or `--max-memory=0`) disables it — see
287 // `resolve_rss_cap`.
288 //
289 // This is a *per-process* fuse, deliberately kept LOW. It must
290 // bound ONE conversion: in production the binary is
291 // single-conversion (one paper per process), and a massively
292 // parallel fleet runs many such processes at once — so the
293 // aggregate host RSS is `N_processes × this_cap`. Raising the
294 // default would let a busy fleet OOM the machine. The
295 // `cortex_worker` fleet OVERRIDES this env to its own per-child
296 // ceiling (`--child-mem-limit-mb`).
297 //
298 // The ONE multi-conversion-in-one-process case is the test
299 // harness: libtest spawns a thread per test, so at `cargo
300 // test`'s default parallelism on a many-core box (e.g. -j128)
301 // the process-wide RSS is the *sum* over all in-flight
302 // conversions and trips this single-conversion cap on
303 // otherwise-fine documents. That is handled NOT by raising this
304 // default but by the harness setting LATEXML_RSS_CAP_BYTES at
305 // test setup (latexml_oxide `util::test::init_test_rss_cap`).
306 // Any other single-process-many-conversion driver should do the
307 // same.
308 if let Some(cap) = resolve_rss_cap()
309 && rss_bytes > cap
310 {
311 // R35.A debug: when LATEXML_DEBUG_MEMBUDGET=1 is set, dump
312 // a stack backtrace before exiting so we can identify the
313 // expansion loop responsible. Backtrace allocation is
314 // fine here — we haven't hit the OS ulimit yet (we're
315 // 1.5 GB below it by default).
316 if std::env::var_os("LATEXML_DEBUG_MEMBUDGET").is_some() {
317 eprintln!(
318 "[membudget] RSS {} MB > cap {} MB — dumping backtrace",
319 rss_bytes / 1_000_000,
320 cap / 1_000_000
321 );
322 // Permanent LATEXML_DEBUG_MEMBUDGET diagnostic: which
323 // accumulating list is growing? (MEMORY.md's OOM-diagnosis
324 // recipe depends on this dump — do not remove as "temp".)
325 if let Ok(st) = STOMACH.try_borrow() {
326 eprintln!(
327 "[membudget] box_list={} (~{} MB est) token_stack={} boxing={} localized_box_list_total={}",
328 st.box_list.len(),
329 estimate_box_list_bytes(&st.box_list) / 1_000_000,
330 st.token_stack.len(),
331 st.boxing.len(),
332 st.localized_box_list.iter().map(|v| v.len()).sum::<usize>(),
333 );
334 }
335 if let Ok(g) = gullet::GULLET.try_borrow() {
336 let pb = g.runtime.as_ref().map(|r| r.pushback.len()).unwrap_or(0);
337 eprintln!("[membudget] gullet pushback={pb} progress={}", g.progress);
338 }
339 let bt = std::backtrace::Backtrace::force_capture();
340 eprintln!("{bt}");
341 }
342 // The actionable half of the message is a KNOWN NEED, not an
343 // anomaly: a document's peak scales with macro expansion and math
344 // density (the 131 MB witness needs ~23 GB resident just to
345 // stream through core), so the honest advice is "raise the
346 // ceiling", plus the derived flag value so the user knows which
347 // number they are raising — the cap here is the 75% fuse, not
348 // the `--max-memory` figure they typed. The latch lets the
349 // binary's end-of-run report add the kernel-tracked peak
350 // (`watchdog::peak_memory_report`, emitted only when this fired).
351 crate::watchdog::note_memory_fatal();
352 fatal!(
353 Timeout,
354 MemoryBudget,
355 format!(
356 "Memory budget exceeded: RSS {} MB > cap {} MB (the cooperative fuse at 75% of \
357 --max-memory={}). This document needs a larger ceiling: rerun with a higher \
358 --max-memory on a machine with enough free RAM.",
359 rss_bytes / 1_000_000,
360 cap / 1_000_000,
361 (cap * 4).div_ceil(3 * 1024 * 1024),
362 )
363 );
364 }
365 }
366 }
367 }
368 }
369 Ok(())
370}
371
372use crate::{
373 BoxOps, Digested, TexMode,
374 comment::Comment,
375 common::{arena, arena::SymHashMap as HashMap, error::*, font, font::Font},
376 definition::{
377 Definition, constructor::Constructor, expandable::Expandable, register::RegisterValue,
378 },
379 gullet,
380 list::List,
381 mouth::{Mouth, MouthOptions},
382 state::*,
383 tbox::*,
384 token::{Catcode, Token},
385 tokens::Tokens,
386};
387
388static MAXSTACK: usize = 200;
389
390/// The Stomach is responsible for digesting tokens into boxes, lists, etc.
391#[derive(Default)]
392pub struct Stomach {
393 /// currently invoked tokens
394 pub token_stack: Vec<Token>,
395 /// tracks the tokens of boxing groups(?)
396 pub boxing: Vec<Token>,
397 /// localized box lists for stacked digestion calls
398 localized_box_list: Vec<Vec<Digested>>,
399 /// collects the intermediate boxes resulting from a `digest` call.
400 pub box_list: Vec<Digested>,
401 /// Windowed cycle detector over the accumulated digest list — the stomach
402 /// analog of the gullet's expansion-stream guard. Catches box-accumulation
403 /// runaways (a recursive macro/path that digests the same boxes forever, e.g.
404 /// pgf's `to [loop]` arc on a pathological picture, 2201.09268) that bypass
405 /// the gullet read loop entirely. Engaged only once `box_list` has grown far
406 /// past any flushed-document size. See [`crate::cycle_guard`].
407 cycle_guard: crate::cycle_guard::CycleGuard,
408 /// Set by the guarded box appenders when a stomach guard fires; consumed
409 /// and turned into a `Fatal` by `check_timeout` (the next
410 /// `Result`-returning checkpoint — `push_box_list` itself returns `()` and
411 /// cannot unwind). Carries the structured category so size/byte/depth
412 /// breaches report as `Stomach:MemoryBudget` while only genuine detected
413 /// cycles report as `Stomach:Recursion` — canvas/telemetry clustering on
414 /// `target:category` can tell them apart (PR #249 review P2-8).
415 pending_cycle_fatal: Option<(ErrorCategory, String)>,
416}
417
418#[thread_local]
419pub static STOMACH: Lazy<RefCell<Stomach>> = Lazy::new(|| RefCell::new(Stomach::default()));
420
421// ---- Fragment yield (streaming mode) -------------------------------------
422//
423// Deliberately OUTSIDE the `Stomach` struct: these are driver-level
424// configuration, like the RSS cap — `initialize_stomach` resets the digestion
425// state between documents but must not forget that the driver asked for
426// fragmented digestion.
427
428/// When `Some(n)`, `digest_next_body` may YIELD — return the boxes accumulated
429/// so far, gullet and State untouched — once the current level holds `n` boxes
430/// AND the position is a legal fragment seam. `None` (default) = eager.
431#[thread_local]
432static FRAGMENT_YIELD_BUDGET: Cell<Option<usize>> = Cell::new(None);
433/// Set on yield; read-and-cleared by the streaming driver to distinguish
434/// "more to come" from EOF.
435#[thread_local]
436static FRAGMENT_YIELDED: Cell<bool> = Cell::new(false);
437/// Total yields this conversion (telemetry + test probe).
438#[thread_local]
439static FRAGMENT_YIELD_COUNT: Cell<usize> = Cell::new(0);
440/// Soft RSS threshold (KiB) above which the yield predicate fires regardless
441/// of the box count. The box budget alone assumes a per-box footprint; on
442/// content whose real cost per box is higher (math-dense trees, debug
443/// builds), RSS crosses the ceiling long before the box count does —
444/// measured on the 19.8 MB witness at cap 24 GB, where the fuse fired
445/// during early fragments while the 2.6M-box budget sat untouched.
446#[thread_local]
447static FRAGMENT_YIELD_RSS_SOFT_KB: Cell<Option<u64>> = Cell::new(None);
448/// Resolved-once floor for the soft-RSS branch — see [`soft_yield_min_boxes`].
449#[thread_local]
450static SOFT_YIELD_MIN_BOXES: Cell<Option<usize>> = Cell::new(None);
451/// The most recent RSS sample from `check_timeout`'s 1024-call cadence, so
452/// the yield predicate reads a cell instead of `/proc`.
453#[thread_local]
454static LAST_SAMPLED_RSS_KB: Cell<u64> = Cell::new(0);
455
456/// Set (or clear) the soft-RSS yield threshold, in KiB.
457pub fn set_fragment_yield_rss_soft_kb(kb: Option<u64>) { FRAGMENT_YIELD_RSS_SOFT_KB.set(kb); }
458
459/// The most recent sampled RSS in KiB (0 until the first sample).
460pub fn last_sampled_rss_kb() -> u64 { LAST_SAMPLED_RSS_KB.get() }
461
462/// Ask digestion to yield at legal fragment seams once `budget` boxes have
463/// accumulated at the current level (`None` restores eager digestion). Set by
464/// the streaming pass-1 driver; the budget is a box COUNT — the driver derives
465/// it from the byte ceiling via the measured per-box footprint, the same basis
466/// as the box-list guards.
467pub fn set_fragment_yield_budget(budget: Option<usize>) {
468 let enabling = budget.is_some();
469 FRAGMENT_YIELD_BUDGET.set(budget);
470 FRAGMENT_YIELDED.set(false);
471 // The count is a per-conversion probe: reset when a driver ENABLES
472 // yielding, and preserved when it disables at end-of-digestion (the driver
473 // clears the budget before the tail phases, and telemetry/tests read the
474 // count after the conversion returns).
475 if enabling {
476 FRAGMENT_YIELD_COUNT.set(0);
477 }
478}
479
480/// Did the last `digest_next_body` return because of the yield budget (rather
481/// than EOF / terminal / depth-drop)? Read-and-clear.
482pub fn take_fragment_yielded() -> bool { FRAGMENT_YIELDED.replace(false) }
483
484/// How many times digestion has yielded since the budget was last set.
485pub fn fragment_yield_count() -> usize { FRAGMENT_YIELD_COUNT.get() }
486
487macro_rules! stomach {
488 () => {
489 (*STOMACH).borrow()
490 };
491}
492macro_rules! stomach_mut {
493 () => {
494 (*STOMACH).borrow_mut()
495 };
496}
497
498/// Initialize various stomach parameters, preload, etc.
499pub fn initialize_stomach() {
500 let mut stomach = stomach_mut!();
501 stomach.boxing = Vec::new();
502 stomach.token_stack = Vec::new();
503 stomach.box_list = Vec::new();
504 stomach.localized_box_list = Vec::new();
505 stomach.cycle_guard.reset();
506 stomach.pending_cycle_fatal = None;
507
508 assign_value("BOUND_MODE", "vertical", Some(Scope::Global));
509 assign_value("MODE", "vertical", Some(Scope::Global));
510 assign_value("IN_MATH", false, Some(Scope::Global));
511 assign_value("PRESERVE_NEWLINES", 1, Some(Scope::Global));
512 assign_value(
513 "afterGroup",
514 Stored::VecDequeStored(VecDeque::new()),
515 Some(Scope::Global),
516 );
517 assign_value("afterAssignment", Stored::None, Some(Scope::Global)); // undef ???
518 assign_value_sym(
519 crate::pin!("groupInitiator"),
520 "Initialization",
521 Some(Scope::Global),
522 );
523 // Setup default fonts.
524 assign_value("font", Font::text_default(), Some(Scope::Global));
525 assign_value("mathfont", Font::math_default(), Some(Scope::Global));
526}
527
528/// steal the previously digested boxes from the current level.
529pub fn regurgitate() -> Vec<Digested> { std::mem::take(&mut stomach_mut!().box_list) }
530
531//**********************************************************************
532// Maintaining state
533//**********************************************************************
534// state changes that the Stomach needs to moderate and know about (?)
535
536//======================================================================
537// Dealing with TeX's bindings & grouping.
538// Note that lookups happen more often than bgroup/egroup (which open/close frames).
539
540/// Adds a new stack frame for a TeX group.
541pub fn push_stack_frame(nobox: bool) {
542 let current_token = get_current_token().unwrap_or_else(|| T_CS!("\\relax"));
543 push_frame();
544 assign_value(
545 "beforeAfterGroup",
546 Stored::VecDequeStored(VecDeque::new()),
547 Some(Scope::Local),
548 ); // ALWAYS bind this!
549 assign_value(
550 "afterGroup",
551 Stored::VecDequeStored(VecDeque::new()),
552 Some(Scope::Local),
553 ); // ALWAYS bind this!
554 assign_value("afterAssignment", Stored::None, Some(Scope::Local)); // ALWAYS bind this!
555 assign_value_sym(crate::pin!("groupNonBoxing"), nobox, Some(Scope::Local)); // ALWAYS bind this!
556 assign_value_sym(
557 crate::pin!("groupInitiator"),
558 current_token,
559 Some(Scope::Local),
560 );
561 assign_value_sym(
562 crate::pin!("groupInitiatorLocator"),
563 gullet::get_locator(),
564 Some(Scope::Local),
565 );
566 if !nobox {
567 // For begingroup/endgroup
568 stomach_mut!().boxing.push(current_token)
569 }
570}
571/// Execute tokens stored on beforeAfterGroup (if any); done before popping a stack frame.
572/// Perl: sub executeBeforeAfterGroup (Stomach.pm lines 286-295)
573pub fn execute_before_after_group() -> Result<()> {
574 if let Some(Stored::VecDequeStored(beforeafter)) = remove_value("beforeAfterGroup")
575 && !beforeafter.is_empty()
576 {
577 let mut result = Vec::with_capacity(beforeafter.len());
578 for beforeafter_frame in beforeafter.into_iter() {
579 match beforeafter_frame {
580 Stored::Tokens(frametoks) => result.push(frametoks.be_digested()?),
581 Stored::Token(frametok) => result.push(frametok.be_digested()?),
582 _ => {
583 // Unexpected value type in beforeAfterGroup — skip silently
584 // rather than panic (could occur with non-standard TeX constructs)
585 },
586 }
587 }
588 // Perl Stomach.pm:182-183 — every digested item must be Box-like
589 // (TBox / List / Whatsit / Alignment); anything else is a binding
590 // bug. Emit Error per offender; the Box-like items still flow
591 // through to box_list so partial output is preserved.
592 // Perl additionally calls `@result = (makeMisdefinedError(@result))`
593 // collapsing everything to a single error sentinel — we keep
594 // the partial-output behaviour (Rust-side divergence; surfacing
595 // *the* offending item via Error! is what the harness needs to
596 // report, while the rest of the box stream is still useful).
597 //
598 // Implementation note: walk the result list with an index loop
599 // rather than `retain(|d| {…})`. The Error! macro can `return
600 // Err(…)` on the max-errors / runaway-loop guards, and a closure
601 // returning `bool` can't propagate that out — only an explicit
602 // for-loop in the surrounding `Result<()>` body can.
603 let mut filtered = Vec::with_capacity(result.len());
604 for d in result {
605 let is_box = matches!(
606 d.data(),
607 DigestedData::TBox(_)
608 | DigestedData::List(_)
609 | DigestedData::Whatsit(_)
610 | DigestedData::Alignment(_)
611 );
612 if is_box {
613 filtered.push(d);
614 } else {
615 let kind_label = match d.data() {
616 DigestedData::Postponed(_) => "Postponed",
617 DigestedData::KeyVals(_) => "KeyVals",
618 DigestedData::RegisterValue(_) => "RegisterValue",
619 DigestedData::Comment(_) => "Comment",
620 _ => "non-Box",
621 };
622 Error!(
623 "misdefined",
624 "<beforeAfterGroup>",
625 format!(
626 "Expected a Box|List|Whatsit, but got '{}' — dropping",
627 kind_label
628 )
629 );
630 }
631 }
632 // Route the group's digested boxes through the GUARDED appender (not a
633 // raw `box_list.extend`) so the stomach's cycle / count / byte-budget
634 // runaway guards see them. This is the path a grouped drawing loop
635 // (`\@whiledim{…\hbox{…}…}`) flushes through, so bypassing it let a
636 // heavy-box runaway accumulate unguarded until only the Linux RSS cap
637 // caught it. Witness math0102053.
638 extend_box_list(filtered);
639 }
640 Ok(())
641}
642
643/// Removes the last/current stack frame, ending a TeX group
644pub fn pop_stack_frame(nobox: bool) -> Result<()> {
645 let after = remove_value("afterGroup");
646 execute_before_after_group()?;
647 pop_frame()?;
648 if !nobox {
649 {
650 stomach_mut!().boxing.pop(); // For begingroup/endgroup
651 }
652 }
653 if let Some(Stored::VecDequeStored(after_entries)) = after {
654 for entry in after_entries.into_iter().rev() {
655 match entry {
656 Stored::Tokens(t) => gullet::unread(t),
657 Stored::Token(t) => gullet::unread_one(t),
658 other => panic!(r"\aftergroup should be used with tokens, got instead: {other:?}"),
659 };
660 }
661 }
662 Ok(())
663}
664
665/// explain the current frame
666pub fn current_frame_message() -> String {
667 let target = if is_value_bound("MODE", Some(0)) {
668 // SET mode in CURRENT frame ?
669 Cow::Owned(s!(
670 "mode-switch to {}",
671 lookup_string_from_sym(crate::pin!("MODE"))
672 ))
673 } else if lookup_bool_sym(crate::pin!("groupNonBoxing")) {
674 // Current frame is a non-boxing group?
675 Cow::Borrowed("non-boxing group")
676 } else {
677 Cow::Borrowed("boxing group")
678 };
679
680 let initiator = if let Some(t) = lookup_token_sym(crate::pin!("groupInitiator")) {
681 t.stringify()
682 } else {
683 String::new()
684 };
685 // Render the initiator's source locator as a readable "file; line N"
686 // (the raw Stored Debug is redacted to `Stored::Locator[[...]]`, which is
687 // useless for diagnosing where an unbalanced group opened).
688 let locator = match lookup_value("groupInitiatorLocator") {
689 Some(Stored::Locator(loc)) => s!("at {}", loc),
690 Some(other) => other.to_string(),
691 None => String::new(),
692 };
693 s!(
694 "current frame is {} due to {} {}",
695 target,
696 initiator,
697 locator
698 )
699}
700
701//======================================================================
702// Grouping pushes a new stack frame for binding definitions, etc.
703//======================================================================
704
705/// Begin a new level of binding by pushing a new stack frame,
706/// and a new level of boxing the digested output.
707pub fn bgroup() {
708 push_stack_frame(false);
709 // Perl's bgroup does NOT touch $ALIGN_STATE — it's tracked only at the scan level
710 // (in read_token/read_x_token). The scan-level tracking in gullet.rs is sufficient.
711}
712/// End a level of binding by popping the last stack frame,
713/// undoing whatever bindings appeared there, and also
714/// decrementing the level of boxing.
715pub fn egroup() -> Result<()> {
716 if is_value_bound("BOUND_MODE", Some(0)) {
717 // Diagnostic for cluster investigation (project_explsyntax_midload.md).
718 if *TRACE_BOUND_MODE {
719 let mode = lookup_string_from_sym(crate::pin!("MODE"));
720 let bound = lookup_string_from_sym(crate::pin!("BOUND_MODE"));
721 let cur_tok = get_current_token()
722 .map(|t| t.to_string())
723 .unwrap_or_default();
724 eprintln!(
725 "[trace] egroup ERROR: cur_tok={cur_tok} BOUND_MODE={bound} MODE={mode}\n{}",
726 std::backtrace::Backtrace::force_capture()
727 );
728 }
729 // Last stack frame was a mode switch!?!?!
730 // Don't pop if there's an error; maybe we'll recover?
731 // Perl Stomach.pm:347-349 passes currentFrameMessage as a SEPARATE
732 // Error detail (its own line), not merged into the primary message.
733 Error!(
734 "unexpected",
735 get_current_token().unwrap_or_else(|| T_CS!("\\?")),
736 s!(
737 "Attempt to close a group that switched to mode {}",
738 lookup_string_from_sym(crate::pin!("MODE"))
739 ),
740 current_frame_message()
741 );
742 } else if lookup_bool_sym(crate::pin!("groupNonBoxing")) {
743 // or group was opened with \begingroup
744 Error!(
745 "unexpected",
746 get_current_token().unwrap_or_else(|| T_CS!("\\?")),
747 "Attempt to close boxing group",
748 current_frame_message()
749 );
750 } else {
751 // Don't pop if there's an error; maybe we'll recover?
752 pop_stack_frame(false)?;
753 }
754 // Perl's egroup does NOT touch $ALIGN_STATE — tracked at scan level only.
755 Ok(())
756}
757/// Begin a new level of binding by pushing a new stack frame.
758pub fn begingroup() {
759 if *TRACE_BOUND_MODE {
760 let depth = get_frame_depth();
761 let loc = gullet::get_locator();
762 eprintln!("[trace] begingroup pre-depth={depth} at {}", loc);
763 }
764 push_stack_frame(true);
765}
766/// End a level of binding by popping the last stack frame,
767/// undoing whatever bindings appeared there.
768pub fn endgroup() -> Result<()> {
769 if *TRACE_BOUND_MODE {
770 let depth = get_frame_depth();
771 let bound = is_value_bound("BOUND_MODE", Some(0));
772 let loc = gullet::get_locator();
773 let tok = get_current_token().unwrap_or_else(|| T_CS!("\\?"));
774 if depth == 0 {
775 eprintln!(
776 "[trace] endgroup at locked frame: tok={} at {}\n{}",
777 tok,
778 loc,
779 std::backtrace::Backtrace::force_capture()
780 );
781 } else {
782 eprintln!(
783 "[trace] endgroup pre-depth={depth} bound_top={bound} tok={} at {}",
784 tok, loc
785 );
786 }
787 }
788 // BAND-AID (commit 3088dbd17 — under root-cause investigation, see
789 // `project_explsyntax_midload.md`): during raw .sty/.tex load
790 // (INTERPRETING_DEFINITIONS=true), suppress strict BOUND_MODE check.
791 // Empirically Perl emits zero errors on the same inputs while strict
792 // checks fire 19 times in our Rust during expl3-code.tex raw load.
793 // Latent bugs found 2026-04-25 when removing this guard:
794 // - `#` (catcode PARAM) escapes to stomach
795 // - `\q_stop` recursion
796 // - residual `\group_end:` mode-switch error (not caught by strict end_mode_opt either —
797 // separate divergence point)
798 // - `\xparse-2018-04-12.sty-h@@k` undefined
799 // Each of those needs its own root-cause investigation.
800 let interpreting = lookup_bool_sym(crate::pin!("INTERPRETING_DEFINITIONS"));
801 if interpreting {
802 // Diagnostic: capture band-aid suppression occurrences for analysis.
803 if *TRACE_BOUND_MODE && is_value_bound("BOUND_MODE", Some(0)) {
804 let mode = lookup_string_from_sym(crate::pin!("MODE"));
805 let bound = lookup_string_from_sym(crate::pin!("BOUND_MODE"));
806 let frame_keys = dump_top_frame_keys();
807 eprintln!(
808 "[trace] endgroup SUPPRESSED-ERR: BOUND_MODE={bound} MODE={mode} frame0_keys={frame_keys:?}",
809 );
810 }
811 pop_stack_frame(true)?;
812 } else if is_value_bound("BOUND_MODE", Some(0)) {
813 // Diagnostic: dump BOUND_MODE binding context for cluster investigation.
814 if *TRACE_BOUND_MODE {
815 let mode = lookup_string_from_sym(crate::pin!("MODE"));
816 let bound = lookup_string_from_sym(crate::pin!("BOUND_MODE"));
817 eprintln!(
818 "[trace] endgroup ERROR: BOUND_MODE={bound} MODE={mode}\n{}",
819 std::backtrace::Backtrace::force_capture()
820 );
821 }
822 // Last stack frame was a mode switch!?!?!
823 // Don't pop if there's an error; maybe we'll recover?
824 // Perl Stomach.pm:367-369: currentFrameMessage is a SEPARATE detail.
825 Error!(
826 "unexpected",
827 get_current_token()
828 .map(|t| t.to_string())
829 .unwrap_or_else(|| String::from("\\?")),
830 s!(
831 "Attempt to close a group that switched to mode {}",
832 lookup_string_from_sym(crate::pin!("MODE"))
833 ),
834 current_frame_message()
835 );
836 } else if !lookup_bool_sym(crate::pin!("groupNonBoxing")) {
837 // or group was opened with \bgroup
838 Error!(
839 "unexpected",
840 get_current_token()
841 .map(|t| t.to_string())
842 .unwrap_or_else(|| String::from("\\?")),
843 "Attempt to close non-boxing group",
844 current_frame_message()
845 );
846 } else {
847 pop_stack_frame(true)?;
848 }
849 Ok(())
850}
851
852//======================================================================
853// Mode (minimal so far; math vs text)
854// Could (should?) be taken up by Stomach by building horizontal, vertical or math lists ?
855
856/// Sets the mode without doing any grouping (NOR does it stack the modes!!)
857///
858/// Useful for environments, where the group has already been established.
859/// (presumably, in the long run, modes & groups should be much less coupled)
860pub fn set_mode(mode: &str) -> Result<()> {
861 let prevmode = lookup_string_from_sym(crate::pin!("MODE"));
862 let ismath = mode.ends_with("math");
863 // Perl: beginMode maps to internal mode names, but set_mode stores as-is
864 // We also set BOUND_MODE so end_mode can find it
865 let bound_mode = bindable_mode(mode).unwrap_or(mode);
866 // Diagnostic
867 if *TRACE_BOUND_MODE {
868 eprintln!(
869 "[trace] set_mode mode={mode} bound_mode={bound_mode}\n{}",
870 std::backtrace::Backtrace::force_capture()
871 );
872 }
873 assign_value("BOUND_MODE", arena::pin(bound_mode), Some(Scope::Local));
874 assign_value("MODE", arena::pin(bound_mode), Some(Scope::Local));
875 assign_value("IN_MATH", ismath, Some(Scope::Local));
876 if mode == prevmode {
877 } else if ismath {
878 let curfont = lookup_font().unwrap();
879 // When entering math mode, we set the font to the default math font,
880 // and save the text font for any embedded text.
881 assign_value("savedfont", curfont.clone(), Some(Scope::Local));
882 // see get_script_level()
883 assign_value("script_base_level", stomach!().boxing.len(), None);
884 let isdisplay = mode.starts_with("display");
885 assign_value("IN_MATH_DISPLAY", isdisplay, Some(Scope::Local));
886 let new_font = Rc::new(lookup_mathfont().unwrap().merge(Font {
887 color: curfont.color,
888 bg: curfont.bg,
889 size: curfont.size,
890 mathstyle: if isdisplay {
891 Some("display".into())
892 } else {
893 Some("text".into())
894 },
895 ..Font::default()
896 }));
897 assign_value(
898 "initial_math_font",
899 Stored::Font(new_font.clone()),
900 Some(Scope::Local),
901 );
902 assign_font(new_font, Some(Scope::Local));
903 // Perl Stomach.pm:505 — `$STATE->assignValue(fontfamily => -1, 'local');`
904 // Resets `\fam` (whose getter reads `fontfamily`) on math entry so that
905 // text-mode `\rm` (which sets `fontfamily=0`) doesn't leak into math.
906 assign_value("fontfamily", -1_i64, Some(Scope::Local));
907 } else {
908 let curfont = lookup_font().unwrap();
909 // When entering text mode, we should set the font to the text font in use before the math
910 // but inherit color and size
911 let saved_opt = lookup_value("savedfont");
912 if let Some(Stored::Font(saved_font)) = saved_opt {
913 assign_font(
914 Rc::new(saved_font.merge(Font {
915 color: curfont.color,
916 bg: curfont.bg,
917 size: curfont.size,
918 ..Font::default()
919 })),
920 Some(Scope::Local),
921 );
922 }
923 }
924 Ok(())
925}
926
927/// Map user-facing mode names to internal bound mode names.
928/// Perl: our %bindable_mode = (text => 'restricted_horizontal', ...)
929fn bindable_mode(umode: &str) -> Option<&'static str> {
930 match umode {
931 "text" | "restricted_horizontal" => Some("restricted_horizontal"),
932 "vertical" | "internal_vertical" => Some("internal_vertical"),
933 // Perl #2798: inline_internal_vertical binds to internal_vertical but does
934 // NOT leaveHorizontal (inline blocks: \vbox/\vtop/\parbox/minipage/picture/
935 // footnotes) — see begin_mode_opt.
936 "inline_internal_vertical" => Some("internal_vertical"),
937 "math" | "inline_math" => Some("math"),
938 "display_math" => Some("display_math"),
939 _ => None,
940 }
941}
942
943/// Begin processing in `mode`; one of "text", "display-math" or "inline-math".
944/// This also begins a new level of grouping and switches to a font
945/// appropriate for the mode.
946/// If `noframe` is true, skip pushing a stack frame (e.g. for \begin{document}).
947/// Perl: sub beginMode (Stomach.pm lines 474-517)
948pub fn begin_mode(mode: &str) -> Result<()> { begin_mode_opt(mode, false) }
949/// Like `begin_mode`, but with an explicit `noframe` option.
950/// When `noframe` is true, no stack frame is pushed (the caller already did bgroup).
951pub fn begin_mode_opt(mode: &str, noframe: bool) -> Result<()> {
952 if let Some(bound_mode) = bindable_mode(mode) {
953 // Perl #2798: beginning a vertical or display-math mode ends the current
954 // paragraph first (leaveHorizontal), UNLESS the *user* mode is an inline
955 // form (inline_internal_vertical / inline_math) — inline blocks must not
956 // break the surrounding paragraph. `leave_horizontal` is itself a no-op
957 // unless mid-paragraph (MODE==horizontal), so this only fires when a
958 // vertical/display construct is encountered inside a paragraph.
959 let is_display = bound_mode.starts_with("display");
960 let is_vertical = is_display || bound_mode.contains("vertical");
961 let is_inline = mode.contains("inline");
962 if is_vertical && !is_inline {
963 leave_horizontal()?;
964 }
965 if !noframe {
966 push_stack_frame(false); // Effectively bgroup
967 }
968 // Diagnostic: tracking who binds BOUND_MODE during raw .sty load
969 // (gated by LXML_TRACE_BOUND_MODE env var to avoid noise in normal runs).
970 // See project_explsyntax_midload.md memory for the active investigation.
971 if *TRACE_BOUND_MODE {
972 eprintln!(
973 "[trace] begin_mode_opt mode={mode} noframe={noframe} bound_mode={bound_mode}\n{}",
974 std::backtrace::Backtrace::force_capture()
975 );
976 }
977 // Perl: $STATE->assignValue(BOUND_MODE => $mode, 'local');
978 assign_value("BOUND_MODE", arena::pin(bound_mode), Some(Scope::Local));
979 set_mode(bound_mode)?;
980 // Perl Stomach.pm lines 504-507: inject \everymath or \everydisplay tokens
981 // Display math gets \everydisplay, inline math gets \everymath (not both).
982 if bound_mode.contains("math") {
983 let is_display = bound_mode == "display_math";
984 let reg_name = if is_display {
985 "\\everydisplay"
986 } else {
987 "\\everymath"
988 };
989 if let Some(RegisterValue::Tokens(toks)) = lookup_register(reg_name, Vec::new())? {
990 let toks = toks.unlist();
991 if !toks.is_empty() {
992 gullet::unread(Tokens::new(toks));
993 }
994 }
995 }
996 Ok(())
997 } else {
998 Warn!("unexpected", mode, s!("Cannot enter {mode} mode"));
999 Ok(())
1000 }
1001}
1002/// End processing in `mode`; an error is signalled if `stomach` is not
1003/// currently in `mode`. This also ends a level of grouping.
1004/// Perl: sub endMode (Stomach.pm lines 522-541)
1005pub fn end_mode(mode: &str) -> Result<()> { end_mode_opt(mode, false) }
1006/// Like `end_mode`, but with an explicit `noframe` option.
1007/// When `noframe` is true, executeBeforeAfterGroup is run but the stack frame is not popped.
1008pub fn end_mode_opt(mode: &str, noframe: bool) -> Result<()> {
1009 if let Some(bound_mode) = bindable_mode(mode) {
1010 // Perl Stomach.pm L527-528:
1011 // if ((!$STATE->isValueBound('BOUND_MODE', 0)) # Last stack frame was NOT a mode switch
1012 // || ($STATE->lookupValue('BOUND_MODE') ne $mode)) # OR switch to a different mode
1013 // Strict Perl-faithful: error if BOUND_MODE is not bound on the top
1014 // frame, OR if its value doesn't match the mode being closed. (Earlier
1015 // versions of this file used a lax value-only check as a workaround
1016 // for the 1112.6246 halign frame-balance issue, since fixed in
1017 // d162803d2.)
1018 let current_bound = lookup_string_from_sym(crate::pin!("BOUND_MODE"));
1019 let bound_on_top = is_value_bound("BOUND_MODE", Some(0));
1020 let make_mode_error = || {
1021 // Perl Stomach.pm:550: Error('unexpected', $CURRENT_TOKEN, $self,
1022 // "Attempt to end mode $mode", currentFrameMessage($self)) — where
1023 // $mode is the BOUND (bindable) mode, and currentFrameMessage is a
1024 // SEPARATE detail (added at the call sites below). The earlier Rust
1025 // wording ("...mode `X` in `Y`") was not Perl-faithful.
1026 let message = s!("Attempt to end mode {}", bound_mode);
1027 let category = match get_current_token() {
1028 Some(ref token) => token.to_string(),
1029 None => String::from("mode"),
1030 };
1031 (category, message)
1032 };
1033 if !bound_on_top || current_bound != bound_mode {
1034 // Last stack frame was NOT a mode switch, or was a switch to a different mode.
1035 // Perl: Don't pop if there's an error; maybe we'll recover?
1036 if *TRACE_BOUND_MODE {
1037 let cur_tok = get_current_token()
1038 .map(|t| t.to_string())
1039 .unwrap_or_default();
1040 eprintln!(
1041 "[trace] end_mode ERROR: mode={mode} cur_tok={cur_tok} bound_on_top={bound_on_top} current_bound={current_bound} depth={}\n {}\n{}",
1042 get_frame_depth(),
1043 current_frame_message(),
1044 std::backtrace::Backtrace::force_capture()
1045 );
1046 }
1047 let (category, message) = make_mode_error();
1048 Error!("unexpected", category, &message, current_frame_message());
1049 } else {
1050 // Perl: leaveHorizontal_internal($self) if $mode =~ /vertical$/;
1051 if bound_mode.ends_with("vertical") {
1052 leave_horizontal_internal();
1053 }
1054 if noframe {
1055 // No pop, but at least do beforeAfterGroup
1056 execute_before_after_group()?;
1057 } else if current_frame_locked() {
1058 // After `leave_horizontal_internal` the only frame left is the LOCKED
1059 // bottom frame — there is no mode-switch frame to pop, so
1060 // `pop_stack_frame` → `pop_frame` would FATAL ("pop last locked stack
1061 // frame"). This happens on a STRAY mode-ender with no matching begin:
1062 // e.g. `$Proof.$ … \quad \endproof` (no `\begin{proof}`) leaves
1063 // BOUND_MODE bound on the bottom frame, so the value-guard above passes
1064 // but the pop is illegal. Emit a recoverable Error and DON'T pop (Perl's
1065 // "maybe we'll recover" intent — Perl completes such papers; Rust used
1066 // to crash). Note the check is HERE (after `leave_horizontal_internal`,
1067 // which can repack a horizontal frame that legitimately becomes the
1068 // pop target — e.g. a normal document's `\end{document}`), not at the
1069 // value-guard above. Witness 1703.05010 (svjour3 + bare `\endproof`).
1070 let (category, message) = make_mode_error();
1071 Error!("unexpected", category, &message);
1072 } else {
1073 pop_stack_frame(false)?;
1074 }
1075 }
1076 } else {
1077 Warn!("unexpected", mode, s!("Cannot end {mode} mode"));
1078 }
1079 Ok(())
1080}
1081
1082/// Switch to horizontal mode without stacking the mode.
1083/// Can only switch from vertical|internal_vertical to horizontal.
1084/// Perl: sub enterHorizontal
1085pub fn enter_horizontal() {
1086 let mode = lookup_string_from_sym(crate::pin!("MODE"));
1087 if mode.ends_with("vertical") {
1088 assign_value_inplace_sym(crate::pin!("MODE"), crate::pin!("horizontal"));
1089 } else if !mode.ends_with("horizontal") && !mode.ends_with("math") {
1090 // Perl L420-422: warn on unexpected mode
1091 Warn!(
1092 "unexpected",
1093 "enterHorizontal",
1094 s!("Unexpected mode '{}' for enterHorizontal", mode)
1095 );
1096 }
1097 // else: already horizontal or math — fine
1098}
1099
1100/// Resume vertical mode by executing \par, in TeX-like fashion.
1101/// Perl: sub leaveHorizontal
1102pub fn leave_horizontal() -> Result<()> {
1103 let mode = lookup_string_from_sym(crate::pin!("MODE"));
1104 let bound = lookup_string_from_sym(crate::pin!("BOUND_MODE"));
1105 if mode == "horizontal" && bound.ends_with("vertical") {
1106 // This needs to be an invisible, and slightly gentler, \par
1107 assign_value("INTERNAL_PAR", true, Some(Scope::Local));
1108 let par_result = invoke_token(&T_CS!("\\par"))?;
1109 push_box_list_vec(par_result);
1110 assign_value("INTERNAL_PAR", false, Some(Scope::Local));
1111 }
1112 Ok(())
1113}
1114
1115/// Resume vertical mode internally: reset mode without firing \par.
1116/// Used within argument digestion, e.g. endMode for vertical modes.
1117/// Perl: sub leaveHorizontal_internal
1118pub fn leave_horizontal_internal() {
1119 let mode = lookup_string_from_sym(crate::pin!("MODE"));
1120 let bound = lookup_string_from_sym(crate::pin!("BOUND_MODE"));
1121 if mode == "horizontal" && bound.ends_with("vertical") {
1122 repack_horizontal();
1123 assign_value_inplace_sym(crate::pin!("MODE"), arena::pin(&bound));
1124 }
1125}
1126
1127/// Repack recently digested horizontal items into single horizontal List.
1128/// Note that TeX would have done paragraph line-breaking, resulting in essentially
1129/// a vertical list.
1130/// Perl: sub repackHorizontal (Stomach.pm lines 440-454)
1131pub fn repack_horizontal() {
1132 let mut stomach = stomach_mut!();
1133 let mut para: Vec<Digested> = Vec::new();
1134 let mut keep = false;
1135
1136 loop {
1137 let should_pop = if let Some(item) = stomach.box_list.last() {
1138 // Perf: compare as &str via with() instead of allocating a String each iter.
1139 // Default mode is "horizontal" (matches previous unwrap_or).
1140 let mode_prop = item.get_property("mode");
1141 let (is_horiz_family, is_plain_horizontal) = match mode_prop.as_deref() {
1142 Some(Stored::String(sym)) => arena::with(*sym, |s| {
1143 let plain = s == "horizontal";
1144 let fam = plain || s == "restricted_horizontal" || s == "math";
1145 (fam, plain)
1146 }),
1147 None => (true, true), // default "horizontal"
1148 Some(other) => {
1149 // Rare path — fall back to Display formatting.
1150 let s = other.to_string();
1151 let plain = s == "horizontal";
1152 let fam = plain || s == "restricted_horizontal" || s == "math";
1153 (fam, plain)
1154 },
1155 };
1156 if is_horiz_family {
1157 if !is_plain_horizontal || !item.get_property_bool("isSpace") {
1158 keep = true;
1159 }
1160 true
1161 } else {
1162 false
1163 }
1164 } else {
1165 false
1166 };
1167
1168 if should_pop {
1169 para.push(stomach.box_list.pop().unwrap());
1170 } else {
1171 break;
1172 }
1173 }
1174
1175 // Items were popped in reverse order, so reverse them back
1176 para.reverse();
1177
1178 if keep {
1179 let mut list = List::new(para);
1180 list.mode = Some(TexMode::Text); // "horizontal" in Perl
1181 // Perl: List(@para, mode => 'horizontal') — set mode property string
1182 // This is needed for compute_boxes_size vertical layout to detect paragraph Lists
1183 list.set_property("mode", Stored::String(pin!("horizontal")));
1184 // Perl #2798 (S4): a finished paragraph List records BOTH the fill width
1185 // (\hsize) and the \baselineskip, so the sizing pass (compute_boxes_size)
1186 // can line-break and stack with the right inter-line spacing.
1187 // $list->setProperty(width => LookupDimension('\hsize'));
1188 // $list->setProperty(baseline => LookupDimension('\baselineskip', 1));
1189 if let Some(hsize) = lookup_dimension("\\hsize") {
1190 list.set_property("width", hsize);
1191 }
1192 if let Some(baseline) = lookup_dimension("\\baselineskip") {
1193 list.set_property("baseline", baseline);
1194 }
1195 stomach.box_list.push(Digested::from(list));
1196 }
1197}
1198
1199pub fn new_local_box_list() {
1200 let mut buffer = Vec::new();
1201 let mut stomach = stomach_mut!();
1202 // Guard the OTHER aberrant accumulation path: the boxing stack. When a loop
1203 // builds *inside* boxes (`\setbox`/`\hbox`), each nesting suspends the partial
1204 // outer list here and opens a fresh `box_list`; an unbounded `\hbox{\hbox{…}}`
1205 // nest grows this stack without ever touching the byte/cycle guards on the
1206 // (small, innermost) `box_list`. A depth cap is O(1) and safe — no real
1207 // document nests boxes anywhere near this deep (typical depth is tens; the
1208 // math0102053 line-drawing loop sits at 13). Platform-independent, fires long
1209 // before any RSS/OOM ceiling.
1210 if stomach.localized_box_list.len() > STOMACH_BOXING_DEPTH_CAP
1211 && stomach.pending_cycle_fatal.is_none()
1212 {
1213 stomach.pending_cycle_fatal = Some((
1214 ErrorCategory::MemoryBudget,
1215 s!(
1216 "Boxing-stack runaway: box nesting depth exceeded {} \
1217 (unbounded \\hbox/\\setbox nesting)",
1218 STOMACH_BOXING_DEPTH_CAP
1219 ),
1220 ));
1221 }
1222 std::mem::swap(&mut stomach.box_list, &mut buffer);
1223 stomach.localized_box_list.push(buffer);
1224}
1225
1226/// Hard cap on box-nesting depth (the `localized_box_list` boxing stack). No
1227/// real document nests `\hbox`/`\setbox` more than tens deep; a runaway nest
1228/// grows this without bound while the per-level `box_list` stays small, evading
1229/// the byte/cycle guards. Platform-independent.
1230const STOMACH_BOXING_DEPTH_CAP: usize = 100_000;
1231pub fn expire_local_box_list() -> Vec<Digested> {
1232 let mut stomach = stomach_mut!();
1233 let mut buffer = stomach.localized_box_list.pop().unwrap_or_default();
1234 std::mem::swap(&mut stomach.box_list, &mut buffer);
1235 buffer
1236}
1237
1238/// Recover the boxes a failed `digest_next_body` left stranded, in document
1239/// order, and reset the accumulation stack.
1240///
1241/// `digest_next_body` accumulates into `box_list` (with outer levels suspended
1242/// on `localized_box_list`) and only hands them back via `expire_local_box_list`
1243/// on the SUCCESS path — so a mid-body Fatal drops every box digested during
1244/// that call. `digest_internal` is written to keep partial output after a
1245/// recoverable Fatal ("Perl finishDigestion L219-220: loop consuming input even
1246/// after errors"), but that intent was defeated whenever the failure landed in
1247/// the FIRST body: the caller's `boxes` was still empty, so the run produced a
1248/// 39-byte empty document instead of the text preceding the bad construct.
1249/// Witness arXiv:2508.07407 (ar5iv #556) — its whole document was lost, though
1250/// only one `\tikz` picture is pathological.
1251///
1252/// `drop_innermost` is for the runaway guards (`Stomach:Recursion`), where the
1253/// innermost level IS the pathology — a 50k-box repeating window. Salvaging it
1254/// would graft the garbage into the document, so drop that level and keep the
1255/// suspended outer ones, which is precisely "drop the offending construct, keep
1256/// the document". For every other recoverable Fatal the current level is honest
1257/// content and is kept.
1258pub fn salvage_pending_box_lists(drop_innermost: bool) -> Vec<Digested> {
1259 let mut stomach = stomach_mut!();
1260 let mut acc = std::mem::take(&mut stomach.box_list);
1261 if drop_innermost {
1262 acc.clear();
1263 }
1264 // Unwind the suspended levels innermost-parent first, each time prefixing the
1265 // parent's own content so the result stays in document order.
1266 while let Some(mut parent) = stomach.localized_box_list.pop() {
1267 parent.append(&mut acc);
1268 acc = parent;
1269 }
1270 // Refuse a salvage that is itself pathological. `drop_innermost` removes the
1271 // runaway level for the STOMACH box-cycle guard, where that level is the
1272 // pathology — but the GULLET cycle guard (`Timeout:Recursion`) fires on the
1273 // token stream, and there the bloated boxes can sit in the suspended outer
1274 // levels instead, so dropping the innermost does not bound anything.
1275 //
1276 // `STOMACH_CYCLE_ACTIVATE` is exactly the engine's own "no honest document
1277 // accumulates this many undrained boxes" line, so reuse it rather than invent
1278 // a second threshold: a salvage at or past it is runaway output, and handing
1279 // it to the builder is worse than handing over nothing. Measured on
1280 // arXiv:2605.25400, where an unbounded salvage turned a 9.7 s fatal into a
1281 // 120 s wall-clock timeout that wrote a ZERO-byte file — strictly worse than
1282 // the 39-byte stub it replaced.
1283 if acc.len() >= STOMACH_CYCLE_ACTIVATE {
1284 acc.clear();
1285 }
1286 acc
1287}
1288
1289/// Stomach-level cycle guard: only once `box_list` has grown far past any
1290/// flushed-document size (a normal `box_list` is drained as paragraphs/boxes
1291/// complete and stays small) do we record the digest-push stream and look for
1292/// a short repeating window — a box-accumulation infinite loop. Cuts it off
1293/// with a clean Fatal long before the RSS soft cap. Caller must already hold
1294/// the stomach borrow and have appended past the activation size.
1295#[inline]
1296fn cycle_guard_record(st: &mut Stomach, d: &Digested) {
1297 // Once a fatal is pending, further detection work is pointless — the raise
1298 // happens at the NEXT `check_timeout` tick, which (since PR #249 review
1299 // P2-6) every digestion loop runs per iteration (`digest_next_body`,
1300 // `digest`, `raw_tex`), so the window between detection and raise is at
1301 // most one `invoke_token`. (Before that fix, a runaway confined to
1302 // `digest()` set the flag and the guards then self-disabled while the list
1303 // grew unbounded — the flag was never raised on that path.)
1304 if st.pending_cycle_fatal.is_none() {
1305 // Hard size backstop — platform-INDEPENDENT (the RSS soft cap in
1306 // `check_timeout` reads `/proc/self/statm` and is therefore Linux-only;
1307 // on macOS/Windows it is inactive). This bounds `box_list` everywhere and
1308 // also catches APERIODIC runaways the windowed cycle detector cannot
1309 // (boxes that vary per iteration, e.g. a `\@whilenum` loop with a
1310 // counter, or period > MAX_WINDOW). 40× the validated cycle-activation
1311 // size, far past any flushed-document list. Analogous to the gullet's
1312 // platform-independent `token_limit`.
1313 if let Some(cap) = box_count_cap()
1314 && st.box_list.len() > cap
1315 {
1316 st.pending_cycle_fatal = Some((
1317 ErrorCategory::MemoryBudget,
1318 s!(
1319 "Box-list runaway: {} accumulated boxes exceeded the hard cap of {} \
1320 (unbounded digestion with no detectable cycle); raise --max-memory, \
1321 or --max-memory=0 to lift the ceiling",
1322 st.box_list.len(),
1323 cap
1324 ),
1325 ));
1326 return;
1327 }
1328 // Portable, BYTE-based memory guard. The count caps above are a proxy for
1329 // memory, but per-box weight varies several-fold (a bare text box vs a
1330 // deeply nested `\hbox{\raise…\hbox{…}}`), so a count calibrated for light
1331 // boxes lets a HEAVY-box runaway sail past it — only the Linux-only RSS cap
1332 // in `check_timeout` (4.5 GB) then catches it, late and non-portably.
1333 // Here we estimate the box list's actual heap footprint (by sampling, so
1334 // it stays O(1) amortised) and `Fatal` once it crosses a budget set BELOW
1335 // the RSS cap. This fires EARLIER than the external RSS guard AND works on
1336 // macOS/Windows where `/proc/self/statm` is unavailable. Driver:
1337 // math0102053 (plain-TeX `\@whiledim` line-drawing loop — Perl OOMs too;
1338 // ~1.87 M heavy line-segment boxes reached 4.5 GB RSS before the 2 M count
1339 // cap could fire).
1340 let len = st.box_list.len();
1341 if let Some(budget) = box_bytes_budget()
1342 && len >= BYTE_CHECK_ACTIVATE
1343 && len.is_multiple_of(BYTE_CHECK_EVERY)
1344 {
1345 let est = estimate_box_list_bytes(&st.box_list);
1346 if est > budget {
1347 st.pending_cycle_fatal = Some((
1348 ErrorCategory::MemoryBudget,
1349 s!(
1350 "Box-list memory runaway: ~{} MB estimated across {} boxes exceeded \
1351 the {} MB budget (unbounded accumulation); raise --max-memory, or \
1352 --max-memory=0 to lift the ceiling. NOTE: the estimate is a LOWER \
1353 BOUND (each box is walked at most {} nodes deep), so true RSS at \
1354 this point is typically several times larger",
1355 est / 1_000_000,
1356 len,
1357 budget / 1_000_000,
1358 crate::digested::EB_BUDGET
1359 ),
1360 ));
1361 return;
1362 }
1363 }
1364 let fp = d.cycle_fingerprint();
1365 if let Some(period) = st.cycle_guard.push(fp) {
1366 st.pending_cycle_fatal = Some((
1367 ErrorCategory::Recursion,
1368 s!(
1369 "Infinite digestion loop: a window of {} box(es) repeated {}+ times \
1370 while the box list grew past {}",
1371 period,
1372 crate::cycle_guard::REPEAT,
1373 STOMACH_CYCLE_ACTIVATE
1374 ),
1375 ));
1376 }
1377 }
1378}
1379
1380/// Hard, platform-independent ceiling on `box_list` length — `None` when the
1381/// memory limit is disabled. A normal list is flushed continuously and stays
1382/// tiny; reaching this is an unbounded accumulation. The backstop for
1383/// very-LIGHT-box runaways, which the byte budget below can under-weigh.
1384///
1385/// **Rides `--max-memory`**, like every other memory ceiling: the resolved soft
1386/// cap divided by [`BYTES_PER_LIGHT_BOX`], which reproduces the historical fixed
1387/// 2 M at the stock `--max-memory=6144` (soft cap 4608 MiB), scales linearly
1388/// with the flag, and is `None` at `--max-memory=0`.
1389///
1390/// It used to be a hardcoded `const`, which made `--max-memory=0` a documented
1391/// lie: the binary prints "memory limiting disabled entirely" and then Fatal'd
1392/// on a memory ceiling anyway, with no flag able to raise it. Witness: a
1393/// ~10 000-page notes document (Nasser Abbasi, rc4 report 2026-07-28) died on
1394/// the byte budget below after 8 h at ~58 GB RSS having explicitly passed
1395/// `--max-memory=0`. Guard: `box_ceilings_follow_the_memory_knob`.
1396fn box_count_cap() -> Option<usize> {
1397 resolve_rss_cap().map(|cap| (cap / BYTES_PER_LIGHT_BOX) as usize)
1398}
1399
1400/// Calibration for [`box_count_cap`]: the per-box footprint of a *light* box,
1401/// chosen so the stock ceiling yields the validated 2 M-box cap.
1402const BYTES_PER_LIGHT_BOX: u64 = 2_416;
1403
1404/// Portable byte-budget for the accumulated `box_list` — `None` when the memory
1405/// limit is disabled. `estimate_bytes` counts each box's OWNED heavy data (the
1406/// `properties` HashMap, the `Tbox` `tokens` source-TeX vector, args/children
1407/// vectors + nested children). Works on macOS/Windows, where the `/proc` RSS
1408/// check is inactive and this is the ONLY memory guard for a heavy-box runaway.
1409///
1410/// **Rides `--max-memory`** (see [`box_count_cap`]): two thirds of the resolved
1411/// soft cap, i.e. 3.22 GB at the stock `--max-memory=6144` — the historical
1412/// fixed 3.2 GB — so on Linux it still `Fatal`s well before the RSS fuse, and
1413/// `None` at `--max-memory=0`.
1414///
1415/// **The estimate is a LOWER BOUND, not an RSS prediction.**
1416/// [`Digested::estimate_bytes`] walks at most `EB_BUDGET` (256) nodes per box,
1417/// so a deep document tree is undercounted by however much hangs below that
1418/// horizon — and the shortfall is content-dependent, not a constant. Measured:
1419/// a flat 600 k-paragraph synthetic crosses the 3.2 GB budget at 5.8 GB true RSS
1420/// (est ≈ 58 % of RSS), while Nasser's deeply-nested notes crossed the *same*
1421/// budget at ~58 GB (est ≈ 6 %). A ~10× spread — so do not read the budget as a
1422/// megabyte ceiling on the process. (The "tracks true RSS within ~10 %" claim
1423/// this doc used to carry held only for its calibration paper, math0102053: a
1424/// plain-TeX `\@whiledim` line-drawing loop whose ~1.87 M boxes are shallow.)
1425fn box_bytes_budget() -> Option<usize> { resolve_rss_cap().map(|cap| (cap / 3 * 2) as usize) }
1426/// Don't bother byte-sampling until the list is already well past the cycle
1427/// activation size (a normal list never gets here).
1428const BYTE_CHECK_ACTIVATE: usize = 200_000;
1429/// Re-estimate the box-list footprint every this-many boxes (amortises the
1430/// sampling cost to O(1) per push).
1431const BYTE_CHECK_EVERY: usize = 50_000;
1432/// Boxes sampled per byte estimate. Box weights are bimodal (light text
1433/// segments vs heavy nested structures), so a *dense* sample is needed to keep
1434/// the extrapolation from aliasing against the heavy-box stride.
1435const BYTE_SAMPLE_N: usize = 8192;
1436
1437/// Cost-bounded estimate of the heap bytes held by `list`, via even sampling +
1438/// extrapolation (each sampled box is itself depth-bounded — see
1439/// [`crate::digested::Digested::estimate_bytes`]). O(`BYTE_SAMPLE_N`) regardless
1440/// of list length. The sample is taken as contiguous *blocks* spread across the
1441/// list rather than a single large stride, which is far more robust to clustered
1442/// heavy boxes than evenly-strided point sampling.
1443fn estimate_box_list_bytes(list: &[Digested]) -> usize {
1444 let len = list.len();
1445 if len == 0 {
1446 return 0;
1447 }
1448 if len <= BYTE_SAMPLE_N {
1449 return list.iter().map(Digested::estimate_bytes).sum();
1450 }
1451 // 32 blocks of (BYTE_SAMPLE_N/32) contiguous boxes, evenly spaced — captures
1452 // local clustering of heavy boxes that point sampling misses.
1453 const BLOCKS: usize = 32;
1454 let block = (BYTE_SAMPLE_N / BLOCKS).max(1);
1455 let gap = len / BLOCKS;
1456 let mut sum = 0usize;
1457 let mut n = 0usize;
1458 for b in 0..BLOCKS {
1459 let start = b * gap;
1460 let end = (start + block).min(len);
1461 for d in &list[start..end] {
1462 sum += d.estimate_bytes();
1463 n += 1;
1464 }
1465 }
1466 // average-per-box × len; usize (64-bit) cannot overflow at realistic sizes.
1467 (sum / n.max(1)) * len
1468}
1469
1470pub fn extend_box_list<I>(arg: I)
1471where I: IntoIterator<Item = Digested> {
1472 let mut st = stomach_mut!();
1473 // Fast path (the overwhelming common case): box list still small — just
1474 // extend, no per-box fingerprinting.
1475 if st.box_list.len() <= STOMACH_CYCLE_ACTIVATE {
1476 st.box_list.extend(arg);
1477 return;
1478 }
1479 // Runaway territory: record each appended box into the cycle guard.
1480 for d in arg {
1481 cycle_guard_record(&mut st, &d);
1482 st.box_list.push(d);
1483 }
1484}
1485pub fn push_box_list(arg: Digested) {
1486 let mut st = stomach_mut!();
1487 if st.box_list.len() > STOMACH_CYCLE_ACTIVATE {
1488 cycle_guard_record(&mut st, &arg);
1489 }
1490 st.box_list.push(arg);
1491}
1492fn push_box_list_vec(args: Vec<Digested>) { extend_box_list(args) }
1493
1494/// Engage the stomach's box-list cycle guard only once the (normally
1495/// flushed-small) `box_list` has grown past this. A real document's list is
1496/// drained continuously; a runaway accumulates boxes without bound. Keeps the
1497/// guard inert for every ordinary conversion. (~50k boxes is already well past
1498/// any sane un-flushed list yet ~30× below the 4.5 GB OOM ceiling.)
1499const STOMACH_CYCLE_ACTIVATE: usize = 50_000;
1500pub fn pop_box_list() -> Option<Digested> { stomach_mut!().box_list.pop() }
1501pub fn with_box_list<R, FnR>(caller: FnR) -> R
1502where FnR: FnOnce(&[Digested]) -> R {
1503 let stomach = stomach!();
1504 let list = &stomach.box_list;
1505 caller(list)
1506}
1507pub fn with_box_list_mut<R, FnR>(caller: FnR) -> R
1508where FnR: FnOnce(&mut [Digested]) -> R {
1509 let mut stomach = stomach_mut!();
1510 let list = &mut stomach.box_list;
1511 caller(list)
1512}
1513/// Access to the current box_list as a `&mut Vec` — allows push/pop operations.
1514pub fn with_box_list_mut_vec<R, FnR>(caller: FnR) -> R
1515where FnR: FnOnce(&mut Vec<Digested>) -> R {
1516 let mut stomach = stomach_mut!();
1517 caller(&mut stomach.box_list)
1518}
1519
1520// **********************************************************************
1521// Digestion
1522// **********************************************************************
1523
1524/// Digest a list of tokens independent from any current Gullet.
1525/// Typically used to digest arguments to primitives or constructors.
1526/// Returns a List containing the digested material.
1527pub fn digest<T: Into<Tokens>>(tokens: T) -> Result<Digested> {
1528 let tokens: Tokens = tokens.into();
1529 if tokens.is_empty() {
1530 return Ok(Digested::default());
1531 }
1532 gullet::reading_from_mouth(Mouth::default(), || {
1533 gullet::unread(tokens);
1534 clear_prefixes(); // prefixes shouldn't apply here.
1535 let mode = if lookup_bool_sym(crate::pin!("IN_MATH")) {
1536 TexMode::Math
1537 } else {
1538 TexMode::Text
1539 };
1540 let initdepth = stomach!().boxing.len();
1541 let depth = initdepth;
1542 new_local_box_list();
1543 while let Some(token) = match gullet::get_pending_comment() {
1544 Some(comment) => Some(comment),
1545 None => gullet::read_x_token(Some(true), false, None)?,
1546 } {
1547 // Raise any pending stomach-guard fatal + deadline/RSS checks. This
1548 // loop is a digestion path of its own — without a tick here, a runaway
1549 // confined to constructor-argument digestion set `pending_cycle_fatal`
1550 // at detection but nothing ever RAISED it (check_timeout's only call
1551 // site was digest_next_body), and the RSS soft cap / wall-clock
1552 // deadline were equally dead on this path. PR #249 review P2-6.
1553 check_timeout()?;
1554 // Done if we run out of tokens
1555 let invoked = invoke_token(&token)?;
1556 extend_box_list(invoked);
1557
1558 if initdepth > stomach!().boxing.len() {
1559 // if we've closed the initial mode.
1560 break;
1561 }
1562 if initdepth < depth {
1563 // TODO
1564 fatal!(Internal, EoF, "We've fallen off the end, somehow !?!?!?");
1565 // Fatal('internal', '<EOF>', self,
1566 // "We've fallen off the end, somehow!?!?!",
1567 // "Last token " . ToString($LaTeXML::CURRENT_TOKEN)
1568 // . " (Boxing depth was $initdepth, now $depth: Boxing generated by "
1569 // . join(', ', map { ToString($_) } @{ $self{boxing} }))
1570 // if $initdepth < $depth;
1571 }
1572 }
1573
1574 let mut digested_list = List::new(expire_local_box_list());
1575 digested_list.mode = Some(mode);
1576 digested_list.into()
1577 })
1578}
1579
1580/// Return the digested `List` after reading and digesting a body from the its Gullet.
1581/// The body extends until the current level of boxing or environment is closed.
1582pub fn digest_next_body(terminal_opt: Option<Token>) -> Result<Vec<Digested>> {
1583 let start_location = { gullet::get_locator() };
1584
1585 let init_depth = { stomach!().boxing.len() };
1586 // Did the loop end because the INPUT RAN OUT (as opposed to reaching the
1587 // terminal or closing the initial mode)? Perl `Stomach.pm` L130 keys the
1588 // trailer box on `unless $token`, and `$token` is undef exactly when the
1589 // `while (defined($token = ...))` condition failed — i.e. on EOF, whether or
1590 // not tokens were read before it. See the trailer push below.
1591 let mut ran_out = true;
1592 let mut found_terminal = false;
1593 new_local_box_list();
1594 let alignment_opt = lookup_alignment();
1595 // TODO: bookkeep for "expected" warning
1596 //let mut aug = Vec::new();
1597
1598 // try reading a executable token
1599 while let Some(token) = match gullet::get_pending_comment() {
1600 Some(comment) => Some(comment),
1601 None => gullet::read_x_token(Some(true), false, None)?,
1602 } {
1603 // Check conversion timeout
1604 check_timeout()?;
1605 // first, check for alignment case
1606 // Perl #2775: only fire at the original alignment nesting level,
1607 // not inside deeper boxing groups (e.g. \vbox inside a tabular cell).
1608 if alignment_opt.is_some()
1609 && !stomach!().box_list.is_empty()
1610 && (stomach!().boxing.len() <= init_depth)
1611 && (token == T_ALIGN!()
1612 || token == T_CS!("\\cr")
1613 || token == T_CS!("\\lx@hidden@cr")
1614 || token == T_CS!("\\lx@hidden@crcr"))
1615 {
1616 gullet::unread_one(token);
1617 return Ok(expire_local_box_list());
1618 }
1619 // normal case
1620 let invoked = invoke_token(&token)?;
1621 extend_box_list(invoked);
1622
1623 if let Some(ref terminal) = terminal_opt
1624 && &token == terminal
1625 {
1626 found_terminal = true;
1627 ran_out = false;
1628 break;
1629 }
1630 if init_depth > stomach!().boxing.len() {
1631 ran_out = false;
1632 break;
1633 }
1634 // Fragment yield (streaming pass 1): between top-level constructs, at a
1635 // legal seam, hand back the boxes accumulated so far so the driver can
1636 // build + spill and re-enter. Everything digestion carries — gullet mouth
1637 // stack, State undo frames, mode, fonts — is thread-local and survives
1638 // between `digest_next_body` calls by construction, so resuming is the
1639 // same operation `digest_internal`'s outer loop already performs; the
1640 // alignment early-return above is the established precedent for
1641 // returning early with a partial list.
1642 //
1643 // Seam legality (probed 2026-07-29 on a real conversion, not assumed):
1644 // only the DRIVER call — `digest_internal` is the one caller that enters
1645 // with an empty boxing stack (`init_depth == 0`); constructor argument
1646 // digests also pass `None` but always sit inside an open box. The boxing
1647 // stack must be back at 0, and the mode VERTICAL-family: the document
1648 // body runs in `internal_vertical` (the `\begin{document}` environment's
1649 // mode — plain `vertical` occurs only before it), and at depth 0 that
1650 // cannot be a vbox/minipage interior, which always sits at deeper boxing.
1651 // A horizontal-mode cut would split the run `repack_horizontal` folds
1652 // into one paragraph; alignment, math, and open conditionals must all be
1653 // closed. A single construct larger than the whole budget simply digests
1654 // through — the existing hard ceilings still protect.
1655 //
1656 // Checked AFTER the terminal/depth exits so a real exit always wins, and
1657 // before the next `read_x_token` so nothing is consumed-then-unread.
1658 if let Some(budget) = FRAGMENT_YIELD_BUDGET.get()
1659 && init_depth == 0
1660 && terminal_opt.is_none()
1661 && alignment_opt.is_none()
1662 && {
1663 // The box budget yields on its own. The soft-RSS branch additionally
1664 // requires a MINIMUM accumulation: it is a level test (`rss > soft`)
1665 // with no hysteresis, so a document whose resident floor sits above
1666 // the watermark latches it on for the whole run and yields at every
1667 // seam with nothing accumulated — see `soft_yield_min_boxes` for the
1668 // measured degeneracy (24 M yields / 5.5 KB segments on the witness).
1669 let accumulated = stomach!().box_list.len();
1670 let rss_kb = LAST_SAMPLED_RSS_KB.get();
1671 accumulated >= budget
1672 || (FRAGMENT_YIELD_RSS_SOFT_KB
1673 .get()
1674 .is_some_and(|soft| rss_kb > soft)
1675 // The floor is waived once pressure is urgent, so pathological
1676 // per-box footprints keep the immediate response this branch
1677 // exists to give (`soft_yield_is_urgent`).
1678 && (accumulated >= soft_yield_min_boxes() || soft_yield_is_urgent(rss_kb)))
1679 }
1680 && stomach!().boxing.is_empty()
1681 && lookup_alignment().is_none()
1682 && !lookup_bool_sym(crate::pin!("IN_MATH"))
1683 && open_conditional_count() == 0
1684 && matches!(
1685 lookup_string_from_sym(crate::pin!("MODE")).as_str(),
1686 "vertical" | "internal_vertical"
1687 )
1688 {
1689 FRAGMENT_YIELDED.set(true);
1690 FRAGMENT_YIELD_COUNT.set(FRAGMENT_YIELD_COUNT.get() + 1);
1691 // No EOF trailer (`ran_out` stays true only through the loop's own
1692 // exhaustion path — we return before reaching it), and no
1693 // `gullet::flush()`: both are end-of-input actions, and input remains.
1694 return Ok(expire_local_box_list());
1695 }
1696 }
1697
1698 if let Some(ref terminal) = terminal_opt
1699 && !found_terminal
1700 {
1701 let message = s!(
1702 "body should have ended with {:?}. current body started at {:?}",
1703 terminal,
1704 start_location
1705 );
1706 Warn!("expected", terminal, message);
1707 }
1708 // and add a Dummy `trailer' if none explicit — Perl `Stomach.pm` L130,
1709 // `push(@LaTeXML::LIST, Box()) unless $token;`.
1710 //
1711 // This was mistranslated as "if we never read ANY token", which is a strictly
1712 // narrower condition: it agrees with Perl only on a body that was empty from
1713 // the start. The case it missed is a body that read content and THEN hit EOF —
1714 // and that is the case the trailer exists for. `readDigested`
1715 // (`Base_ParameterTypes.pool.ltxml` L374, ported in `base_parameter_types.rs`)
1716 // does `push(@list, digestNextBody()); pop(@list);` to strip the closing `}`
1717 // box; with no trailer pushed, that `pop` silently ate a box of REAL CONTENT.
1718 // Concretely: one runaway `.bib` field swallowed the rest of the entry into
1719 // its own argument, and the `pop` then removed the boxes carrying every
1720 // following entry — an empty bibliography where Perl renders all of them.
1721 if ran_out {
1722 push_box_list(Digested::from(Tbox::default()));
1723 }
1724 Ok(expire_local_box_list())
1725}
1726
1727/// a convenience function for including chunks of raw TeX (or LaTeX) code
1728/// It is useful for copying portions of the normal
1729/// implementation that can be handled simply using macros and primitives.
1730pub fn raw_tex(text: &str) -> Result<()> {
1731 // It could be as simple as this, except if catcodes get changed, it's too late!!!
1732 // Digest(TokenizeInternal($text));
1733 let raw_tex_mouth = Mouth::new(
1734 text,
1735 Some(MouthOptions {
1736 fordefinitions: true,
1737 at_letter: true,
1738 ..MouthOptions::default()
1739 }),
1740 )?;
1741 gullet::reading_from_mouth(raw_tex_mouth, || -> Result<()> {
1742 while let Some(token) = gullet::read_x_token(Some(false), false, None)? {
1743 // Same per-iteration guard tick as digest()/digest_next_body — see the
1744 // comment in `digest` (PR #249 review P2-6): raw-loaded .sty/.cls
1745 // digestion must raise pending stomach fatals and honor the deadline
1746 // and RSS caps too.
1747 check_timeout()?;
1748 if token.get_catcode() != Catcode::SPACE {
1749 invoke_token(&token)?;
1750 }
1751 }
1752 Ok(())
1753 })?;
1754 Ok(())
1755}
1756
1757/// Invoke a token
1758///
1759/// If it is a primitive or constructor, the definition will be invoked,
1760/// possibly arguments will be parsed from the Gullet.
1761/// Otherwise, the token is simply digested: turned into an appropriate box.
1762/// Returns a list of boxes/whatsits.
1763pub fn invoke_token(input_token: &Token) -> Result<Vec<Digested>> {
1764 // Perf: Token is Copy (SymStr + Catcode, ~5 bytes), so we pass by value
1765 // directly instead of wrapping in Cow<Token>.
1766 let mut maybe_token: Option<Token> = Some(*input_token);
1767 let mut result: Vec<Digested> = Vec::new();
1768 // INVOKE:
1769 while let Some(token) = maybe_token.take() {
1770 // RAII guard: auto-pops current_token on scope exit (even on early return/panic)
1771 let _token_guard = local_current_token_guard(token);
1772 {
1773 stomach_mut!().token_stack.push(token);
1774 }
1775 if { stomach!().token_stack.len() } > MAXSTACK {
1776 fatal!(
1777 Stomach,
1778 Recursion,
1779 s!(
1780 "Excessive recursion(?): Tokens on stack: {:?}",
1781 stomach!().token_stack
1782 )
1783 );
1784 }
1785 result = Vec::new();
1786
1787 // Rust notes: It would be ideal if we could unify the cases for (Primtive, Constructor,
1788 // MathPrimitive), as well as (Expandable, Conditional) since the
1789 // API is identical. However, as the types are different, Rust
1790 // constrains us here, we need separate match arms for each
1791 // distinctly typed enum case.
1792 let digestable_def = lookup_digestable_definition(&token);
1793 match digestable_def {
1794 None | Some(Stored::None) => {
1795 result = invoke_token_undefined(&token)?;
1796 },
1797 Some(Stored::Token(meaning)) => {
1798 // Common case
1799 let cc = meaning.get_catcode();
1800 if cc == Catcode::CS {
1801 result = invoke_token_undefined(&token)?;
1802 } else if cc.is_absorbable() {
1803 if let Some(digested) = invoke_token_simple(meaning)? {
1804 result.push(digested);
1805 }
1806 } else {
1807 // Perl L187-189: deactivate T_ALIGN to prevent error flood in tables
1808 if token.get_catcode() == Catcode::ALIGN
1809 && let Some(relax_meaning) = lookup_meaning(&T_CS!("\\relax"))
1810 {
1811 assign_meaning(&token, relax_meaning, Some(Scope::Local));
1812 }
1813 let message = s!(
1814 "The token {:?} (catcode {:?}) should never reach Stomach!",
1815 token,
1816 cc
1817 );
1818 Error!("misdefined", token, &message);
1819 if let Some(digested) = invoke_token_simple(meaning)? {
1820 result.push(digested);
1821 }
1822 }
1823 },
1824 Some(Stored::Expandable(meaning)) => {
1825 // A math-active character will (typically) be a macro,
1826 // but it isn't expanded in the gullet, but later when digesting, in math mode
1827 // (? I think)
1828 let invoked_meaning = meaning.invoke(false)?;
1829 if !invoked_meaning.is_empty() {
1830 {
1831 gullet::unread(invoked_meaning);
1832 }
1833 }
1834 // replace the token by it's expansion!!!
1835 maybe_token = gullet::read_x_token(None, false, None)?;
1836 {
1837 stomach_mut!().token_stack.pop();
1838 }
1839 drop(_token_guard); // expire current token via RAII
1840 continue;
1841 },
1842 Some(Stored::Conditional(meaning)) => {
1843 // Conditionals are "expandable", use the regular invoke.
1844 let invoked_meaning = meaning.invoke(false)?;
1845 gullet::unread(invoked_meaning);
1846 maybe_token = gullet::read_x_token(None, false, None)?;
1847 {
1848 stomach_mut!().token_stack.pop();
1849 }
1850 drop(_token_guard); // expire current token via RAII
1851 continue;
1852 },
1853 Some(Stored::Constructor(meaning)) => {
1854 // Perl Stomach.pm L187-189: deactivate T_ALIGN to `\relax` LOCAL
1855 // on first non-table encounter, to prevent error flood. The
1856 // existing guard at the Stored::Token branch (above) only fires
1857 // when `&` has been Let'd to another token, but the `&`
1858 // CC_ALIGN char-token is bound to a Constructor (TeX_Tables.pool
1859 // L49: `DefConstructorI('&', undef, sub { Error('unexpected', '&',
1860 // $_[0], "Stray alignment \"&\"") })`), so it falls into THIS
1861 // branch instead. Without this guard, papers with multiple stray
1862 // `&` (e.g. astro-ph0107583's bibitem with unescaped `Hirose &
1863 // Osaki`) emit one Error per occurrence; Perl emits ONE total
1864 // because of the LOCAL `\relax` rebinding. Self-deactivate here
1865 // too so subsequent `&` invocations no-op.
1866 if token.get_catcode() == Catcode::ALIGN
1867 && let Some(relax_meaning) = lookup_meaning(&T_CS!("\\relax"))
1868 {
1869 assign_meaning(&token, relax_meaning, Some(Scope::Local));
1870 }
1871 // `meaning` IS the state table's `Rc<Constructor>`, so hand it to the
1872 // Whatsit rather than letting `invoke_primitive` deep-clone the
1873 // definition once per invocation (see `Constructor::invoke_primitive_shared`).
1874 result = Constructor::invoke_primitive_shared(&meaning)?;
1875 if !meaning.is_prefix() {
1876 clear_prefixes(); // Clear prefixes unless we just set one.
1877 }
1878 },
1879 Some(Stored::Primitive(meaning)) => {
1880 // Otherwise, a normal primitive or constructor
1881 result = meaning.invoke_primitive()?;
1882 if !meaning.is_prefix() {
1883 clear_prefixes(); // Clear prefixes unless we just set one.
1884 }
1885 },
1886 Some(Stored::MathPrimitive(meaning)) => {
1887 // Copy of regular Primitive
1888 // Otherwise, a normal primitive or constructor
1889 result = meaning.invoke_primitive()?;
1890 if !meaning.is_prefix() {
1891 clear_prefixes(); // Clear prefixes unless we just set one.
1892 }
1893 },
1894 Some(Stored::Register(meaning)) => {
1895 // Registers are special primitives
1896 result = meaning.invoke_primitive()?;
1897 if !meaning.is_prefix() {
1898 clear_prefixes(); // Clear prefixes unless we just set one.
1899 }
1900 },
1901 meaning => {
1902 // Perl: Error + makeMisdefinedError (non-fatal). Don't crash.
1903 Error!(
1904 "misdefined",
1905 token,
1906 s!("Unexpected object in Stomach: {:?}", meaning)
1907 );
1908 },
1909 }
1910 // _token_guard drops here, auto-expiring current token
1911 break;
1912 }
1913 stomach_mut!().token_stack.pop();
1914 Ok(result)
1915}
1916
1917fn invoke_token_undefined(token: &Token) -> Result<Vec<Digested>> {
1918 // The LaTeX format may not be loaded yet (a document may use a kernel CS
1919 // before `\documentclass` — real LaTeX has no "before the kernel"). If this
1920 // is a kernel CS, pull the format in and re-digest instead of stubbing it as
1921 // `<ltx:ERROR/>`. Fires at most once per session; see
1922 // `binding::kernel_autoload`. Same retry shape as the `\ifsomething` arm below.
1923 if crate::binding::kernel_autoload::try_autoload(token) {
1924 gullet::unread_one(*token); // Retry, now that the kernel is in state.
1925 return Ok(Vec::new());
1926 }
1927 let cs = token.with_cs_name(|cs| String::from(cs));
1928 // Gate the undefined-CS summary tally and the Error! emission by
1929 // SUPPRESS_UNDEFINED_ERRORS. During expl3-code.tex raw load we install
1930 // the ERROR stub silently — forward references resolve when subsequent
1931 // post-load fixups rebind the canonical CS (see expl3_sty.rs L161-167
1932 // for \iow_wrap stubs that overwrite ERROR after the raw load). Mirrors
1933 // the existing gate at state.rs::generate_error_stub L1018-L1030.
1934 let suppressed = lookup_bool_sym(crate::pin!("SUPPRESS_UNDEFINED_ERRORS"));
1935 if !suppressed {
1936 note_status(LogStatus::Undefined, Some(&cs));
1937 }
1938
1939 // To minimize chatter, go ahead and define it...
1940 if cs.starts_with("\\if") {
1941 // Apparently an \ifsomething ???
1942 let name = cs.replace("\\if", "");
1943 if !suppressed {
1944 let message = s!("The token {} is not defined.", token.stringify());
1945 Error!(
1946 "undefined",
1947 token,
1948 &message,
1949 "Defining it now as with \\newif"
1950 );
1951 }
1952 // install stub definitions for new conditional
1953 install_definition(
1954 Expandable::new(
1955 T_CS!(s!("\\{}true", name)),
1956 None,
1957 Tokens!(T_CS!("\\let"), T_CS!(&cs), T_CS!("\\iftrue")).into(),
1958 None,
1959 )?,
1960 None,
1961 );
1962 install_definition(
1963 Expandable::new(
1964 T_CS!(s!("\\{}false", name)),
1965 None,
1966 Tokens!(T_CS!("\\let"), T_CS!(cs), T_CS!("\\iffalse")).into(),
1967 None,
1968 )?,
1969 None,
1970 );
1971
1972 let_i(token, &T_CS!("\\iffalse"), None);
1973 gullet::unread_one(*token); // Retry
1974 Ok(Vec::new())
1975 } else {
1976 if !suppressed {
1977 let message = s!("The token {} is not defined.", token.stringify());
1978 Error!(
1979 "undefined",
1980 token,
1981 &message,
1982 "Defining it now as <ltx:ERROR/>"
1983 );
1984 }
1985 install_definition(
1986 Constructor {
1987 cs: *token,
1988 paramlist: None,
1989 replacement: Some(Rc::new(move |document, _args, _props| {
1990 document.make_error("undefined", &cs)
1991 })),
1992 ..Constructor::default()
1993 },
1994 Some(Scope::Global),
1995 );
1996 // Perl: unread the token and return empty, so the outer loop re-reads
1997 // and dispatches through the normal path (with the newly installed stub).
1998 // This ensures gullet-level side effects (filtering, expansion) are applied.
1999 gullet::unread_one(*token);
2000 Ok(Vec::new())
2001 }
2002}
2003
2004fn invoke_token_simple(meaning: Token) -> Result<Option<Digested>> {
2005 let cc = meaning.get_catcode();
2006 let font = lookup_font();
2007 // token-locators: the leaf char box's exact source position comes from the
2008 // token's origin handle — the position that survived expansion to digestion
2009 // (Experiments 1–3 showed it cannot be re-derived from the mouth here, which
2010 // is past the construct). `None` → `Tbox::new` falls back to the gullet's
2011 // current locator (the eating-disorder heuristic). See SOURCE_PROVENANCE §3.1.1.
2012 // Stamp a leaf box only from a *genuine* (read-from-source) origin. An
2013 // inherited origin — a macro's expansion attributed to its call site, e.g.
2014 // `\section`'s structural body literals at the `\section` column — must not
2015 // become a located leaf, or box-level `get_locator()` aggregation would widen
2016 // a construct past its content (the `\section{Intro}` title would start at the
2017 // command, not at "Intro"). The inherited origin still rides the token, so
2018 // `constructor::child_span`'s genuine-first scan can recover it as the
2019 // fallback for the origin-less case (`\today`). See SOURCE_PROVENANCE §3.1.3.
2020 #[cfg(feature = "token-locators")]
2021 let origin_loc: Option<crate::common::locator::Locator> =
2022 crate::token::get_token_origin(meaning.loc)
2023 .filter(|o| !o.inherited)
2024 .map(|o| {
2025 crate::common::arena::with(o.source, |s| {
2026 crate::common::locator::Locator::new(s, o.line, o.col, o.line, o.col)
2027 })
2028 });
2029 #[cfg(not(feature = "token-locators"))]
2030 let origin_loc: Option<crate::common::locator::Locator> = None;
2031 match cc {
2032 Catcode::SPACE => {
2033 clear_prefixes(); // Perl Stomach.pm line 234: prefixes shouldn't apply here.
2034 // Perl: if($STATE->lookupValue('MODE') =~ /(?:math|vertical)$/) { return (); }
2035 let mode = lookup_string_from_sym(crate::pin!("MODE"));
2036 if mode.ends_with("math") || mode.ends_with("vertical") {
2037 Ok(None)
2038 } else {
2039 enter_horizontal();
2040 Ok(Some(Digested::from(Tbox::new(
2041 meaning.get_sym(),
2042 font,
2043 origin_loc,
2044 Tokens!(meaning),
2045 HashMap::default(),
2046 ))))
2047 }
2048 },
2049 Catcode::COMMENT => {
2050 // Perl Stomach.pm lines 241-244: decode comment via font encoding
2051 let decoded = font::decode_string(meaning.get_sym(), None, true);
2052 let comment = arena::with(decoded, |s| {
2053 // However, spaces normally would have be digested away as positioning...
2054 // Replace NBSP + combining strikethrough (OT1 space position) with actual space
2055 s.replace("\u{00A0}\u{0335}", " ")
2056 });
2057 // Perl: returns LaTeXML::Core::Comment->new($comment)
2058 // which gets absorbed as an XML comment node via Document::insertComment
2059 Ok(Some(Digested::from(Comment(comment))))
2060 },
2061 _ => {
2062 clear_prefixes(); // Perl Stomach.pm line 247: prefixes shouldn't apply here.
2063 // Perl: check mathcode for IN_MATH characters (Stomach.pm lines 248-251)
2064 // In Perl, all math chars go through decodeMathChar which decodes via
2065 // the font encoding. In Rust, Tbox::new already handles IN_MATH:
2066 // it sets mode="math", looks up math_token_attributes for role/meaning/name,
2067 // and specializes the font. This produces the correct LaTeXML-level properties.
2068 // The mathchar parsing handles non-ASCII chars needing font map lookup.
2069 // TODO: Use for chars where font-encoding glyph differs from input.
2070 // Perl L248-257: if IN_MATH && mathcode → decodeMathChar (math box)
2071 // else → enterHorizontal + text box (covers non-math AND math-but-no-mathcode)
2072 if lookup_bool_sym(crate::pin!("IN_MATH"))
2073 && let Some(mathcode) = lookup_mathcode_sym(meaning.get_sym())
2074 {
2075 return crate::common::mathchar::decode_math_char_for_stomach(mathcode, meaning);
2076 }
2077 // Fallthrough: either not in math, or in math but no mathcode
2078 enter_horizontal();
2079 let text = font::decode_string(meaning.get_sym(), None, true);
2080 Ok(Some(Digested::from(Tbox::new(
2081 text,
2082 None,
2083 origin_loc,
2084 Tokens!(meaning), // tokens
2085 HashMap::default(), // properties
2086 ))))
2087 },
2088 }
2089}
2090
2091pub fn set_stomach(new_stomach: Stomach) {
2092 let mut singleton = stomach_mut!();
2093 *singleton = new_stomach;
2094}
2095pub fn clone_box_list() -> Vec<Digested> { stomach!().box_list.clone() }
2096
2097/// get the current boxing level
2098pub fn get_boxing_level() -> usize { stomach!().boxing.len() }
2099
2100/// ScriptLevel is similar to boxing level, but relative to current Math mode's level
2101///
2102/// This is used for the scriptpos attribute to recognize overlapping sccripts.
2103/// Making it relative to the math's level avoids unnecessary changes
2104pub fn get_script_level() -> usize {
2105 let boxlevel = get_boxing_level();
2106 with_value("script_base_level", |val_opt| {
2107 if let Some(Stored::Int(prevlevel)) = val_opt {
2108 boxlevel - (*prevlevel as usize) + 1
2109 } else {
2110 boxlevel
2111 }
2112 })
2113}
2114
2115#[cfg(test)]
2116mod memory_cap_tests {
2117 use super::{
2118 apply_memory_ceiling, box_bytes_budget, box_count_cap, resolve_rss_cap, set_memory_cap,
2119 soft_cap_from_ceiling, soft_yield_urgency,
2120 };
2121
2122 // The box-list ceilings are memory ceilings, so they must ride the SAME
2123 // `--max-memory` knob as the RSS fuse — including its "0 = no limit" meaning.
2124 // They were hardcoded `const`s (2 M boxes / 3.2 GB estimate) read
2125 // unconditionally, which made `--max-memory=0` a documented lie: the binary
2126 // prints "memory limiting disabled entirely" and then Fatal'd on a memory
2127 // ceiling no flag could raise. Witness: a ~10 000-page notes document
2128 // (Nasser Abbasi, rc4 report 2026-07-28) died on the byte budget after 8 h at
2129 // ~58 GB RSS having explicitly passed `--max-memory=0`.
2130 #[test]
2131 fn box_ceilings_follow_the_memory_knob() {
2132 // Stock ceiling reproduces the historical fixed values.
2133 apply_memory_ceiling(6144);
2134 assert_eq!(
2135 box_count_cap(),
2136 Some(1_999_933),
2137 "≈ the validated 2 M boxes"
2138 );
2139 let budget = box_bytes_budget().expect("stock ceiling has a byte budget");
2140 assert_eq!(budget, 3_221_225_472, "≈ the historical 3.2 GB");
2141
2142 // `--max-memory=0` lifts BOTH — that is the whole point of the flag.
2143 apply_memory_ceiling(0);
2144 assert_eq!(box_count_cap(), None, "--max-memory=0 lifts the count cap");
2145 assert_eq!(
2146 box_bytes_budget(),
2147 None,
2148 "--max-memory=0 lifts the byte budget"
2149 );
2150
2151 // A tighter ceiling scales them down rather than leaving them at the
2152 // stock value (which would sit far ABOVE the requested ceiling).
2153 apply_memory_ceiling(2000);
2154 assert!(box_count_cap().unwrap() < 1_999_933);
2155 assert!(box_bytes_budget().unwrap() < budget);
2156
2157 // The byte budget stays UNDER the RSS fuse, so on Linux the portable
2158 // estimate still fires first for an accurately-estimated runaway.
2159 assert!(box_bytes_budget().unwrap() < resolve_rss_cap().unwrap() as usize);
2160
2161 set_memory_cap(None);
2162 }
2163
2164 // The single knob must reach the fuse through `apply_memory_ceiling`, which is
2165 // what every conversion path calls. `--max-memory=0` has to leave NO ceiling:
2166 // the plain path, the `--server` forked body child and the in-process fallback
2167 // all rely on this one function, and the LSP pair used to skip it entirely.
2168 //
2169 // The env-precedence half is deliberately NOT asserted here: proving it needs
2170 // `set_var`, and this workspace has a standing rule against touching the
2171 // process env from tests (a concurrent read races glibc's getenv). It holds by
2172 // construction instead — `apply_memory_ceiling` sets the override
2173 // unconditionally, and `resolve_rss_cap` only consults the env when no
2174 // override is present.
2175 #[test]
2176 fn apply_memory_ceiling_drives_the_fuse() {
2177 apply_memory_ceiling(6144);
2178 assert_eq!(resolve_rss_cap(), Some(4608 * 1024 * 1024));
2179
2180 // 0 means "no limit", not "abort immediately".
2181 apply_memory_ceiling(0);
2182 assert_eq!(resolve_rss_cap(), None, "--max-memory=0 leaves no ceiling");
2183
2184 // A tight ceiling is honored rather than ignored in favour of the old
2185 // built-in 4.5 GB default, which sat far ABOVE such a ceiling.
2186 apply_memory_ceiling(2000);
2187 assert_eq!(resolve_rss_cap(), Some(1500 * 1024 * 1024));
2188
2189 set_memory_cap(None);
2190 }
2191
2192 // The override path is thread-local (race-free across libtest threads) and
2193 // never reads the process-global env, so these assertions are deterministic.
2194 #[test]
2195 fn override_zero_disables_budget() {
2196 // `--max-memory=0` maps to `set_memory_cap(Some(0))`, which must resolve
2197 // to "no ceiling" — NOT a 0-byte cap that fatals every conversion.
2198 set_memory_cap(Some(0));
2199 assert_eq!(resolve_rss_cap(), None, "cap 0 disables the soft budget");
2200 // A positive override is honored verbatim.
2201 set_memory_cap(Some(1_000));
2202 assert_eq!(resolve_rss_cap(), Some(1_000));
2203 // Restore the default so we don't leak state onto other tests sharing
2204 // this thread.
2205 set_memory_cap(None);
2206 }
2207
2208 #[test]
2209 fn soft_cap_tracks_the_single_knob() {
2210 // 0 in → 0 out: `--max-memory=0` disables the soft fuse (and, via
2211 // resolve_rss_cap, the whole memory limit).
2212 assert_eq!(soft_cap_from_ceiling(0), 0);
2213 // The soft fuse always sits strictly below the hard ceiling (graceful
2214 // cooperative failure fires first), and reproduces the historical
2215 // ~4.5 GB-under-6 GiB relationship at the 6144 MiB default.
2216 let hard = 6144u64 * 1024 * 1024;
2217 let soft = soft_cap_from_ceiling(6144);
2218 assert_eq!(soft, 4608u64 * 1024 * 1024);
2219 assert!(soft < hard, "soft fuse must be below the hard ceiling");
2220 // It scales with the knob, so a tight ceiling still gets a cooperative
2221 // guard below it (fixing the old fixed-4.5 GB decoupling).
2222 assert!(soft_cap_from_ceiling(2000) < 2000 * 1024 * 1024);
2223 assert!(soft_cap_from_ceiling(20000) > 6144 * 1024 * 1024);
2224 }
2225
2226 /// The soft-yield floor waiver: waived at/above the watermark→fuse midpoint,
2227 /// applied below it, and never waived when either bound is absent or
2228 /// degenerate. Guards the safety valve `soft_yield_min_boxes` sits on — a
2229 /// pathological per-box footprint must regain per-seam yielding before the
2230 /// fuse fires inside one un-yielded window.
2231 #[test]
2232 fn soft_yield_floor_waiver_boundaries() {
2233 const MB: u64 = 1024 * 1024;
2234 let wm = Some(12_000 * MB); // the witness's watermark at --max-memory 48000
2235 let fuse = Some(36_000 * MB); // and its fuse; midpoint = 24_000 MB
2236 let mark_kb = 24_000 * 1024;
2237 assert!(
2238 !soft_yield_urgency(mark_kb - 1, wm, fuse),
2239 "below the midpoint the floor applies"
2240 );
2241 assert!(
2242 soft_yield_urgency(mark_kb, wm, fuse),
2243 "at the midpoint the floor is waived"
2244 );
2245 assert!(soft_yield_urgency(mark_kb + 1, wm, fuse), "above it too");
2246 // --max-memory=0 shapes: no fuse, no watermark, or fuse <= watermark
2247 // (a calibration LATEXML_SPILL_AT_MIB above the fuse) — never urgent.
2248 assert!(!soft_yield_urgency(u64::MAX / 1024, wm, None));
2249 assert!(!soft_yield_urgency(u64::MAX / 1024, None, fuse));
2250 assert!(
2251 !soft_yield_urgency(u64::MAX / 1024, fuse, wm),
2252 "fuse below watermark is degenerate"
2253 );
2254 // saturating_mul: an absurd rss_kb must not overflow into false.
2255 assert!(soft_yield_urgency(u64::MAX, wm, fuse));
2256 }
2257}