sup_xml_tree/dict.rs
1//! Per-document string-interning dict with refcounted ownership.
2//!
3//! A [`Dict`] gives every distinct byte sequence a stable, dict-owned,
4//! NUL-terminated pointer. Looking up the same bytes twice returns
5//! the same pointer; consumers may rely on pointer equality to test
6//! "is this the same name?" without a `strcmp`.
7//!
8//! # Refcount model
9//!
10//! Each `Dict` carries an internal atomic refcount, mirroring
11//! libxml2's `xmlDict` ownership convention. This lets multiple
12//! independent consumers — a parser context, a built document, the
13//! C-ABI consumer's own handle — co-own the same dict without
14//! coordination beyond `xmlDictReference` / `xmlDictFree`.
15//!
16//! * [`Dict::new_refcounted`] returns a freshly heap-allocated dict
17//! at refcount 1 (one outstanding reference, owned by the caller).
18//! * [`Dict::add_ref`] bumps the count for an additional borrow.
19//! * [`Dict::release`] decrements; the last release drops the dict
20//! and frees every interned string.
21//!
22//! Atomic ordering uses Release on decrement and an Acquire fence on
23//! the last release — the standard pattern (cf. `std::sync::Arc`).
24//!
25//! # Why interning instead of arena allocation
26//!
27//! Element / attribute / namespace names in XML repeat heavily. An
28//! HTML page has thousands of `<p>` / `<a>` / `<div>` tags; an OSM
29//! dump has millions of `<node>` records. Bumpalo-arena allocation
30//! is fast but stores each occurrence as a separate copy — the same
31//! name string ends up at thousands of distinct heap addresses,
32//! defeating pointer equality and wasting space.
33//!
34//! The dict trades a hashmap lookup per *unique* name for one
35//! allocation per unique name and constant-cost equality checks
36//! across all occurrences. For docs with high name repetition the
37//! total bytes-stored often drops by 10×.
38//!
39//! Content strings (text node bodies, attribute values) are NOT
40//! interned — they're typically unique per occurrence and would
41//! waste memory hashing things that never collide. They stay in
42//! the document's bumpalo arena.
43//!
44//! # Performance notes
45//!
46//! * The hashmap uses `std`'s default `RandomState` (SipHash). For
47//! XML names (typically ≤16 bytes) that's a few-ns hash; the more
48//! expensive path is the hashmap probe + alloc on miss. A fast
49//! non-DoS-resistant hasher (FxHash / ahash) would shave ~20-30%
50//! off the hash cost — worth doing if profiling identifies it.
51//! * Storage uses one `Box<[u8]>` per unique name. An alternative
52//! would back the strings with a bumpalo-style arena owned by the
53//! dict itself, paying only one allocation per insert and freeing
54//! everything wholesale on Dict drop. The cost of the doubled
55//! allocation is amortised across all *occurrences* of the name,
56//! which is usually many — switch only if profiling demands.
57//! * The hot path (lookup of an already-interned name) is one hash
58//! + one byte comparison. No allocation. Designed to be cheap
59//! enough to call on every element / attribute name during parse.
60
61#![allow(unsafe_code)] // see module docs
62
63use std::collections::{HashMap, HashSet};
64use std::ptr::NonNull;
65use std::sync::{Arc, Mutex};
66use std::sync::atomic::{AtomicUsize, Ordering};
67
68use bumpalo::Bump;
69
70/// A refcounted per-document string interner.
71///
72/// Never construct directly with `Dict { ... }` — use
73/// [`Dict::new_refcounted`] which guarantees the heap allocation and
74/// initial refcount of 1. Internally not `Send`/`Sync` despite the
75/// atomic refcount: the `RefCell` guarding the table is single-
76/// threaded. C-ABI consumers (libxml2) are GIL-protected on the
77/// Python side and single-thread the dict accordingly.
78pub struct Dict {
79 refcount: AtomicUsize,
80 /// Parent dict for a sub-dictionary (libxml2 `xmlDictCreateSub`),
81 /// or NULL for a root dict. A sub-dict shares the parent's
82 /// interned strings: a lookup that the parent already holds
83 /// returns the *parent's* canonical pointer, so pointer-equality
84 /// holds across the boundary. This is load-bearing for libxslt,
85 /// whose transform-context dict is a sub-dict of the stylesheet
86 /// dict and which compares interned variable-name pointers.
87 ///
88 /// The sub-dict owns one reference to its parent (taken at
89 /// creation, released when the sub-dict is freed).
90 parent: *mut Dict,
91 /// `Mutex`-guarded because lxml's threaded operations (e.g.
92 /// `moveNodeToDocument` called from one thread on a doc parsed
93 /// in another) cross the GIL boundary — lxml releases the GIL
94 /// inside its `cdef` parser paths. Two threads concurrently
95 /// calling `xmlDictLookup` on the same dict is therefore a
96 /// realistic occurrence; a `RefCell` panics in that case.
97 /// `Mutex` serialises the access correctly with negligible
98 /// overhead on the contended path (one atomic CAS per call).
99 inner: Mutex<DictInner>,
100}
101
102struct DictInner {
103 /// Key: input bytes (no NUL). Value: a `Box::into_raw` pointer to
104 /// the NUL-terminated canonical buffer.
105 ///
106 /// The buffer is stored as a raw pointer rather than a live
107 /// `Box<[u8]>` so the stable `*const u8` returned by `intern` keeps
108 /// valid provenance for the dict's whole lifetime. Moving a `Box`
109 /// re-asserts unique ownership over its pointee, and the map moves
110 /// every value on each resize — with live boxes that would
111 /// invalidate every interned pointer handed out before the resize.
112 /// Raw-pointer values are `Copy`, so moving them re-tags nothing.
113 /// Reclaimed in [`DictInner`]'s `Drop`.
114 table: HashMap<Vec<u8>, NonNull<[u8]>>,
115 /// Side set keyed by canonical pointer address for O(1) `owns`.
116 owned: HashSet<usize>,
117 /// Origin document arenas retained on behalf of a cross-thread node
118 /// move. When lxml moves a node to a document on another thread it
119 /// re-interns the node's name out of the origin dict into this
120 /// (destination-thread) dict; the node *memory*, however, stays in
121 /// the origin document's arena. Holding an `Arc` clone of that
122 /// arena keeps the moved node alive for this dict's lifetime — which
123 /// spans the destination thread, hence every destination document.
124 /// Deduped by arena pointer; empty until the first cross-thread
125 /// graft.
126 retained_arenas: Vec<Arc<Bump>>,
127}
128
129impl Drop for DictInner {
130 /// Reclaim every canonical buffer leaked via `Box::into_raw` in
131 /// [`Dict::intern`]. Runs once, when the dict's last reference drops.
132 fn drop(&mut self) {
133 for &canonical in self.table.values() {
134 // SAFETY: each value is a `Box::into_raw(Box<[u8]>)` produced
135 // in `intern`, owned solely by this table, and freed exactly
136 // once — here.
137 unsafe { drop(Box::from_raw(canonical.as_ptr())); }
138 }
139 }
140}
141
142impl Dict {
143 /// Allocate a fresh dict on the heap. Refcount starts at 1
144 /// (the caller's reference). Returns a raw pointer; callers
145 /// must eventually balance with [`Dict::release`].
146 pub fn new_refcounted() -> *mut Dict {
147 Self::new_with_parent(std::ptr::null_mut())
148 }
149
150 /// Allocate a sub-dictionary chained to `parent` (libxml2
151 /// `xmlDictCreateSub`). The sub-dict shares the parent's interned
152 /// strings — see the [`parent`](Self::parent) field — and takes one
153 /// reference to it, released when the sub-dict is freed. A NULL
154 /// `parent` is equivalent to [`new_refcounted`](Self::new_refcounted).
155 ///
156 /// # Safety
157 ///
158 /// `parent` must be NULL or a live `Dict*` on which the caller can
159 /// take a reference.
160 pub unsafe fn new_sub(parent: *mut Dict) -> *mut Dict {
161 if !parent.is_null() {
162 // SAFETY: caller asserts `parent` is live.
163 unsafe { (*parent).add_ref(); }
164 }
165 Self::new_with_parent(parent)
166 }
167
168 fn new_with_parent(parent: *mut Dict) -> *mut Dict {
169 let boxed = Box::new(Self {
170 refcount: AtomicUsize::new(1),
171 parent,
172 inner: Mutex::new(DictInner {
173 table: HashMap::new(),
174 owned: HashSet::new(),
175 retained_arenas: Vec::new(),
176 }),
177 });
178 Box::into_raw(boxed)
179 }
180
181 /// Canonical pointer for `input` if any ancestor dict already
182 /// interned it, NULL otherwise. Walks the parent chain only —
183 /// the local table is checked by the callers.
184 fn ancestor_lookup(&self, input: &[u8]) -> *const u8 {
185 let mut cur = self.parent;
186 while !cur.is_null() {
187 // SAFETY: a sub-dict holds a reference to its parent for its
188 // whole lifetime, so the chain stays live while `self` does.
189 let d = unsafe { &*cur };
190 let hit = d.inner.lock().expect("Dict mutex poisoned")
191 .table.get(input).map(|&c| Self::canonical_ptr(c));
192 if let Some(p) = hit { return p; }
193 cur = d.parent;
194 }
195 std::ptr::null()
196 }
197
198 /// Retain `arena` (an origin document's node arena) so a node moved
199 /// out of it — whose name re-interned into this dict during a
200 /// cross-thread graft — outlives a drop of its origin document.
201 /// Deduped by pointer, so repeated grafts from one source arena keep
202 /// a single entry. The caller guarantees `arena` is not the
203 /// destination's own (a same-arena move needs no retention).
204 pub fn retain_arena(&self, arena: Arc<Bump>) {
205 let mut g = self.inner.lock().unwrap();
206 if g.retained_arenas.iter().any(|a| Arc::ptr_eq(a, &arena)) {
207 return;
208 }
209 g.retained_arenas.push(arena);
210 }
211
212 /// Increment the refcount. Use when copying a `*mut Dict` to a
213 /// new owner. Cheap atomic op.
214 pub fn add_ref(&self) {
215 self.refcount.fetch_add(1, Ordering::Relaxed);
216 }
217
218 /// Decrement the refcount. When the last reference goes away,
219 /// drops every interned string and the dict itself. Returns
220 /// `true` if this call was the one that freed.
221 ///
222 /// # Safety
223 ///
224 /// `ptr` must be a non-null pointer previously returned by
225 /// [`Dict::new_refcounted`], and the caller must own one
226 /// reference (i.e. exactly one outstanding `add_ref` not yet
227 /// paired with a `release`).
228 pub unsafe fn release(ptr: *mut Dict) -> bool {
229 // SAFETY: caller asserts `ptr` is live and they own a ref.
230 let prev = unsafe { (*ptr).refcount.fetch_sub(1, Ordering::Release) };
231 if prev == 1 {
232 // Last reference — synchronise with previous releases so
233 // we observe their pre-release writes, then drop.
234 std::sync::atomic::fence(Ordering::Acquire);
235 // A sub-dict holds one reference to its parent; release it
236 // after the child is gone. Read it out before the box drop.
237 let parent = unsafe { (*ptr).parent };
238 // SAFETY: refcount went 1 → 0; we hold the only reference;
239 // no other thread can observe the dict after this point.
240 unsafe { drop(Box::from_raw(ptr)); }
241 if !parent.is_null() {
242 // SAFETY: the sub-dict owned this reference for its lifetime.
243 unsafe { Dict::release(parent); }
244 }
245 true
246 } else {
247 false
248 }
249 }
250
251 /// Current refcount. For tests + debugging; not load-bearing.
252 pub fn refcount(&self) -> usize {
253 self.refcount.load(Ordering::Relaxed)
254 }
255
256 /// Look up `input`, inserting if absent. Returns the canonical
257 /// NUL-terminated pointer.
258 pub fn intern(&self, input: &[u8]) -> *const u8 {
259 // Reuse an ancestor's canonical pointer when present so interned
260 // pointers are equal across the parent/child boundary.
261 let ancestor = self.ancestor_lookup(input);
262 if !ancestor.is_null() {
263 return ancestor;
264 }
265 let mut inner = self.inner.lock().expect("Dict mutex poisoned");
266 if let Some(&canonical) = inner.table.get(input) {
267 return Self::canonical_ptr(canonical);
268 }
269 let mut buf = Vec::with_capacity(input.len() + 1);
270 buf.extend_from_slice(input);
271 buf.push(0);
272 // Leak the buffer to a raw pointer so the returned `*const u8`
273 // keeps allocation provenance across the map resizes that move
274 // the stored value. Reclaimed in `DictInner::drop`.
275 // SAFETY: `Box::into_raw` never returns null.
276 let canonical = unsafe {
277 NonNull::new_unchecked(Box::into_raw(buf.into_boxed_slice()))
278 };
279 let ptr = Self::canonical_ptr(canonical);
280 inner.owned.insert(ptr as usize);
281 inner.table.insert(input.to_vec(), canonical);
282 ptr
283 }
284
285 /// The stable, read-only interned pointer for a stored canonical
286 /// buffer: a thin pointer to its first byte carrying the buffer's
287 /// allocation provenance.
288 #[inline]
289 fn canonical_ptr(canonical: NonNull<[u8]>) -> *const u8 {
290 canonical.cast::<u8>().as_ptr()
291 }
292
293 /// `&str`-keyed convenience over [`Self::intern`].
294 pub fn intern_str(&self, s: &str) -> *const u8 {
295 self.intern(s.as_bytes())
296 }
297
298 /// Non-allocating lookup. Returns the canonical pointer on hit,
299 /// NULL on miss.
300 pub fn lookup(&self, input: &[u8]) -> *const u8 {
301 let local = self.inner
302 .lock().expect("Dict mutex poisoned")
303 .table
304 .get(input)
305 .map(|&c| Self::canonical_ptr(c));
306 match local {
307 Some(p) => p,
308 None => self.ancestor_lookup(input),
309 }
310 }
311
312 /// Does `ptr` originate from this dict or any ancestor? libxml2's
313 /// `xmlDictOwns` walks the sub-dict chain, so a string interned in
314 /// the parent is "owned" when probed through a child.
315 pub fn owns(&self, ptr: *const u8) -> bool {
316 if self.inner
317 .lock().expect("Dict mutex poisoned")
318 .owned.contains(&(ptr as usize))
319 {
320 return true;
321 }
322 let mut cur = self.parent;
323 while !cur.is_null() {
324 // SAFETY: parent chain is kept live by the sub-dict's reference.
325 let d = unsafe { &*cur };
326 if d.inner.lock().expect("Dict mutex poisoned")
327 .owned.contains(&(ptr as usize))
328 {
329 return true;
330 }
331 cur = d.parent;
332 }
333 false
334 }
335
336 /// Number of distinct strings interned.
337 pub fn len(&self) -> usize {
338 self.inner.lock().expect("Dict mutex poisoned").table.len()
339 }
340
341 pub fn is_empty(&self) -> bool { self.len() == 0 }
342}
343
344#[cfg(test)]
345mod tests {
346 use super::*;
347
348 #[test]
349 fn refcount_lifecycle() {
350 let p = Dict::new_refcounted();
351 assert_eq!(unsafe { (*p).refcount() }, 1);
352 unsafe { (*p).add_ref(); }
353 assert_eq!(unsafe { (*p).refcount() }, 2);
354 let freed = unsafe { Dict::release(p) };
355 assert!(!freed);
356 assert_eq!(unsafe { (*p).refcount() }, 1);
357 let freed = unsafe { Dict::release(p) };
358 assert!(freed);
359 }
360
361 #[test]
362 fn intern_returns_stable_pointer() {
363 let p = Dict::new_refcounted();
364 let d = unsafe { &*p };
365 let p1 = d.intern(b"div");
366 let p2 = d.intern(b"div");
367 assert_eq!(p1, p2);
368 assert_eq!(unsafe { *p1.add(3) }, 0);
369 let slice = unsafe { std::slice::from_raw_parts(p1, 3) };
370 assert_eq!(slice, b"div");
371 unsafe { Dict::release(p); }
372 }
373
374 #[test]
375 fn different_inputs_different_pointers() {
376 let p = Dict::new_refcounted();
377 let d = unsafe { &*p };
378 let pa = d.intern(b"p");
379 let pb = d.intern(b"span");
380 assert_ne!(pa, pb);
381 assert_eq!(d.len(), 2);
382 unsafe { Dict::release(p); }
383 }
384
385 #[test]
386 fn owns_recognises_canonicals_only() {
387 let p = Dict::new_refcounted();
388 let d = unsafe { &*p };
389 let canonical = d.intern(b"strong");
390 assert!(d.owns(canonical));
391 let foreign: *const u8 = b"strong".as_ptr();
392 assert!(!d.owns(foreign));
393 unsafe { Dict::release(p); }
394 }
395
396 #[test]
397 fn lookup_returns_null_on_miss() {
398 let p = Dict::new_refcounted();
399 let d = unsafe { &*p };
400 assert!(d.lookup(b"unknown").is_null());
401 d.intern(b"known");
402 assert!(!d.lookup(b"known").is_null());
403 unsafe { Dict::release(p); }
404 }
405
406 #[test]
407 fn sub_dict_shares_parent_interned_pointers() {
408 // libxslt interns a variable name in the stylesheet (parent)
409 // dict, then re-looks it up through the transform (sub) dict and
410 // compares the two pointers for identity. The sub-dict must
411 // return the parent's canonical pointer for that to hold.
412 let parent = Dict::new_refcounted();
413 let p = unsafe { &*parent };
414 let name_in_parent = p.intern(b"v");
415
416 let sub = unsafe { Dict::new_sub(parent) };
417 let s = unsafe { &*sub };
418 // Re-interning through the child yields the parent's pointer.
419 assert_eq!(s.intern(b"v"), name_in_parent);
420 // lookup() and owns() both chain to the parent.
421 assert_eq!(s.lookup(b"v"), name_in_parent);
422 assert!(s.owns(name_in_parent));
423 // A name only the child holds stays local to the child.
424 let child_only = s.intern(b"child");
425 assert!(s.owns(child_only));
426 assert!(!p.owns(child_only));
427 assert!(p.lookup(b"child").is_null());
428
429 // Creating the sub took a reference to the parent; releasing the
430 // sub releases it, leaving the caller's original parent ref.
431 assert_eq!(p.refcount(), 2);
432 unsafe { Dict::release(sub); }
433 assert_eq!(p.refcount(), 1);
434 unsafe { Dict::release(parent); }
435 }
436
437 #[test]
438 fn retains_foreign_arena_and_keeps_it_alive() {
439 use std::sync::Weak;
440 // A destination dict retaining a foreign (origin) arena must
441 // keep that arena's memory alive even after every other holder
442 // drops it — the cross-thread-graft invariant.
443 let dst = Dict::new_refcounted();
444 let dst_d = unsafe { &*dst };
445
446 let weak: Weak<Bump>;
447 {
448 // Stand-in for the origin document's arena.
449 let src_arena = Arc::new(Bump::new());
450 weak = Arc::downgrade(&src_arena);
451 // The move hook retains the origin arena onto the dest dict.
452 dst_d.retain_arena(Arc::clone(&src_arena));
453 // `src_arena` goes out of scope here; only the destination
454 // dict's retention keeps the arena alive.
455 }
456 assert!(weak.upgrade().is_some(), "destination dict must keep the foreign arena alive");
457
458 // Dedup: retaining the same arena twice keeps a single entry.
459 let again = weak.upgrade().unwrap();
460 dst_d.retain_arena(Arc::clone(&again));
461 assert_eq!(dst_d.inner.lock().unwrap().retained_arenas.len(), 1);
462
463 // Releasing the destination dict drops the last reference.
464 drop(again);
465 unsafe { Dict::release(dst); }
466 assert!(weak.upgrade().is_none(), "arena should free once the destination dict drops");
467 }
468}