Skip to main content

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}