Skip to main content

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}