leptris 1.9.207.0 → 1.9.208.0

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b10f689b2142684316157264cfd935433d6d0d22db8cf90b4a27e9c4ba79bd88
4
- data.tar.gz: f3e625c375d995087bec8cd6c058a7a9c900791125f1422f6da8b2436e90047b
3
+ metadata.gz: 8c88c4d67f3b29d8e39348f96c9dfd6580563de8c1adcbb76a35a29a932a45dc
4
+ data.tar.gz: '030917e922c2ae16f63d588378d7d5552e52d03cfc2b5e33043aaea39c067c78'
5
5
  SHA512:
6
- metadata.gz: ddf28875bb34e2eb14074c51834614f5cb5acddf0a33c58f55a50d9e6970a065eb70cb6f3da51c1114cad3a8c062a6dec98d6ce271ff27a81a77033f92e774ea
7
- data.tar.gz: b1c7bc0629b2840a7a23d71bd27b08303426ab7b013d8a272d9a1fedc82b8f40ba8fdd6805724f878fbf4374752912658ea5922e79548f16a53a859cf4de3164
6
+ metadata.gz: '09ba6dc5293eb1d93becec0eef194a97466ae800017ba5e2d44169dc3912b9620398f090300147ed62a8ebcacce5b83a7165b133f9bb84771cbdefc0e4c8d303'
7
+ data.tar.gz: 8b88a8828ce0bed5e3b7b8140bbb3f31238fc5e484737e6dcdc3a3f4171f3c36e768dd8245c3c7097657a7715add8afcf1c2f0a22d42c0f6fb5631b7d1771650
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.207"
11
+ LIBLEPTRIS_VERSION = "1.9.208"
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.207.0"
4
+ VERSION = "1.9.208.0"
5
5
  end
