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}