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
1082thread_local! {
1083 // Re-entrancy guard so `\everypar`'s own digestion can't recursively re-fire it.
1084 static EVERYPAR_FIRING: Cell<bool> = const { Cell::new(false) };
1085}
1086
1087/// Fire `\the\everypar` when a paragraph enters horizontal mode, the way tex.web's
1088/// `new_graf` (background/tex.web L21117) does `begin_token_list(every_par)`.
1089///
1090/// Guarded two ways, because LaTeXML's `\everypar` is not TeX's:
1091/// * `\everypar` is empty for every ordinary paragraph (post-`\begin{document}` the
1092/// register is cleared — see `latex_constructs.rs`), so this is a cheap early
1093/// return except where a package populates it (algorithm2e line numbering sets
1094/// `\everypar`→`\algocf@everypar`→`\nl` inside a listing).
1095/// * We fire ONLY in the document body. In the preamble / during kernel load
1096/// `\everypar` holds the unmodelled LaTeX3 para-hook list
1097/// `\g__para_standard_everypar_tl` (from raw-loading `ltpara`); firing it trips
1098/// `\@nodocument` ("Missing \begin{document}"). `\begin{document}` lets
1099/// `\@nodocument`→`\relax`, so "document started" is exactly that test.
1100///
1101/// The digested boxes are pushed to the current box list BEFORE the triggering box
1102/// (the caller `extend_box_list`s that after), so `\nl`'s tag lands at the head of
1103/// the listingline. Errors are swallowed (this rides the infallible mode-switch
1104/// path); a genuine fatal is re-detected at the next digest-loop checkpoint.
1105fn fire_everypar() {
1106 if EVERYPAR_FIRING.with(|f| f.get()) {
1107 return;
1108 }
1109 let toks = match lookup_register("\\everypar", Vec::new()) {
1110 Ok(Some(RegisterValue::Tokens(t))) if !t.is_empty() => t,
1111 _ => return, // empty \everypar — the normal body paragraph
1112 };
1113 // Skip the preamble/kernel-load para-hook \everypar (see doc comment).
1114 if !x_equals(&T_CS!("\\@nodocument"), &T_CS!("\\relax")) {
1115 return;
1116 }
1117 EVERYPAR_FIRING.with(|f| f.set(true));
1118 if let Ok(digested) = digest(toks) {
1119 // A List box is unwound (flattened) on absorption, so `\nl`'s tag-whatsit runs
1120 // inline in the current listingline rather than under a wrapper.
1121 push_box_list(digested);
1122 }
1123 EVERYPAR_FIRING.with(|f| f.set(false));
1124}
1125
1126/// Switch to horizontal mode without stacking the mode.
1127/// Can only switch from vertical|internal_vertical to horizontal.
1128/// Perl: sub enterHorizontal.
1129/// tex.web `new_graf` (L21117) fires `\everypar` here (`begin_token_list`).
1130pub fn enter_horizontal() {
1131 let mode = lookup_string_from_sym(crate::pin!("MODE"));
1132 if mode.ends_with("vertical") {
1133 assign_value_inplace_sym(crate::pin!("MODE"), crate::pin!("horizontal"));
1134 fire_everypar();
1135 } else if !mode.ends_with("horizontal") && !mode.ends_with("math") {
1136 // Perl L420-422: warn on unexpected mode
1137 Warn!(
1138 "unexpected",
1139 "enterHorizontal",
1140 s!("Unexpected mode '{}' for enterHorizontal", mode)
1141 );
1142 }
1143 // else: already horizontal or math — fine
1144}
1145
1146/// Resume vertical mode by executing \par, in TeX-like fashion.
1147/// Perl: sub leaveHorizontal
1148pub fn leave_horizontal() -> Result<()> {
1149 let mode = lookup_string_from_sym(crate::pin!("MODE"));
1150 let bound = lookup_string_from_sym(crate::pin!("BOUND_MODE"));
1151 if mode == "horizontal" && bound.ends_with("vertical") {
1152 // This needs to be an invisible, and slightly gentler, \par
1153 assign_value("INTERNAL_PAR", true, Some(Scope::Local));
1154 let par_result = invoke_token(&T_CS!("\\par"))?;
1155 push_box_list_vec(par_result);
1156 assign_value("INTERNAL_PAR", false, Some(Scope::Local));
1157 }
1158 Ok(())
1159}
1160
1161/// Resume vertical mode internally: reset mode without firing \par.
1162/// Used within argument digestion, e.g. endMode for vertical modes.
1163/// Perl: sub leaveHorizontal_internal
1164pub fn leave_horizontal_internal() {
1165 let mode = lookup_string_from_sym(crate::pin!("MODE"));
1166 let bound = lookup_string_from_sym(crate::pin!("BOUND_MODE"));
1167 if mode == "horizontal" && bound.ends_with("vertical") {
1168 repack_horizontal();
1169 assign_value_inplace_sym(crate::pin!("MODE"), arena::pin(&bound));
1170 }
1171}
1172
1173/// Repack recently digested horizontal items into single horizontal List.
1174/// Note that TeX would have done paragraph line-breaking, resulting in essentially
1175/// a vertical list.
1176/// Perl: sub repackHorizontal (Stomach.pm lines 440-454)
1177pub fn repack_horizontal() {
1178 let mut stomach = stomach_mut!();
1179 let mut para: Vec<Digested> = Vec::new();
1180 let mut keep = false;
1181
1182 loop {
1183 let should_pop = if let Some(item) = stomach.box_list.last() {
1184 // Perf: compare as &str via with() instead of allocating a String each iter.
1185 // Default mode is "horizontal" (matches previous unwrap_or).
1186 let mode_prop = item.get_property("mode");
1187 let (is_horiz_family, is_plain_horizontal) = match mode_prop.as_deref() {
1188 Some(Stored::String(sym)) => arena::with(*sym, |s| {
1189 let plain = s == "horizontal";
1190 let fam = plain || s == "restricted_horizontal" || s == "math";
1191 (fam, plain)
1192 }),
1193 None => (true, true), // default "horizontal"
1194 Some(other) => {
1195 // Rare path — fall back to Display formatting.
1196 let s = other.to_string();
1197 let plain = s == "horizontal";
1198 let fam = plain || s == "restricted_horizontal" || s == "math";
1199 (fam, plain)
1200 },
1201 };
1202 if is_horiz_family {
1203 if !is_plain_horizontal || !item.get_property_bool("isSpace") {
1204 keep = true;
1205 }
1206 true
1207 } else {
1208 false
1209 }
1210 } else {
1211 false
1212 };
1213
1214 if should_pop {
1215 para.push(stomach.box_list.pop().unwrap());
1216 } else {
1217 break;
1218 }
1219 }
1220
1221 // Items were popped in reverse order, so reverse them back
1222 para.reverse();
1223
1224 if keep {
1225 let mut list = List::new(para);
1226 list.mode = Some(TexMode::Text); // "horizontal" in Perl
1227 // Perl: List(@para, mode => 'horizontal') — set mode property string
1228 // This is needed for compute_boxes_size vertical layout to detect paragraph Lists
1229 list.set_property("mode", Stored::String(pin!("horizontal")));
1230 // Perl #2798 (S4): a finished paragraph List records BOTH the fill width
1231 // (\hsize) and the \baselineskip, so the sizing pass (compute_boxes_size)
1232 // can line-break and stack with the right inter-line spacing.
1233 // $list->setProperty(width => LookupDimension('\hsize'));
1234 // $list->setProperty(baseline => LookupDimension('\baselineskip', 1));
1235 if let Some(hsize) = lookup_dimension("\\hsize") {
1236 list.set_property("width", hsize);
1237 }
1238 if let Some(baseline) = lookup_dimension("\\baselineskip") {
1239 list.set_property("baseline", baseline);
1240 }
1241 stomach.box_list.push(Digested::from(list));
1242 }
1243}
1244
1245pub fn new_local_box_list() {
1246 let mut buffer = Vec::new();
1247 let mut stomach = stomach_mut!();
1248 // Guard the OTHER aberrant accumulation path: the boxing stack. When a loop
1249 // builds *inside* boxes (`\setbox`/`\hbox`), each nesting suspends the partial
1250 // outer list here and opens a fresh `box_list`; an unbounded `\hbox{\hbox{…}}`
1251 // nest grows this stack without ever touching the byte/cycle guards on the
1252 // (small, innermost) `box_list`. A depth cap is O(1) and safe — no real
1253 // document nests boxes anywhere near this deep (typical depth is tens; the
1254 // math0102053 line-drawing loop sits at 13). Platform-independent, fires long
1255 // before any RSS/OOM ceiling.
1256 if stomach.localized_box_list.len() > STOMACH_BOXING_DEPTH_CAP
1257 && stomach.pending_cycle_fatal.is_none()
1258 {
1259 stomach.pending_cycle_fatal = Some((
1260 ErrorCategory::MemoryBudget,
1261 s!(
1262 "Boxing-stack runaway: box nesting depth exceeded {} \
1263 (unbounded \\hbox/\\setbox nesting)",
1264 STOMACH_BOXING_DEPTH_CAP
1265 ),
1266 ));
1267 }
1268 std::mem::swap(&mut stomach.box_list, &mut buffer);
1269 stomach.localized_box_list.push(buffer);
1270}
1271
1272/// Hard cap on box-nesting depth (the `localized_box_list` boxing stack). No
1273/// real document nests `\hbox`/`\setbox` more than tens deep; a runaway nest
1274/// grows this without bound while the per-level `box_list` stays small, evading
1275/// the byte/cycle guards. Platform-independent.
1276const STOMACH_BOXING_DEPTH_CAP: usize = 100_000;
1277pub fn expire_local_box_list() -> Vec<Digested> {
1278 let mut stomach = stomach_mut!();
1279 let mut buffer = stomach.localized_box_list.pop().unwrap_or_default();
1280 std::mem::swap(&mut stomach.box_list, &mut buffer);
1281 buffer
1282}
1283
1284/// Recover the boxes a failed `digest_next_body` left stranded, in document
1285/// order, and reset the accumulation stack.
1286///
1287/// `digest_next_body` accumulates into `box_list` (with outer levels suspended
1288/// on `localized_box_list`) and only hands them back via `expire_local_box_list`
1289/// on the SUCCESS path — so a mid-body Fatal drops every box digested during
1290/// that call. `digest_internal` is written to keep partial output after a
1291/// recoverable Fatal ("Perl finishDigestion L219-220: loop consuming input even
1292/// after errors"), but that intent was defeated whenever the failure landed in
1293/// the FIRST body: the caller's `boxes` was still empty, so the run produced a
1294/// 39-byte empty document instead of the text preceding the bad construct.
1295/// Witness arXiv:2508.07407 (ar5iv #556) — its whole document was lost, though
1296/// only one `\tikz` picture is pathological.
1297///
1298/// `drop_innermost` is for the runaway guards (`Stomach:Recursion`), where the
1299/// innermost level IS the pathology — a 50k-box repeating window. Salvaging it
1300/// would graft the garbage into the document, so drop that level and keep the
1301/// suspended outer ones, which is precisely "drop the offending construct, keep
1302/// the document". For every other recoverable Fatal the current level is honest
1303/// content and is kept.
1304pub fn salvage_pending_box_lists(drop_innermost: bool) -> Vec<Digested> {
1305 let mut stomach = stomach_mut!();
1306 let mut acc = std::mem::take(&mut stomach.box_list);
1307 if drop_innermost {
1308 acc.clear();
1309 }
1310 // Unwind the suspended levels innermost-parent first, each time prefixing the
1311 // parent's own content so the result stays in document order.
1312 while let Some(mut parent) = stomach.localized_box_list.pop() {
1313 parent.append(&mut acc);
1314 acc = parent;
1315 }
1316 // Refuse a salvage that is itself pathological. `drop_innermost` removes the
1317 // runaway level for the STOMACH box-cycle guard, where that level is the
1318 // pathology — but the GULLET cycle guard (`Timeout:Recursion`) fires on the
1319 // token stream, and there the bloated boxes can sit in the suspended outer
1320 // levels instead, so dropping the innermost does not bound anything.
1321 //
1322 // `STOMACH_CYCLE_ACTIVATE` is exactly the engine's own "no honest document
1323 // accumulates this many undrained boxes" line, so reuse it rather than invent
1324 // a second threshold: a salvage at or past it is runaway output, and handing
1325 // it to the builder is worse than handing over nothing. Measured on
1326 // arXiv:2605.25400, where an unbounded salvage turned a 9.7 s fatal into a
1327 // 120 s wall-clock timeout that wrote a ZERO-byte file — strictly worse than
1328 // the 39-byte stub it replaced.
1329 if acc.len() >= STOMACH_CYCLE_ACTIVATE {
1330 acc.clear();
1331 }
1332 acc
1333}
1334
1335/// Stomach-level cycle guard: only once `box_list` has grown far past any
1336/// flushed-document size (a normal `box_list` is drained as paragraphs/boxes
1337/// complete and stays small) do we record the digest-push stream and look for
1338/// a short repeating window — a box-accumulation infinite loop. Cuts it off
1339/// with a clean Fatal long before the RSS soft cap. Caller must already hold
1340/// the stomach borrow and have appended past the activation size.
1341#[inline]
1342fn cycle_guard_record(st: &mut Stomach, d: &Digested) {
1343 // Once a fatal is pending, further detection work is pointless — the raise
1344 // happens at the NEXT `check_timeout` tick, which (since PR #249 review
1345 // P2-6) every digestion loop runs per iteration (`digest_next_body`,
1346 // `digest`, `raw_tex`), so the window between detection and raise is at
1347 // most one `invoke_token`. (Before that fix, a runaway confined to
1348 // `digest()` set the flag and the guards then self-disabled while the list
1349 // grew unbounded — the flag was never raised on that path.)
1350 if st.pending_cycle_fatal.is_none() {
1351 // Hard size backstop — platform-INDEPENDENT (the RSS soft cap in
1352 // `check_timeout` reads `/proc/self/statm` and is therefore Linux-only;
1353 // on macOS/Windows it is inactive). This bounds `box_list` everywhere and
1354 // also catches APERIODIC runaways the windowed cycle detector cannot
1355 // (boxes that vary per iteration, e.g. a `\@whilenum` loop with a
1356 // counter, or period > MAX_WINDOW). 40× the validated cycle-activation
1357 // size, far past any flushed-document list. Analogous to the gullet's
1358 // platform-independent `token_limit`.
1359 if let Some(cap) = box_count_cap()
1360 && st.box_list.len() > cap
1361 {
1362 st.pending_cycle_fatal = Some((
1363 ErrorCategory::MemoryBudget,
1364 s!(
1365 "Box-list runaway: {} accumulated boxes exceeded the hard cap of {} \
1366 (unbounded digestion with no detectable cycle); raise --max-memory, \
1367 or --max-memory=0 to lift the ceiling",
1368 st.box_list.len(),
1369 cap
1370 ),
1371 ));
1372 return;
1373 }
1374 // Portable, BYTE-based memory guard. The count caps above are a proxy for
1375 // memory, but per-box weight varies several-fold (a bare text box vs a
1376 // deeply nested `\hbox{\raise…\hbox{…}}`), so a count calibrated for light
1377 // boxes lets a HEAVY-box runaway sail past it — only the Linux-only RSS cap
1378 // in `check_timeout` (4.5 GB) then catches it, late and non-portably.
1379 // Here we estimate the box list's actual heap footprint (by sampling, so
1380 // it stays O(1) amortised) and `Fatal` once it crosses a budget set BELOW
1381 // the RSS cap. This fires EARLIER than the external RSS guard AND works on
1382 // macOS/Windows where `/proc/self/statm` is unavailable. Driver:
1383 // math0102053 (plain-TeX `\@whiledim` line-drawing loop — Perl OOMs too;
1384 // ~1.87 M heavy line-segment boxes reached 4.5 GB RSS before the 2 M count
1385 // cap could fire).
1386 let len = st.box_list.len();
1387 if let Some(budget) = box_bytes_budget()
1388 && len >= BYTE_CHECK_ACTIVATE
1389 && len.is_multiple_of(BYTE_CHECK_EVERY)
1390 {
1391 let est = estimate_box_list_bytes(&st.box_list);
1392 if est > budget {
1393 st.pending_cycle_fatal = Some((
1394 ErrorCategory::MemoryBudget,
1395 s!(
1396 "Box-list memory runaway: ~{} MB estimated across {} boxes exceeded \
1397 the {} MB budget (unbounded accumulation); raise --max-memory, or \
1398 --max-memory=0 to lift the ceiling. NOTE: the estimate is a LOWER \
1399 BOUND (each box is walked at most {} nodes deep), so true RSS at \
1400 this point is typically several times larger",
1401 est / 1_000_000,
1402 len,
1403 budget / 1_000_000,
1404 crate::digested::EB_BUDGET
1405 ),
1406 ));
1407 return;
1408 }
1409 }
1410 let fp = d.cycle_fingerprint();
1411 if let Some(period) = st.cycle_guard.push(fp) {
1412 st.pending_cycle_fatal = Some((
1413 ErrorCategory::Recursion,
1414 s!(
1415 "Infinite digestion loop: a window of {} box(es) repeated {}+ times \
1416 while the box list grew past {}",
1417 period,
1418 crate::cycle_guard::REPEAT,
1419 STOMACH_CYCLE_ACTIVATE
1420 ),
1421 ));
1422 }
1423 }
1424}
1425
1426/// Hard, platform-independent ceiling on `box_list` length — `None` when the
1427/// memory limit is disabled. A normal list is flushed continuously and stays
1428/// tiny; reaching this is an unbounded accumulation. The backstop for
1429/// very-LIGHT-box runaways, which the byte budget below can under-weigh.
1430///
1431/// **Rides `--max-memory`**, like every other memory ceiling: the resolved soft
1432/// cap divided by [`BYTES_PER_LIGHT_BOX`], which reproduces the historical fixed
1433/// 2 M at the stock `--max-memory=6144` (soft cap 4608 MiB), scales linearly
1434/// with the flag, and is `None` at `--max-memory=0`.
1435///
1436/// It used to be a hardcoded `const`, which made `--max-memory=0` a documented
1437/// lie: the binary prints "memory limiting disabled entirely" and then Fatal'd
1438/// on a memory ceiling anyway, with no flag able to raise it. Witness: a
1439/// ~10 000-page notes document (Nasser Abbasi, rc4 report 2026-07-28) died on
1440/// the byte budget below after 8 h at ~58 GB RSS having explicitly passed
1441/// `--max-memory=0`. Guard: `box_ceilings_follow_the_memory_knob`.
1442fn box_count_cap() -> Option<usize> {
1443 resolve_rss_cap().map(|cap| (cap / BYTES_PER_LIGHT_BOX) as usize)
1444}
1445
1446/// Calibration for [`box_count_cap`]: the per-box footprint of a *light* box,
1447/// chosen so the stock ceiling yields the validated 2 M-box cap.
1448const BYTES_PER_LIGHT_BOX: u64 = 2_416;
1449
1450/// Portable byte-budget for the accumulated `box_list` — `None` when the memory
1451/// limit is disabled. `estimate_bytes` counts each box's OWNED heavy data (the
1452/// `properties` HashMap, the `Tbox` `tokens` source-TeX vector, args/children
1453/// vectors + nested children). Works on macOS/Windows, where the `/proc` RSS
1454/// check is inactive and this is the ONLY memory guard for a heavy-box runaway.
1455///
1456/// **Rides `--max-memory`** (see [`box_count_cap`]): two thirds of the resolved
1457/// soft cap, i.e. 3.22 GB at the stock `--max-memory=6144` — the historical
1458/// fixed 3.2 GB — so on Linux it still `Fatal`s well before the RSS fuse, and
1459/// `None` at `--max-memory=0`.
1460///
1461/// **The estimate is a LOWER BOUND, not an RSS prediction.**
1462/// [`Digested::estimate_bytes`] walks at most `EB_BUDGET` (256) nodes per box,
1463/// so a deep document tree is undercounted by however much hangs below that
1464/// horizon — and the shortfall is content-dependent, not a constant. Measured:
1465/// a flat 600 k-paragraph synthetic crosses the 3.2 GB budget at 5.8 GB true RSS
1466/// (est ≈ 58 % of RSS), while Nasser's deeply-nested notes crossed the *same*
1467/// budget at ~58 GB (est ≈ 6 %). A ~10× spread — so do not read the budget as a
1468/// megabyte ceiling on the process. (The "tracks true RSS within ~10 %" claim
1469/// this doc used to carry held only for its calibration paper, math0102053: a
1470/// plain-TeX `\@whiledim` line-drawing loop whose ~1.87 M boxes are shallow.)
1471fn box_bytes_budget() -> Option<usize> { resolve_rss_cap().map(|cap| (cap / 3 * 2) as usize) }
1472/// Don't bother byte-sampling until the list is already well past the cycle
1473/// activation size (a normal list never gets here).
1474const BYTE_CHECK_ACTIVATE: usize = 200_000;
1475/// Re-estimate the box-list footprint every this-many boxes (amortises the
1476/// sampling cost to O(1) per push).
1477const BYTE_CHECK_EVERY: usize = 50_000;
1478/// Boxes sampled per byte estimate. Box weights are bimodal (light text
1479/// segments vs heavy nested structures), so a *dense* sample is needed to keep
1480/// the extrapolation from aliasing against the heavy-box stride.
1481const BYTE_SAMPLE_N: usize = 8192;
1482
1483/// Cost-bounded estimate of the heap bytes held by `list`, via even sampling +
1484/// extrapolation (each sampled box is itself depth-bounded — see
1485/// [`crate::digested::Digested::estimate_bytes`]). O(`BYTE_SAMPLE_N`) regardless
1486/// of list length. The sample is taken as contiguous *blocks* spread across the
1487/// list rather than a single large stride, which is far more robust to clustered
1488/// heavy boxes than evenly-strided point sampling.
1489fn estimate_box_list_bytes(list: &[Digested]) -> usize {
1490 let len = list.len();
1491 if len == 0 {
1492 return 0;
1493 }
1494 if len <= BYTE_SAMPLE_N {
1495 return list.iter().map(Digested::estimate_bytes).sum();
1496 }
1497 // 32 blocks of (BYTE_SAMPLE_N/32) contiguous boxes, evenly spaced — captures
1498 // local clustering of heavy boxes that point sampling misses.
1499 const BLOCKS: usize = 32;
1500 let block = (BYTE_SAMPLE_N / BLOCKS).max(1);
1501 let gap = len / BLOCKS;
1502 let mut sum = 0usize;
1503 let mut n = 0usize;
1504 for b in 0..BLOCKS {
1505 let start = b * gap;
1506 let end = (start + block).min(len);
1507 for d in &list[start..end] {
1508 sum += d.estimate_bytes();
1509 n += 1;
1510 }
1511 }
1512 // average-per-box × len; usize (64-bit) cannot overflow at realistic sizes.
1513 (sum / n.max(1)) * len
1514}
1515
1516pub fn extend_box_list<I>(arg: I)
1517where I: IntoIterator<Item = Digested> {
1518 let mut st = stomach_mut!();
1519 // Fast path (the overwhelming common case): box list still small — just
1520 // extend, no per-box fingerprinting.
1521 if st.box_list.len() <= STOMACH_CYCLE_ACTIVATE {
1522 st.box_list.extend(arg);
1523 return;
1524 }
1525 // Runaway territory: record each appended box into the cycle guard.
1526 for d in arg {
1527 cycle_guard_record(&mut st, &d);
1528 st.box_list.push(d);
1529 }
1530}
1531pub fn push_box_list(arg: Digested) {
1532 let mut st = stomach_mut!();
1533 if st.box_list.len() > STOMACH_CYCLE_ACTIVATE {
1534 cycle_guard_record(&mut st, &arg);
1535 }
1536 st.box_list.push(arg);
1537}
1538fn push_box_list_vec(args: Vec<Digested>) { extend_box_list(args) }
1539
1540/// Engage the stomach's box-list cycle guard only once the (normally
1541/// flushed-small) `box_list` has grown past this. A real document's list is
1542/// drained continuously; a runaway accumulates boxes without bound. Keeps the
1543/// guard inert for every ordinary conversion. (~50k boxes is already well past
1544/// any sane un-flushed list yet ~30× below the 4.5 GB OOM ceiling.)
1545const STOMACH_CYCLE_ACTIVATE: usize = 50_000;
1546pub fn pop_box_list() -> Option<Digested> { stomach_mut!().box_list.pop() }
1547pub fn with_box_list<R, FnR>(caller: FnR) -> R
1548where FnR: FnOnce(&[Digested]) -> R {
1549 let stomach = stomach!();
1550 let list = &stomach.box_list;
1551 caller(list)
1552}
1553pub fn with_box_list_mut<R, FnR>(caller: FnR) -> R
1554where FnR: FnOnce(&mut [Digested]) -> R {
1555 let mut stomach = stomach_mut!();
1556 let list = &mut stomach.box_list;
1557 caller(list)
1558}
1559/// Access to the current box_list as a `&mut Vec` — allows push/pop operations.
1560pub fn with_box_list_mut_vec<R, FnR>(caller: FnR) -> R
1561where FnR: FnOnce(&mut Vec<Digested>) -> R {
1562 let mut stomach = stomach_mut!();
1563 caller(&mut stomach.box_list)
1564}
1565
1566// **********************************************************************
1567// Digestion
1568// **********************************************************************
1569
1570/// Digest a list of tokens independent from any current Gullet.
1571/// Typically used to digest arguments to primitives or constructors.
1572/// Returns a List containing the digested material.
1573pub fn digest<T: Into<Tokens>>(tokens: T) -> Result<Digested> {
1574 let tokens: Tokens = tokens.into();
1575 if tokens.is_empty() {
1576 return Ok(Digested::default());
1577 }
1578 gullet::reading_from_mouth(Mouth::default(), || {
1579 gullet::unread(tokens);
1580 clear_prefixes(); // prefixes shouldn't apply here.
1581 let mode = if lookup_bool_sym(crate::pin!("IN_MATH")) {
1582 TexMode::Math
1583 } else {
1584 TexMode::Text
1585 };
1586 let initdepth = stomach!().boxing.len();
1587 let depth = initdepth;
1588 new_local_box_list();
1589 while let Some(token) = match gullet::get_pending_comment() {
1590 Some(comment) => Some(comment),
1591 None => gullet::read_x_token(Some(true), false, None)?,
1592 } {
1593 // Raise any pending stomach-guard fatal + deadline/RSS checks. This
1594 // loop is a digestion path of its own — without a tick here, a runaway
1595 // confined to constructor-argument digestion set `pending_cycle_fatal`
1596 // at detection but nothing ever RAISED it (check_timeout's only call
1597 // site was digest_next_body), and the RSS soft cap / wall-clock
1598 // deadline were equally dead on this path. PR #249 review P2-6.
1599 check_timeout()?;
1600 // Done if we run out of tokens
1601 let invoked = invoke_token(&token)?;
1602 extend_box_list(invoked);
1603
1604 if initdepth > stomach!().boxing.len() {
1605 // if we've closed the initial mode.
1606 break;
1607 }
1608 if initdepth < depth {
1609 // TODO
1610 fatal!(Internal, EoF, "We've fallen off the end, somehow !?!?!?");
1611 // Fatal('internal', '<EOF>', self,
1612 // "We've fallen off the end, somehow!?!?!",
1613 // "Last token " . ToString($LaTeXML::CURRENT_TOKEN)
1614 // . " (Boxing depth was $initdepth, now $depth: Boxing generated by "
1615 // . join(', ', map { ToString($_) } @{ $self{boxing} }))
1616 // if $initdepth < $depth;
1617 }
1618 }
1619
1620 let mut digested_list = List::new(expire_local_box_list());
1621 digested_list.mode = Some(mode);
1622 digested_list.into()
1623 })
1624}
1625
1626/// Return the digested `List` after reading and digesting a body from the its Gullet.
1627/// The body extends until the current level of boxing or environment is closed.
1628pub fn digest_next_body(terminal_opt: Option<Token>) -> Result<Vec<Digested>> {
1629 let start_location = { gullet::get_locator() };
1630
1631 let init_depth = { stomach!().boxing.len() };
1632 // Did the loop end because the INPUT RAN OUT (as opposed to reaching the
1633 // terminal or closing the initial mode)? Perl `Stomach.pm` L130 keys the
1634 // trailer box on `unless $token`, and `$token` is undef exactly when the
1635 // `while (defined($token = ...))` condition failed — i.e. on EOF, whether or
1636 // not tokens were read before it. See the trailer push below.
1637 let mut ran_out = true;
1638 let mut found_terminal = false;
1639 new_local_box_list();
1640 let alignment_opt = lookup_alignment();
1641 // TODO: bookkeep for "expected" warning
1642 //let mut aug = Vec::new();
1643
1644 // try reading a executable token
1645 while let Some(token) = match gullet::get_pending_comment() {
1646 Some(comment) => Some(comment),
1647 None => gullet::read_x_token(Some(true), false, None)?,
1648 } {
1649 // Check conversion timeout
1650 check_timeout()?;
1651 // first, check for alignment case
1652 // Perl #2775: only fire at the original alignment nesting level,
1653 // not inside deeper boxing groups (e.g. \vbox inside a tabular cell).
1654 if alignment_opt.is_some()
1655 && !stomach!().box_list.is_empty()
1656 && (stomach!().boxing.len() <= init_depth)
1657 && (token == T_ALIGN!()
1658 || token == T_CS!("\\cr")
1659 || token == T_CS!("\\lx@hidden@cr")
1660 || token == T_CS!("\\lx@hidden@crcr"))
1661 {
1662 gullet::unread_one(token);
1663 return Ok(expire_local_box_list());
1664 }
1665 // normal case
1666 let invoked = invoke_token(&token)?;
1667 extend_box_list(invoked);
1668
1669 if let Some(ref terminal) = terminal_opt
1670 && &token == terminal
1671 {
1672 found_terminal = true;
1673 ran_out = false;
1674 break;
1675 }
1676 if init_depth > stomach!().boxing.len() {
1677 ran_out = false;
1678 break;
1679 }
1680 // Fragment yield (streaming pass 1): between top-level constructs, at a
1681 // legal seam, hand back the boxes accumulated so far so the driver can
1682 // build + spill and re-enter. Everything digestion carries — gullet mouth
1683 // stack, State undo frames, mode, fonts — is thread-local and survives
1684 // between `digest_next_body` calls by construction, so resuming is the
1685 // same operation `digest_internal`'s outer loop already performs; the
1686 // alignment early-return above is the established precedent for
1687 // returning early with a partial list.
1688 //
1689 // Seam legality (probed 2026-07-29 on a real conversion, not assumed):
1690 // only the DRIVER call — `digest_internal` is the one caller that enters
1691 // with an empty boxing stack (`init_depth == 0`); constructor argument
1692 // digests also pass `None` but always sit inside an open box. The boxing
1693 // stack must be back at 0, and the mode VERTICAL-family: the document
1694 // body runs in `internal_vertical` (the `\begin{document}` environment's
1695 // mode — plain `vertical` occurs only before it), and at depth 0 that
1696 // cannot be a vbox/minipage interior, which always sits at deeper boxing.
1697 // A horizontal-mode cut would split the run `repack_horizontal` folds
1698 // into one paragraph; alignment, math, and open conditionals must all be
1699 // closed. A single construct larger than the whole budget simply digests
1700 // through — the existing hard ceilings still protect.
1701 //
1702 // Checked AFTER the terminal/depth exits so a real exit always wins, and
1703 // before the next `read_x_token` so nothing is consumed-then-unread.
1704 if let Some(budget) = FRAGMENT_YIELD_BUDGET.get()
1705 && init_depth == 0
1706 && terminal_opt.is_none()
1707 && alignment_opt.is_none()
1708 && {
1709 // The box budget yields on its own. The soft-RSS branch additionally
1710 // requires a MINIMUM accumulation: it is a level test (`rss > soft`)
1711 // with no hysteresis, so a document whose resident floor sits above
1712 // the watermark latches it on for the whole run and yields at every
1713 // seam with nothing accumulated — see `soft_yield_min_boxes` for the
1714 // measured degeneracy (24 M yields / 5.5 KB segments on the witness).
1715 let accumulated = stomach!().box_list.len();
1716 let rss_kb = LAST_SAMPLED_RSS_KB.get();
1717 accumulated >= budget
1718 || (FRAGMENT_YIELD_RSS_SOFT_KB
1719 .get()
1720 .is_some_and(|soft| rss_kb > soft)
1721 // The floor is waived once pressure is urgent, so pathological
1722 // per-box footprints keep the immediate response this branch
1723 // exists to give (`soft_yield_is_urgent`).
1724 && (accumulated >= soft_yield_min_boxes() || soft_yield_is_urgent(rss_kb)))
1725 }
1726 && stomach!().boxing.is_empty()
1727 && lookup_alignment().is_none()
1728 && !lookup_bool_sym(crate::pin!("IN_MATH"))
1729 && open_conditional_count() == 0
1730 && matches!(
1731 lookup_string_from_sym(crate::pin!("MODE")).as_str(),
1732 "vertical" | "internal_vertical"
1733 )
1734 {
1735 FRAGMENT_YIELDED.set(true);
1736 FRAGMENT_YIELD_COUNT.set(FRAGMENT_YIELD_COUNT.get() + 1);
1737 // No EOF trailer (`ran_out` stays true only through the loop's own
1738 // exhaustion path — we return before reaching it), and no
1739 // `gullet::flush()`: both are end-of-input actions, and input remains.
1740 return Ok(expire_local_box_list());
1741 }
1742 }
1743
1744 if let Some(ref terminal) = terminal_opt
1745 && !found_terminal
1746 {
1747 let message = s!(
1748 "body should have ended with {:?}. current body started at {:?}",
1749 terminal,
1750 start_location
1751 );
1752 Warn!("expected", terminal, message);
1753 }
1754 // and add a Dummy `trailer' if none explicit — Perl `Stomach.pm` L130,
1755 // `push(@LaTeXML::LIST, Box()) unless $token;`.
1756 //
1757 // This was mistranslated as "if we never read ANY token", which is a strictly
1758 // narrower condition: it agrees with Perl only on a body that was empty from
1759 // the start. The case it missed is a body that read content and THEN hit EOF —
1760 // and that is the case the trailer exists for. `readDigested`
1761 // (`Base_ParameterTypes.pool.ltxml` L374, ported in `base_parameter_types.rs`)
1762 // does `push(@list, digestNextBody()); pop(@list);` to strip the closing `}`
1763 // box; with no trailer pushed, that `pop` silently ate a box of REAL CONTENT.
1764 // Concretely: one runaway `.bib` field swallowed the rest of the entry into
1765 // its own argument, and the `pop` then removed the boxes carrying every
1766 // following entry — an empty bibliography where Perl renders all of them.
1767 if ran_out {
1768 push_box_list(Digested::from(Tbox::default()));
1769 }
1770 Ok(expire_local_box_list())
1771}
1772
1773/// a convenience function for including chunks of raw TeX (or LaTeX) code
1774/// It is useful for copying portions of the normal
1775/// implementation that can be handled simply using macros and primitives.
1776pub fn raw_tex(text: &str) -> Result<()> {
1777 // It could be as simple as this, except if catcodes get changed, it's too late!!!
1778 // Digest(TokenizeInternal($text));
1779 let raw_tex_mouth = Mouth::new(
1780 text,
1781 Some(MouthOptions {
1782 fordefinitions: true,
1783 at_letter: true,
1784 ..MouthOptions::default()
1785 }),
1786 )?;
1787 gullet::reading_from_mouth(raw_tex_mouth, || -> Result<()> {
1788 while let Some(token) = gullet::read_x_token(Some(false), false, None)? {
1789 // Same per-iteration guard tick as digest()/digest_next_body — see the
1790 // comment in `digest` (PR #249 review P2-6): raw-loaded .sty/.cls
1791 // digestion must raise pending stomach fatals and honor the deadline
1792 // and RSS caps too.
1793 check_timeout()?;
1794 if token.get_catcode() != Catcode::SPACE {
1795 invoke_token(&token)?;
1796 }
1797 }
1798 Ok(())
1799 })?;
1800 Ok(())
1801}
1802
1803/// Invoke a token
1804///
1805/// If it is a primitive or constructor, the definition will be invoked,
1806/// possibly arguments will be parsed from the Gullet.
1807/// Otherwise, the token is simply digested: turned into an appropriate box.
1808/// Returns a list of boxes/whatsits.
1809pub fn invoke_token(input_token: &Token) -> Result<Vec<Digested>> {
1810 // Perf: Token is Copy (SymStr + Catcode, ~5 bytes), so we pass by value
1811 // directly instead of wrapping in Cow<Token>.
1812 let mut maybe_token: Option<Token> = Some(*input_token);
1813 let mut result: Vec<Digested> = Vec::new();
1814 // INVOKE:
1815 while let Some(token) = maybe_token.take() {
1816 // RAII guard: auto-pops current_token on scope exit (even on early return/panic)
1817 let _token_guard = local_current_token_guard(token);
1818 {
1819 stomach_mut!().token_stack.push(token);
1820 }
1821 if { stomach!().token_stack.len() } > MAXSTACK {
1822 fatal!(
1823 Stomach,
1824 Recursion,
1825 s!(
1826 "Excessive recursion(?): Tokens on stack: {:?}",
1827 stomach!().token_stack
1828 )
1829 );
1830 }
1831 result = Vec::new();
1832
1833 // Rust notes: It would be ideal if we could unify the cases for (Primtive, Constructor,
1834 // MathPrimitive), as well as (Expandable, Conditional) since the
1835 // API is identical. However, as the types are different, Rust
1836 // constrains us here, we need separate match arms for each
1837 // distinctly typed enum case.
1838 let digestable_def = lookup_digestable_definition(&token);
1839 match digestable_def {
1840 None | Some(Stored::None) => {
1841 result = invoke_token_undefined(&token)?;
1842 },
1843 Some(Stored::Token(meaning)) => {
1844 // Common case
1845 let cc = meaning.get_catcode();
1846 if cc == Catcode::CS {
1847 result = invoke_token_undefined(&token)?;
1848 } else if cc.is_absorbable() {
1849 if let Some(digested) = invoke_token_simple(meaning)? {
1850 result.push(digested);
1851 }
1852 } else {
1853 // Perl L187-189: deactivate T_ALIGN to prevent error flood in tables
1854 if token.get_catcode() == Catcode::ALIGN
1855 && let Some(relax_meaning) = lookup_meaning(&T_CS!("\\relax"))
1856 {
1857 assign_meaning(&token, relax_meaning, Some(Scope::Local));
1858 }
1859 let message = s!(
1860 "The token {:?} (catcode {:?}) should never reach Stomach!",
1861 token,
1862 cc
1863 );
1864 Error!("misdefined", token, &message);
1865 if let Some(digested) = invoke_token_simple(meaning)? {
1866 result.push(digested);
1867 }
1868 }
1869 },
1870 Some(Stored::Expandable(meaning)) => {
1871 // A math-active character will (typically) be a macro,
1872 // but it isn't expanded in the gullet, but later when digesting, in math mode
1873 // (? I think)
1874 let invoked_meaning = meaning.invoke(false)?;
1875 if !invoked_meaning.is_empty() {
1876 {
1877 gullet::unread(invoked_meaning);
1878 }
1879 }
1880 // replace the token by it's expansion!!!
1881 maybe_token = gullet::read_x_token(None, false, None)?;
1882 {
1883 stomach_mut!().token_stack.pop();
1884 }
1885 drop(_token_guard); // expire current token via RAII
1886 continue;
1887 },
1888 Some(Stored::Conditional(meaning)) => {
1889 // Conditionals are "expandable", use the regular invoke.
1890 let invoked_meaning = meaning.invoke(false)?;
1891 gullet::unread(invoked_meaning);
1892 maybe_token = gullet::read_x_token(None, false, None)?;
1893 {
1894 stomach_mut!().token_stack.pop();
1895 }
1896 drop(_token_guard); // expire current token via RAII
1897 continue;
1898 },
1899 Some(Stored::Constructor(meaning)) => {
1900 // Perl Stomach.pm L187-189: deactivate T_ALIGN to `\relax` LOCAL
1901 // on first non-table encounter, to prevent error flood. The
1902 // existing guard at the Stored::Token branch (above) only fires
1903 // when `&` has been Let'd to another token, but the `&`
1904 // CC_ALIGN char-token is bound to a Constructor (TeX_Tables.pool
1905 // L49: `DefConstructorI('&', undef, sub { Error('unexpected', '&',
1906 // $_[0], "Stray alignment \"&\"") })`), so it falls into THIS
1907 // branch instead. Without this guard, papers with multiple stray
1908 // `&` (e.g. astro-ph0107583's bibitem with unescaped `Hirose &
1909 // Osaki`) emit one Error per occurrence; Perl emits ONE total
1910 // because of the LOCAL `\relax` rebinding. Self-deactivate here
1911 // too so subsequent `&` invocations no-op.
1912 if token.get_catcode() == Catcode::ALIGN
1913 && let Some(relax_meaning) = lookup_meaning(&T_CS!("\\relax"))
1914 {
1915 assign_meaning(&token, relax_meaning, Some(Scope::Local));
1916 }
1917 // `meaning` IS the state table's `Rc<Constructor>`, so hand it to the
1918 // Whatsit rather than letting `invoke_primitive` deep-clone the
1919 // definition once per invocation (see `Constructor::invoke_primitive_shared`).
1920 result = Constructor::invoke_primitive_shared(&meaning)?;
1921 if !meaning.is_prefix() {
1922 clear_prefixes(); // Clear prefixes unless we just set one.
1923 }
1924 },
1925 Some(Stored::Primitive(meaning)) => {
1926 // Otherwise, a normal primitive or constructor
1927 result = meaning.invoke_primitive()?;
1928 if !meaning.is_prefix() {
1929 clear_prefixes(); // Clear prefixes unless we just set one.
1930 }
1931 },
1932 Some(Stored::MathPrimitive(meaning)) => {
1933 // Copy of regular Primitive
1934 // Otherwise, a normal primitive or constructor
1935 result = meaning.invoke_primitive()?;
1936 if !meaning.is_prefix() {
1937 clear_prefixes(); // Clear prefixes unless we just set one.
1938 }
1939 },
1940 Some(Stored::Register(meaning)) => {
1941 // Registers are special primitives
1942 result = meaning.invoke_primitive()?;
1943 if !meaning.is_prefix() {
1944 clear_prefixes(); // Clear prefixes unless we just set one.
1945 }
1946 },
1947 meaning => {
1948 // Perl: Error + makeMisdefinedError (non-fatal). Don't crash.
1949 Error!(
1950 "misdefined",
1951 token,
1952 s!("Unexpected object in Stomach: {:?}", meaning)
1953 );
1954 },
1955 }
1956 // _token_guard drops here, auto-expiring current token
1957 break;
1958 }
1959 stomach_mut!().token_stack.pop();
1960 Ok(result)
1961}
1962
1963fn invoke_token_undefined(token: &Token) -> Result<Vec<Digested>> {
1964 // The LaTeX format may not be loaded yet (a document may use a kernel CS
1965 // before `\documentclass` — real LaTeX has no "before the kernel"). If this
1966 // is a kernel CS, pull the format in and re-digest instead of stubbing it as
1967 // `<ltx:ERROR/>`. Fires at most once per session; see
1968 // `binding::kernel_autoload`. Same retry shape as the `\ifsomething` arm below.
1969 if crate::binding::kernel_autoload::try_autoload(token) {
1970 gullet::unread_one(*token); // Retry, now that the kernel is in state.
1971 return Ok(Vec::new());
1972 }
1973 let cs = token.with_cs_name(|cs| String::from(cs));
1974 // Gate the undefined-CS summary tally and the Error! emission by
1975 // SUPPRESS_UNDEFINED_ERRORS. During expl3-code.tex raw load we install
1976 // the ERROR stub silently — forward references resolve when subsequent
1977 // post-load fixups rebind the canonical CS (see expl3_sty.rs L161-167
1978 // for \iow_wrap stubs that overwrite ERROR after the raw load). Mirrors
1979 // the existing gate at state.rs::generate_error_stub L1018-L1030.
1980 let suppressed = lookup_bool_sym(crate::pin!("SUPPRESS_UNDEFINED_ERRORS"));
1981 if !suppressed {
1982 note_status(LogStatus::Undefined, Some(&cs));
1983 }
1984
1985 // To minimize chatter, go ahead and define it...
1986 if cs.starts_with("\\if") {
1987 // Apparently an \ifsomething ???
1988 let name = cs.replace("\\if", "");
1989 if !suppressed {
1990 let message = s!("The token {} is not defined.", token.stringify());
1991 Error!(
1992 "undefined",
1993 token,
1994 &message,
1995 "Defining it now as with \\newif"
1996 );
1997 }
1998 // install stub definitions for new conditional
1999 install_definition(
2000 Expandable::new(
2001 T_CS!(s!("\\{}true", name)),
2002 None,
2003 Tokens!(T_CS!("\\let"), T_CS!(&cs), T_CS!("\\iftrue")).into(),
2004 None,
2005 )?,
2006 None,
2007 );
2008 install_definition(
2009 Expandable::new(
2010 T_CS!(s!("\\{}false", name)),
2011 None,
2012 Tokens!(T_CS!("\\let"), T_CS!(cs), T_CS!("\\iffalse")).into(),
2013 None,
2014 )?,
2015 None,
2016 );
2017
2018 let_i(token, &T_CS!("\\iffalse"), None);
2019 gullet::unread_one(*token); // Retry
2020 Ok(Vec::new())
2021 } else {
2022 if !suppressed {
2023 let message = s!("The token {} is not defined.", token.stringify());
2024 Error!(
2025 "undefined",
2026 token,
2027 &message,
2028 "Defining it now as <ltx:ERROR/>"
2029 );
2030 }
2031 install_definition(
2032 Constructor {
2033 cs: *token,
2034 paramlist: None,
2035 replacement: Some(Rc::new(move |document, _args, _props| {
2036 document.make_error("undefined", &cs)
2037 })),
2038 ..Constructor::default()
2039 },
2040 Some(Scope::Global),
2041 );
2042 // Perl: unread the token and return empty, so the outer loop re-reads
2043 // and dispatches through the normal path (with the newly installed stub).
2044 // This ensures gullet-level side effects (filtering, expansion) are applied.
2045 gullet::unread_one(*token);
2046 Ok(Vec::new())
2047 }
2048}
2049
2050fn invoke_token_simple(meaning: Token) -> Result<Option<Digested>> {
2051 let cc = meaning.get_catcode();
2052 let font = lookup_font();
2053 // token-locators: the leaf char box's exact source position comes from the
2054 // token's origin handle — the position that survived expansion to digestion
2055 // (Experiments 1–3 showed it cannot be re-derived from the mouth here, which
2056 // is past the construct). `None` → `Tbox::new` falls back to the gullet's
2057 // current locator (the eating-disorder heuristic). See SOURCE_PROVENANCE §3.1.1.
2058 // Stamp a leaf box only from a *genuine* (read-from-source) origin. An
2059 // inherited origin — a macro's expansion attributed to its call site, e.g.
2060 // `\section`'s structural body literals at the `\section` column — must not
2061 // become a located leaf, or box-level `get_locator()` aggregation would widen
2062 // a construct past its content (the `\section{Intro}` title would start at the
2063 // command, not at "Intro"). The inherited origin still rides the token, so
2064 // `constructor::child_span`'s genuine-first scan can recover it as the
2065 // fallback for the origin-less case (`\today`). See SOURCE_PROVENANCE §3.1.3.
2066 #[cfg(feature = "token-locators")]
2067 let origin_loc: Option<crate::common::locator::Locator> =
2068 crate::token::get_token_origin(meaning.loc)
2069 .filter(|o| !o.inherited)
2070 .map(|o| {
2071 crate::common::arena::with(o.source, |s| {
2072 crate::common::locator::Locator::new(s, o.line, o.col, o.line, o.col)
2073 })
2074 });
2075 #[cfg(not(feature = "token-locators"))]
2076 let origin_loc: Option<crate::common::locator::Locator> = None;
2077 match cc {
2078 Catcode::SPACE => {
2079 clear_prefixes(); // Perl Stomach.pm line 234: prefixes shouldn't apply here.
2080 // Perl: if($STATE->lookupValue('MODE') =~ /(?:math|vertical)$/) { return (); }
2081 let mode = lookup_string_from_sym(crate::pin!("MODE"));
2082 if mode.ends_with("math") || mode.ends_with("vertical") {
2083 Ok(None)
2084 } else {
2085 enter_horizontal();
2086 Ok(Some(Digested::from(Tbox::new(
2087 meaning.get_sym(),
2088 font,
2089 origin_loc,
2090 Tokens!(meaning),
2091 HashMap::default(),
2092 ))))
2093 }
2094 },
2095 Catcode::COMMENT => {
2096 // Perl Stomach.pm lines 241-244: decode comment via font encoding
2097 let decoded = font::decode_string(meaning.get_sym(), None, true);
2098 let comment = arena::with(decoded, |s| {
2099 // However, spaces normally would have be digested away as positioning...
2100 // Replace NBSP + combining strikethrough (OT1 space position) with actual space
2101 s.replace("\u{00A0}\u{0335}", " ")
2102 });
2103 // Perl: returns LaTeXML::Core::Comment->new($comment)
2104 // which gets absorbed as an XML comment node via Document::insertComment
2105 Ok(Some(Digested::from(Comment(comment))))
2106 },
2107 _ => {
2108 clear_prefixes(); // Perl Stomach.pm line 247: prefixes shouldn't apply here.
2109 // Perl: check mathcode for IN_MATH characters (Stomach.pm lines 248-251)
2110 // In Perl, all math chars go through decodeMathChar which decodes via
2111 // the font encoding. In Rust, Tbox::new already handles IN_MATH:
2112 // it sets mode="math", looks up math_token_attributes for role/meaning/name,
2113 // and specializes the font. This produces the correct LaTeXML-level properties.
2114 // The mathchar parsing handles non-ASCII chars needing font map lookup.
2115 // TODO: Use for chars where font-encoding glyph differs from input.
2116 // Perl L248-257: if IN_MATH && mathcode → decodeMathChar (math box)
2117 // else → enterHorizontal + text box (covers non-math AND math-but-no-mathcode)
2118 if lookup_bool_sym(crate::pin!("IN_MATH"))
2119 && let Some(mathcode) = lookup_mathcode_sym(meaning.get_sym())
2120 {
2121 return crate::common::mathchar::decode_math_char_for_stomach(mathcode, meaning);
2122 }
2123 // Fallthrough: either not in math, or in math but no mathcode
2124 enter_horizontal();
2125 let text = font::decode_string(meaning.get_sym(), None, true);
2126 Ok(Some(Digested::from(Tbox::new(
2127 text,
2128 None,
2129 origin_loc,
2130 Tokens!(meaning), // tokens
2131 HashMap::default(), // properties
2132 ))))
2133 },
2134 }
2135}
2136
2137pub fn set_stomach(new_stomach: Stomach) {
2138 let mut singleton = stomach_mut!();
2139 *singleton = new_stomach;
2140}
2141pub fn clone_box_list() -> Vec<Digested> { stomach!().box_list.clone() }
2142
2143/// get the current boxing level
2144pub fn get_boxing_level() -> usize { stomach!().boxing.len() }
2145
2146/// ScriptLevel is similar to boxing level, but relative to current Math mode's level
2147///
2148/// This is used for the scriptpos attribute to recognize overlapping sccripts.
2149/// Making it relative to the math's level avoids unnecessary changes
2150pub fn get_script_level() -> usize {
2151 let boxlevel = get_boxing_level();
2152 with_value("script_base_level", |val_opt| {
2153 if let Some(Stored::Int(prevlevel)) = val_opt {
2154 boxlevel - (*prevlevel as usize) + 1
2155 } else {
2156 boxlevel
2157 }
2158 })
2159}
2160
2161#[cfg(test)]
2162mod memory_cap_tests {
2163 use super::{
2164 apply_memory_ceiling, box_bytes_budget, box_count_cap, resolve_rss_cap, set_memory_cap,
2165 soft_cap_from_ceiling, soft_yield_urgency,
2166 };
2167
2168 // The box-list ceilings are memory ceilings, so they must ride the SAME
2169 // `--max-memory` knob as the RSS fuse — including its "0 = no limit" meaning.
2170 // They were hardcoded `const`s (2 M boxes / 3.2 GB estimate) read
2171 // unconditionally, which made `--max-memory=0` a documented lie: the binary
2172 // prints "memory limiting disabled entirely" and then Fatal'd on a memory
2173 // ceiling no flag could raise. Witness: a ~10 000-page notes document
2174 // (Nasser Abbasi, rc4 report 2026-07-28) died on the byte budget after 8 h at
2175 // ~58 GB RSS having explicitly passed `--max-memory=0`.
2176 #[test]
2177 fn box_ceilings_follow_the_memory_knob() {
2178 // Stock ceiling reproduces the historical fixed values.
2179 apply_memory_ceiling(6144);
2180 assert_eq!(
2181 box_count_cap(),
2182 Some(1_999_933),
2183 "≈ the validated 2 M boxes"
2184 );
2185 let budget = box_bytes_budget().expect("stock ceiling has a byte budget");
2186 assert_eq!(budget, 3_221_225_472, "≈ the historical 3.2 GB");
2187
2188 // `--max-memory=0` lifts BOTH — that is the whole point of the flag.
2189 apply_memory_ceiling(0);
2190 assert_eq!(box_count_cap(), None, "--max-memory=0 lifts the count cap");
2191 assert_eq!(
2192 box_bytes_budget(),
2193 None,
2194 "--max-memory=0 lifts the byte budget"
2195 );
2196
2197 // A tighter ceiling scales them down rather than leaving them at the
2198 // stock value (which would sit far ABOVE the requested ceiling).
2199 apply_memory_ceiling(2000);
2200 assert!(box_count_cap().unwrap() < 1_999_933);
2201 assert!(box_bytes_budget().unwrap() < budget);
2202
2203 // The byte budget stays UNDER the RSS fuse, so on Linux the portable
2204 // estimate still fires first for an accurately-estimated runaway.
2205 assert!(box_bytes_budget().unwrap() < resolve_rss_cap().unwrap() as usize);
2206
2207 set_memory_cap(None);
2208 }
2209
2210 // The single knob must reach the fuse through `apply_memory_ceiling`, which is
2211 // what every conversion path calls. `--max-memory=0` has to leave NO ceiling:
2212 // the plain path, the `--server` forked body child and the in-process fallback
2213 // all rely on this one function, and the LSP pair used to skip it entirely.
2214 //
2215 // The env-precedence half is deliberately NOT asserted here: proving it needs
2216 // `set_var`, and this workspace has a standing rule against touching the
2217 // process env from tests (a concurrent read races glibc's getenv). It holds by
2218 // construction instead — `apply_memory_ceiling` sets the override
2219 // unconditionally, and `resolve_rss_cap` only consults the env when no
2220 // override is present.
2221 #[test]
2222 fn apply_memory_ceiling_drives_the_fuse() {
2223 apply_memory_ceiling(6144);
2224 assert_eq!(resolve_rss_cap(), Some(4608 * 1024 * 1024));
2225
2226 // 0 means "no limit", not "abort immediately".
2227 apply_memory_ceiling(0);
2228 assert_eq!(resolve_rss_cap(), None, "--max-memory=0 leaves no ceiling");
2229
2230 // A tight ceiling is honored rather than ignored in favour of the old
2231 // built-in 4.5 GB default, which sat far ABOVE such a ceiling.
2232 apply_memory_ceiling(2000);
2233 assert_eq!(resolve_rss_cap(), Some(1500 * 1024 * 1024));
2234
2235 set_memory_cap(None);
2236 }
2237
2238 // The override path is thread-local (race-free across libtest threads) and
2239 // never reads the process-global env, so these assertions are deterministic.
2240 #[test]
2241 fn override_zero_disables_budget() {
2242 // `--max-memory=0` maps to `set_memory_cap(Some(0))`, which must resolve
2243 // to "no ceiling" — NOT a 0-byte cap that fatals every conversion.
2244 set_memory_cap(Some(0));
2245 assert_eq!(resolve_rss_cap(), None, "cap 0 disables the soft budget");
2246 // A positive override is honored verbatim.
2247 set_memory_cap(Some(1_000));
2248 assert_eq!(resolve_rss_cap(), Some(1_000));
2249 // Restore the default so we don't leak state onto other tests sharing
2250 // this thread.
2251 set_memory_cap(None);
2252 }
2253
2254 #[test]
2255 fn soft_cap_tracks_the_single_knob() {
2256 // 0 in → 0 out: `--max-memory=0` disables the soft fuse (and, via
2257 // resolve_rss_cap, the whole memory limit).
2258 assert_eq!(soft_cap_from_ceiling(0), 0);
2259 // The soft fuse always sits strictly below the hard ceiling (graceful
2260 // cooperative failure fires first), and reproduces the historical
2261 // ~4.5 GB-under-6 GiB relationship at the 6144 MiB default.
2262 let hard = 6144u64 * 1024 * 1024;
2263 let soft = soft_cap_from_ceiling(6144);
2264 assert_eq!(soft, 4608u64 * 1024 * 1024);
2265 assert!(soft < hard, "soft fuse must be below the hard ceiling");
2266 // It scales with the knob, so a tight ceiling still gets a cooperative
2267 // guard below it (fixing the old fixed-4.5 GB decoupling).
2268 assert!(soft_cap_from_ceiling(2000) < 2000 * 1024 * 1024);
2269 assert!(soft_cap_from_ceiling(20000) > 6144 * 1024 * 1024);
2270 }
2271
2272 /// The soft-yield floor waiver: waived at/above the watermark→fuse midpoint,
2273 /// applied below it, and never waived when either bound is absent or
2274 /// degenerate. Guards the safety valve `soft_yield_min_boxes` sits on — a
2275 /// pathological per-box footprint must regain per-seam yielding before the
2276 /// fuse fires inside one un-yielded window.
2277 #[test]
2278 fn soft_yield_floor_waiver_boundaries() {
2279 const MB: u64 = 1024 * 1024;
2280 let wm = Some(12_000 * MB); // the witness's watermark at --max-memory 48000
2281 let fuse = Some(36_000 * MB); // and its fuse; midpoint = 24_000 MB
2282 let mark_kb = 24_000 * 1024;
2283 assert!(
2284 !soft_yield_urgency(mark_kb - 1, wm, fuse),
2285 "below the midpoint the floor applies"
2286 );
2287 assert!(
2288 soft_yield_urgency(mark_kb, wm, fuse),
2289 "at the midpoint the floor is waived"
2290 );
2291 assert!(soft_yield_urgency(mark_kb + 1, wm, fuse), "above it too");
2292 // --max-memory=0 shapes: no fuse, no watermark, or fuse <= watermark
2293 // (a calibration LATEXML_SPILL_AT_MIB above the fuse) — never urgent.
2294 assert!(!soft_yield_urgency(u64::MAX / 1024, wm, None));
2295 assert!(!soft_yield_urgency(u64::MAX / 1024, None, fuse));
2296 assert!(
2297 !soft_yield_urgency(u64::MAX / 1024, fuse, wm),
2298 "fuse below watermark is degenerate"
2299 );
2300 // saturating_mul: an absurd rss_kb must not overflow into false.
2301 assert!(soft_yield_urgency(u64::MAX, wm, fuse));
2302 }
2303}