leptris 1.9.226.0 → 1.9.229.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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +27 -0
  3. data/Rakefile +1 -1
  4. data/lib/leptris/vendor/aarch64-linux/libleptris.so +0 -0
  5. data/lib/leptris/vendor/aarch64-linux-musl/libleptris.so +0 -0
  6. data/lib/leptris/vendor/arm-linux/libleptris.so +0 -0
  7. data/lib/leptris/vendor/arm-linux-musl/libleptris.so +0 -0
  8. data/lib/leptris/vendor/arm64-darwin/libleptris.dylib +0 -0
  9. data/lib/leptris/vendor/ppc64le-linux/libleptris.so +0 -0
  10. data/lib/leptris/vendor/s390x-linux-musl/libleptris.so +0 -0
  11. data/lib/leptris/vendor/x86_64-darwin/libleptris.dylib +0 -0
  12. data/lib/leptris/vendor/x86_64-linux/libleptris.so +0 -0
  13. data/lib/leptris/vendor/x86_64-linux-musl/libleptris.so +0 -0
  14. data/lib/leptris/version.rb +1 -1
  15. data/vendor-src/README.md +1 -1
  16. data/vendor-src/libleptris/CHANGELOG.md +89 -0
  17. data/vendor-src/libleptris/CMakeLists.txt +1 -1
  18. data/vendor-src/libleptris/src/leptris/dom/cdata.c +1 -2
  19. data/vendor-src/libleptris/src/leptris/dom/comment.c +0 -1
  20. data/vendor-src/libleptris/src/leptris/dom/compact.c +19 -1
  21. data/vendor-src/libleptris/src/leptris/dom/compact.h +22 -1
  22. data/vendor-src/libleptris/src/leptris/dom/element.h +6 -7
  23. data/vendor-src/libleptris/src/leptris/dom/element_modify.c +19 -13
  24. data/vendor-src/libleptris/src/leptris/dom/entity_ref.c +0 -1
  25. data/vendor-src/libleptris/src/leptris/dom/node.h +3 -5
  26. data/vendor-src/libleptris/src/leptris/dom/node_public.c +76 -5
  27. data/vendor-src/libleptris/src/leptris/dom/pi.c +0 -1
  28. data/vendor-src/libleptris/src/leptris/dom/text.c +17 -10
  29. data/vendor-src/libleptris/src/leptris/dom/text.h +89 -12
  30. data/vendor-src/libleptris/src/leptris/dtd/validator.c +3 -1
  31. data/vendor-src/libleptris/src/leptris/flat/direct_parse.c +29 -8
  32. data/vendor-src/libleptris/src/leptris/html/html_parse.c +11 -11
  33. data/vendor-src/libleptris/src/leptris/leptris.c +5 -0
  34. data/vendor-src/libleptris/src/leptris/leptris_internal.h +9 -0
  35. data/vendor-src/libleptris/src/leptris/serialize/serialize.c +5 -5
  36. data/vendor-src/libleptris/src/leptris/xinclude/xinclude.c +15 -10
  37. data/vendor-src/libleptris/src/leptris/xpath/evaluator_types.c +6 -2
  38. data/vendor-src/libleptris/src/leptris/xslt/xslt_exec.c +13 -6
  39. data/vendor-src/libleptris/test/abi/test_header_hygiene.cpp +7 -0
  40. data/vendor-src/libleptris/test/dom/test_dom.cpp +45 -0
  41. data/vendor-src/libleptris/test/dom/test_text_borrowed.cpp +37 -33
  42. data/vendor-src/libleptris/test/parser/test_interleaved.cpp +74 -8
  43. data/vendor-src/libleptris/vcpkg.json +1 -1
  44. metadata +1 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 91fcb464484e2d21d1636be2ea5a90c9271519f6af65a41a3329c590ca220bb0
4
- data.tar.gz: 1b11c8f6ea582bdb6c9be3cdb688f59ff53ad96c35cf782db81c856da3259fea
3
+ metadata.gz: 7f13ce441b0f9543d4a9f3d12ef01e802eb0883e3a9a3d11fe31bc91ac73509d
4
+ data.tar.gz: b78b9d374a05196a4f4c33f41b301f7ea1f9b9dec1c540495b3a19b556a25dbc
5
5
  SHA512:
