leptris 1.9.229.0-x86_64-linux → 1.9.231.0-x86_64-linux

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +21 -0
  3. data/Rakefile +1 -1
  4. data/lib/leptris/version.rb +1 -1
  5. data/lib/libleptris.so +0 -0
  6. data/vendor-src/README.md +1 -1
  7. data/vendor-src/libleptris/CHANGELOG.md +49 -0
  8. data/vendor-src/libleptris/CMakeLists.txt +1 -1
  9. data/vendor-src/libleptris/benchmarks/serialization/bench_leptris_serialize.c +4 -4
  10. data/vendor-src/libleptris/benchmarks/workflows/bench_end_to_end.c +1 -1
  11. data/vendor-src/libleptris/src/leptris/dom/root_doc_map.c +120 -36
  12. data/vendor-src/libleptris/src/leptris/dom/root_doc_map.h +3 -1
  13. data/vendor-src/libleptris/src/leptris/xpath/functions.c +220 -31
  14. data/vendor-src/libleptris/test/CMakeLists.txt +3 -0
  15. data/vendor-src/libleptris/test/concurrency/test_root_doc_cross_thread.cpp +104 -0
  16. data/vendor-src/libleptris/test/perf/test_perf.cpp +20 -3
  17. data/vendor-src/libleptris/test/xquery/qt3/fn/boolean.xml +1342 -0
  18. data/vendor-src/libleptris/test/xquery/qt3/fn/normalize-space.xml +429 -0
  19. data/vendor-src/libleptris/test/xquery/qt3/fn/string-length.xml +349 -0
  20. data/vendor-src/libleptris/test/xquery/qt3/fn/substring-after.xml +594 -0
  21. data/vendor-src/libleptris/test/xquery/qt3/fn/substring-before.xml +597 -0
  22. data/vendor-src/libleptris/test/xquery/qt3/fn/translate.xml +410 -0
  23. data/vendor-src/libleptris/test/xquery/test_qt3.cpp +38 -0
  24. data/vendor-src/libleptris/vcpkg.json +1 -1
  25. metadata +8 -22
  26. data/vendor-src/libleptris/benchmarks/results/BENCHMARK_REPORT.md +0 -226
  27. data/vendor-src/libleptris/benchmarks/results/baseline_report.md +0 -219
  28. data/vendor-src/libleptris/benchmarks/results/benchmark_20251223_134550.json +0 -13
  29. data/vendor-src/libleptris/benchmarks/results/benchmark_20260129_225844.json +0 -13
  30. data/vendor-src/libleptris/benchmarks/results/leptris_dom_output.txt +0 -24
  31. data/vendor-src/libleptris/benchmarks/results/leptris_modify.json +0 -36
  32. data/vendor-src/libleptris/benchmarks/results/leptris_modify_optimized.json +0 -36
  33. data/vendor-src/libleptris/benchmarks/results/leptris_modify_session1.json +0 -36
  34. data/vendor-src/libleptris/benchmarks/results/leptris_modify_session2.json +0 -36
  35. data/vendor-src/libleptris/benchmarks/results/leptris_serialize.json +0 -34
  36. data/vendor-src/libleptris/benchmarks/results/leptris_xpath_output.txt +0 -24
  37. data/vendor-src/libleptris/benchmarks/results/libxml2_dom_output.txt +0 -24
  38. data/vendor-src/libleptris/benchmarks/results/libxml2_xpath_output.txt +0 -24
  39. data/vendor-src/libleptris/benchmarks/results/phase16_session2.json +0 -21
  40. data/vendor-src/libleptris/benchmarks/results/phase17.5_session1.json +0 -30
  41. data/vendor-src/libleptris/benchmarks/results/phase17_session1.json +0 -30
  42. data/vendor-src/libleptris/benchmarks/results/pugixml_dom_output.txt +0 -51
  43. data/vendor-src/libleptris/benchmarks/results/xpath_axes_leptris.json +0 -318
  44. data/vendor-src/libleptris/benchmarks/results/xpath_axes_libxml2.json +0 -318
  45. data/vendor-src/libleptris/benchmarks/results/xpath_functions_leptris.json +0 -222
  46. data/vendor-src/libleptris/benchmarks/results/xpath_functions_libxml2.json +0 -222
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 94655f5964d22d85d415d8e8f8b48c7a8fc0772c6dec1bde5a3e539a1d569820
4
- data.tar.gz: 8771d75bff1b08340b07b0db2c8996d4dcc9b65097d8766b62aeada7fd1e93a8
3
+ metadata.gz: 012e5580dc974f792458c6943f8059a5d7e3aa467a9805c7eae937f1039e5a83
4
+ data.tar.gz: f0c40fc816995d4f545988578dd70f66b62844bb4ff4fd6dcb43182efb023c1f
5
5
  SHA512:
