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}