6
- metadata.gz: b690240b654c96bba424f13372c2009a4106ca2b35889f143853b83056fbb9cf0245ca474d69d1b4cd95c9b6c646dd0f72202b444ba6184124c068046e156764
7
- data.tar.gz: c4dab6ff7ba03ebe4a4730af6fe29fb053eca3b9554c4bfb29ed7235fc3a562d9030bed648fdc29228557552a9b32c2242d78f509524b7217565772781f57173
6
+ metadata.gz: eb8ff7b63f6e9cccd7a3489d3d46a69272df6909f7f807d001f19474a2e9dacfabdf9d70a60cf6feb4c47b39f53b3372233a9d5459e804f771c2f9516ce164f2
7
+ data.tar.gz: d42e6b9c9332f668b5d0b25909667e3bfa4860d6c88f07cff041ddd7287e980d97b755935210bad6653af251f7e8efd69903d50d9f85ae798fddd5cebeb389c6
data/CHANGELOG.md CHANGED
@@ -5,6 +5,33 @@ 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.229.0] - 2026-09-23
9
+
10
+ ### Performance — engine sync (1.9.229)
11
+
12
+ - Vendored libleptris 1.9.229: the text node struct shrinks
13
+ 40 → 32 bytes on LP64 and ILP32 alike (#1285 slice 4b) — the
14
+ content pointer became an int32 self-relative offset with
15
+ doc-tagged overflow stamps (0 = NULL, shared sentinel for the
16
+ static empty string, byte offset otherwise; >2 GB deltas spill
17
+ to the overflow table tagged EXPLICIT). No binding-facing
18
+ surface changes; audit 351/351, suite 781/0.
19
+
20
+ ## [1.9.228.0] - 2026-09-23
21
+
22
+ ### Fixed — Text#content= on created-but-unattached text nodes (#318; engine 1.9.227–228)
23
+
24
+ - **The #318 regression is fixed at the engine root**: engine
25
+ 1.9.228 (upstream leptris#1320) gives detached text nodes the
26
+ same #519 owner treatment PI/comment/CDATA already had — an
27
+ owner backref stamped by `leptris_text_node_create` (compact
28
+ int32 offset, LP64 layout preserved) — so
29
+ `leptris_text_node_set_content` works before attach again.
30
+ The create → fill → attach builder sequence moxml's
31
+ `Text#content=` depends on is unblocked; verified against the
32
+ vendored 1.9.228 (the #318 repro passes; suite 778 → 781/0).
33
+ - 1.9.227 rides along (header-hygiene spec zeroing).
34
+
8
35
  ## [1.9.226.0] - 2026-09-23
9
36
 
10
37
  ### 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.229"
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.229.0"
5
5
  end
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.229
5
5
  - utf8proc v2.11.0
6
6
 
7
7
  Rebuild by hand:
@@ -1,5 +1,94 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [1.9.229] - 2026-09-23
4
+
5
+ ### Performance
6
+
7
+ - **dom: text node 40 → 32 bytes — content pointer became an int32
8
+ self-relative offset, with doc-tagged overflow stamps (#1285
9
+ slice 4b).** The struct is base + five int32s on both LP64 and
10
+ ILP32 (content off, uint32 len, int32 next, int32 parent, int32
11
+ owner doc). Encoding: 0 = NULL, a shared sentinel = the static
12
+ empty string (xsl:strip-space writes land there instead of one
13
+ overflow-table entry per stripped node), any other value a byte
14
+ offset from the node. **Thread safety:** the rare >2 GB delta
15
+ spills to the overflow table tagged with the EXPLICIT owning
16
+ document — the direct-parse lane is overflow-table-free by design
17
+ (it never sets the TLS current-doc), so a TLS-tagged spill leaked
18
+ the entry past document teardown, which Linux CI ASAN/LSan
19
+ surfaced as aborting the two concurrency tests. Every content
20
+ stamp now carries its document; the untagged setter is deleted
21
+ from the codebase. Local gates: 1683/1683 both parse lanes; the
22
+ CI ASAN leg that caught the leak is green.
23
+
24
+
25
+
26
+ ## [1.9.228] - 2026-09-23
27
+
28
+ ### Fixed
29
+
30
+ - **dom: `leptris_text_node_set_content` works again on
31
+ created-but-unattached text nodes (#1320).** The 1.9.223–1.9.226
32
+ window returned `LEPTRIS_ERROR_INVALID_ARG` for exactly the
33
+ create → fill → attach builder sequence bindings use (moxml's
34
+ `Text#content=` failed 5 shared examples). Root cause: issue #519
35
+ gave detached PI/comment/CDATA nodes an `owner_doc` field but
36
+ skipped text — at the time text nodes carried their own pool
37
+ backref, which #1285 slice 4 then deleted, leaving detached text
38
+ with no document to allocate from. Text now gets the same #519
39
+ treatment: an owner backref stamped by the public creator only,
40
+ stored as a compact int32 offset (the LP64 struct keeps its 40
41
+ bytes — the field lands in existing padding; ILP32 32), with
42
+ overflow-table fallback tagged with the EXPLICIT document (the
43
+ parse-context TLS slot holds the wrong doc or NULL outside parse).
44
+ Every parse-carve path zero-initializes the field; the
45
+ attached/parsed parent-walk fast path is untouched. TDD'd
46
+ red-first against the reported minimal repro;
47
+ `DetachedNodes.TextSetContentWorksBeforeAttach` pins the
48
+ unattached, attach-then-set, and parsed paths.
49
+
50
+
51
+
52
+ ## [1.9.227] - 2026-09-23
53
+
54
+ ### Fixed
55
+
56
+ - **test:** the header-hygiene compact-pointer edge specs zero their
57
+ fake stack-allocated nodes before use. The `last_child` and
58
+ `last_attribute` getters *walk* the sibling/attribute list, so the
59
+ uninitialized fake node's next-edge was read as stack garbage —
60
+ zero on arm64, nonzero on x86_64, where the slice-3b layout shift
61
+ surfaced the latent UB as a real CI failure. It was always UB.
62
+
63
+ ### Performance
64
+
65
+ - **dom: binding_wrapper out of the node base — element 48B, text
66
+ 40B, every node type −8B (#1285 slice 3b).** `LeptrisNode` itself
67
+ drops 24 → 12 bytes; the element struct reaches the lane's stated
68
+ goal of 48 bytes. The `binding_wrapper` field (#262) was never
69
+ adopted — zero callers in leptris-ruby and leptris-py (both cache
70
+ wrappers on their own side), making it pure dead weight at 8 bytes
71
+ per node per document, and it carried the #421 stale-pointer bug
72
+ class, which the removal structurally eliminates. The public
73
+ `leptris_node_get/set_binding_wrapper` API is unchanged and now
74
+ serves from a lazily-allocated open-addressed map owned by the
75
+ document: documents that never wrap (all of them today) allocate
76
+ nothing, and entries cannot outlive their nodes because the map
77
+ dies with the document.
78
+ - **parse: interleaved lane size floor — small docs defer to classic
79
+ (#1258).** The two-phase scan+replay lane pays a fixed setup cost
80
+ that small documents cannot amortize: canon's gate measured ~25%
81
+ slower on a 24KB readonly shape while large documents run 0.69–0.74×
82
+ (256KB–3.4MB). `dp_il_try` now defers to the classic parser below a
83
+ 64KB floor (the midpoint between the measured loss and the wins);
84
+ `LEPTRIS_IL_MIN` overrides it in bytes, with explicit `0` disabling
85
+ the floor. Coverage is preserved, not narrowed: the parity suite
86
+ pins `LEPTRIS_IL_MIN=1` in every lane toggle, the lane-forced CI leg
87
+ sets it to `0` so the full suite still exercises the lane, and a new
88
+ spec pins the deferral itself.
89
+
90
+
91
+
3
92
  ## [1.9.226] - 2026-09-22
4
93
 
5
94
  ### 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.229
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
 
@@ -286,7 +286,9 @@ int32_t leptris_compact_int32_encode(void* base, void* target,
286
286
  const int32_t* field_addr) {
287
287
  if (!target) return 0;
288
288
  ptrdiff_t d = (char*)target - (char*)base;
289
- if (d < INT32_MIN || d > INT32_MAX) {
289
+ /* INT32_MAX is the EMPTY sentinel (compact.h) — spill exact
290
+ * deltas there too so raw offsets never collide with it. */
291
+ if (d < INT32_MIN || d >= INT32_MAX) {
290
292
  LeptrisCompactOverflowTable* table = get_overflow_table();
291
293
  struct leptris_document* doc = get_current_document();
292
294
  if (table && leptris_compact_overflow_set(table, field_addr,
@@ -298,6 +300,22 @@ int32_t leptris_compact_int32_encode(void* base, void* target,
298
300
  return (int32_t)d;
299
301
  }
300
302
 
303
+ int32_t leptris_compact_int32_encode_doc(void* base, void* target,
304
+ const int32_t* field_addr,
305
+ struct leptris_document* doc) {
306
+ if (!target) return 0;
307
+ ptrdiff_t d = (char*)target - (char*)base;
308
+ if (d < INT32_MIN || d > INT32_MAX) {
309
+ LeptrisCompactOverflowTable* table = get_overflow_table();
310
+ if (table && leptris_compact_overflow_set(table, field_addr,
311
+ target, doc) == 0) {
312
+ return LEPTRIS_INT32_OVERFLOW_SENTINEL;
313
+ }
314
+ return 0;
315
+ }
316
+ return (int32_t)d;
317
+ }
318
+
301
319
  void* leptris_compact_int32_decode(void* base, int32_t off,
302
320
  const int32_t* field_addr) {
303
321
  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
@@ -118,6 +127,15 @@ void* leptris_compact_int32_decode(void* base, int32_t off,
118
127
  * value is INT32_MIN). */
119
128
  #define LEPTRIS_INT32_OVERFLOW_SENTINEL_H ((int32_t)(-2147483647 - 1))
120
129
 
130
+ /* int32 sentinel for a non-NULL static "" pointer (#1285 slice 4b).
131
+ * Text content can be set to the empty string from far-away
132
+ * read-only storage (xsl:strip-space field writes); encoding that
133
+ * address would register one overflow-table entry per stripped
134
+ * node. Decoding returns a shared "" instead. Collision-free: the
135
+ * encode fast path below spills exact-INT32_MAX deltas to the
136
+ * overflow table, so no raw offset ever equals this value. */
137
+ #define LEPTRIS_COMPACT_INT32_EMPTY ((int32_t)2147483647)
138
+
121
139
  /* Inline fast paths (lane 18): the shared encode/decode live in
122
140
  * compact.c, so every tree-edge store or sibling/parent read paid an
123
141
  * out-of-line call — two per node in the parse loop and one per edge
@@ -131,7 +149,10 @@ static inline int32_t leptris_compact_int32_encode_inline(
131
149
  void* base, void* target, const int32_t* field_addr) {
132
150
  if (!target) return 0;
133
151
  ptrdiff_t d = (char*)target - (char*)base;
134
- if (d < INT32_MIN || d > INT32_MAX)
152
+ /* INT32_MAX is the EMPTY sentinel — a real delta of exactly
153
+ * that size spills to the overflow table (correct, just not
154
+ * inline). */
155
+ if (d < INT32_MIN || d >= INT32_MAX)
135
156
  return leptris_compact_int32_encode(base, target, field_addr);
136
157
  return (int32_t)d;
137
158
  }
@@ -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");
@@ -677,7 +677,8 @@ LeptrisStatus leptris_element_set_text(LeptrisElement elem, const char* text) {
677
677
  if (text) {
678
678
  /* Create new text node (even if empty to match pugixml behavior) */
679
679
  LeptrisMemoryPool* pool = leptris_element_get_pool(elem);
680
- LeptrisTextNode* text_node = leptris_text_create(text, strlen(text), pool);
680
+ LeptrisTextNode* text_node = leptris_text_create(text, strlen(text), pool,
681
+ leptris_element_get_document(elem));
681
682
  if (!text_node) {
682
683
  return LEPTRIS_ERROR_MEMORY;
683
684
  }
@@ -1341,9 +1342,10 @@ LeptrisElement leptris_element_append_copy(LeptrisElement parent, LeptrisElement
1341
1342
  LeptrisTextNode* text = (LeptrisTextNode*)child;
1342
1343
  /* SAFETY: Verify text pointer is valid before accessing content */
1343
1344
  if ((uintptr_t)text > 0x1000) {
1344
- LeptrisTextNode* text_copy = leptris_text_create(text->content,
1345
+ LeptrisTextNode* text_copy = leptris_text_create(leptris_textnode_content(text),
1345
1346
  text->content_len,
1346
- leptris_element_get_pool(copy));
1347
+ leptris_element_get_pool(copy),
1348
+ leptris_element_get_document(copy));
1347
1349
  if (text_copy) {
1348
1350
  leptris_element_append_child_internal(copy, (LeptrisNode*)text_copy);
1349
1351
  }
@@ -1507,9 +1509,10 @@ LeptrisElement leptris_element_prepend_copy(LeptrisElement parent, LeptrisElemen
1507
1509
  leptris_element_append_copy(copy, child);
1508
1510
  } else if (child_node->type == LEPTRIS_NODE_TYPE_TEXT) {
1509
1511
  LeptrisTextNode* text = (LeptrisTextNode*)child;
1510
- LeptrisTextNode* text_copy = leptris_text_create(text->content,
1512
+ LeptrisTextNode* text_copy = leptris_text_create(leptris_textnode_content(text),
1511
1513
  text->content_len,
1512
- leptris_element_get_pool(copy));
1514
+ leptris_element_get_pool(copy),
1515
+ leptris_element_get_document(copy));
1513
1516
  if (text_copy) {
1514
1517
  leptris_element_append_child_internal(copy, (LeptrisNode*)text_copy);
1515
1518
  }
@@ -1622,9 +1625,10 @@ LeptrisElement leptris_element_insert_copy_after(LeptrisElement sibling, Leptris
1622
1625
  leptris_element_append_copy(copy, child);
1623
1626
  } else if (child_node->type == LEPTRIS_NODE_TYPE_TEXT) {
1624
1627
  LeptrisTextNode* text = (LeptrisTextNode*)child;
1625
- LeptrisTextNode* text_copy = leptris_text_create(text->content,
1628
+ LeptrisTextNode* text_copy = leptris_text_create(leptris_textnode_content(text),
1626
1629
  text->content_len,
1627
- leptris_element_get_pool(copy));
1630
+ leptris_element_get_pool(copy),
1631
+ leptris_element_get_document(copy));
1628
1632
  if (text_copy) {
1629
1633
  leptris_element_append_child_internal(copy, (LeptrisNode*)text_copy);
1630
1634
  }
@@ -1737,9 +1741,10 @@ LeptrisElement leptris_element_insert_copy_before(LeptrisElement sibling, Leptri
1737
1741
  leptris_element_append_copy(copy, child);
1738
1742
  } else if (child_node->type == LEPTRIS_NODE_TYPE_TEXT) {
1739
1743
  LeptrisTextNode* text = (LeptrisTextNode*)child;
1740
- LeptrisTextNode* text_copy = leptris_text_create(text->content,
1744
+ LeptrisTextNode* text_copy = leptris_text_create(leptris_textnode_content(text),
1741
1745
  text->content_len,
1742
- leptris_element_get_pool(copy));
1746
+ leptris_element_get_pool(copy),
1747
+ leptris_element_get_document(copy));
1743
1748
  if (text_copy) {
1744
1749
  leptris_element_append_child_internal(copy, (LeptrisNode*)text_copy);
1745
1750
  }
@@ -1925,9 +1930,10 @@ static LeptrisElement leptris_element_copy_subtree_bulk_internal(
1925
1930
  } else if (child_node->type == LEPTRIS_NODE_TYPE_TEXT) {
1926
1931
  /* Copy text node (not bulk-allocated) */
1927
1932
  LeptrisTextNode* text = (LeptrisTextNode*)child;
1928
- LeptrisTextNode* text_copy = leptris_text_create(text->content,
1933
+ LeptrisTextNode* text_copy = leptris_text_create(leptris_textnode_content(text),
1929
1934
  text->content_len,
1930
- leptris_element_get_pool(copy));
1935
+ leptris_element_get_pool(copy),
1936
+ leptris_element_get_document(copy));
1931
1937
  if (text_copy) {
1932
1938
  /* Link as child using internal function */
1933
1939
  leptris_element_append_child_internal(copy, (LeptrisNode*)text_copy);
@@ -2066,8 +2072,8 @@ static LeptrisElement copy_subtree_detached(LeptrisElement source,
2066
2072
  copy, pool, doc);
2067
2073
  } else if (ty == LEPTRIS_NODE_TYPE_TEXT) {
2068
2074
  LeptrisTextNode* t = (LeptrisTextNode*)c;
2069
- cc = (LeptrisNodeRef)leptris_text_create(t->content,
2070
- t->content_len, pool);
2075
+ cc = (LeptrisNodeRef)leptris_text_create(leptris_textnode_content(t),
2076
+ t->content_len, pool, doc);
2071
2077
  if (cc) leptris_textnode_set_parent((LeptrisTextNode*)cc, copy);
2072
2078
  } else if (ty == LEPTRIS_NODE_TYPE_CDATA) {
2073
2079
  LeptrisCDATANode* cd = (LeptrisCDATANode*)c;
@@ -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:
@@ -328,7 +333,14 @@ LEPTRIS_API LeptrisNodeRef leptris_text_node_create(LeptrisDocument doc,
328
333
  if (!doc->pool) return NULL;
329
334
  if (!content) content = "";
330
335
  size_t len = strlen(content);
331
- LeptrisTextNode* n = leptris_text_create(content, len, doc->pool);
336
+ LeptrisTextNode* n = leptris_text_create(content, len, doc->pool, doc);
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
 
@@ -404,7 +416,7 @@ LEPTRIS_API LeptrisStatus leptris_text_node_set_content(LeptrisNodeRef node,
404
416
  if (!doc || !doc->pool) return LEPTRIS_ERROR_INVALID_ARG;
405
417
  char* copy = node_public_pool_strdup(doc->pool, content, len);
406
418
  if (!copy) return LEPTRIS_ERROR_MEMORY;
407
- t->content = copy;
419
+ leptris_textnode_set_content_ptr_doc(t, copy, doc);
408
420
  t->content_len = (uint32_t)len;
409
421
  return LEPTRIS_OK;
410
422
  }
@@ -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;
@@ -18,7 +18,8 @@
18
18
  * CRITICAL: Content is NEVER trimmed — preserved exactly as given. */
19
19
  LeptrisTextNode* leptris_text_create(const char* content,
20
20
  size_t content_len,
21
- LeptrisMemoryPool* pool) {
21
+ LeptrisMemoryPool* pool,
22
+ struct leptris_document* doc) {
22
23
  if (!pool) return NULL;
23
24
 
24
25
  char* content_storage;
@@ -30,18 +31,18 @@ LeptrisTextNode* leptris_text_create(const char* content,
30
31
  node->base.frozen = 0;
31
32
  node->base.version = 0;
32
33
  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
34
  node->parent_off = 0;
38
35
  node->next_sibling_off = 0;
36
+ node->owner_doc_off = 0; /* #1320: only the public creator stamps */
39
37
 
40
38
  if (content && content_len > 0) {
41
39
  memcpy(content_storage, content, content_len);
42
40
  }
43
41
  content_storage[content_len] = '\0';
44
- node->content = content_storage;
42
+ /* Contiguous with the node in the same pool alloc — the offset
43
+ * is a small positive delta; doc tags the oversized-content
44
+ * fallback should it ever leave the inline window. */
45
+ leptris_textnode_set_content_ptr_doc(node, content_storage, doc);
45
46
  node->content_len = (uint32_t)content_len;
46
47
 
47
48
  return node;
@@ -57,7 +58,8 @@ LeptrisTextNode* leptris_text_create(const char* content,
57
58
  * the parser's pool. */
58
59
  LeptrisTextNode* leptris_text_create_borrowed(const char* content,
59
60
  size_t content_len,
60
- LeptrisMemoryPool* pool) {
61
+ LeptrisMemoryPool* pool,
62
+ struct leptris_document* doc) {
61
63
  if (!pool) return NULL;
62
64
 
63
65
  LeptrisTextNode* node = (LeptrisTextNode*)leptris_pool_alloc(pool, sizeof(LeptrisTextNode));
@@ -70,11 +72,15 @@ LeptrisTextNode* leptris_text_create_borrowed(const char* content,
70
72
  * uninitialized raw bit silently flips DOE/cdata behavior by
71
73
  * build configuration. */
72
74
  node->base.raw = 0;
73
- node->base.binding_wrapper = NULL;
74
- node->content = (char*)content; /* Non-owning; caller guarantees lifetime + termination. */
75
+ /* Non-owning view into the doc-owned buffer; caller guarantees
76
+ * lifetime + termination (#1285 slice 4). The buffer-to-node
77
+ * distance crosses allocations — the doc-tagged stamp is
78
+ * MANDATORY here (TLS is unset on parse worker threads). */
79
+ leptris_textnode_set_content_ptr_doc(node, content, doc);
75
80
  node->content_len = (uint32_t)content_len;
76
81
  node->parent_off = 0;
77
82
  node->next_sibling_off = 0;
83
+ node->owner_doc_off = 0; /* #1320: only the public creator stamps */
78
84
 
79
85
  return node;
80
86
  }
@@ -98,5 +104,6 @@ void leptris_text_free(LeptrisTextNode* text) {
98
104
  * here. */
99
105
  const char* leptris_text_get_content(LeptrisTextNode* text) {
100
106
  if (!text) return NULL;
101
- return text->content ? text->content : "";
107
+ const char* c = leptris_textnode_content(text);
108
+ return c ? c : "";
102
109
  }