6
- metadata.gz: 205f207da94cc4e8378554692b658bb3bf4e99e5b21755f5933a59691c044304dbb28a92af2307a728409e9fce18b0a7658f0d8cec438168a72a962ccc9f77ba
7
- data.tar.gz: 31528505abde42209dc834e1385b1f7c4693000f5f048a91ee8cc1f9c62ec47fd38e6b1b9781787afec368566909722f2b44e5735ee6adac14432e4e641488cf
6
+ metadata.gz: 44c3ed7dced5f3bc3700458535e214366ca35be93ea4a8312801660598ade70c64254ad9ddf9b57c7647a3d23d28209b2ef6e61dd2e7d632d92c073c116964c8
7
+ data.tar.gz: b10d3216646b6974c356e2a55384ab33985badde6e36499050427d1802730bf282c050237603bbfde6b64a72fb0df4832e9b8e37115b7d2922c7cb4e8d846442
data/CHANGELOG.md CHANGED
@@ -5,6 +5,27 @@ All notable changes to Leptris will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.9.231.0] - 2026-09-23
9
+
10
+ ### Fixed
11
+
12
+ - Vendored libleptris 1.9.231: the root-doc map is process-global
13
+ now — a document freed on a thread other than the one that
14
+ registered its roots (binding GC finalizers run on arbitrary
15
+ threads) no longer leaves a stale `{root -> doc}` entry in the
16
+ owner thread's map. Pre-fix, address-matched walks dereferenced
17
+ (and rewired) the freed document: the
18
+ `leptris_root_doc_unregister+0x6c` segfault under heavy parse
19
+ churn on macOS Ruby 3.3 (#321), plus silent freed-document
20
+ resolution through lookups and the per-thread memo. The memo
21
+ stays lock-free, validated by a global atomic generation.
22
+ - Also riding 1.9.230's string-length fix (continuation-count
23
+ table, ac6f9738): the old lead-byte table over-advanced past
24
+ the NUL, and the NFD spec expectation enshrined the phantom
25
+ byte (4.0). Re-pinned to the correct contracts — verbatim
26
+ one-argument passthrough (2 codepoints) and two-argument NFD
27
+ (splits the precomposed é, 3 codepoints).
28
+
8
29
  ## [1.9.229.0] - 2026-09-23
9
30
 
10
31
  ### Performance — engine sync (1.9.229)
data/Rakefile CHANGED
@@ -8,7 +8,7 @@ RSpec::Core::RakeTask.new(:spec)
8
8
  # Pin for `rake compile` and the platform-gem builds. Keep in lockstep
9
9
  # with .github/workflows/build.yml (which calls `rake compile`) and the
10
10
  # CHANGELOG when libleptris releases.
11
- LIBLEPTRIS_VERSION = "1.9.229"
11
+ LIBLEPTRIS_VERSION = "1.9.231"
12
12
  # Vendored alongside libleptris for fn:normalize-unicode (TODO
13
13
  # .restructure/20): built per platform with a RELOCATABLE @rpath
14
14
  # install name, loaded by ffi.rb before libleptris so the
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Leptris
4
- VERSION = "1.9.229.0"
4
+ VERSION = "1.9.231.0"
5
5
  end
data/lib/libleptris.so CHANGED
Binary file
data/vendor-src/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  Sources vendored in this gem (recompile rights; the ruby
2
2
  variant builds them at install via extconf.rb):
3
3
 
4
- - libleptris v1.9.229
4
+ - libleptris v1.9.231
5
5
  - utf8proc v2.11.0
6
6
 
7
7
  Rebuild by hand:
@@ -1,5 +1,54 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [1.9.231] - 2026-09-23
4
+
5
+ ### Fixed
6
+
7
+ - root-doc map is process-global — cross-thread document death poisoned owner-thread state (dom)
8
+
9
+
10
+
11
+ ## [1.9.230] - 2026-09-23
12
+
13
+ ### Added
14
+
15
+ - **xquery: QT3 stage-2 corpus slice — 228 newly gated conformance
16
+ cases across six core families** (translate, normalize-space,
17
+ substring-before, substring-after, boolean, string-length),
18
+ vendored from the W3C qt3tests suite with the driver's exact
19
+ `agree == run == pinned` gate. fn:number is deliberately not
20
+ adopted: its string assertions want canonical E-notation doubles,
21
+ which the number formatter deliberately does not print
22
+ (libxml2 xmlXPathFormatNumber parity is load-bearing for the
23
+ libxslt suite).
24
+
25
+ ### Fixed
26
+
27
+ - **xpath: `fn:translate` is now codepoint-correct.** The old
28
+ implementation built a 256-entry byte-level table, so every
29
+ multi-byte UTF-8 character matched byte-by-byte and astral
30
+ codepoints could not be addressed at all. The rewrite decodes
31
+ `from`/`to` into codepoints (first occurrence governs, per
32
+ F&O), supports cross-width replacements, and emits into a
33
+ growable buffer.
34
+ - **xpath: `fn:string-length` counts codepoints, not bytes** —
35
+ `string-length("𐀂")` returned 4; the walk now steps by
36
+ lead-byte length.
37
+ - **xpath: `substring-before`/`substring-after` accept the 3-arg
38
+ collation overload.** The codepoint-collation URI (under which
39
+ the collation is the default byte comparison) and the empty URI
40
+ are accepted; any other URI raises the unsupported-collation
41
+ error.
42
+ - **bench: the CI benchmark legs' fixture/result paths unified
43
+ under one convention** (harnesses read `fixtures/` and write
44
+ `results/` relative to their working directory; CI stages both
45
+ once). SAX and XPath benchmark results that were silently
46
+ skipped or failing to write now produce real data, and 21 stale
47
+ committed result files (including a nine-month-old comparison
48
+ report) are gone from the tree.
49
+
50
+
51
+
3
52
  ## [1.9.229] - 2026-09-23
4
53
 
5
54
  ### Performance
@@ -3,7 +3,7 @@
3
3
  cmake_minimum_required(VERSION 3.20)
4
4
 
5
5
  project(leptris
6
- VERSION 1.9.229
6
+ VERSION 1.9.231
7
7
  DESCRIPTION "Fast XML parser and XPath evaluator in pure C"
8
8
  LANGUAGES C CXX
9
9
  )
