leptris 1.9.226.0-x86_64-linux → 1.9.228.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b58468e929b871a6903114e7caba6a95cdf7132525b9d09ff9d1f8b81073ce5a
4
- data.tar.gz: 498713bb12c67829f1b3c390a9e5d16e83e5b44f94ec8595c4283cbf948f06f4
3
+ metadata.gz: de81e97aa3ab8a52850dbb21c9b9fbfdd47707458ebc1132817cffd36bd7dcdf
4
+ data.tar.gz: b11e49c626fd6995a0b6c60fe0730dbec6543c88dac1951ea87345c0054de15f
5
5
  SHA512:
6
- metadata.gz: 5caf23541329a1de994066a6a679a07010266d91590cb97f4388292d8584c7fe485d654f84ff73203300dbd5df672783684730099e7e1c339755aa03810e7c37
7
- data.tar.gz: 47a8dbed7879615c5b7c24e00881c4cf5513df3e5f5ebe91467bf7be8859f4d7763f1b2963cca7cb67f8b30a61d598a21702eb99bdfbab58effbd69ed3ce4d8f
6
+ metadata.gz: c6d7f0bff8c2b63decad14b04c787e7243521163ff505daa7e1e760b4d4cbf3a6c2d13e5f8b6306eaec2dd721f3508efca5f515bc333d6181232fefd0f2a7527
7
+ data.tar.gz: cc50b867785d0a8ee19b1185ac681fffc8e46677d8d3bc0e4897f60888741a155d56cde58d43ea7a72d7fca20fafd6bdf4230e23942052588489bcf6dfdbce65
data/CHANGELOG.md CHANGED
@@ -5,6 +5,21 @@ 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.228.0] - 2026-09-23
9
+
10
+ ### Fixed — Text#content= on created-but-unattached text nodes (#318; engine 1.9.227–228)
11
+
12
+ - **The #318 regression is fixed at the engine root**: engine
13
+ 1.9.228 (upstream leptris#1320) gives detached text nodes the
14
+ same #519 owner treatment PI/comment/CDATA already had — an
15
+ owner backref stamped by `leptris_text_node_create` (compact
16
+ int32 offset, LP64 layout preserved) — so
17
+ `leptris_text_node_set_content` works before attach again.
18
+ The create → fill → attach builder sequence moxml's
19
+ `Text#content=` depends on is unblocked; verified against the
20
+ vendored 1.9.228 (the #318 repro passes; suite 778 → 781/0).
21
+ - 1.9.227 rides along (header-hygiene spec zeroing).
22
+
8
23
  ## [1.9.226.0] - 2026-09-23
9
24
 
10
25
  ### Performance — engine sync (1.9.226)
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.226"
11
+ LIBLEPTRIS_VERSION = "1.9.228"
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.226.0"
4
+ VERSION = "1.9.228.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.226
4
+ - libleptris v1.9.228
5
5
  - utf8proc v2.11.0
6
6
 
7
7
  Rebuild by hand:
@@ -1,5 +1,71 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [1.9.228] - 2026-09-23
4
+
5
+ ### Fixed
6
+
7
+ - **dom: `leptris_text_node_set_content` works again on
8
+ created-but-unattached text nodes (#1320).** The 1.9.223–1.9.226
9
+ window returned `LEPTRIS_ERROR_INVALID_ARG` for exactly the
10
+ create → fill → attach builder sequence bindings use (moxml's
11
+ `Text#content=` failed 5 shared examples). Root cause: issue #519
12
+ gave detached PI/comment/CDATA nodes an `owner_doc` field but
13
+ skipped text — at the time text nodes carried their own pool
14
+ backref, which #1285 slice 4 then deleted, leaving detached text
15
+ with no document to allocate from. Text now gets the same #519
16
+ treatment: an owner backref stamped by the public creator only,
17
+ stored as a compact int32 offset (the LP64 struct keeps its 40
18
+ bytes — the field lands in existing padding; ILP32 32), with
19
+ overflow-table fallback tagged with the EXPLICIT document (the
20
+ parse-context TLS slot holds the wrong doc or NULL outside parse).
21
+ Every parse-carve path zero-initializes the field; the
22
+ attached/parsed parent-walk fast path is untouched. TDD'd
23
+ red-first against the reported minimal repro;
24
+ `DetachedNodes.TextSetContentWorksBeforeAttach` pins the
25
+ unattached, attach-then-set, and parsed paths.
26
+
27
+
28
+
29
+ ## [1.9.227] - 2026-09-23
30
+
31
+ ### Fixed
32
+
33
+ - **test:** the header-hygiene compact-pointer edge specs zero their
34
+ fake stack-allocated nodes before use. The `last_child` and
35
+ `last_attribute` getters *walk* the sibling/attribute list, so the
36
+ uninitialized fake node's next-edge was read as stack garbage —
37
+ zero on arm64, nonzero on x86_64, where the slice-3b layout shift
38
+ surfaced the latent UB as a real CI failure. It was always UB.
39
+
40
+ ### Performance
41
+
42
+ - **dom: binding_wrapper out of the node base — element 48B, text
43
+ 40B, every node type −8B (#1285 slice 3b).** `LeptrisNode` itself
44
+ drops 24 → 12 bytes; the element struct reaches the lane's stated
45
+ goal of 48 bytes. The `binding_wrapper` field (#262) was never
46
+ adopted — zero callers in leptris-ruby and leptris-py (both cache
47
+ wrappers on their own side), making it pure dead weight at 8 bytes
48
+ per node per document, and it carried the #421 stale-pointer bug
49
+ class, which the removal structurally eliminates. The public
50
+ `leptris_node_get/set_binding_wrapper` API is unchanged and now
51
+ serves from a lazily-allocated open-addressed map owned by the
52
+ document: documents that never wrap (all of them today) allocate
53
+ nothing, and entries cannot outlive their nodes because the map
54
+ dies with the document.
55
+ - **parse: interleaved lane size floor — small docs defer to classic
56
+ (#1258).** The two-phase scan+replay lane pays a fixed setup cost
57
+ that small documents cannot amortize: canon's gate measured ~25%
58
+ slower on a 24KB readonly shape while large documents run 0.69–0.74×
59
+ (256KB–3.4MB). `dp_il_try` now defers to the classic parser below a
60
+ 64KB floor (the midpoint between the measured loss and the wins);
61
+ `LEPTRIS_IL_MIN` overrides it in bytes, with explicit `0` disabling
62
+ the floor. Coverage is preserved, not narrowed: the parity suite
63
+ pins `LEPTRIS_IL_MIN=1` in every lane toggle, the lane-forced CI leg
64
+ sets it to `0` so the full suite still exercises the lane, and a new
65
+ spec pins the deferral itself.
66
+
67
+
68
+
3
69
  ## [1.9.226] - 2026-09-22
4
70
 
5
71
  ### Performance
@@ -3,7 +3,7 @@
3
3
  cmake_minimum_required(VERSION 3.20)
4
4
 
5
5
  project(leptris
6
- VERSION 1.9.226
6
+ VERSION 1.9.228
7
7
  DESCRIPTION "Fast XML parser and XPath evaluator in pure C"
8
8
  LANGUAGES C CXX
9
9
  )
@@ -23,8 +23,7 @@ LeptrisCDATANode* leptris_cdata_create(const char* content,
23
23
  node->base.type = LEPTRIS_NODE_TYPE_CDATA;
24
24
  node->base.frozen = 0;
25
25
  node->base.version = 0;
26
- node->base.binding_wrapper = NULL;
27
- node->parent_off = 0;
26
+ node->parent_off = 0;
28
27
  node->next_sibling_off = 0;
29
28
 
30
29
  if (content && content_len > 0) {
@@ -24,7 +24,6 @@ LeptrisCommentNode* leptris_comment_create(const char* content,
24
24
  node->base.type = LEPTRIS_NODE_TYPE_COMMENT;
25
25
  node->base.frozen = 0;
26
26
  node->base.version = 0;
27
- node->base.binding_wrapper = NULL;
28
27
  node->parent_off = 0;
29
28
  node->next_sibling_off = 0;
30
29
 
@@ -298,6 +298,22 @@ int32_t leptris_compact_int32_encode(void* base, void* target,
298
298
  return (int32_t)d;
299
299
  }
300
300
 
301
+ int32_t leptris_compact_int32_encode_doc(void* base, void* target,
302
+ const int32_t* field_addr,
303
+ struct leptris_document* doc) {
304
+ if (!target) return 0;
305
+ ptrdiff_t d = (char*)target - (char*)base;
306
+ if (d < INT32_MIN || d > INT32_MAX) {
307
+ LeptrisCompactOverflowTable* table = get_overflow_table();
308
+ if (table && leptris_compact_overflow_set(table, field_addr,
309
+ target, doc) == 0) {
310
+ return LEPTRIS_INT32_OVERFLOW_SENTINEL;
311
+ }
312
+ return 0;
313
+ }
314
+ return (int32_t)d;
315
+ }
316
+
301
317
  void* leptris_compact_int32_decode(void* base, int32_t off,
302
318
  const int32_t* field_addr) {
303
319
  if (off == 0) return NULL;
@@ -109,6 +109,15 @@ int32_t leptris_compact_int32_encode(void* base, void* target,
109
109
  const int32_t* field_addr);
110
110
  void* leptris_compact_int32_decode(void* base, int32_t off,
111
111
  const int32_t* field_addr);
112
+ /* #1320: same encoding, but the overflow-table entry is tagged with
113
+ * the caller's explicit document instead of the thread-local
114
+ * "current document" — for stamps made OUTSIDE a parse context
115
+ * (public node creators), where the TLS slot holds the wrong doc or
116
+ * NULL and a mis-tagged entry would outlive its document. */
117
+ struct leptris_document;
118
+ int32_t leptris_compact_int32_encode_doc(void* base, void* target,
119
+ const int32_t* field_addr,
120
+ struct leptris_document* doc);
112
121
  #ifdef __cplusplus
113
122
  }
114
123
  #endif
@@ -683,17 +683,16 @@ static inline struct leptris_namespace** leptris_elem_namespaces_ptr(
683
683
  * pinned structs hold only pointers and int-sized scalars, so i686
684
684
  * and armv7 produce identical layouts. */
685
685
  #if SIZE_MAX == UINT64_MAX
686
- LEPTRIS_STATIC_ASSERT(sizeof(struct leptris_element) == 64,
687
- "#1285 slice 3a: ns_cache_off + offsets side table -> 64 bytes");
686
+ LEPTRIS_STATIC_ASSERT(sizeof(struct leptris_element) == 48,
687
+ "#1285 slice 3b: binding_wrapper out of the base -> 48 bytes");
688
688
 
689
689
  LEPTRIS_STATIC_ASSERT(sizeof(struct leptris_attribute) == 40,
690
690
  "round 19 attr layout: 16+16+4+2+2 = 40");
691
691
  #else
692
- /* ILP32: LeptrisNode 24->16 (binding_wrapper), element name +
693
- * ns_cache pointers 8->4 each -> 56; attribute name_view 16->8
694
- * (value union stays 16 via inline_value[16]) -> 32. */
695
- LEPTRIS_STATIC_ASSERT(sizeof(struct leptris_element) == 48,
696
- "#1285 slice 3a ILP32: 48 bytes");
692
+ /* ILP32: LeptrisNode 16->12 (#1285 slice 3b); attribute
693
+ * name_view 16->8 (value union stays 16 via inline_value[16]). */
694
+ LEPTRIS_STATIC_ASSERT(sizeof(struct leptris_element) == 44,
695
+ "#1285 slice 3b ILP32: base 12 + 8 + 6*4 + ptr 4 = 44 bytes");
697
696
 
698
697
  LEPTRIS_STATIC_ASSERT(sizeof(struct leptris_attribute) == 32,
699
698
  "leptris_attribute ILP32 layout: 8+16+4+2+2 = 32");
@@ -24,7 +24,6 @@ LeptrisEntityRefNode* leptris_entity_ref_create(const char* name,
24
24
  node->base.type = LEPTRIS_NODE_TYPE_ENTITY_REF;
25
25
  node->base.frozen = 0;
26
26
  node->base.version = 0;
27
- node->base.binding_wrapper = NULL;
28
27
  node->parent_off = 0;
29
28
  node->next_sibling_off = 0;
30
29
  node->owner_doc = NULL;
@@ -57,11 +57,9 @@ typedef struct leptris_node {
57
57
  * parse-time bulk strides hold. */
58
58
  unsigned int version : 30; /* COW 2.2: Node version for tracking modifications */
59
59
  uint32_t line; /* Source line (1-based, 0 = unknown). Issue #223 */
60
- void* binding_wrapper; /* FFI wrapper cache (#262). NULL when no binding
61
- * is attached. Set by the language binding on
62
- * first node wrap; subsequent traversals find
63
- * the cached wrapper without FFI call overhead. */
64
- /* NOTE: Parent/sibling pointers stored in compressed form in specific node types */
60
+ /* NOTE: Parent/sibling pointers stored in compressed form in specific node types.
61
+ * #1285 slice 3b: binding_wrapper (#262) moved to the document-
62
+ * owned wrapper map — see leptris_internal.h; base is 12 bytes. */
65
63
  } LeptrisNode;
66
64
 
67
65
  /* ============================================================================
@@ -296,8 +296,13 @@ static LeptrisDocument node_public_document(LeptrisNodeRef node) {
296
296
  LeptrisElement parent = leptris_node_parent(node);
297
297
  if (parent) return leptris_element_get_document(parent);
298
298
  /* Issue #519: detached non-element nodes carry their owning
299
- * document — mutations must work before any attach. */
299
+ * document — mutations must work before any attach. Text was the
300
+ * one kind #519 skipped (its pool backref covered it until #1285
301
+ * slice 4 removed it — the #1320 regression); its stamp is an
302
+ * int32 offset instead of a field. */
300
303
  switch (node->type) {
304
+ case LEPTRIS_NODE_TYPE_TEXT:
305
+ return leptris_textnode_owner_doc((const LeptrisTextNode*)node);
301
306
  case LEPTRIS_NODE_TYPE_PI:
302
307
  return ((LeptrisPINode*)node)->owner_doc;
303
308
  case LEPTRIS_NODE_TYPE_COMMENT:
@@ -329,6 +334,13 @@ LEPTRIS_API LeptrisNodeRef leptris_text_node_create(LeptrisDocument doc,
329
334
  if (!content) content = "";
330
335
  size_t len = strlen(content);
331
336
  LeptrisTextNode* n = leptris_text_create(content, len, doc->pool);
337
+ if (n) {
338
+ /* #1320: stamp the owner so detached mutations can reach the
339
+ * doc's pool (PI/comment/CDATA do this via owner_doc fields;
340
+ * explicit-doc encoder — no parse context here). */
341
+ n->owner_doc_off = leptris_compact_int32_encode_doc(
342
+ n, doc, &n->owner_doc_off, doc);
343
+ }
332
344
  return n ? (LeptrisNodeRef)n : NULL;
333
345
  }
334
346
 
@@ -688,12 +700,71 @@ LEPTRIS_API void leptris_node_source_position(LeptrisNodeRef node,
688
700
  }
689
701
  }
690
702
 
703
+ /* #1285 slice 3b: binding_wrapper storage moved out of LeptrisNode
704
+ * (both bindings have zero callers — they cache wrappers on their
705
+ * own side — and the in-node field carried the #421 stale-pointer
706
+ * bug class on recycled arena pages). Open-addressed map keyed by
707
+ * node pointer, lazily allocated per document, freed with it: map
708
+ * entries can never outlive their nodes, and documents that never
709
+ * wrap (all of them today) pay nothing. NULL before any set — the
710
+ * parse-NULL contract holds by construction. */
711
+ struct leptris_wrapper_entry {
712
+ LeptrisNodeRef node; /* 0 = empty slot */
713
+ void* wrapper;
714
+ };
715
+
716
+ static size_t wrapper_hash(LeptrisNodeRef n, size_t cap) {
717
+ uintptr_t k = (uintptr_t)n;
718
+ return ((size_t)((k * 0x9E3779B97F4A7C15ULL) >> 32)) & (cap - 1);
719
+ }
720
+
721
+ static int wrapper_grow(struct leptris_document* doc) {
722
+ size_t ncap = doc->wrapper_cap ? doc->wrapper_cap * 2 : 16;
723
+ struct leptris_wrapper_entry* nt = (struct leptris_wrapper_entry*)
724
+ calloc(ncap, sizeof(*nt));
725
+ if (!nt) return -1;
726
+ for (size_t i = 0; i < doc->wrapper_cap; i++) {
727
+ struct leptris_wrapper_entry* e = &doc->wrapper_map[i];
728
+ if (e->node) {
729
+ size_t j = wrapper_hash(e->node, ncap);
730
+ while (nt[j].node) j = (j + 1) & (ncap - 1);
731
+ nt[j] = *e;
732
+ }
733
+ }
734
+ free(doc->wrapper_map);
735
+ doc->wrapper_map = nt;
736
+ doc->wrapper_cap = ncap;
737
+ return 0;
738
+ }
739
+
691
740
  LEPTRIS_API void* leptris_node_get_binding_wrapper(LeptrisNodeRef node) {
692
- return node ? node->binding_wrapper : NULL;
741
+ if (!node) return NULL;
742
+ struct leptris_document* doc = node_public_document(node);
743
+ if (!doc || !doc->wrapper_map) return NULL;
744
+ size_t i = wrapper_hash(node, doc->wrapper_cap);
745
+ while (doc->wrapper_map[i].node) {
746
+ if (doc->wrapper_map[i].node == node)
747
+ return doc->wrapper_map[i].wrapper;
748
+ i = (i + 1) & (doc->wrapper_cap - 1);
749
+ }
750
+ return NULL;
693
751
  }
694
752
 
695
753
  LEPTRIS_API void leptris_node_set_binding_wrapper(LeptrisNodeRef node, void* wrapper) {
696
- if (node) node->binding_wrapper = wrapper;
754
+ if (!node) return;
755
+ struct leptris_document* doc = node_public_document(node);
756
+ if (!doc) return; /* detached, unregistered node: no carrier */
757
+ if (doc->wrapper_count * 2 + 2 >= doc->wrapper_cap) {
758
+ if (wrapper_grow(doc) != 0) return;
759
+ }
760
+ size_t i = wrapper_hash(node, doc->wrapper_cap);
761
+ while (doc->wrapper_map[i].node && doc->wrapper_map[i].node != node)
762
+ i = (i + 1) & (doc->wrapper_cap - 1);
763
+ if (!doc->wrapper_map[i].node) {
764
+ doc->wrapper_map[i].node = node;
765
+ doc->wrapper_count++;
766
+ }
767
+ doc->wrapper_map[i].wrapper = wrapper;
697
768
  }
698
769
 
699
770
  LEPTRIS_API int leptris_node_compare(LeptrisNodeRef a, LeptrisNodeRef b) {
@@ -46,7 +46,6 @@ LeptrisPINode* leptris_pi_create(const char* target,
46
46
  node->base.type = LEPTRIS_NODE_TYPE_PI;
47
47
  node->base.frozen = 0;
48
48
  node->base.version = 0;
49
- node->base.binding_wrapper = NULL;
50
49
  node->parent_off = 0;
51
50
  node->next_sibling_off = 0;
52
51
  node->target = target_storage;
@@ -30,12 +30,9 @@ LeptrisTextNode* leptris_text_create(const char* content,
30
30
  node->base.frozen = 0;
31
31
  node->base.version = 0;
32
32
  node->base.raw = 0; /* uninitialized: see create_borrowed */
33
- /* Explicit: pool memory is not zeroed and the retained arena
34
- * recycles dirty pages — a stale binding_wrapper would hand
35
- * bindings a dangling pointer. */
36
- node->base.binding_wrapper = NULL;
37
33
  node->parent_off = 0;
38
34
  node->next_sibling_off = 0;
35
+ node->owner_doc_off = 0; /* #1320: only the public creator stamps */
39
36
 
40
37
  if (content && content_len > 0) {
41
38
  memcpy(content_storage, content, content_len);
@@ -70,11 +67,11 @@ LeptrisTextNode* leptris_text_create_borrowed(const char* content,
70
67
  * uninitialized raw bit silently flips DOE/cdata behavior by
71
68
  * build configuration. */
72
69
  node->base.raw = 0;
73
- node->base.binding_wrapper = NULL;
74
70
  node->content = (char*)content; /* Non-owning; caller guarantees lifetime + termination. */
75
71
  node->content_len = (uint32_t)content_len;
76
72
  node->parent_off = 0;
77
73
  node->next_sibling_off = 0;
74
+ node->owner_doc_off = 0; /* #1320: only the public creator stamps */
78
75
 
79
76
  return node;
80
77
  }
@@ -26,7 +26,15 @@
26
26
  * are outside every supported target.
27
27
  *
28
28
  * Issue #168: parent_off mirrors next_sibling_off so the parent of a
29
- * text node can be queried in O(1). */
29
+ * text node can be queried in O(1).
30
+ *
31
+ * Issue #1320: owner_doc_off gives DETACHED text nodes their owning
32
+ * document — #519 added an owner_doc field to PI/comment/CDATA but
33
+ * skipped text (whose pool backref covered it at the time); #1285
34
+ * slice 4 deleted that backref and detached set_content started
35
+ * returning INVALID_ARG. Stamped by the public creator only —
36
+ * parse-carved nodes are attached by construction. int32, so the
37
+ * LP64 struct keeps its 40 bytes (padding absorbs it). */
30
38
  typedef struct leptris_text_node {
31
39
  LeptrisNode base; /* MUST be first */
32
40
  char* content; /* Text content - NEVER trim! NUL-terminated at [content_len] */
@@ -40,6 +48,7 @@ typedef struct leptris_text_node {
40
48
  * 0 = NULL. */
41
49
  int32_t next_sibling_off;
42
50
  int32_t parent_off; /* Byte offset to parent element (0=NULL) */
51
+ int32_t owner_doc_off; /* Byte offset to owning document (0=unstamped; #1320) */
43
52
  } LeptrisTextNode;
44
53
 
45
54
  /* Text node creation.
@@ -110,4 +119,17 @@ static inline void leptris_textnode_set_parent(LeptrisTextNode* t, LeptrisElemen
110
119
  t->parent_off = leptris_compact_int32_encode_inline(t, parent, &t->parent_off);
111
120
  }
112
121
 
122
+ /* Owning-document access (issue #1320). The stamp is written by
123
+ * leptris_text_node_create with the explicit-doc encoder (an
124
+ * overflow-table entry made outside a parse context must carry the
125
+ * REAL document tag, not the TLS current-doc). Decode is the
126
+ * standard edge decode. */
127
+ struct leptris_document;
128
+ static inline struct leptris_document* leptris_textnode_owner_doc(const LeptrisTextNode* t) {
129
+ return (t && t->owner_doc_off != 0)
130
+ ? (struct leptris_document*)leptris_compact_int32_decode_inline(
131
+ (void*)t, t->owner_doc_off, &t->owner_doc_off)
132
+ : NULL;
133
+ }
134
+
113
135
  #endif /* LEPTRIS_DOM_TEXT_H */
@@ -1005,7 +1005,6 @@ static inline LeptrisTextNode* dp_text_create(DParser* p,
1005
1005
  * serialize_text_internal then emitted text VERBATIM (raw < in
1006
1006
  * the output, invalid XML). */
1007
1007
  tn->base.raw = 0;
1008
- tn->base.binding_wrapper = NULL;
1009
1008
  tn->base.line = 0; /* caller stamps the offset */
1010
1009
  /* #1285 slice 4: the run is NUL-terminated in place BEFORE this
1011
1010
  * carve (dispatch-entry dp_nul / walker '&' NUL / EOF sentinel)
@@ -1015,6 +1014,7 @@ static inline LeptrisTextNode* dp_text_create(DParser* p,
1015
1014
  tn->content_len = (uint32_t)content_len;
1016
1015
  tn->parent_off = 0;
1017
1016
  tn->next_sibling_off = 0;
1017
+ tn->owner_doc_off = 0; /* #1320: parse-carved nodes attach */
1018
1018
  return tn;
1019
1019
  }
1020
1020
 
@@ -2598,6 +2598,7 @@ static struct leptris_document* dp_il_build(
2598
2598
  * run is already NUL-terminated at carve) */
2599
2599
  tn->parent_off = 0;
2600
2600
  tn->next_sibling_off = 0;
2601
+ tn->owner_doc_off = 0; /* #1320: parse-carved nodes attach */
2601
2602
  smap[i] = (LeptrisNode*)tn;
2602
2603
  pp.depth = (int)rdepth[r->parent] + 1;
2603
2604
  pp.last_child_stack[pp.depth - 1] = lc[r->parent];
@@ -2686,6 +2687,25 @@ static struct leptris_document* dp_il_try(const char* xml, size_t len,
2686
2687
  if (!gate || !*gate) return NULL;
2687
2688
  if (keep_ent || dtd_attrs) return NULL;
2688
2689
  if (len == 0 || len >= 0x7FFFFFFFu) return NULL;
2690
+ /* #1258: small documents do not amortize the record pass.
2691
+ * Canon's moxml shape (24KB / 902 elements) measured ~25%
2692
+ * SLOWER on the lane while allocations stayed identical - the
2693
+ * two-phase scan+replay pays fixed setup that a small doc
2694
+ * cannot win back; large docs take 0.69-0.74x. Below the
2695
+ * minimum the lane defers to the classic parser. LEPTRIS_IL_MIN
2696
+ * overrides (bytes; 0 = no threshold) for hosts and for the
2697
+ * parity gates, which force it to 0 so the lane stays covered
2698
+ * by the suite at fixture sizes. */
2699
+ {
2700
+ const char* mins = getenv("LEPTRIS_IL_MIN");
2701
+ size_t min_len = 64 * 1024;
2702
+ if (mins && *mins) {
2703
+ long mv = strtol(mins, NULL, 10);
2704
+ if (mv > 0) min_len = (size_t)mv;
2705
+ else min_len = 0; /* explicit 0 disables */
2706
+ }
2707
+ if (len < min_len) return NULL;
2708
+ }
2689
2709
 
2690
2710
  /* Scratch is built UP FRONT (slice 2): il_scan reads it with
2691
2711
  * the NUL-sentinel discipline, and dp_il_build reuses the same
@@ -967,6 +967,11 @@ LEPTRIS_API void leptris_document_free(struct leptris_document* doc) {
967
967
  free(doc->elem_pos);
968
968
  doc->elem_pos = NULL;
969
969
  doc->elem_pos_count = doc->elem_pos_cap = 0;
970
+ /* #1285 slice 3b: binding-wrapper map — entries die with the
971
+ * document, so they can never outlive their nodes. */
972
+ free(doc->wrapper_map);
973
+ doc->wrapper_map = NULL;
974
+ doc->wrapper_count = doc->wrapper_cap = 0;
970
975
  if (doc->xml_buffer && doc->xml_buffer_needs_free) {
971
976
  /* Release through the retained-buffer free list (see
972
977
  * memory/arena.c): large inputs would otherwise be munmapped
@@ -226,6 +226,15 @@ struct leptris_document {
226
226
  * xml_buffer) and resolves queries via binary search, caching
227
227
  * the result in the node with the high bit set. NULL = not yet
228
228
  * built. Freed with the document. */
229
+ /* #1285 slice 3b: binding_wrapper left the node base (#262's
230
+ * FFI cache was never adopted — zero callers in leptris-ruby/py
231
+ * — and its stale-pointer bug class, #421, is structural). The
232
+ * public get/set API now serves from this lazily-allocated
233
+ * open-addressed map, keyed by node pointer; it dies with the
234
+ * document so entries can never outlive their nodes. NULL until
235
+ * the first set — documents that never wrap pay nothing. */
236
+ struct leptris_wrapper_entry* wrapper_map;
237
+ size_t wrapper_count, wrapper_cap;
229
238
  int html_mode; /* #1309: HTML-parsed / create_html documents
230
239
  serialize with the §16.2 HTML method by
231
240
  default (libxml2/Nokogiri behavior). */
@@ -103,6 +103,12 @@ TEST(HeaderHygiene, ElementTreeEdgeRoundTrip) {
103
103
  alignas(kAlign) char buf[2 * sizeof(struct leptris_element)];
104
104
  struct leptris_element* a = (struct leptris_element*)buf;
105
105
  struct leptris_element* b = (struct leptris_element*)(buf + sizeof(*a));
106
+ /* The last_child getter WALKS the child list, so b's sibling
107
+ * edge is read — zero the fake nodes or the walk follows
108
+ * uninitialized stack garbage. (#1285 slice 3b moved the field
109
+ * onto a stack slot whose garbage is nonzero on x86_64, turning
110
+ * the latent UB into a real failure; it was always UB.) */
111
+ memset(buf, 0, sizeof(buf));
106
112
 
107
113
  /* parent round-trip */
108
114
  leptris_elem_set_parent(a, b);
@@ -132,6 +138,7 @@ TEST(HeaderHygiene, ElementAttributeEdgeRoundTrip) {
132
138
  struct leptris_element* e = (struct leptris_element*)buf;
133
139
  struct leptris_attribute* attr =
134
140
  (struct leptris_attribute*)(buf + sizeof(*e));
141
+ memset(buf, 0, sizeof(buf));
135
142
  leptris_attr_set_next(attr, NULL);
136
143
 
137
144
  leptris_elem_set_first_attribute(e, attr);
@@ -2014,6 +2014,51 @@ TEST(DetachedNodes, MutationWorksBeforeAttach) {
2014
2014
  leptris_document_free(doc);
2015
2015
  }
2016
2016
 
2017
+ /* Issue #1320: TEXT was the one node kind #519's detached-mutation
2018
+ * fix skipped — no owner_doc, so after #1285 slice 4 removed the
2019
+ * node-side pool backref, set_content on a created-but-unattached
2020
+ * text node resolved no document and returned INVALID_ARG. moxml's
2021
+ * create → fill → attach builder sequence hit it on every leg. */
2022
+ TEST(DetachedNodes, TextSetContentWorksBeforeAttach) {
2023
+ /* Unattached: the discriminator that regressed (1.9.223–1.9.226
2024
+ * returned EINVAL here; content stayed at the create-time value). */
2025
+ LeptrisDocument doc = leptris_document_create();
2026
+ ASSERT_NE(doc, nullptr);
2027
+ LeptrisNodeRef t = leptris_text_node_create(doc, "abc");
2028
+ ASSERT_NE(t, nullptr);
2029
+ EXPECT_EQ(leptris_text_node_set_content(t, "world"), LEPTRIS_OK);
2030
+ EXPECT_STREQ(leptris_text_node_get_content(t), "world");
2031
+
2032
+ /* The mutation survives attach + serialize. */
2033
+ LeptrisElement root = leptris_element_create(doc, "r");
2034
+ ASSERT_EQ(leptris_document_set_root(doc, root), LEPTRIS_OK);
2035
+ ASSERT_EQ(leptris_element_append_child(root, (LeptrisElement)t), LEPTRIS_OK);
2036
+ char* x = leptris_document_serialize(doc, nullptr);
2037
+ ASSERT_NE(x, nullptr);
2038
+ EXPECT_NE(std::strstr(x, "<r>world</r>"), nullptr);
2039
+ leptris_free_string(x);
2040
+
2041
+ /* Attach-then-set and set-after-parse still work (never
2042
+ * regressed — pin them so the owner path can't shadow the
2043
+ * parent-walk path). */
2044
+ LeptrisNodeRef t2 = leptris_text_node_create(doc, "one");
2045
+ ASSERT_EQ(leptris_element_append_child(root, (LeptrisElement)t2), LEPTRIS_OK);
2046
+ EXPECT_EQ(leptris_text_node_set_content(t2, "two"), LEPTRIS_OK);
2047
+ EXPECT_STREQ(leptris_text_node_get_content(t2), "two");
2048
+
2049
+ leptris_document_free(doc);
2050
+
2051
+ const char xml[] = "<r>parsed</r>";
2052
+ LeptrisDocument doc2 = leptris_parse_string(xml, std::strlen(xml), nullptr);
2053
+ ASSERT_NE(doc2, nullptr);
2054
+ LeptrisElement root2 = leptris_document_root(doc2);
2055
+ LeptrisNodeRef pt = leptris_node_first_child(leptris_element_as_node(root2));
2056
+ ASSERT_NE(pt, nullptr);
2057
+ EXPECT_EQ(leptris_text_node_set_content(pt, "replaced"), LEPTRIS_OK);
2058
+ EXPECT_STREQ(leptris_text_node_get_content(pt), "replaced");
2059
+ leptris_document_free(doc2);
2060
+ }
2061
+
2017
2062
  /* Issue #526: document-level PI enumeration + creation. */
2018
2063
  TEST(DocumentPIs, EnumerateAndAdd) {
2019
2064
  const char xml[] = "<?xml version='1.0'?><?docpi d1?><r/>";
@@ -50,25 +50,28 @@ TEST(TextBorrowed, ParsedTextNodeIsTerminatedInPlace) {
50
50
  TEST(TextBorrowed, NodeStructIsBasePointerAndThreeInt32) {
51
51
  /* #1285 slice 4: pool + borrowed removed, content_len is uint32.
52
52
  * The width-independent invariant: base + ONE content pointer +
53
- * three int32s (len, next, parent), rounded to pointer alignment
54
- * — nothing else. 64 -> 48 on LP64; 32 on ILP32. The issue's
55
- * 32B sketch assumed a 12-byte base and a 64-bit host; going
56
- * lower needs content as an int32 pool offset (slice 4b). The
53
+ * FOUR int32s (len, next, parent, owner doc — #1320), rounded to
54
+ * pointer alignment — nothing else. 40 on LP64 (the owner field
55
+ * lands in padding; sizeof unchanged); 32 on ILP32. Going lower
56
+ * needs content as an int32 pool offset (slice 4b). The
57
57
  * parse-time bulk strides (dp text block, il lane table) hardcode
58
58
  * this layout economics — a silent field growth would erode the
59
59
  * #1222 parse win this slice exists for. */
60
- /* C++11: no constexpr lambda — inline the round-up expression. */
61
- constexpr size_t kRaw = sizeof(LeptrisNode) + sizeof(void*) +
62
- 3 * sizeof(int32_t);
60
+ /* C++11: no constexpr lambda — inline the round-ups. The
61
+ * content pointer needs pointer alignment INSIDE the struct:
62
+ * base rounds up to the pointer's alignment first. */
63
63
  constexpr size_t kA = alignof(void*);
64
+ constexpr size_t kBaseA =
65
+ (sizeof(LeptrisNode) + kA - 1) / kA * kA;
66
+ constexpr size_t kRaw = kBaseA + sizeof(void*) + 4 * sizeof(int32_t);
64
67
  constexpr size_t kAligned = (kRaw + kA - 1) / kA * kA;
65
68
  static_assert(sizeof(LeptrisTextNode) == kAligned,
66
69
  "LeptrisTextNode must be base + one content pointer + "
67
- "uint32 len + int32 next + int32 parent (aligned) "
68
- "no pool/borrowed fields (#1285 slice 4)");
70
+ "uint32 len + int32 next + int32 parent + int32 owner "
71
+ "(aligned) — no pool/borrowed fields (#1285 slice 4, #1320)");
69
72
  EXPECT_EQ(sizeof(LeptrisTextNode), kAligned);
70
73
  #if defined(__LP64__) || defined(_WIN64)
71
- EXPECT_EQ(sizeof(LeptrisTextNode), (size_t)48);
74
+ EXPECT_EQ(sizeof(LeptrisTextNode), (size_t)40);
72
75
  #else
73
76
  EXPECT_EQ(sizeof(LeptrisTextNode), (size_t)32);
74
77
  #endif
@@ -55,8 +55,16 @@ const char* kBail[] = {
55
55
  };
56
56
 
57
57
  std::string parse_str(const char* xml, bool lane) {
58
- if (lane) set_env("LEPTRIS_INTERLEAVED", "1");
59
- else unset_env("LEPTRIS_INTERLEAVED");
58
+ if (lane) {
59
+ set_env("LEPTRIS_INTERLEAVED", "1");
60
+ /* #1258: fixtures are far below the 64KB default minimum —
61
+ * force the lane's floor to 1 byte so parity specs exercise
62
+ * the lane, never a silent classic fallback. */
63
+ set_env("LEPTRIS_IL_MIN", "1");
64
+ } else {
65
+ unset_env("LEPTRIS_INTERLEAVED");
66
+ unset_env("LEPTRIS_IL_MIN");
67
+ }
60
68
  LeptrisStatus st = LEPTRIS_OK;
61
69
  LeptrisDocument d = leptris_parse_string(xml, strlen(xml), &st);
62
70
  std::string out = d ? "" : "(null)";
@@ -128,6 +136,7 @@ TEST(InterleavedParity, BailFixturesByteIdentical) {
128
136
  TEST(InterleavedParity, AttrAndNameAccessIdentical) {
129
137
  const char* xml = "<r k='v' n='m'><child z='9'>tx</child></r>";
130
138
  set_env("LEPTRIS_INTERLEAVED", "1");
139
+ set_env("LEPTRIS_IL_MIN", "1");
131
140
  LeptrisStatus st = LEPTRIS_OK;
132
141
  LeptrisDocument d = leptris_parse_string(xml, strlen(xml), &st);
133
142
  ASSERT_NE(d, nullptr);
@@ -186,6 +195,7 @@ TEST(InterleavedParity, EngagementCanaryFiresOnlyWhenLaneRuns) {
186
195
  };
187
196
 
188
197
  set_env("LEPTRIS_INTERLEAVED", "1");
198
+ set_env("LEPTRIS_IL_MIN", "1");
189
199
  set_env("LEPTRIS_IL_STATS", "1");
190
200
  char lane[512] = {0};
191
201
  ssize_t n = capture(lane, sizeof(lane));
@@ -195,6 +205,7 @@ TEST(InterleavedParity, EngagementCanaryFiresOnlyWhenLaneRuns) {
195
205
  EXPECT_NE(strstr(lane, "attrs=1"), nullptr) << lane;
196
206
 
197
207
  unset_env("LEPTRIS_IL_STATS");
208
+ unset_env("LEPTRIS_IL_MIN");
198
209
  char classic[512] = {0};
199
210
  n = capture(classic, sizeof(classic));
200
211
  ASSERT_GE(n, 0);
@@ -216,8 +227,10 @@ TEST(InterleavedParity, XmlAttrNamespaceParity) {
216
227
  const char* xml = "<root><code xml:space='preserve'>t</code></root>";
217
228
  for (int lane = 0; lane <= 1; lane++) {
218
229
  SCOPED_TRACE(lane ? "interleaved" : "classic");
219
- if (lane) set_env("LEPTRIS_INTERLEAVED", "1");
220
- else unset_env("LEPTRIS_INTERLEAVED");
230
+ if (lane) { set_env("LEPTRIS_INTERLEAVED", "1");
231
+ set_env("LEPTRIS_IL_MIN", "1"); }
232
+ else { unset_env("LEPTRIS_INTERLEAVED");
233
+ unset_env("LEPTRIS_IL_MIN"); }
221
234
  LeptrisStatus st = LEPTRIS_OK;
222
235
  LeptrisDocument d = leptris_parse_string(xml, strlen(xml), &st);
223
236
  ASSERT_NE(d, nullptr);
@@ -247,8 +260,10 @@ TEST(InterleavedParity, DeclaredPrefixNamespaceParity) {
247
260
  const char* xml = "<d xmlns:q='http://example/q' q:k='v'/>";
248
261
  for (int lane = 0; lane <= 1; lane++) {
249
262
  SCOPED_TRACE(lane ? "interleaved" : "classic");
250
- if (lane) set_env("LEPTRIS_INTERLEAVED", "1");
251
- else unset_env("LEPTRIS_INTERLEAVED");
263
+ if (lane) { set_env("LEPTRIS_INTERLEAVED", "1");
264
+ set_env("LEPTRIS_IL_MIN", "1"); }
265
+ else { unset_env("LEPTRIS_INTERLEAVED");
266
+ unset_env("LEPTRIS_IL_MIN"); }
252
267
  LeptrisStatus st = LEPTRIS_OK;
253
268
  LeptrisDocument d = leptris_parse_string(xml, strlen(xml), &st);
254
269
  ASSERT_NE(d, nullptr);
@@ -266,8 +281,10 @@ TEST(InterleavedParity, DeclaredPrefixNamespaceParity) {
266
281
  TEST(InterleavedParity, DupAttrDiagnosticParity) {
267
282
  const char* xml = "<r a='1' a='2'/>";
268
283
  for (int lane = 0; lane <= 1; lane++) {
269
- if (lane) set_env("LEPTRIS_INTERLEAVED", "1");
270
- else unset_env("LEPTRIS_INTERLEAVED");
284
+ if (lane) { set_env("LEPTRIS_INTERLEAVED", "1");
285
+ set_env("LEPTRIS_IL_MIN", "1"); }
286
+ else { unset_env("LEPTRIS_INTERLEAVED");
287
+ unset_env("LEPTRIS_IL_MIN"); }
271
288
  LeptrisStatus st = LEPTRIS_OK;
272
289
  LeptrisDocument d = leptris_parse_string(xml, strlen(xml), &st);
273
290
  ASSERT_NE(d, nullptr);
@@ -280,3 +297,52 @@ TEST(InterleavedParity, DupAttrDiagnosticParity) {
280
297
  unset_env("LEPTRIS_INTERLEAVED");
281
298
  }
282
299
  }
300
+
301
+
302
+ #ifndef _WIN32
303
+ /* #1258: the size floor. Below LEPTRIS_IL_MIN (default 64KB) the
304
+ * lane defers to the classic parser even when the gate is on —
305
+ * small documents cannot amortize the record pass (canon measured
306
+ * ~25% slower at 24KB). Forced floor (IL_MIN=1) restores engagement. */
307
+ TEST(InterleavedParity, SizeFloorDefersSmallDocsToClassic) {
308
+ const char* xml = "<r><a x='1'>t</a></r>";
309
+ set_env("LEPTRIS_INTERLEAVED", "1");
310
+ set_env("LEPTRIS_IL_STATS", "1");
311
+ unset_env("LEPTRIS_IL_MIN"); /* default floor: 64KB */
312
+
313
+ /* stderr capture, same pipe discipline as the canary spec. */
314
+ auto capture_one = [&](char* buf, size_t bufsz) -> void {
315
+ int fds[2];
316
+ ASSERT_EQ(pipe(fds), 0);
317
+ int saved = dup(STDERR_FILENO);
318
+ fflush(stderr);
319
+ dup2(fds[1], STDERR_FILENO);
320
+ close(fds[1]);
321
+ LeptrisStatus st = LEPTRIS_OK;
322
+ LeptrisDocument d = leptris_parse_string(xml, strlen(xml), &st);
323
+ if (d) leptris_document_free(d);
324
+ fflush(stderr);
325
+ dup2(saved, STDERR_FILENO);
326
+ close(saved);
327
+ ssize_t n = read(fds[0], buf, bufsz - 1);
328
+ if (n >= 0) buf[n] = '\0';
329
+ close(fds[0]);
330
+ };
331
+
332
+ char small[512] = {0};
333
+ capture_one(small, sizeof(small));
334
+ EXPECT_EQ(strstr(small, "il: records="), nullptr)
335
+ << "small doc must NOT engage the lane under the default floor: "
336
+ << small;
337
+
338
+ set_env("LEPTRIS_IL_MIN", "1");
339
+ char forced[512] = {0};
340
+ capture_one(forced, sizeof(forced));
341
+ EXPECT_NE(strstr(forced, "il: records="), nullptr)
342
+ << "forced floor must restore engagement";
343
+
344
+ unset_env("LEPTRIS_IL_STATS");
345
+ unset_env("LEPTRIS_IL_MIN");
346
+ unset_env("LEPTRIS_INTERLEAVED");
347
+ }
348
+ #endif /* !_WIN32: pipe/dup2 capture */
@@ -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.226",
4
+ "version": "1.9.228",
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.226.0
4
+ version: 1.9.228.0
5
5
  platform: x86_64-linux
6
6
  authors:
7
7
  - Ribose Inc.