@@ -1,5 +1,30 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [1.9.208] - 2026-09-19
4
+
5
+ ### Fixed
6
+
7
+ - Per-machine process-global corruption (#1242): the TLS (root, doc)
8
+ last-hit memo in `leptris_element_get_document` trusted the root
9
+ ADDRESS and was cleared only by document identity — an element-level
10
+ unregistration left `(freed_address, doc)` behind, and a
11
+ recycled address resolved the WRONG document (poisoning attribute
12
+ ownership: whole-run all-NULL `leptris_element_attribute` reads;
13
+ and tripping set_root's cross-document check). The memo is now
14
+ cleared whenever the outgoing root address matches, in both the
15
+ element-level unregister and the document sweep — closing the
16
+ same recycled-address class #1038 fixed for the root-doc map.
17
+ - `leptris_document_set_root` now sets a fresh thread-local error on
18
+ every rejection (NULL_ARG + both EINVAL paths) — previously the
19
+ previous operation's message leaked through (the reported "EINVAL
20
+ with a stale XPath parse error").
21
+ - New diagnostic: `LEPTRIS_DEBUG_ATTR_MISS=1` dumps attribute-chain
22
+ state on lookup misses (chain length, per-attr name bytes/hash15)
23
+ — the discriminator for any residual binding-side corruption
24
+ report.
25
+
26
+
27
+
3
28
  ## [1.9.207] - 2026-09-19
4
29
 
5
30
  ### Fixed
@@ -3,7 +3,7 @@
3
3
  cmake_minimum_required(VERSION 3.20)
4
4
 
5
5
  project(leptris
6
- VERSION 1.9.207
6
+ VERSION 1.9.208
7
7
  DESCRIPTION "Fast XML parser and XPath evaluator in pure C"
8
8
  LANGUAGES C CXX
9
9
  )
@@ -5,12 +5,12 @@ RED bench first where perf claims are made.
5
5
 
6
6
  | # | Lever | Type | Effort | Status |
7
7
  |---|-------|------|--------|--------|
8
- | 3 | iterparse fixed-cost (6.9µs) + error channel (#563) | perf | bench landed #1195; remaining = zero-copy one-shot emission (design with lever 2) | half done |
8
+ | 3 | iterparse fixed-cost (6.9µs) + error channel (#563) | perf | bench #1195; zero-copy emission landed #1236 (295->52us full-doc); remaining = iterparse per-event DOM construction (0.45->0.15us/event stretch) | mostly done |
9
9
  | 1 | SIMD/memchr HTML tokenizer scans | perf | memchr ×2 (#1176/#1190) + LUT (#1199, +30%/+51%) | DONE v1.9.199 |
10
10
  | 7 | leptris validate CLI + bindings | feature | engine half merged #1198 (--dtd + DTD engine fixes); bindings remain | half done |
11
- | 8 | native XML diff consumer surface | feature | small | next |
11
+ | 8 | native XML diff consumer surface | feature | CLI diff text/JSON/--summary + engine API shipped | DONE (#1184 closed) |
12
12
  | 4 | compact-node micro-opts (non-layout) | perf | audited: TODOs 172/195/Lane18-P3 already in; residual = node-creation cost (node-layout fork, owner call) | parked on fork |
13
- | 2 | compiled-dispatch tokenizer | perf | medium; design together with lever 3's zero-copy emission | queued |
13
+ | 2 | compiled-dispatch tokenizer | perf | slices 1+3 landed #1236; slice 2's class LUT + dense switch pre-existed; residual = iterparse consumer (row 3) | mostly done |
14
14
  | 5 | XSLT 2x per-bench profile rounds (#682) | perf | medium, ongoing | in progress |
15
15
  | 6 | language tails (QT3/dates/fn-items/lane 15) | correctness | medium-large | queued |
16
16
  | 9 | prebuilt-gem 12-platform matrix | release | small, BLOCKED on owner go | blocked |
@@ -1,6 +1,12 @@
1
1
  # Levers 2+3 — compiled-dispatch + zero-copy one-shot emission (design)
2
2
 
3
- Status: DESIGNED (2026-09-19). Implementation slices below.
3
+ Status: LANDED (2026-09-19, PR #1236). Slices 1+3 implemented;
4
+ slice 2's class LUT (`sxs_chartype`) + dense state switch already
5
+ existed. Measured: bench_iterparse full-doc 295->52-63us wall,
6
+ 15.9->74-91 MB/s, 2.54->0.45-0.55us/event. Residual to the 0.15
7
+ stretch = iterparse per-event DOM construction (element create +
8
+ namespace resolution through the public element API), a consumer
9
+ lever, not string copies.
4
10
  Owners: engine repo. Bench: `benchmarks/sax/bench_iterparse.c` (#1195).
5
11
 
6
12
  ## Problem
@@ -178,7 +178,49 @@ LEPTRIS_API const char* leptris_element_attribute(LeptrisElement elem, const cha
178
178
  if (!uri) return NULL; /* (4) */
179
179
  attr = find_attr_expanded(elem, uri, colon + 1); /* (2) */
180
180
  }
181
- if (!attr) return NULL;
181
+ if (!attr) {
182
+ /* #1242 diagnostic: LEPTRIS_DEBUG_ATTR_MISS=1 dumps the
183
+ * element's attribute-chain state for the first 5 misses in
184
+ * the process — distinguishes construction loss
185
+ * (chain_len=0) from storage corruption (garbage name
186
+ * bytes) from hash-discipline drift (correct bytes,
187
+ * mismatching hash15). Off by default; fprintf only on
188
+ * miss. */
189
+ static int dumped = 0;
190
+ if (dumped < 5 && getenv("LEPTRIS_DEBUG_ATTR_MISS")) {
191
+ dumped++;
192
+ size_t chain = 0;
193
+ for (struct leptris_attribute* a =
194
+ leptris_element_get_first_attribute(elem);
195
+ a; a = leptris_attr_next(a))
196
+ chain++;
197
+ fprintf(stderr,
198
+ "[leptris-attr-miss] elem=%p needle='%s' "
199
+ "chain_len=%zu\n",
200
+ (void*)elem, name, chain);
201
+ size_t i = 0;
202
+ for (struct leptris_attribute* a =
203
+ leptris_element_get_first_attribute(elem);
204
+ a && i < 8; a = leptris_attr_next(a), i++) {
205
+ char nb[17];
206
+ size_t nl = a->name_view.length < 16
207
+ ? a->name_view.length : 16;
208
+ for (size_t k = 0; k < nl; k++) {
209
+ unsigned char c = (unsigned char)a->name_view.data[k];
210
+ nb[k] = (c >= 32 && c < 127) ? (char)c : '.';
211
+ }
212
+ nb[nl] = '\0';
213
+ fprintf(stderr,
214
+ " attr[%zu] addr=%p name_ptr=%p len=%zu "
215
+ "bytes='%s' hash15=%u entities=%d\n",
216
+ i, (void*)a, (void*)a->name_view.data,
217
+ a->name_view.length, nb,
218
+ (unsigned)(a->name_hash & 0x7FFFu),
219
+ (int)((a->name_hash & 0x8000u) != 0));
220
+ }
221
+ }
222
+ return NULL;
223
+ }
182
224
 
183
225
  /* Single representation (round 4): entity values expand lazily
184
226
  * INTO the view (owned copy); no-entity views are already
@@ -102,6 +102,15 @@ static size_t bucket_index(LeptrisElement root) {
102
102
  return (size_t)(v & (ROOT_DOC_BUCKETS - 1));
103
103
  }
104
104
 
105
+ /* #682 2x lever (sound): last-hit (root, doc) memo. The climb to
106
+ * the root is unavoidable without a per-element field, but the TLS
107
+ * bucket-array walk is not — consecutive queries in a transform
108
+ * hit the same root, so one pointer compare replaces the hash
109
+ * bucket chain. One TLS access total (the memo pair), and the
110
+ * 1M-loop sentinel becomes a plain NULL check. */
111
+ static LEPTRIS_THREAD_LOCAL LeptrisElement g_memo_root;
112
+ static LEPTRIS_THREAD_LOCAL struct leptris_document* g_memo_doc;
113
+
105
114
  void leptris_root_doc_register(LeptrisElement root, struct leptris_document* doc) {
106
115
  if (!root || !doc) return;
107
116
  size_t idx = bucket_index(root);
@@ -160,6 +169,17 @@ void leptris_root_doc_drain_thread_caches(void) {
160
169
  void leptris_root_doc_unregister(LeptrisElement root) {
161
170
  if (!root) return;
162
171
  if (!rootmap_marked(root)) return; /* never registered: O(1) out */
172
+ /* #1242: the (root, doc) memo trusts the root ADDRESS. An
173
+ * element-level unregister frees that address while the doc
174
+ * lives — the doc-keyed invalidation never fires, and the
175
+ * allocator recycling the address into another element's climb
176
+ * target made get_document return the WRONG document. Same
177
+ * exposure class #1038 closed for the map; the memo needs it
178
+ * at element granularity. */
179
+ if (g_memo_root == root) {
180
+ g_memo_root = NULL;
181
+ g_memo_doc = NULL;
182
+ }
163
183
  size_t idx = bucket_index(root);
164
184
  RootDocEntry** pp = &g_root_doc_buckets[idx];
165
185
  while (*pp) {
@@ -202,6 +222,13 @@ size_t leptris_root_doc_unregister_doc(struct leptris_document* doc) {
202
222
  doc->map_entries = NULL;
203
223
  while (e) {
204
224
  RootDocEntry* next = e->doc_next;
225
+ /* #1242: a swept root address must not survive in the memo
226
+ * even when the memo's doc differs (a previously poisoned
227
+ * pair). */
228
+ if (g_memo_root == e->root) {
229
+ g_memo_root = NULL;
230
+ g_memo_doc = NULL;
231
+ }
205
232
  size_t idx = bucket_index(e->root);
206
233
  RootDocEntry** pp = &g_root_doc_buckets[idx];
207
234
  while (*pp) {
@@ -234,15 +261,6 @@ struct leptris_document* leptris_root_doc_lookup(LeptrisElement root) {
234
261
  * are skipped entirely (get_document was 3.5% of the light bench).
235
262
  * Hint is thread-local-scoped by construction (one transform per
236
263
  * thread), validated by the root-walk fallback when it misses. */
237
- /* #682 2x lever (sound): last-hit (root, doc) memo. The climb to
238
- * the root is unavoidable without a per-element field, but the TLS
239
- * bucket-array walk is not — consecutive queries in a transform
240
- * hit the same root, so one pointer compare replaces the hash
241
- * bucket chain. One TLS access total (the memo pair), and the
242
- * 1M-loop sentinel becomes a plain NULL check. */
243
- static LEPTRIS_THREAD_LOCAL LeptrisElement g_memo_root;
244
- static LEPTRIS_THREAD_LOCAL struct leptris_document* g_memo_doc;
245
-
246
264
  /* #904: prime the TLS last-root memo from a driver that already
247
265
  * knows the (root, doc) pair — the iterparse yield path hands out
248
266
  * a subtree that is released before the next, so the memo misses
@@ -263,6 +281,10 @@ void leptris_root_doc_memo_invalidate(const struct leptris_document* doc) {
263
281
  }
264
282
  }
265
283
 
284
+ LeptrisElement leptris_root_doc_memo_root_for_tests(void) {
285
+ return g_memo_root;
286
+ }
287
+
266
288
  struct leptris_document* leptris_element_get_document(LeptrisElement elem) {
267
289
  if (!elem) return NULL;
268
290
  LeptrisElement cur = elem;
@@ -15,6 +15,8 @@ void leptris_root_doc_register(LeptrisElement root, struct leptris_document* doc
15
15
  void leptris_root_doc_unregister(LeptrisElement root);
16
16
  struct leptris_document* leptris_root_doc_lookup(LeptrisElement root);
17
17
  void leptris_root_doc_memo_invalidate(const struct leptris_document* doc);
18
+ /* Test hook (#1242): the memo's current root, or NULL. */
19
+ LeptrisElement leptris_root_doc_memo_root_for_tests(void);
18
20
  /* #904: prime the TLS (root, doc) memo from a driver that knows
19
21
  * the pair (iterparse yields). Root must be registered. */
20
22
  void leptris_root_doc_memo_prime(LeptrisElement root,
@@ -253,12 +253,19 @@ struct leptris_document* leptris_document_create_on_arena(
253
253
  */
254
254
  LEPTRIS_API LeptrisStatus leptris_document_set_root(LeptrisDocument doc,
255
255
  LeptrisElement root) {
256
- if (!doc || !root) return LEPTRIS_ERROR_NULL_ARG;
256
+ if (!doc || !root) {
257
+ leptris_set_error(LEPTRIS_ERROR_NULL_ARG,
258
+ "set_root: NULL document or element");
259
+ return LEPTRIS_ERROR_NULL_ARG;
260
+ }
257
261
 
258
262
  /* Already attached under a parent? (parent_off == 0 encodes NULL;
259
263
  * leptris_elem_parent is the static inline accessor from dom/element.h) */
260
- if (leptris_elem_parent(root))
264
+ if (leptris_elem_parent(root)) {
265
+ leptris_set_error(LEPTRIS_ERROR_INVALID_ARG,
266
+ "set_root: element is attached to a parent");
261
267
  return LEPTRIS_ERROR_INVALID_ARG;
268
+ }
262
269
 
263
270
  /* Cross-document attach would dangle the source pool on free.
264
271
  * Lane 18 P1: resolve via get_document (map + namebp fallback)
@@ -266,8 +273,11 @@ LEPTRIS_API LeptrisStatus leptris_document_set_root(LeptrisDocument doc,
266
273
  * lookup rejected every one of them. */
267
274
  extern struct leptris_document* leptris_element_get_document(
268
275
  LeptrisElement elem);
269
- if (leptris_element_get_document(root) != doc)
276
+ if (leptris_element_get_document(root) != doc) {
277
+ leptris_set_error(LEPTRIS_ERROR_INVALID_ARG,
278
+ "set_root: element belongs to a different document");
270
279
  return LEPTRIS_ERROR_INVALID_ARG;
280
+ }
271
281
 
272
282
  doc->root = root;
273
283
  doc->new_dom_root = root;
@@ -3,8 +3,17 @@
3
3
  #include <gtest/gtest.h>
4
4
 
5
5
  #include "leptris.h"
6
+ #include "leptris/error.h"
6
7
  /* #1125 pristine-buffer gate: reach doc->xml_buffer directly. */
7
8
  #include "../leptris/leptris_internal.h"
9
+ /* Internal root-doc-map surface (#1242 spec); C linkage. */
10
+ extern "C" {
11
+ LeptrisElement leptris_root_doc_memo_root_for_tests(void);
12
+ void leptris_root_doc_register(LeptrisElement root,
13
+ struct leptris_document* doc);
14
+ void leptris_root_doc_unregister(LeptrisElement root);
15
+ struct leptris_document* leptris_element_get_document(LeptrisElement elem);
16
+ }
8
17
  #include "../leptris/memory/pool.h"
9
18
 
10
19
  #include <cstring>
@@ -92,6 +101,30 @@ TEST(DomBasics, DocumentSetRootRejectsNullInputs) {
92
101
  leptris_document_free(doc);
93
102
  }
94
103
 
104
+ // #1242 symptom 2: a failed set_root must set a FRESH thread-local
105
+ // error — the binding's exception message was stitched from whatever
106
+ // the previous operation left behind (a stale XPath parse error).
107
+ TEST(DomBasics, DocumentSetRootFailureSetsFreshError) {
108
+ LeptrisDocument doc_a = leptris_document_create();
109
+ LeptrisDocument doc_b = leptris_document_create();
110
+ ASSERT_NE(doc_a, nullptr);
111
+ ASSERT_NE(doc_b, nullptr);
112
+ /* Poison the thread-local slot with an unrelated message. */
113
+ ASSERT_EQ(leptris_parse_string("<a><b></a>", 10, nullptr), nullptr);
114
+
115
+ LeptrisElement foreign = leptris_element_create(doc_b, "b");
116
+ ASSERT_NE(foreign, nullptr);
117
+ EXPECT_EQ(leptris_document_set_root(doc_a, foreign),
118
+ LEPTRIS_ERROR_INVALID_ARG);
119
+ const char* msg = leptris_last_error();
120
+ ASSERT_NE(msg, nullptr);
121
+ EXPECT_EQ(strstr(msg, "set_root"), msg)
122
+ << "expected a fresh set_root message, got: " << msg;
123
+
124
+ leptris_document_free(doc_a);
125
+ leptris_document_free(doc_b);
126
+ }
127
+
95
128
  TEST(DomBasics, DocumentSetRootRejectsForeignElement) {
96
129
  LeptrisDocument doc_a = leptris_document_create();
97
130
  LeptrisDocument doc_b = leptris_document_create();
@@ -117,6 +150,69 @@ TEST(DomBasics, DocumentSetRootRejectsParentedElement) {
117
150
  leptris_document_free(doc);
118
151
  }
119
152
 
153
+ /* #1242: the TLS (root, doc) memo trusts the root address. An
154
+ * element-level unregister frees that address while the document
155
+ * lives; a recycled address later made get_document resolve the
156
+ * WRONG document (per-machine corruption signature). Unregistering
157
+ * a root must drop the memo. */
158
+ TEST(DomBasics, RootDocMemoClearedWhenRootUnregisters) {
159
+ LeptrisDocument doc = leptris_document_create();
160
+ ASSERT_NE(doc, nullptr);
161
+ LeptrisElement e = leptris_element_create(doc, "r");
162
+ ASSERT_NE(e, nullptr);
163
+ leptris_root_doc_register(e, doc);
164
+ /* Prime the memo through a resolution. */
165
+ ASSERT_EQ(leptris_element_get_document(e), doc);
166
+ ASSERT_EQ(leptris_root_doc_memo_root_for_tests(), e);
167
+ /* Element-level lifecycle: the root goes away, the doc stays. */
168
+ leptris_root_doc_unregister(e);
169
+ EXPECT_EQ(leptris_root_doc_memo_root_for_tests(), nullptr)
170
+ << "stale (root, doc) memo survived root unregistration";
171
+ leptris_document_free(doc);
172
+ }
173
+
174
+ /* #1242: every status-returning rejection must refresh the
175
+ * thread-local error channel — a stale message from an earlier
176
+ * operation leaking through an EINVAL is the reported signature. */
177
+ TEST(DomBasics, DocumentSetRootRejectionSetsFreshError) {
178
+ const char xml[] = "<a><b/></a>";
179
+ LeptrisStatus st = LEPTRIS_OK;
180
+ LeptrisDocument doc = leptris_parse_string(xml, std::strlen(xml), &st);
181
+ ASSERT_NE(doc, nullptr);
182
+ /* Poison the channel with an XPath syntax error and snapshot it. */
183
+ LeptrisXPathResult bad = leptris_xpath_eval(doc, nullptr, "<<");
184
+ if (bad) leptris_xpath_result_free(bad);
185
+ std::string poisoned = leptris_last_error() ? leptris_last_error() : "";
186
+ ASSERT_FALSE(poisoned.empty());
187
+
188
+ LeptrisElement b = (LeptrisElement)leptris_node_first_child(
189
+ leptris_element_as_node(leptris_document_root(doc)));
190
+ ASSERT_NE(b, nullptr);
191
+ EXPECT_EQ(leptris_document_set_root(doc, b), LEPTRIS_ERROR_INVALID_ARG);
192
+ /* The channel must now describe THIS failure, not the XPath one. */
193
+ const char* err = leptris_last_error();
194
+ ASSERT_TRUE(err != nullptr && err[0] != '\0');
195
+ EXPECT_STRNE(err, poisoned.c_str())
196
+ << "stale XPath error leaked through set_root EINVAL: " << err;
197
+ EXPECT_TRUE(strstr(err, "root") != nullptr)
198
+ << "expected a set_root-specific message, got: " << err;
199
+
200
+ /* Cross-document rejection refreshes the channel too. */
201
+ LeptrisDocument other = leptris_document_create();
202
+ ASSERT_NE(other, nullptr);
203
+ LeptrisElement foreign = leptris_element_create(other, "f");
204
+ ASSERT_NE(foreign, nullptr);
205
+ EXPECT_EQ(leptris_document_set_root(doc, foreign),
206
+ LEPTRIS_ERROR_INVALID_ARG);
207
+ err = leptris_last_error();
208
+ ASSERT_TRUE(err != nullptr && err[0] != '\0');
209
+ EXPECT_STRNE(err, poisoned.c_str());
210
+ EXPECT_TRUE(strstr(err, "document") != nullptr)
211
+ << "expected a cross-document message, got: " << err;
212
+ leptris_document_free(other);
213
+ leptris_document_free(doc);
214
+ }
215
+
120
216
  TEST(DomBasics, AttributeLookupByName) {
121
217
  const char xml[] = "<r a='1' b='2' c='3'/>";
122
218
  LeptrisStatus st = LEPTRIS_OK;
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://raw.githubusercontent.com/microsoft/vcpkg/master/scripts/vcpkg.schema.json",
3
3
  "name": "leptris",
4
- "version": "1.9.207",
4
+ "version": "1.9.208",
5
5
  "description": "High-performance XML parser and XPath 1.0 engine in C (libleptris)",
6
6
  "homepage": "https://github.com/leptris/leptris",
7
7
  "license": "MIT",
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: leptris
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.9.207.0
4
+ version: 1.9.208.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.