leptris 1.9.309.0-ppc64le-linux → 1.9.311.1-ppc64le-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: 5c4fe15a8a13df3de39519a4a2985c6f3a5f23202a3b6f1f00d0fad934d7a08c
4
- data.tar.gz: 1009e7705a3b262341e0411544ca1e55e75f70b72334aaa66f8db695fe3d21d7
3
+ metadata.gz: b4652fa888a962a651464d12ed97e9a17cc8c19056f55f4694eb97ecd7f8447b
4
+ data.tar.gz: 3614fc667bddf01d0be1f014b0076988eb0a53f52ed3d9e1a70a14fb270fe85a
5
5
  SHA512:
6
- metadata.gz: 911f4eb491ac63fcfd89d0d0f1a19871937039b490ae07c1de3bd2a09554d37a51ece8ecfefbb2c0b78f3ab0f256663c54523ac514976ee55162cde8d1c11f24
7
- data.tar.gz: 9755c0907775f1b0dc41c78587437259c552d9a3d054a7e86aaf15a7abae703cc5516371ef659531ff14813b809832861d63f90e48f68c1cd3b13fbf367b90f4
6
+ metadata.gz: 0711c12380bb334b39f888aef74f8045940f4d726a9932e7f69eb93c9a80af6c32d3b4e7359675e0206329d855bad9905e06ffc05e623226b3dc6eedb3770543
7
+ data.tar.gz: 964a54d1fb7ab8976a52d11fb12b6e799afd21a7e865fa0fa8f382dcf298906214260e567b0459e3f52ca81f1abdbffbf4836a6db034f4205c56bc51dbb46c50
data/CHANGELOG.md CHANGED
@@ -1,3 +1,52 @@
1
+ ## [1.9.311.1] - 2026-10-06
2
+
3
+ ### Fixed
4
+
5
+ - **Docless children now route through the adoption contract
6
+ (#376)**: moxml's created nodes arrive as Node wrappers whose
7
+ #document is nil but whose C node is live in a foreign pool —
8
+ the splice sites' foreign check required a document, so those
9
+ children bypassed the namespace lift and, worse, the returned
10
+ wrapper addressed the ABANDONED source instead of the installed
11
+ in-tree copy (moxml's flows then mutated a node the tree never
12
+ saw). `foreign_child?` now treats document-nil as foreign at
13
+ add_child, prepend_child, add_next_sibling,
14
+ add_previous_sibling, and append_children, so every splice
15
+ returns the installed wrapper. Document#root= routes docless
16
+ roots through the copying ex-face too — the plain set_root of a
17
+ foreign-pool node linked the pointer WITHOUT adopting it into
18
+ the destination pool, so destination-side writes landed in the
19
+ source arena and reads through the root memo missed them.
20
+ Node-level mutations on docless
21
+ wrappers no longer crash the version gate (they memoize
22
+ nothing, so there is no version to advance).
23
+
24
+ ## [1.9.311.0] - 2026-10-06
25
+
26
+ ### Added
27
+
28
+ - **Document#absorb — move semantics for doomed splice sources
29
+ (libleptris 1.9.311, engine #1548)**: one call transfers a
30
+ scratch document's pool ownership into the destination, so
31
+ subsequent cross-document splices of its nodes move BY REFERENCE
32
+ — O(1), no deep copy, no live-set doubling (the sectioned-
33
+ cleanup flow's 7GB cg3 overrun; completes without the copy
34
+ blow-up). The absorbed document's #free is handle-only (the
35
+ destination's free performs the real release); splices into any
36
+ THIRD document still deep-copy; chaining rejects. The splice
37
+ seams skip source-side removal for absorbed sources (the move
38
+ already transferred the node — nothing to remove).
39
+ leptris_document_absorb attached (audit 370/369).
40
+
41
+ ### Fixed — engine sync (1.9.309-1.9.311)
42
+
43
+ - 1.9.309: XPath signed exponent literals (`1E+1` truncated at
44
+ the E; the sign is part of the literal now).
45
+ - 1.9.310: a typed cast stored the operator frame's stack buffer
46
+ in the result's borrowed atomic_type slot (ASAN stack-use-after-
47
+ return); recursive closures sharing parameter names with an
48
+ outer frame no longer drop the outer bindings.
49
+
1
50
  ## [1.9.308.0] - 2026-10-05
2
51
 
3
52
  ### Engine sync (libleptris 1.9.308)
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.309"
11
+ LIBLEPTRIS_VERSION = "1.9.311"
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.309.0"
4
+ VERSION = "1.9.311.1"
5
5
  end
@@ -388,7 +388,12 @@ class Leptris::XML::Document
388
388
  # the foreign subtree into this document's pool and reports the
389
389
  # installed element — a fresh handle distinct from the source
390
390
  # pointer. Same-document installs keep the native fast path.
391
- cross = element.document && !element.document.equal?(self)
391
+ # Docless roots count as foreign (#376): a plain set_root of a
392
+ # foreign-pool node links the pointer WITHOUT adopting it into
393
+ # this pool — writes through this document land in the source
394
+ # arena and reads through the root memo miss them. The ex-face
395
+ # copies properly.
396
+ cross = Leptris::XML::Element.foreign_to?(element, self)
392
397
  installed_ptr = nil
393
398
  if cross
394
399
  out = ::FFI::MemoryPointer.new(:pointer)
@@ -561,10 +566,51 @@ class Leptris::XML::Document
561
566
  end
562
567
  alias_method :c14n, :canonicalize
563
568
 
569
+ # Move semantics for doomed splice sources (1.9.311, engine
570
+ # #1548): transfer +other+'s pool ownership into this document
571
+ # in one call. Every subsequent cross-document splice of other's
572
+ # nodes into THIS document moves by reference — O(1), no deep
573
+ # copy, no live-set doubling (the sectioned-cleanup flow's 7GB
574
+ # cg3 overrun). other's handle becomes handle-only: its #free
575
+ # releases just the wrapper; this document's free performs the
576
+ # real release. Chaining rejects (an absorbed document cannot
577
+ # absorb or be absorbed again). Returns self.
578
+ def absorb(other)
579
+ raise Leptris::XML::UseAfterFreeError if @freed.state == :freed
580
+ unless other.is_a?(Leptris::XML::Document)
581
+ raise ArgumentError, "absorb expects a Leptris::XML::Document"
582
+ end
583
+
584
+ Leptris::XML::FFI.check_status(
585
+ Leptris::XML::FFI.leptris_document_absorb(c_ptr, other.c_ptr))
586
+ other.mark_absorbed_into(self)
587
+ self
588
+ end
589
+
590
+ # Absorption bookkeeping (private seam — never set ivars across
591
+ # objects by hand): the absorbed document's #free skips the
592
+ # engine release, and splice seams skip source-side removals
593
+ # because absorbed splices MOVE the node (nothing to remove).
594
+ def mark_absorbed_into(destination)
595
+ @absorbed_into = destination
596
+ end
597
+
598
+ def absorbed_into?(destination)
599
+ @absorbed_into&.equal?(destination)
600
+ end
601
+
602
+ def absorbed?
603
+ !@absorbed_into.nil?
604
+ end
605
+ private :absorbed?
564
606
  def free
565
607
  return if @freed.state == :freed
566
608
  @freed.state = :freed
567
- Leptris::XML::FFI.leptris_document_free(c_ptr) unless c_ptr.nil?
609
+ # An absorbed source's pool is owned by its destination — this
610
+ # free is handle-only; the destination's free releases memory.
611
+ unless absorbed? || c_ptr.nil?
612
+ Leptris::XML::FFI.leptris_document_free(c_ptr)
613
+ end
568
614
  @c_ptr = nil
569
615
  @c_address = nil
570
616
  @wrapper_cache&.clear
@@ -389,6 +389,15 @@ class Leptris::XML::Element < Leptris::XML::Node
389
389
  !Leptris::XML::Native.ns_lift_needed?(node.c_ptr.address)
390
390
  end
391
391
 
392
+ # A node is foreign to +document+ when it belongs to another one
393
+ # — or to NONE (#376): moxml's created nodes are live C nodes
394
+ # wrapped without a document, and they splice by copy exactly
395
+ # like attached-foreign ones. One definition of "foreign" for
396
+ # the element splices and Document#root=.
397
+ def self.foreign_to?(node, document)
398
+ node.document.nil? || !node.document.equal?(document)
399
+ end
400
+
392
401
  def self.lift_namespaces_for_adoption(node, target_scope)
393
402
  return unless node.is_a?(Leptris::XML::Element)
394
403
  # Declarations the node already carries (its own definitions —
@@ -419,11 +428,11 @@ class Leptris::XML::Element < Leptris::XML::Node
419
428
  # C-bound insert (TODO.perf/14): gates + predicate + version
420
429
  # bump + engine insert in one dispatch; Qnil = the child needs
421
430
  # the namespace lift — the full path below handles it.
422
- # Cross-document nodes skip the fast path entirely (1.9.304,
423
- # #1528): the engine deep-copies them at the splice, so the
424
- # full path's lift must run FIRST and the source original is
425
- # removed after (move semantics).
426
- if node.document && !node.document.equal?(@document)
431
+ # Cross-document and docless nodes skip the fast path entirely
432
+ # (1.9.304, #1528; #376): the engine deep-copies them at the
433
+ # splice, so the full path's lift must run FIRST and the source
434
+ # original is removed after (move semantics).
435
+ if Leptris::XML::Element.foreign_to?(node, @document)
427
436
  ensure_writable!
428
437
  unless Leptris::XML::Element.skip_adoption_lift?(node)
429
438
  Leptris::XML::Element.lift_namespaces_for_adoption(node, namespaces)
@@ -455,10 +464,10 @@ class Leptris::XML::Element < Leptris::XML::Node
455
464
  # C-bound insert (TODO.perf/14): gates + predicate + version
456
465
  # bump + engine insert in one dispatch; Qnil = the child needs
457
466
  # the namespace lift — the full path below handles it.
458
- # Cross-document: full path first (lift + engine copy), then
459
- # the source original is removed; returns the installed copy
460
- # (self's new next sibling).
461
- if node.document && !node.document.equal?(@document)
467
+ # Cross-document and docless (#376): full path first (lift +
468
+ # engine copy), then the source original is removed; returns
469
+ # the installed copy (self's new next sibling).
470
+ if Leptris::XML::Element.foreign_to?(node, @document)
462
471
  ensure_writable!
463
472
  unless Leptris::XML::Element.skip_adoption_lift?(node)
464
473
  Leptris::XML::Element.lift_namespaces_for_adoption(node, namespaces)
@@ -490,10 +499,10 @@ class Leptris::XML::Element < Leptris::XML::Node
490
499
  # C-bound insert (TODO.perf/14): gates + predicate + version
491
500
  # bump + engine insert in one dispatch; Qnil = the child needs
492
501
  # the namespace lift — the full path below handles it.
493
- # Cross-document: full path first (lift + engine copy), then
494
- # the source original is removed; returns the installed copy
495
- # (self's new previous sibling).
496
- if node.document && !node.document.equal?(@document)
502
+ # Cross-document and docless (#376): full path first (lift +
503
+ # engine copy), then the source original is removed; returns
504
+ # the installed copy (self's new previous sibling).
505
+ if Leptris::XML::Element.foreign_to?(node, @document)
497
506
  ensure_writable!
498
507
  unless Leptris::XML::Element.skip_adoption_lift?(node)
499
508
  Leptris::XML::Element.lift_namespaces_for_adoption(node, namespaces)
@@ -551,9 +560,7 @@ class Leptris::XML::Element < Leptris::XML::Node
551
560
  nodes.each { |n| add_child(n) }
552
561
  return
553
562
  end
554
- cross = nodes.any? do |n|
555
- n.document && !n.document.equal?(@document)
556
- end
563
+ cross = nodes.any? { |n| Leptris::XML::Element.foreign_to?(n, @document) }
557
564
  if native_fast_children?
558
565
  addrs = nodes.map { |n| n.c_ptr.address }
559
566
  done = Leptris::XML::Native.append_binding_children(
@@ -668,13 +675,13 @@ class Leptris::XML::Element < Leptris::XML::Node
668
675
  ensure_writable!
669
676
  case node_or_markup
670
677
  when Leptris::XML::Node
671
- # Cross-document adoption (1.9.304, #1528): the engine
678
+ # Foreign-child adoption (1.9.304, #1528): the engine
672
679
  # deep-copies foreign nodes at the splice — the returned
673
680
  # wrapper must be the INSTALLED copy, not the detached
674
- # source. Route those through the re-resolving path.
675
- cross = node_or_markup.document &&
676
- !node_or_markup.document.equal?(@document)
677
- return append_installed(node_or_markup) if cross
681
+ # source. Docless wrappers count as foreign (#376): moxml's
682
+ # created nodes carry live C pointers with no owning
683
+ # document, and their callers expect the in-tree handle.
684
+ return append_installed(node_or_markup) if Leptris::XML::Element.foreign_to?(node_or_markup, @document)
678
685
  # C-bound append (TODO.perf/08-09): one dispatch runs the
679
686
  # readonly/liveness gates, the provable no-op lift
680
687
  # predicate, the version bump, and the engine append. Qnil
@@ -752,6 +759,11 @@ class Leptris::XML::Element < Leptris::XML::Node
752
759
  # source parent and remove through a wrapped handle.
753
760
  def remove_from_source_original(node)
754
761
  return unless node.document && !node.document.equal?(@document)
762
+ # An ABSORBED source's splices move by reference (engine
763
+ # #1548): the node is already owned by this document — there is
764
+ # no source-side original to remove, and the engine parent is
765
+ # this tree's own parent.
766
+ return if node.document.absorbed_into?(@document)
755
767
 
756
768
  srcp = Leptris::XML::FFI.leptris_node_parent(node.c_ptr)
757
769
  return if srcp.nil? || srcp.null?
@@ -490,6 +490,14 @@ attach_function :leptris_parse_string,
490
490
  [:string, :pointer], :pointer
491
491
  attach_function :leptris_document_free,
492
492
  [:leptris_document], :void
493
+ # 1.9.311 (#1548): move semantics for doomed splice sources —
494
+ # one call transfers a source document's pool ownership into
495
+ # the destination, so subsequent cross-document splices move
496
+ # nodes by reference (O(1), no deep copy, no live-set
497
+ # doubling). The caller's src handle becomes handle-only;
498
+ # dst's free performs the real release. Chaining rejects.
499
+ attach_function :leptris_document_absorb,
500
+ [:leptris_document, :leptris_document], :leptris_status
493
501
  attach_function :leptris_document_create,
494
502
  [], :leptris_document
495
503
  attach_function :leptris_document_set_root,
@@ -281,14 +281,15 @@ class Leptris::XML::Node
281
281
  # passes through this gate, so it is where the document's mutation
282
282
  # version advances — the invalidation behind writable-document
283
283
  # memoization. Bumping before the C call is conservative: a failed
284
- # mutation merely discards memos.
284
+ # mutation merely discards memos. Docless wrappers (#376) mutate
285
+ # fine — they memoize nothing, so there is no version to advance.
285
286
  def ensure_writable!
286
287
  ensure_alive!
287
288
  if readonly_document?
288
289
  raise Leptris::XML::ReadOnlyError,
289
290
  "document is readonly — mutation attempted on #{inspect}"
290
291
  end
291
- @document.advance_version
292
+ @document&.advance_version
292
293
  nil
293
294
  end
294
295
 
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.309
4
+ - libleptris v1.9.311
5
5
  - utf8proc v2.11.0
6
6
 
7
7
  Rebuild by hand:
@@ -1,5 +1,74 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [1.9.311] - 2026-10-06
4
+
5
+ ### Added
6
+
7
+ - **`leptris_document_absorb(dst, src)` — move semantics for doomed
8
+ splice sources (#1548).** The #1528 cross-document adoption gate
9
+ deep-copies every foreign splice, doubling the live set; the
10
+ sectioned-semantic cleanup flow (scratch-document splices) pushed
11
+ the cg3 corpus past a 7GB budget (fatal alloc ~396s; completes at
12
+ 16GB). One call before the splices transfers a doomed source
13
+ document's lifetime into the destination (riding the existing
14
+ xi:include ownership chain): the adoption gate then moves nodes by
15
+ reference — O(1) per seam, no copy, no live-set doubling, and
16
+ existing splice code is unchanged. The caller's later
17
+ `leptris_document_free(src)` releases only the handle; the
18
+ destination's free performs the real release. Splicing an absorbed
19
+ document's nodes into any OTHER document still deep-copies, and
20
+ the no-absorb copy contract is unchanged. No chaining: an
21
+ absorbed document cannot absorb or be absorbed again.
22
+
23
+
24
+
25
+ ## [1.9.310] - 2026-10-06
26
+
27
+ ### Fixed
28
+
29
+ - **Memory safety: a typed cast stored the operator frame's stack
30
+ buffer in the result's borrowed `atomic_type` slot — read after
31
+ return by the sequence fold (ASAN stack-use-after-return, caught
32
+ by Linux CI on this very batch).** Cast targets now assign
33
+ lifetime-stable family-table labels.
34
+ - **Recursive closures: a nested call sharing parameter names with
35
+ an outer frame removed the outer frame's bindings on exit —
36
+ `$a` vanished mid-recursion.** Both dynamic-call sites
37
+ detach/save the prior nodeset and restore it (fold-left/
38
+ fold-right-101..104: the spec'd recursive folds over `tail()`).
39
+
40
+ ### Added
41
+
42
+ - **XQuery closure-call carriers, end-to-end — 19 more QT3
43
+ adoptions (for-each-010, for-each-pair-024/026/027, fold-left
44
+ -019/020/021/101..104, fold-right-013/019/020/101..104,
45
+ K2-SeqReverseFunc-1, cbcl-distinct-values-002).**
46
+ - `declare function` parameter types skip depth-aware — a
47
+ function-test type's own commas
48
+ (`$f as function(item()*, item()) as item()*`) broke the
49
+ parameter list; inline function literals accept `as TYPE`
50
+ annotations on params and the return.
51
+ - Callback arguments and accumulators keep their shape across
52
+ every hop: sequences ride the member carrier, empty sequences
53
+ bind zero nodes (`empty($acc)` on a `()` zero is true — the
54
+ average fold yields 8.625, min folds return 1), booleans stay
55
+ falsy through variable round-trips (and-folds return false).
56
+ - `xs:untypedAtomic` rides its own family mark: value-eq casts
57
+ it to the counterpart (cbcl-002 matches Saxon byte-for-byte)
58
+ while a plain `xs:string` never merges with typed values.
59
+ - QName tail: `namespace-uri-from-QName` resolves prefixed
60
+ lexical QNames in the static namespaces (`xs:int#1` → the
61
+ XMLSchema namespace); `castable/cast as xs:QName` accept
62
+ NCName:NCName lexicals; QName eq compares full lexicals.
63
+ - Range sequences and nodeset growth admit 1,000,000 items —
64
+ `fold-left(1 to 1000000, 0, ...)` computes the full million
65
+ (previously truncated at exactly 524288 items).
66
+ - Remaining QT3 pins are down to three, each documented in the
67
+ harness: fold-left-009/016 (node-typed accumulators must be
68
+ real nodes for path/identity shapes) and cbcl-002b (XQ10 vs
69
+ XQ30 value-eq contradiction).
70
+
71
+
3
72
  ## [1.9.309] - 2026-10-05
4
73
 
5
74
  ### Fixed
@@ -3,7 +3,7 @@
3
3
  cmake_minimum_required(VERSION 3.20)
4
4
 
5
5
  project(leptris
6
- VERSION 1.9.309
6
+ VERSION 1.9.311
7
7
  DESCRIPTION "Fast XML parser and XPath evaluator in pure C"
8
8
  LANGUAGES C CXX
9
9
  )
@@ -1068,6 +1068,31 @@ LEPTRIS_API void leptris_document_free(LeptrisDocument doc);
1068
1068
  LEPTRIS_API void leptris_document_adopt_child(LeptrisDocument parent,
1069
1069
  LeptrisDocument child);
1070
1070
 
1071
+ /**
1072
+ * Absorb a DOOMED source document into the destination: the source's
1073
+ * node pool is released with the destination, so cross-document
1074
+ * splices from source to destination move nodes by reference instead
1075
+ * of deep-copying (#1548 — the #1528 adoption copy doubled the live
1076
+ * set on scratch-document splice flows).
1077
+ *
1078
+ * After a successful absorb the caller must treat `src` as consumed:
1079
+ * its tree may be spliced into `dst` (zero-copy), and the caller's
1080
+ * leptris_document_free(src) releases only the handle — the memory
1081
+ * dies with `dst`. Splicing an absorbed document's nodes into any
1082
+ * OTHER document still deep-copies (#1528 semantics).
1083
+ *
1084
+ * @param dst Destination document (survives; owns src's pool)
1085
+ * @param src Doomed source document (handle consumed)
1086
+ * @return LEPTRIS_OK; LEPTRIS_ERROR_NULL_ARG on NULL arguments;
1087
+ * LEPTRIS_ERROR_INVALID_ARG when src == dst or src is
1088
+ * already absorbed (chaining is not supported).
1089
+ *
1090
+ * Memory: src's pool ownership transfers to dst. The caller's src
1091
+ * handle becomes handle-only.
1092
+ */
1093
+ LEPTRIS_API LeptrisStatus leptris_document_absorb(LeptrisDocument dst,
1094
+ LeptrisDocument src);
1095
+
1071
1096
  /**
1072
1097
  * Get root element of document
1073
1098
  *
@@ -416,6 +416,10 @@ static LeptrisNode* adopt_for_splice(LeptrisNode* node,
416
416
  src = leaf_owner_doc(top);
417
417
  }
418
418
  if (!src || src == target) return node;
419
+ /* #1548: the source is a doomed scratch doc absorbed into
420
+ * the target — its pool dies with the target, so the node
421
+ * moves by reference. */
422
+ if (src->absorbed_by == target) return node;
419
423
  LeptrisNode* copy = copy_leaf_chain(node, target);
420
424
  return copy;
421
425
  }
@@ -430,6 +434,10 @@ static LeptrisNode* adopt_for_splice(LeptrisNode* node,
430
434
  struct leptris_document* src =
431
435
  leptris_element_get_document((LeptrisElement)top);
432
436
  if (!src || src == target) return node;
437
+ /* #1548: the source is a doomed scratch doc absorbed into
438
+ * the target — its pool dies with the target, so the subtree
439
+ * moves by reference (O(1), no live-set doubling). */
440
+ if (src->absorbed_by == target) return node;
433
441
  LeptrisElement copy =
434
442
  leptris_element_copy((LeptrisElement)node, target);
435
443
  return copy ? (LeptrisNode*)copy : NULL;
@@ -884,6 +884,24 @@ LEPTRIS_API LeptrisDocument leptris_parse_file(const char* filepath, LeptrisStat
884
884
  /**
885
885
  * Free document and all its contents
886
886
  */
887
+ LEPTRIS_API LeptrisStatus leptris_document_absorb(LeptrisDocument dst,
888
+ LeptrisDocument src) {
889
+ if (!dst || !src) return LEPTRIS_ERROR_NULL_ARG;
890
+ if (dst == src) return LEPTRIS_ERROR_INVALID_ARG;
891
+ /* No chains: an absorbed document's lifetime already belongs to
892
+ * its absorber; re-absorbing would give one pool two owners. */
893
+ if (src->absorbed_by || dst->absorbed_by)
894
+ return LEPTRIS_ERROR_INVALID_ARG;
895
+
896
+ /* Ride the xi:include child_docs chain (TODO 117): document_free
897
+ * releases adopted children with the parent, AFTER its own tree
898
+ * references are gone — exactly the #1548 lifetime. */
899
+ leptris_document_adopt_child(dst, src);
900
+ src->absorbed_by = dst;
901
+ src->absorbed_handle = 1;
902
+ return LEPTRIS_OK;
903
+ }
904
+
887
905
  LEPTRIS_API void leptris_document_adopt_child(LeptrisDocument parent,
888
906
  LeptrisDocument child) {
889
907
  if (!parent || !child) return;
@@ -913,6 +931,14 @@ LEPTRIS_API void leptris_document_free(struct leptris_document* doc) {
913
931
  }
914
932
  if (!doc) return;
915
933
 
934
+ /* #1548: an absorbed document's MEMORY belongs to its absorber
935
+ * (it rides the absorber's child_docs chain). A caller free
936
+ * releases only the outstanding handle. */
937
+ if (doc->absorbed_by) {
938
+ doc->absorbed_handle = 0;
939
+ return;
940
+ }
941
+
916
942
  /* Decrement reference count */
917
943
  if (doc->ref_count > 0) {
918
944
  doc->ref_count--;
@@ -995,6 +1021,11 @@ LEPTRIS_API void leptris_document_free(struct leptris_document* doc) {
995
1021
  * doesn't try to walk the
996
1022
  * adopted subtree. */
997
1023
  ch->root = NULL;
1024
+ /* #1548: the anchor's free is the REAL one — clear the
1025
+ * absorbed state so the early handle-return above does
1026
+ * not swallow it. */
1027
+ ch->absorbed_by = NULL;
1028
+ ch->absorbed_handle = 0;
998
1029
  leptris_document_free(ch);
999
1030
  ch = next;
1000
1031
  }
@@ -179,10 +179,22 @@ struct leptris_document {
179
179
  * copies / analyze-string result trees, released with THIS
180
180
  * document so borrowed nodes live exactly as long as the
181
181
  * source tree (the ctx owned_docs chain dies at public-eval
182
- * return and cannot serve plain leptris_xpath_eval results). */
182
+ * return and cannot serve plain leptris_xpath_eval results).
183
+ * Absorbed documents (#1548) ride the SAME chain: a doomed
184
+ * scratch doc's pool is released with the absorbing document,
185
+ * letting the splice adoption gate move nodes by reference. */
183
186
  struct leptris_document** anchored_docs;
184
187
  size_t n_anchored_docs;
185
188
  size_t cap_anchored_docs;
189
+ /* #1548 absorb-adoption: set by leptris_document_absorb on the
190
+ * SOURCE. absorbed_by owns this document's lifetime (it is in
191
+ * absorbed_by->anchored_docs); cross-document splice adoption
192
+ * into absorbed_by moves nodes by reference instead of
193
+ * copying. absorbed_handle counts outstanding CALLER handles:
194
+ * the caller's leptris_document_free releases only the handle
195
+ * (the anchor frees the memory later). */
196
+ struct leptris_document* absorbed_by;
197
+ int absorbed_handle;
186
198
  /* XML Declaration support */
187
199
  char* xml_version; /* "1.0", "1.1", etc. or NULL if not present */
188
200
  int standalone; /* -1=not set, 0=no, 1=yes */
@@ -650,7 +650,7 @@ void xpath_nodeset_add(XPathNodeSet* nodeset, void* node) {
650
650
 
651
651
  /* SAFETY: Validate nodeset structure before reallocation
652
652
  * Corruption in count/capacity can cause heap corruption during realloc */
653
- if (nodeset->count > nodeset->capacity || nodeset->capacity > 1000000) {
653
+ if (nodeset->count > nodeset->capacity || nodeset->capacity > 4000000) {
654
654
  /* Corrupted structure - skip this addition */
655
655
  return;
656
656
  }
@@ -659,8 +659,11 @@ void xpath_nodeset_add(XPathNodeSet* nodeset, void* node) {
659
659
  if (nodeset->count >= nodeset->capacity) {
660
660
  size_t new_capacity = nodeset->capacity == 0 ? 4 : nodeset->capacity * 2;
661
661
 
662
- /* SAFETY: Check for overflow */
663
- if (new_capacity > 1000000) {
662
+ /* SAFETY: Check for overflow. 4M admits the corpus's
663
+ * million-item range folds (`1 to 1000000` needs a
664
+ * power-of-two capacity ABOVE 1M — the old 1M bound
665
+ * truncated the sequence at exactly 524288). */
666
+ if (new_capacity > 4000000) {
664
667
  return;
665
668
  }
666
669