@@ -35,7 +35,7 @@ static char* load_file(const char* path, size_t* size) {
35
35
  // Benchmark: Serialize small document
36
36
  static void bench_serialize_small(void) {
37
37
  size_t size;
38
- char* xml = load_file("benchmarks/fixtures/small.xml", &size);
38
+ char* xml = load_file("fixtures/small.xml", &size);
39
39
  if (!xml) {
40
40
  printf("ERROR: Could not load small.xml\n");
41
41
  return;
@@ -68,7 +68,7 @@ static void bench_serialize_small(void) {
68
68
  // Benchmark: Serialize medium document
69
69
  static void bench_serialize_medium(void) {
70
70
  size_t size;
71
- char* xml = load_file("benchmarks/fixtures/medium.xml", &size);
71
+ char* xml = load_file("fixtures/medium.xml", &size);
72
72
  if (!xml) {
73
73
  printf("ERROR: Could not load medium.xml\n");
74
74
  return;
@@ -101,7 +101,7 @@ static void bench_serialize_medium(void) {
101
101
  // Benchmark: Serialize large document
102
102
  static void bench_serialize_large(void) {
103
103
  size_t size;
104
- char* xml = load_file("benchmarks/fixtures/large.xml", &size);
104
+ char* xml = load_file("fixtures/large.xml", &size);
105
105
  if (!xml) {
106
106
  printf("ERROR: Could not load large.xml\n");
107
107
  return;
@@ -134,7 +134,7 @@ static void bench_serialize_large(void) {
134
134
  // Benchmark: Pretty-print vs compact
135
135
  static void bench_format_comparison(void) {
136
136
  size_t size;
137
- char* xml = load_file("benchmarks/fixtures/small.xml", &size);
137
+ char* xml = load_file("fixtures/small.xml", &size);
138
138
  if (!xml) {
139
139
  printf("ERROR: Could not load small.xml\n");
140
140
  return;
@@ -513,7 +513,7 @@ int main(int argc, char** argv) {
513
513
  snprintf(size_medium, sizeof(size_medium), "%.1fKB", medium_len / 1024.0);
514
514
  snprintf(size_large, sizeof(size_large), "%.1fKB", large_len / 1024.0);
515
515
 
516
- write_json_results("benchmarks/results/phase17.5_session1.json",
516
+ write_json_results("results/phase17.5_session1.json",
517
517
  size_small, size_medium, size_large,
518
518
  &small_results, &medium_results, &large_results,
519
519
  iterations);
@@ -1,9 +1,21 @@
1
- /* dom/root_doc_map.c — Thread-local root-element → document mapping.
1
+ /* dom/root_doc_map.c — root-element → document mapping.
2
2
  *
3
3
  * TODO 155 Phase A: the `document` field was removed from struct
4
4
  * leptris_element to fit it in one 64-byte cache line. Non-root
5
5
  * elements reach their document by walking parent_off to the root,
6
- * then looking up the root in this thread-local hash table.
6
+ * then looking up the root in this hash table.
7
+ *
8
+ * leptris-ruby#321: the map (buckets, entries, generation) was
9
+ * THREAD-LOCAL, but document death is not — binding finalizers free
10
+ * documents on arbitrary threads. A cross-thread free could not
11
+ * reach the owner thread's buckets, leaving a stale {root -> freed
12
+ * doc} entry and a memo the owner's TLS generation still trusted:
13
+ * the next owner-thread walk that address-matched the stale entry
14
+ * dereferenced (and rewired) the freed document, and lookups handed
15
+ * the freed document to callers. The map state is process-global
16
+ * now, guarded by one mutex; the per-thread (root, doc) memo stays
17
+ * lock-free, validated against a GLOBAL atomic generation that any
18
+ * thread's mutation bumps.
7
19
  *
8
20
  * TODO 157 (perf): uses a free-list to avoid per-parse malloc/free.
9
21
  * After warmup, register and unregister are O(1) with zero heap ops. */
@@ -39,7 +51,12 @@ typedef struct root_doc_entry {
39
51
  struct root_doc_entry* doc_next; /* owning doc's chain */
40
52
  } RootDocEntry;
41
53
 
42
- static LEPTRIS_THREAD_LOCAL RootDocEntry* g_root_doc_buckets[ROOT_DOC_BUCKETS];
54
+ static RootDocEntry* g_root_doc_buckets[ROOT_DOC_BUCKETS];
55
+
56
+ /* One lock guards buckets, entry chunks/free-list, and doc chains.
57
+ * Uncontended acquisition is the whole per-op cost single-threaded
58
+ * callers pay; the memo fast path below needs no lock at all. */
59
+ static leptris_mutex_t g_root_doc_lock = LEPTRIS_MUTEX_INIT;
43
60
 
44
61
  /* Doc-entry chain helpers: every register pushes the entry onto its
45
62
  * document's list (doc->map_entries), so unregister_doc walks exactly
@@ -62,7 +79,7 @@ static void doc_chain_unlink(struct leptris_document* doc, RootDocEntry* e) {
62
79
 
63
80
  /* Free-list: recycled entries from unregistered roots. Eliminates
64
81
  * malloc/free churn on the parse→free cycle. */
65
- static LEPTRIS_THREAD_LOCAL RootDocEntry* g_free_list;
82
+ static RootDocEntry* g_free_list;
66
83
 
67
84
  /* Entry chunks (lane 18 P0): one TLS bump chunk serves fresh
68
85
  * entries so a create-heavy document pays no malloc per element —
@@ -74,9 +91,9 @@ typedef struct root_doc_chunk {
74
91
  struct root_doc_chunk* next;
75
92
  RootDocEntry entries[ROOTMAP_CHUNK];
76
93
  } RootDocChunk;
77
- static LEPTRIS_THREAD_LOCAL RootDocChunk* g_entry_chunks;
78
- static LEPTRIS_THREAD_LOCAL RootDocEntry* g_entry_cursor;
79
- static LEPTRIS_THREAD_LOCAL RootDocEntry* g_entry_end;
94
+ static RootDocChunk* g_entry_chunks;
95
+ static RootDocEntry* g_entry_cursor;
96
+ static RootDocEntry* g_entry_end;
80
97
 
81
98
  static RootDocEntry* entry_take(void) {
82
99
  if (g_free_list) {
@@ -128,13 +145,33 @@ static LEPTRIS_THREAD_LOCAL struct leptris_document* g_memo_doc;
128
145
  * lookups only, so the memo keeps hitting there; any churn —
129
146
  * precisely the strike interleaving — kills it deterministically
130
147
  * instead of by allocator luck. */
131
- static LEPTRIS_THREAD_LOCAL uint64_t g_rootmap_generation;
148
+ /* Global: a mutation on ANY thread must invalidate EVERY thread's
149
+ * memo snapshot (the TLS counter let a foreign document death go
150
+ * unseen — leptris-ruby#321). Portability per arena.c's lock
151
+ * precedent: __atomic on GCC/Clang, _Interlocked on MSVC. */
152
+ static uint64_t g_rootmap_generation;
132
153
  static LEPTRIS_THREAD_LOCAL uint64_t g_memo_generation;
133
154
 
134
- static void rootmap_generation_bump(void) { g_rootmap_generation++; }
155
+ #if defined(_MSC_VER)
156
+ #include <intrin.h>
157
+ static uint64_t rootmap_generation_load(void) {
158
+ return _InterlockedCompareExchange64(&g_rootmap_generation, 0, 0);
159
+ }
160
+ static uint64_t rootmap_generation_bump(void) {
161
+ return _InterlockedIncrement64(&g_rootmap_generation) - 1;
162
+ }
163
+ #else
164
+ static uint64_t rootmap_generation_load(void) {
165
+ return __atomic_load_n(&g_rootmap_generation, __ATOMIC_ACQUIRE);
166
+ }
167
+ static uint64_t rootmap_generation_bump(void) {
168
+ return __atomic_add_fetch(&g_rootmap_generation, 1, __ATOMIC_ACQ_REL);
169
+ }
170
+ #endif
135
171
 
136
172
  void leptris_root_doc_register(LeptrisElement root, struct leptris_document* doc) {
137
173
  if (!root || !doc) return;
174
+ LEPTRIS_MUTEX_LOCK(&g_root_doc_lock);
138
175
  rootmap_generation_bump();
139
176
  size_t idx = bucket_index(root);
140
177
  if (rootmap_marked(root)) {
@@ -149,48 +186,55 @@ void leptris_root_doc_register(LeptrisElement root, struct leptris_document* doc
149
186
  e->doc = doc;
150
187
  doc_chain_push(doc, e);
151
188
  }
189
+ LEPTRIS_MUTEX_UNLOCK(&g_root_doc_lock);
152
190
  return;
153
191
  }
154
192
  }
155
193
  } else {
156
194
  /* Never registered: prepend directly, no duplicate walk. */
157
195
  RootDocEntry* e = entry_take();
158
- if (!e) return;
196
+ if (!e) {
197
+ LEPTRIS_MUTEX_UNLOCK(&g_root_doc_lock);
198
+ return;
199
+ }
159
200
  e->root = root; e->doc = doc;
160
201
  e->next = g_root_doc_buckets[idx];
161
202
  g_root_doc_buckets[idx] = e;
162
203
  doc_chain_push(doc, e);
163
204
  rootmap_set(root, 1);
205
+ LEPTRIS_MUTEX_UNLOCK(&g_root_doc_lock);
164
206
  return;
165
207
  }
166
208
  /* Pop from free-list, or the bump chunk. */
167
209
  RootDocEntry* e = entry_take();
168
- if (!e) return;
210
+ if (!e) {
211
+ LEPTRIS_MUTEX_UNLOCK(&g_root_doc_lock);
212
+ return;
213
+ }
169
214
  e->root = root; e->doc = doc;
170
215
  e->next = g_root_doc_buckets[idx];
171
216
  g_root_doc_buckets[idx] = e;
172
217
  doc_chain_push(doc, e);
218
+ LEPTRIS_MUTEX_UNLOCK(&g_root_doc_lock);
173
219
  }
174
220
 
175
- /* TODO.concurrency/08: TLS free-list entries outlive their thread
176
- * (no portable C99 TLS destructor). leptris_thread_cleanup() drains
177
- * them from each worker thread before exit. */
221
+ /* TODO.concurrency/08, adjusted for #321: entry chunks are
222
+ * process-global and reused for the life of the process (they were
223
+ * per-thread and never torn down either). A dying thread now has
224
+ * only its memo to drop. */
178
225
  void leptris_root_doc_drain_thread_caches(void) {
179
- while (g_free_list) {
180
- RootDocEntry* next = g_free_list->next;
181
- g_free_list = next; /* chunk-owned; freed with the chunks */
182
- }
183
- while (g_entry_chunks) {
184
- RootDocChunk* next = g_entry_chunks->next;
185
- free(g_entry_chunks);
186
- g_entry_chunks = next;
187
- }
188
- g_entry_cursor = NULL;
189
- g_entry_end = NULL;
226
+ g_memo_root = NULL;
227
+ g_memo_doc = NULL;
228
+ g_memo_generation = rootmap_generation_load();
190
229
  }
191
230
 
192
231
  void leptris_root_doc_unregister(LeptrisElement root) {
193
232
  if (!root) return;
233
+ /* The flag read touches element storage; elements die only with
234
+ * their document, and a cross-thread document death cannot run
235
+ * concurrently with THIS thread's unregister for the same tree
236
+ * (the binding never touches a freed document), so the probe is
237
+ * safe outside the lock. */
194
238
  if (!rootmap_marked(root)) return; /* never registered: O(1) out */
195
239
  /* #1242: any map mutation kills the memo (generation bump).
196
240
  * The address-match clear below stays as the immediate drop;
@@ -201,6 +245,7 @@ void leptris_root_doc_unregister(LeptrisElement root) {
201
245
  g_memo_root = NULL;
202
246
  g_memo_doc = NULL;
203
247
  }
248
+ LEPTRIS_MUTEX_LOCK(&g_root_doc_lock);
204
249
  size_t idx = bucket_index(root);
205
250
  RootDocEntry** pp = &g_root_doc_buckets[idx];
206
251
  while (*pp) {
@@ -214,10 +259,12 @@ void leptris_root_doc_unregister(LeptrisElement root) {
214
259
  freed->next = g_free_list;
215
260
  g_free_list = freed;
216
261
  rootmap_set(root, 0);
262
+ LEPTRIS_MUTEX_UNLOCK(&g_root_doc_lock);
217
263
  return;
218
264
  }
219
265
  pp = &(*pp)->next;
220
266
  }
267
+ LEPTRIS_MUTEX_UNLOCK(&g_root_doc_lock);
221
268
  }
222
269
 
223
270
  /* #1038: root-doc map entries must die with their document. The
@@ -238,6 +285,7 @@ void leptris_root_doc_unregister(LeptrisElement root) {
238
285
  * preserving the adopted-pool rule above. */
239
286
  size_t leptris_root_doc_unregister_doc(struct leptris_document* doc) {
240
287
  if (!doc) return 0;
288
+ LEPTRIS_MUTEX_LOCK(&g_root_doc_lock);
241
289
  size_t removed = 0;
242
290
  /* #1242: doc sweep mutates the map — kill the memo. */
243
291
  rootmap_generation_bump();
@@ -266,16 +314,20 @@ size_t leptris_root_doc_unregister_doc(struct leptris_document* doc) {
266
314
  removed++;
267
315
  e = next;
268
316
  }
317
+ LEPTRIS_MUTEX_UNLOCK(&g_root_doc_lock);
269
318
  return removed;
270
319
  }
271
320
 
272
321
  struct leptris_document* leptris_root_doc_lookup(LeptrisElement root) {
273
322
  if (!root) return NULL;
323
+ LEPTRIS_MUTEX_LOCK(&g_root_doc_lock);
274
324
  size_t idx = bucket_index(root);
325
+ struct leptris_document* found = NULL;
275
326
  for (RootDocEntry* e = g_root_doc_buckets[idx]; e; e = e->next) {
276
- if (e->root == root) return e->doc;
327
+ if (e->root == root) { found = e->doc; break; }
277
328
  }
278
- return NULL;
329
+ LEPTRIS_MUTEX_UNLOCK(&g_root_doc_lock);
330
+ return found;
279
331
  }
280
332
 
281
333
  /* #682 2x lever: an ambient document hint set by the transform
@@ -293,9 +345,24 @@ struct leptris_document* leptris_root_doc_lookup(LeptrisElement root) {
293
345
  void leptris_root_doc_memo_prime(LeptrisElement root,
294
346
  struct leptris_document* doc) {
295
347
  if (!root || !doc) return;
348
+ LEPTRIS_MUTEX_LOCK(&g_root_doc_lock);
349
+ /* Verify the pair is still live under the lock: a document that
350
+ * died on another thread between the caller's registration
351
+ * guarantee and this prime must not seed a trusted memo. */
352
+ size_t idx = bucket_index(root);
353
+ struct leptris_document* live = NULL;
354
+ for (RootDocEntry* e = g_root_doc_buckets[idx]; e; e = e->next) {
355
+ if (e->root == root) { live = e->doc; break; }
356
+ }
357
+ if (live != doc) {
358
+ LEPTRIS_MUTEX_UNLOCK(&g_root_doc_lock);
359
+ return;
360
+ }
361
+ uint64_t gen = rootmap_generation_load();
362
+ LEPTRIS_MUTEX_UNLOCK(&g_root_doc_lock);
296
363
  g_memo_root = root;
297
364
  g_memo_doc = doc;
298
- g_memo_generation = g_rootmap_generation;
365
+ g_memo_generation = gen;
299
366
  }
300
367
 
301
368
  void leptris_root_doc_memo_invalidate(const struct leptris_document* doc) {
@@ -310,7 +377,7 @@ void leptris_root_doc_memo_invalidate(const struct leptris_document* doc) {
310
377
  * when the memo is generation-stale (primed before a map
311
378
  * mutation), even if the pair fields still hold values. */
312
379
  LeptrisElement leptris_root_doc_memo_root_for_tests(void) {
313
- if (g_memo_generation != g_rootmap_generation) return NULL;
380
+ if (g_memo_generation != rootmap_generation_load()) return NULL;
314
381
  return g_memo_root;
315
382
  }
316
383
 
@@ -334,7 +401,8 @@ struct leptris_document* leptris_element_get_document(LeptrisElement elem) {
334
401
  * address memo. A freed root's address recycled by the
335
402
  * allocator made the memo hit with a stale document
336
403
  * before the authoritative namebp was consulted. */
337
- if (cur == memo_root && memo_generation == g_rootmap_generation &&
404
+ if (cur == memo_root &&
405
+ memo_generation == rootmap_generation_load() &&
338
406
  !leptris_elem_has_namebp(cur))
339
407
  return g_memo_doc;
340
408
  LeptrisElement parent = leptris_elem_parent(cur);
@@ -346,12 +414,28 @@ struct leptris_document* leptris_element_get_document(LeptrisElement elem) {
346
414
  if (!parent) break;
347
415
  cur = parent;
348
416
  }
349
- struct leptris_document* d = leptris_root_doc_lookup(cur);
350
- if (d) {
351
- g_memo_root = cur;
352
- g_memo_doc = d;
353
- g_memo_generation = g_rootmap_generation;
354
- return d;
417
+ /* Locked bucket walk with an UNDER-THE-LOCK generation
418
+ * snapshot: a death that acquires the lock after we release
419
+ * bumps the global generation past our snapshot, so the primed
420
+ * pair is untrusted on the next read. Priming from outside the
421
+ * lock (the old shape) could snapshot the NEW generation for a
422
+ * pair whose document was freed in between (#321). */
423
+ {
424
+ struct leptris_document* d = NULL;
425
+ uint64_t now;
426
+ LEPTRIS_MUTEX_LOCK(&g_root_doc_lock);
427
+ size_t ridx = bucket_index(cur);
428
+ for (RootDocEntry* e = g_root_doc_buckets[ridx]; e; e = e->next) {
429
+ if (e->root == cur) { d = e->doc; break; }
430
+ }
431
+ now = rootmap_generation_load();
432
+ LEPTRIS_MUTEX_UNLOCK(&g_root_doc_lock);
433
+ if (d) {
434
+ g_memo_root = cur;
435
+ g_memo_doc = d;
436
+ g_memo_generation = now;
437
+ return d;
438
+ }
355
439
  }
356
440
  /* Round 21: unattached mutation elements carry their doc in the
357
441
  * name slot backpointer — a stateless fallback that replaced the
@@ -1,4 +1,6 @@
1
- /* dom/root_doc_map.h — Thread-local root-element → document mapping. */
1
+ /* dom/root_doc_map.h — root-element → document mapping (process-global
2
+ * state behind one mutex; per-thread memo validated by a global
3
+ * generation — see leptris-ruby#321). */
2
4
  #ifndef LEPTRIS_DOM_ROOT_DOC_MAP_H
3
5
  #define LEPTRIS_DOM_ROOT_DOC_MAP_H
4
6