Skip to main content

latexml_core/common/
arena.rs

1//! An arena for interning strings
2//!  (global, mutable, single thread only)
3//!
4//! Note: works under the assumption of a single threaded, short-lived process,
5//! where memory starvation will not be an issue. It would need a hard reset if the same process
6//! does multiple conversions, and a different implementation if one needs a thread-local arena.
7//!
8//! ## Borrow safety
9//!
10//! All arena access goes through `with_arena_mut()`, which acquires a mutable
11//! borrow from `RefCell` on first call and caches the raw pointer in a
12//! thread-local `Cell`. Re-entrant calls (e.g., `pin()` inside a `with()`
13//! closure) reuse the cached pointer without touching `RefCell`, eliminating
14//! borrow conflicts entirely. A RAII guard clears the pointer on scope exit
15//! (including panics), so the invariant holds through unwinds.
16
17use std::{
18  cell::{Cell, RefCell},
19  hash::BuildHasherDefault,
20};
21
22use once_cell::sync::Lazy;
23use rustc_hash::FxHasher;
24use string_interner::{StringInterner, backend::BufferBackend};
25
26pub mod data;
27pub use data::{SymHashMap, SymStr};
28
29type Interner = StringInterner<BufferBackend, BuildHasherDefault<FxHasher>>;
30
31#[thread_local]
32static ARENA: Lazy<RefCell<Interner>> = Lazy::new(|| {
33  // 131,072 = 2^17 — sized to absorb the latex.dump (109,863 entries)
34  // plus a typical conversion's content (~15k more), so the hot path
35  // hits no reallocation. Profiled 2026-05-12: a representative
36  // `\usepackage{glossaries}` + math conversion ends at ~125,628
37  // strings allocated.
38  //
39  // Cost: ~2-3 MB extra startup memory vs the prior 32,768. Worth it
40  // when running batched conversions over a corpus (one fewer
41  // BufferBackend Vec realloc + HashMap rehash per process startup);
42  // negligible on a single short-lived run.
43  RefCell::new(StringInterner::with_capacity_and_hasher(
44    131_072,
45    BuildHasherDefault::<FxHasher>::default(),
46  ))
47});
48
49/// Cached raw pointer to the arena's inner interner, valid only while the
50/// outermost `with_arena_mut` holds its `RefMut` guard. Null when idle.
51#[thread_local]
52static ACTIVE: Cell<*mut Interner> = Cell::new(std::ptr::null_mut());
53
54/// RAII guard that clears `ACTIVE` on drop (including during unwinds).
55/// Declared AFTER the `RefMut` guard so it drops FIRST (Rust drops in
56/// reverse declaration order), ensuring no window where ACTIVE is stale.
57struct ArenaCleanup;
58impl Drop for ArenaCleanup {
59  fn drop(&mut self) { ACTIVE.set(std::ptr::null_mut()); }
60}
61
62/// Execute `f` with mutable access to the interner.
63/// First (outermost) call acquires `borrow_mut()` from `RefCell` and caches
64/// the raw pointer. Re-entrant calls reuse the cached pointer — no RefCell
65/// interaction, so no borrow conflicts.
66///
67/// # Safety
68/// Sound because: (1) `#[thread_local]` guarantees single-thread access,
69/// (2) `ArenaCleanup` clears the pointer before the `RefMut` drops,
70/// (3) re-entrant access is strictly nested (same stack, same thread).
71#[inline]
72fn with_arena_mut<R>(f: impl FnOnce(&mut Interner) -> R) -> R {
73  let ptr = ACTIVE.get();
74  if !ptr.is_null() {
75    // Re-entrant call — reuse existing mutable borrow.
76    // SAFETY: ptr was set by the outermost call on this thread, which still
77    // holds the RefMut guard. We are nested on the same stack.
78    f(unsafe { &mut *ptr })
79  } else {
80    // Outermost call — acquire mutable borrow from RefCell.
81    let mut guard = ARENA.borrow_mut();
82    let ptr = &mut *guard as *mut Interner;
83    ACTIVE.set(ptr);
84    let _cleanup = ArenaCleanup; // drops BEFORE guard (reverse order)
85    f(&mut guard)
86  }
87}
88
89/// Assign a static str into the arena, returning a unique symbol.
90pub fn pin_static(text: &'static str) -> SymStr {
91  with_arena_mut(|arena| arena.get_or_intern_static(text))
92}
93
94/// Call-site-cached interning for string literals — the first call on
95/// a thread pins the literal via `pin_static`, later calls return the
96/// cached `SymStr` directly (thread-local `OnceCell` load, no arena
97/// access). Use this from hot state-key lookup sites so you can keep
98/// writing string literals at the call site and still skip the per-call
99/// `pin()` hash probe:
100///
101/// ```ignore
102/// if state::lookup_bool_sym(pin!("groupNonBoxing")) { ... }
103/// ```
104///
105/// Each call site gets its own thread-local cache (no global registry,
106/// no dedicated pub-static constant per key), so there is no ergonomic
107/// cost beyond typing the macro name.
108///
109/// Note: the macro `pin!` and the runtime-string function
110/// `arena::pin(s)` share a name but occupy different namespaces in
111/// Rust — `pin!(…)` is the macro, `pin(…)` is the function — so both
112/// remain callable.
113#[macro_export]
114macro_rules! pin {
115  ($s:literal) => {{
116    std::thread_local! {
117      static CACHED: std::cell::OnceCell<$crate::common::arena::SymStr>
118        = const { std::cell::OnceCell::new() };
119    }
120    CACHED.with(|c| *c.get_or_init(|| $crate::common::arena::pin_static($s)))
121  }};
122}
123
124/// Assign a string into the arena, returning a unique symbol.
125///
126/// No overflow guard: the main-level wall-clock watchdog (watchdog.rs)
127/// catches genuinely runaway loops that would eventually saturate the
128/// BufferBackend's u32 byte-offset range (~4.29 GB) long before any
129/// real-world workload approaches it. Earlier versions had both a
130/// call-count and a distinct-symbol sentinel; the call-count one
131/// false-fired on dedup-heavy hot loops, and the distinct-symbol one
132/// added a per-call `arena.len()` read on a hot path (~350k calls
133/// per doc). Neither cost was paying for itself.
134pub fn pin<S: AsRef<str>>(text: S) -> SymStr { with_arena_mut(|arena| arena.get_or_intern(text)) }
135
136/// Probe-only lookup: the symbol for `text` if it was ever interned, without
137/// interning it. For existence-style checks (e.g. `install_definition`'s
138/// `"{cs}:locked"` gate) a `None` here proves the key cannot be bound in any
139/// state table — and skipping the intern avoids permanently growing the arena
140/// with one probe-key twin per defined control sequence (2026-08-23 audit R6).
141pub fn get<S: AsRef<str>>(text: S) -> Option<SymStr> { with_arena_mut(|arena| arena.get(text)) }
142
143/// ASCII char-pin cache: every unique ASCII byte resolves to a single
144/// SymStr for the lifetime of the thread (arena is append-only, syms
145/// never change). Cache entries use `u32::MAX` as the "not yet pinned"
146/// sentinel — all valid interner offsets are strictly below that.
147/// Called from `lookup_catcode` / `assign_catcode` on every token,
148/// so the RefCell + hashmap overhead on `pin` is a measurable cost
149/// (1.4% Ir per callgrind on siunitx-heavy fixtures). The fast path
150/// avoids `with_arena_mut` entirely for the common ASCII case.
151#[thread_local]
152static ASCII_CHAR_SYM: [Cell<u32>; 128] = [const { Cell::new(u32::MAX) }; 128];
153
154pub fn pin_char(c: char) -> SymStr {
155  use string_interner::Symbol;
156  let code = c as u32;
157  if code < 128 {
158    let cached = ASCII_CHAR_SYM[code as usize].get();
159    if cached != u32::MAX {
160      // SAFETY: cached was produced by a prior successful `pin` below, so
161      // the SymStr is valid for this arena.
162      return SymStr::try_from_usize(cached as usize).expect("invalid cached ASCII SymStr");
163    }
164  }
165  let sym = {
166    let mut tmp = [0u8; 4];
167    let s = c.encode_utf8(&mut tmp);
168    pin(s)
169  };
170  if code < 128 {
171    ASCII_CHAR_SYM[code as usize].set(sym.to_usize() as u32);
172  }
173  sym
174}
175
176/// Resolve a symbol and call the closure with a `&str` reference.
177/// The closure may safely call `pin()` or any other arena function —
178/// re-entrant access reuses the cached borrow.
179///
180/// # Safety
181///
182/// Uses `resolve_unchecked` → `from_utf8_unchecked`. Sound because
183/// every path into the arena (`pin_static(&'static str)`,
184/// `pin<S: AsRef<str>>(s)`, `pin_char(c: char)`) can only produce a
185/// SymStr from content that was already valid UTF-8. The interner's
186/// buffer is append-only by design: once a byte range is associated
187/// with a symbol it is never mutated. Callgrind showed the default
188/// validating `resolve` was ~3% of total Ir via `str::from_utf8`.
189pub fn with<R, FnR>(sym: SymStr, caller: FnR) -> R
190where FnR: FnOnce(&str) -> R {
191  with_arena_mut(|arena| {
192    // SAFETY: all input strings were valid UTF-8 at intern time (see
193    // docstring above); every SymStr in this codebase originates
194    // from a successful `get_or_intern(_static|_char)` call on a
195    // valid `&str`, so the symbol always corresponds to a valid
196    // byte range in the interner's buffer.
197    let s = unsafe { arena.resolve_unchecked(sym) };
198    caller(s)
199  })
200}
201
202pub fn with2<R, FnR>(sym1: SymStr, sym2: SymStr, caller: FnR) -> R
203where FnR: FnOnce(&str, &str) -> R {
204  with_arena_mut(|arena| {
205    // SAFETY: same invariant as `arena::with` — every SymStr here was
206    // returned by a successful intern of a valid &str.
207    let s1 = unsafe { arena.resolve_unchecked(sym1) };
208    let s2 = unsafe { arena.resolve_unchecked(sym2) };
209    caller(s1, s2)
210  })
211}
212
213pub fn with3<R, FnR>(sym1: SymStr, sym2: SymStr, sym3: SymStr, caller: FnR) -> R
214where FnR: FnOnce(&str, &str, &str) -> R {
215  with_arena_mut(|arena| {
216    // SAFETY: see `arena::with`.
217    let s1 = unsafe { arena.resolve_unchecked(sym1) };
218    let s2 = unsafe { arena.resolve_unchecked(sym2) };
219    let s3 = unsafe { arena.resolve_unchecked(sym3) };
220    caller(s1, s2, s3)
221  })
222}
223
224pub fn with_many<R, FnR>(syms: &[SymStr], caller: FnR) -> R
225where FnR: FnOnce(Vec<&str>) -> R {
226  with_arena_mut(|arena| {
227    // SAFETY: see `arena::with`.
228    let many = syms
229      .iter()
230      .map(|sym| unsafe { arena.resolve_unchecked(*sym) })
231      .collect();
232    caller(many)
233  })
234}
235
236pub fn to_string(sym: SymStr) -> String {
237  with_arena_mut(|arena| {
238    // SAFETY: see `arena::with`.
239    unsafe { arena.resolve_unchecked(sym) }.to_owned()
240  })
241}
242
243pub fn join(syms: &[SymStr], sep: &str) -> String { with_many(syms, |strs| strs.join(sep)) }
244
245pub fn len() -> usize { with_arena_mut(|arena| arena.len()) }
246
247/// Free every interned string on this thread, returning the arena to a
248/// fresh, empty state.
249///
250/// **Danger:** this invalidates *every* outstanding [`SymStr`] on the
251/// thread — they become dangling indices that may resolve to unrelated
252/// strings after re-interning. It is only sound when **nothing on the
253/// thread will read a pre-reset `SymStr` again**: i.e. between fully
254/// independent conversions in a reused process (the test harness, where
255/// each test has already serialized its output to owned `String`s and
256/// the thread is about to exit or be re-initialized) or a future daemon
257/// that re-initializes the engine afterward. The single-conversion
258/// `latexml_oxide` binary never calls this — it exits instead.
259///
260/// Needed because the engine's roots are `#[thread_local]` *attribute*
261/// statics, which (unlike the `thread_local!` macro) do **not** run
262/// destructors on thread exit. Without an explicit reset, every reused
263/// thread leaks its interner (~tens of MB for a full document). See
264/// `latexml_core::reset_thread_engine`.
265pub fn reset() {
266  with_arena_mut(|arena| {
267    *arena =
268      StringInterner::with_capacity_and_hasher(131_072, BuildHasherDefault::<FxHasher>::default());
269  });
270}
271
272/// Eagerly initialize this thread's `#[thread_local]` `ARENA` Lazy.
273///
274/// `ARENA` is the *leaf* of the engine's thread-local dependency graph:
275/// every other root's `Lazy` initializer interns symbols via [`pin`], so it
276/// reaches into `ARENA`. Calling this at conversion entry (see
277/// `Core::new`), before any other root is touched, guarantees those later
278/// initializers find a fully-constructed `ARENA` instead of triggering its
279/// initialization *re-entrantly from within their own*. That re-entrant
280/// cross-`#[thread_local]` initialization is benign on Linux/ELF TLS but is
281/// the documented macOS hazard (rust-lang/rust#29594) behind the macOS
282/// worker-thread memory corruption in issue #217. `ARENA`'s own initializer
283/// touches no other thread-local, so forcing it first is always safe.
284/// No behavioral change on Linux.
285pub(crate) fn force_init() { Lazy::force(&ARENA); }
286
287#[cfg(test)]
288mod tests {
289  use super::*;
290
291  #[test]
292  fn pin_dedups_equal_strings() {
293    let a = pin("arena_test_foo");
294    let b = pin("arena_test_foo");
295    assert_eq!(a, b, "equal strings must return the same SymStr");
296  }
297
298  #[test]
299  fn pin_distinguishes_different_strings() {
300    let a = pin("arena_test_bar");
301    let b = pin("arena_test_baz");
302    assert_ne!(a, b);
303  }
304
305  #[test]
306  fn pin_static_matches_pin() {
307    let a = pin_static("arena_test_qux");
308    let b = pin("arena_test_qux");
309    assert_eq!(a, b, "pin_static and pin should intern to the same SymStr");
310  }
311
312  #[test]
313  fn to_string_roundtrips() {
314    let sym = pin("arena_test_quux");
315    assert_eq!(to_string(sym), "arena_test_quux");
316  }
317
318  #[test]
319  fn with_borrows_without_allocating() {
320    let sym = pin("arena_test_corge");
321    let len = with(sym, |s| s.len());
322    assert_eq!(len, "arena_test_corge".len());
323  }
324
325  #[test]
326  fn with_predicate_returns_bool() {
327    let sym = pin("arena_test_predicate");
328    let starts_with = with(sym, |s| s.starts_with("arena"));
329    assert!(starts_with);
330  }
331
332  #[test]
333  fn reset_empties_interner_and_stays_usable() {
334    // Each `#[test]` runs on its own thread, so this thread's arena
335    // starts empty and the reset is isolated from sibling tests.
336    let _ = pin("arena_reset_alpha");
337    let _ = pin("arena_reset_beta");
338    assert!(len() >= 2, "expected the two pins to be interned");
339    reset();
340    assert_eq!(len(), 0, "reset must return the interner to empty");
341    // Interning still works after a reset (fresh backend installed).
342    let s = pin("arena_reset_gamma");
343    assert_eq!(to_string(s), "arena_reset_gamma");
344  }
345
346  #[test]
347  fn pin_char_ascii_roundtrips() {
348    let sym = pin_char('a');
349    assert_eq!(to_string(sym), "a");
350    let sym2 = pin_char('a');
351    assert_eq!(sym, sym2, "ASCII char pin is cached");
352  }
353
354  #[test]
355  fn pin_char_distinct_chars_distinct_syms() {
356    assert_ne!(pin_char('a'), pin_char('b'));
357    assert_ne!(pin_char('0'), pin_char('1'));
358  }
359
360  #[test]
361  fn pin_char_unicode_roundtrips() {
362    // Non-ASCII chars go through the general arena path.
363    let sym = pin_char('π');
364    assert_eq!(to_string(sym), "π");
365  }
366
367  #[test]
368  fn join_concatenates_with_separator() {
369    let a = pin("arena_test_alpha");
370    let b = pin("arena_test_beta");
371    let c = pin("arena_test_gamma");
372    let out = join(&[a, b, c], ",");
373    assert_eq!(out, "arena_test_alpha,arena_test_beta,arena_test_gamma");
374  }
375
376  #[test]
377  fn pin_macro_caches_per_site() {
378    // The `pin!` macro returns a cached SymStr per call site. Two
379    // call sites with identical strings cache independently but
380    // intern to the same underlying symbol.
381    let a = pin!("arena_test_literal");
382    let b = pin!("arena_test_literal");
383    assert_eq!(a, b, "same literal at different call sites → same SymStr");
384  }
385}