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/// ASCII char-pin cache: every unique ASCII byte resolves to a single
137/// SymStr for the lifetime of the thread (arena is append-only, syms
138/// never change). Cache entries use `u32::MAX` as the "not yet pinned"
139/// sentinel — all valid interner offsets are strictly below that.
140/// Called from `lookup_catcode` / `assign_catcode` on every token,
141/// so the RefCell + hashmap overhead on `pin` is a measurable cost
142/// (1.4% Ir per callgrind on siunitx-heavy fixtures). The fast path
143/// avoids `with_arena_mut` entirely for the common ASCII case.
144#[thread_local]
145static ASCII_CHAR_SYM: [Cell<u32>; 128] = [const { Cell::new(u32::MAX) }; 128];
146
147pub fn pin_char(c: char) -> SymStr {
148  use string_interner::Symbol;
149  let code = c as u32;
150  if code < 128 {
151    let cached = ASCII_CHAR_SYM[code as usize].get();
152    if cached != u32::MAX {
153      // SAFETY: cached was produced by a prior successful `pin` below, so
154      // the SymStr is valid for this arena.
155      return SymStr::try_from_usize(cached as usize).expect("invalid cached ASCII SymStr");
156    }
157  }
158  let sym = {
159    let mut tmp = [0u8; 4];
160    let s = c.encode_utf8(&mut tmp);
161    pin(s)
162  };
163  if code < 128 {
164    ASCII_CHAR_SYM[code as usize].set(sym.to_usize() as u32);
165  }
166  sym
167}
168
169/// Resolve a symbol and call the closure with a `&str` reference.
170/// The closure may safely call `pin()` or any other arena function —
171/// re-entrant access reuses the cached borrow.
172///
173/// # Safety
174///
175/// Uses `resolve_unchecked` → `from_utf8_unchecked`. Sound because
176/// every path into the arena (`pin_static(&'static str)`,
177/// `pin<S: AsRef<str>>(s)`, `pin_char(c: char)`) can only produce a
178/// SymStr from content that was already valid UTF-8. The interner's
179/// buffer is append-only by design: once a byte range is associated
180/// with a symbol it is never mutated. Callgrind showed the default
181/// validating `resolve` was ~3% of total Ir via `str::from_utf8`.
182pub fn with<R, FnR>(sym: SymStr, caller: FnR) -> R
183where FnR: FnOnce(&str) -> R {
184  with_arena_mut(|arena| {
185    // SAFETY: all input strings were valid UTF-8 at intern time (see
186    // docstring above); every SymStr in this codebase originates
187    // from a successful `get_or_intern(_static|_char)` call on a
188    // valid `&str`, so the symbol always corresponds to a valid
189    // byte range in the interner's buffer.
190    let s = unsafe { arena.resolve_unchecked(sym) };
191    caller(s)
192  })
193}
194
195pub fn with2<R, FnR>(sym1: SymStr, sym2: SymStr, caller: FnR) -> R
196where FnR: FnOnce(&str, &str) -> R {
197  with_arena_mut(|arena| {
198    // SAFETY: same invariant as `arena::with` — every SymStr here was
199    // returned by a successful intern of a valid &str.
200    let s1 = unsafe { arena.resolve_unchecked(sym1) };
201    let s2 = unsafe { arena.resolve_unchecked(sym2) };
202    caller(s1, s2)
203  })
204}
205
206pub fn with3<R, FnR>(sym1: SymStr, sym2: SymStr, sym3: SymStr, caller: FnR) -> R
207where FnR: FnOnce(&str, &str, &str) -> R {
208  with_arena_mut(|arena| {
209    // SAFETY: see `arena::with`.
210    let s1 = unsafe { arena.resolve_unchecked(sym1) };
211    let s2 = unsafe { arena.resolve_unchecked(sym2) };
212    let s3 = unsafe { arena.resolve_unchecked(sym3) };
213    caller(s1, s2, s3)
214  })
215}
216
217pub fn with_many<R, FnR>(syms: &[SymStr], caller: FnR) -> R
218where FnR: FnOnce(Vec<&str>) -> R {
219  with_arena_mut(|arena| {
220    // SAFETY: see `arena::with`.
221    let many = syms
222      .iter()
223      .map(|sym| unsafe { arena.resolve_unchecked(*sym) })
224      .collect();
225    caller(many)
226  })
227}
228
229pub fn to_string(sym: SymStr) -> String {
230  with_arena_mut(|arena| {
231    // SAFETY: see `arena::with`.
232    unsafe { arena.resolve_unchecked(sym) }.to_owned()
233  })
234}
235
236pub fn join(syms: &[SymStr], sep: &str) -> String { with_many(syms, |strs| strs.join(sep)) }
237
238pub fn len() -> usize { with_arena_mut(|arena| arena.len()) }
239
240/// Free every interned string on this thread, returning the arena to a
241/// fresh, empty state.
242///
243/// **Danger:** this invalidates *every* outstanding [`SymStr`] on the
244/// thread — they become dangling indices that may resolve to unrelated
245/// strings after re-interning. It is only sound when **nothing on the
246/// thread will read a pre-reset `SymStr` again**: i.e. between fully
247/// independent conversions in a reused process (the test harness, where
248/// each test has already serialized its output to owned `String`s and
249/// the thread is about to exit or be re-initialized) or a future daemon
250/// that re-initializes the engine afterward. The single-conversion
251/// `latexml_oxide` binary never calls this — it exits instead.
252///
253/// Needed because the engine's roots are `#[thread_local]` *attribute*
254/// statics, which (unlike the `thread_local!` macro) do **not** run
255/// destructors on thread exit. Without an explicit reset, every reused
256/// thread leaks its interner (~tens of MB for a full document). See
257/// `latexml_core::reset_thread_engine`.
258pub fn reset() {
259  with_arena_mut(|arena| {
260    *arena =
261      StringInterner::with_capacity_and_hasher(131_072, BuildHasherDefault::<FxHasher>::default());
262  });
263}
264
265/// Eagerly initialize this thread's `#[thread_local]` `ARENA` Lazy.
266///
267/// `ARENA` is the *leaf* of the engine's thread-local dependency graph:
268/// every other root's `Lazy` initializer interns symbols via [`pin`], so it
269/// reaches into `ARENA`. Calling this at conversion entry (see
270/// `Core::new`), before any other root is touched, guarantees those later
271/// initializers find a fully-constructed `ARENA` instead of triggering its
272/// initialization *re-entrantly from within their own*. That re-entrant
273/// cross-`#[thread_local]` initialization is benign on Linux/ELF TLS but is
274/// the documented macOS hazard (rust-lang/rust#29594) behind the macOS
275/// worker-thread memory corruption in issue #217. `ARENA`'s own initializer
276/// touches no other thread-local, so forcing it first is always safe.
277/// No behavioral change on Linux.
278pub(crate) fn force_init() { Lazy::force(&ARENA); }
279
280#[cfg(test)]
281mod tests {
282  use super::*;
283
284  #[test]
285  fn pin_dedups_equal_strings() {
286    let a = pin("arena_test_foo");
287    let b = pin("arena_test_foo");
288    assert_eq!(a, b, "equal strings must return the same SymStr");
289  }
290
291  #[test]
292  fn pin_distinguishes_different_strings() {
293    let a = pin("arena_test_bar");
294    let b = pin("arena_test_baz");
295    assert_ne!(a, b);
296  }
297
298  #[test]
299  fn pin_static_matches_pin() {
300    let a = pin_static("arena_test_qux");
301    let b = pin("arena_test_qux");
302    assert_eq!(a, b, "pin_static and pin should intern to the same SymStr");
303  }
304
305  #[test]
306  fn to_string_roundtrips() {
307    let sym = pin("arena_test_quux");
308    assert_eq!(to_string(sym), "arena_test_quux");
309  }
310
311  #[test]
312  fn with_borrows_without_allocating() {
313    let sym = pin("arena_test_corge");
314    let len = with(sym, |s| s.len());
315    assert_eq!(len, "arena_test_corge".len());
316  }
317
318  #[test]
319  fn with_predicate_returns_bool() {
320    let sym = pin("arena_test_predicate");
321    let starts_with = with(sym, |s| s.starts_with("arena"));
322    assert!(starts_with);
323  }
324
325  #[test]
326  fn reset_empties_interner_and_stays_usable() {
327    // Each `#[test]` runs on its own thread, so this thread's arena
328    // starts empty and the reset is isolated from sibling tests.
329    let _ = pin("arena_reset_alpha");
330    let _ = pin("arena_reset_beta");
331    assert!(len() >= 2, "expected the two pins to be interned");
332    reset();
333    assert_eq!(len(), 0, "reset must return the interner to empty");
334    // Interning still works after a reset (fresh backend installed).
335    let s = pin("arena_reset_gamma");
336    assert_eq!(to_string(s), "arena_reset_gamma");
337  }
338
339  #[test]
340  fn pin_char_ascii_roundtrips() {
341    let sym = pin_char('a');
342    assert_eq!(to_string(sym), "a");
343    let sym2 = pin_char('a');
344    assert_eq!(sym, sym2, "ASCII char pin is cached");
345  }
346
347  #[test]
348  fn pin_char_distinct_chars_distinct_syms() {
349    assert_ne!(pin_char('a'), pin_char('b'));
350    assert_ne!(pin_char('0'), pin_char('1'));
351  }
352
353  #[test]
354  fn pin_char_unicode_roundtrips() {
355    // Non-ASCII chars go through the general arena path.
356    let sym = pin_char('π');
357    assert_eq!(to_string(sym), "π");
358  }
359
360  #[test]
361  fn join_concatenates_with_separator() {
362    let a = pin("arena_test_alpha");
363    let b = pin("arena_test_beta");
364    let c = pin("arena_test_gamma");
365    let out = join(&[a, b, c], ",");
366    assert_eq!(out, "arena_test_alpha,arena_test_beta,arena_test_gamma");
367  }
368
369  #[test]
370  fn pin_macro_caches_per_site() {
371    // The `pin!` macro returns a cached SymStr per call site. Two
372    // call sites with identical strings cache independently but
373    // intern to the same underlying symbol.
374    let a = pin!("arena_test_literal");
375    let b = pin!("arena_test_literal");
376    assert_eq!(a, b, "same literal at different call sites → same SymStr");
377  }
378}