leptris 1.9.163.5 → 1.9.163.6

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: fc446877d41a4725feaa4ac5a451398395c2b74888ecbe6b89bd15c07586c5b1
4
- data.tar.gz: e86fb2e1ad7af3a32ca07f5fdb93936b88c2910d61618f4c5e3afb9fe8828a21
3
+ metadata.gz: ed19c6383e8e83433ffcc99c8843337eb64ba48ac4f40349d5c5e0a537a31ab9
4
+ data.tar.gz: 3096440c8ba68d7eb54bfb2fd133098c795b025ec74389d09fd09cacd12f6311
5
5
  SHA512:
6
- metadata.gz: 49705259500bd74522dbfbdd2f9703249eed074148f73d7207f430072e531194740232ba6caa27eb7e21d5a67a14f25a06b9fe5b82f1e10fb6b65e394c1f7c2a
7
- data.tar.gz: fbea087c956ce35ba6e30f274db4c9db2f7b194e61e4f78633a9510fe85d6151c35f4758ade380d8a1397d261756fbd415d78b0587b628965b8ca82b9179af5f
6
+ metadata.gz: 5bef6e81be43ea7d536252201a4a59477d6c296a14ef186f9adb28fd304a13fb533218d46ac4d2245e55b724a62d13ab6d39088f82a750bbdfdb797c26c64c93
7
+ data.tar.gz: 9a61e4f09d0df6fc73479e1a25b83a146e77e28bae50b1d4dd82e5ed3b5f10be0e9b879e866d70f1207ac0abeda302d32f92c7b7efb4a27da8ee1958c6c63edd
data/CHANGELOG.md CHANGED
@@ -5,6 +5,46 @@ 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.163.6] - 2026-09-15
9
+
10
+ ### Changed
11
+
12
+ - **Document lifetime in C (TODO.perf/12)**: a TypedData DocHandle
13
+ (dfree -> leptris_document_free) replaces the ObjectSpace
14
+ finalizer whenever the native layer is enabled — no Ruby
15
+ finalizer invocation, no FFI dispatch from finalizer context,
16
+ for every parsed and created document. `Document.create` runs
17
+ as one C dispatch (engine create + ivar-seeded wrapper +
18
+ handle). The proc finalizer remains for `LEPTRIS_NO_NATIVE`.
19
+ - **Structural memos (TODO.perf/13)**: `Document#root`,
20
+ `Node#parent`, `#next_sibling`, `#previous_sibling` keep
21
+ version-stamped memos (~72-82ns hits vs ~300-500ns deriving,
22
+ measured under host load 17). Fixes a real FFI-mode bug: the
23
+ children walk seeded `@parent`, and a later move left the stale
24
+ parent answering. Cross-document moves clear the moved node's
25
+ stamps and advance the source document's version; the
26
+ adoption-lift ancestor walk derives unstamped (a stamped
27
+ derive between the version bump and the engine move would
28
+ record post-bump versions carrying pre-move truth); scope-owned
29
+ iterparse elements never memoize.
30
+ - **C-bound insert family (TODO.perf/14)**: `prepend_child`,
31
+ `add_next_sibling`, `add_previous_sibling` dispatch through
32
+ `Native.insert_binding_child` (gates + predicate + version bump
33
+ + engine insert in one call; lift fallback preserved). The
34
+ cold `Element#[]` first-touch fill rides the native face.
35
+ - **Compiled-expression cache (TODO.perf/15)**: plain
36
+ `xpath`/`at_xpath` evaluate a bounded LRU (64) of compiled
37
+ handles — repeat expressions measured 3.7µs -> ~1.9µs (~2x);
38
+ CSS rides it automatically. Failed compiles never cache;
39
+ version-pinned and namespace-bound entries unchanged.
40
+
41
+ ### Added
42
+
43
+ - `Leptris::XML::DocHandle` (internal), `Native.doc_handle_attach/
44
+ release/create_binding_document/insert_binding_child`,
45
+ `XPath#eval_ptrs`, `Searchable.compiled_expression`, and
46
+ `Node#unstamped_parent` (protected ancestor-walk seam).
47
+
8
48
  ## [1.9.163.5] - 2026-09-15
9
49
 
10
50
  ### Changed
@@ -0,0 +1,47 @@
1
+ # 12 — Document lifetime in C: TypedData handle replaces the ObjectSpace finalizer
2
+
3
+ Status: DONE (1.9.163.6)
4
+
5
+ Every document (parsed AND created) registers an ObjectSpace
6
+ finalizer: a per-document proc allocation (~110ns), the
7
+ define_finalizer call (~315ns), the Freed struct, and — the real
8
+ cost — a Ruby finalizer invocation at GC time that must dispatch
9
+ back through FFI to leptris_document_free (~1-2µs per document
10
+ inside GC pauses). Fresh-document build loops measured 7.1µs per
11
+ Document.create in-loop, ~4-5µs of it GC/finalizer amortization
12
+ (stackprof: GC ~19% of a build loop, define_finalizer+proc in the
13
+ top frames).
14
+
15
+ Fix: a TypedData DocHandle (struct { void *doc; }) whose dfree
16
+ calls leptris_document_free directly — no Ruby finalizer, no FFI
17
+ from finalizer context, no proc. The handle is referenced only by
18
+ the Document's @doc_handle ivar, so its lifetime IS the
19
+ Document's. Document#free keeps its explicit path (FFI free +
20
+ release the handle's pointer so dfree no-ops — the same
21
+ double-free protocol Freed enforces today, in C). The proc
22
+ finalizer stays for LEPTRIS_NO_NATIVE mode.
23
+
24
+ Companion: Native.create_binding_document — the full
25
+ Document.create in one C dispatch (engine create + wrapper
26
+ allocation + @c_ptr/@c_address/@freed/@readonly/@version + handle
27
+ attach). Document.create today pays FFI marshaling + wrap frames
28
+ (~250-600ns) on top of the engine call.
29
+
30
+ Gates: lifetime_contract_spec + memory_spec green in both modes;
31
+ double-free impossible (free → dfire no-op, finalizer-mode
32
+ unchanged); Document.create and fresh-doc build rows improve.
33
+
34
+ ## Outcome (1.9.163.6)
35
+
36
+ DocHandle TypedData (struct { void *doc; }, RUBY_TYPED_FREE_IMMEDIATELY,
37
+ dfree -> leptris_document_free) attached as @doc_handle on every
38
+ wrap() when native is enabled; Document#free detaches it (dfree
39
+ no-ops). Native.create_binding_document does engine create +
40
+ ivar-seeded wrapper + handle in one dispatch (Document.create
41
+ routes through it). Freed stays the shared free-state; the proc
42
+ finalizer remains for LEPTRIS_NO_NATIVE. 3,000-doc GC-reclamation
43
+ and explicit-free-then-GC specs green; 647/0 both modes. Measured
44
+ under host load 14-30: GC-amortized Document.create ~5.0us (the
45
+ Ruby-finalizer invocation and FFI-dispatch-at-GC are gone from
46
+ every document lifecycle; clean-load numbers pending the battery
47
+ — the box never dropped under load 13 this session).
@@ -0,0 +1,49 @@
1
+ # 13 — Version-stamped structural memos: root/parent/siblings (+ FFI-mode stale-@parent fix)
2
+
3
+ Status: DONE (1.9.163.6)
4
+
5
+ Document#root re-derives (FFI + wrap) on every call — every
6
+ pipeline entry pays it. Node#parent and the sibling hops
7
+ (next_sibling/previous_sibling) pay FFI + Node.wrap per call, so
8
+ Nokogiri-style sibling-advancement loops pay ~300ns/hop.
9
+
10
+ BUG found while designing this: in FFI mode the children walk
11
+ seeds @parent, and a later move (add_child to another parent)
12
+ leaves the stale @parent — child.parent answers the OLD parent.
13
+ Native mode masks it (the bulk path does not seed @parent). The
14
+ version-stamped memo fixes both modes: derive once, stamp with
15
+ the owning document's mutation version, re-derive after any
16
+ mutation that bumps it.
17
+
18
+ Design: parent memoizes only for document-owned nodes
19
+ (scope-owned iterparse elements keep deriving — a scope element
20
+ adopted into a document moves without its scope's version
21
+ advancing, so a stamp would lie). Cross-document adoption bumps
22
+ BOTH documents' versions (the source document's other nodes hold
23
+ sibling/parent stamps that the move invalidates); the moved
24
+ node's stamps clear at the mutation site. next_sibling /
25
+ previous_sibling memoize the same way. Document#root memoizes
26
+ against the document's own version (root= bumps it; free clears).
27
+
28
+ Gates: parent correct after same-doc and cross-doc moves in BOTH
29
+ modes (the FFI staleness becomes a spec); sibling chains correct
30
+ around unlink/insert; root identity stable; traversal-loop rows
31
+ improve.
32
+
33
+ ## Outcome (1.9.163.6)
34
+
35
+ Version-stamped memos on Document#root (pure ivar compare, ~82ns
36
+ under load 17), Node#parent / next_sibling / previous_sibling
37
+ (~72-76ns memo hits vs ~300-500ns deriving; @structure_memoizable
38
+ computed once at construction — including the C-constructed
39
+ wrappers — keeping the hit path free of method dispatch). The
40
+ FFI-mode stale-@parent bug is FIXED (spec-pinned: parent correct
41
+ after same-doc and cross-doc moves; the seed stamps only
42
+ constructor-KNOWN parents — nil constructor parent means unknown,
43
+ because the engine may attach during creation, e.g. create_child).
44
+ Cross-document moves clear the moved node's stamps and advance
45
+ the SOURCE document's version. The adoption-lift ancestor walk
46
+ uses Node#unstamped_parent: deriving a stamped parent between the
47
+ version bump and the engine move would record post-bump versions
48
+ carrying pre-move truth. Scope-owned iterparse elements never
49
+ memoize.
@@ -0,0 +1,31 @@
1
+ # 14 — C-bound insert family + cold-[] native fill
2
+
3
+ Status: DONE (1.9.163.6)
4
+
5
+ add_child and []= are C-bound (TODO.perf/08-09); prepend_child,
6
+ add_next_sibling, and add_previous_sibling still run the full
7
+ Ruby path (ensure_writable + eager-namespace guard ordering +
8
+ FFI). One C face with a mode int covers all engine insertions
9
+ (append/prepend/insert_after/insert_before — same
10
+ int(void*, void*) ABI): gates + predicate + version bump + engine
11
+ insert in one dispatch, Qnil fallback when the child needs the
12
+ namespace lift, exactly like append_binding_child.
13
+
14
+ Also: the completely-cold branch of Element#[] fills through
15
+ leptris_element_attribute (FFI) while the partial-memo branch
16
+ above it uses the native face — the cold path should ride the
17
+ native face too (~150ns per first-touch attribute read).
18
+
19
+ Gates: insert semantics unchanged (specs exist for sibling
20
+ inserts); namespaced children still lift; readonly raises.
21
+
22
+ ## Outcome (1.9.163.6)
23
+
24
+ Native.insert_binding_child(document, anchor, child, mode): 1
25
+ prepend / 2 after / 3 before — gates + predicate + version bump +
26
+ engine insert in one dispatch, Qnil fallback to the Ruby lift
27
+ path. prepend_child / add_next_sibling / add_previous_sibling
28
+ carry the fast branch + cross-document invalidation. The
29
+ completely-cold Element#[] first-touch fill rides the native face
30
+ (matching the partial-memo branch). Specs: ordering, lift
31
+ fallback, readonly raise.
@@ -0,0 +1,35 @@
1
+ # 15 — Compiled-expression cache for Searchable#xpath / #at_xpath
2
+
3
+ Status: DONE (1.9.163.6)
4
+
5
+ Searchable#xpath passes the expression STRING to
6
+ leptris_xpath_eval each call. The engine caches compiled
7
+ expressions by string (measured: repeat-string eval 7.0µs vs
8
+ varied-string 34.98µs — the ~28µs is per-call parsing), but the
9
+ string path still pays cache lookup + hashing per call. A
10
+ compiled handle evaluated directly measured 4.58µs on the same
11
+ shape — 34% under the string path.
12
+
13
+ The binding already exposes XPath.compile (parse once, eval
14
+ many). Add a bounded LRU keyed on the expression string inside
15
+ Searchable: plain (no version pin, no ns bindings) xpath/at_xpath
16
+ evaluations route through cached compiled handles. Cache only
17
+ successful compiles; bounded (~64 entries, Hash#shift eviction);
18
+ GVL makes the Hash ops safe.
19
+
20
+ Gates: xpath/at_xpath results identical (nodesets, scalars,
21
+ empty results); invalid expressions still raise XPathError;
22
+ namespace-bound and version-pinned paths unchanged; repeat-call
23
+ rows improve ~30%.
24
+
25
+ ## Outcome (1.9.163.6)
26
+
27
+ Searchable.compiled_expression: bounded LRU (64 entries, Hash#shift
28
+ eviction, LRU refresh on hit) keyed on the expression string;
29
+ plain xpath/at_xpath evaluate cached compiled handles via
30
+ XPath#eval_ptrs (leptris_xpath_compiled_eval). Failed compiles
31
+ never cache (fall back to the string entry so the error surface
32
+ is unchanged); version-pinned and namespace-bound paths keep
33
+ their dedicated entries. Measured: repeat at_xpath 3.7us ->
34
+ 1.9us under load 17-30 (~2x); CSS rides it automatically
35
+ (deterministic translation -> same key).
@@ -74,6 +74,7 @@ static elem_create_fn f_elem_create;
74
74
  static text_create_fn f_text_create;
75
75
  static create_child_fn f_create_child;
76
76
  static append_child_fn f_append_child;
77
+ static append_child_fn f_prepend_child, f_insert_after, f_insert_before;
77
78
  static set_attr_fn f_set_attr;
78
79
  static set_root_fn f_set_root;
79
80
  static doc_free_fn f_doc_free;
@@ -171,13 +172,17 @@ static void resolve_symbols(const char *lib_path)
171
172
  f_text_create = (text_create_fn)lib_sym(h, "leptris_text_node_create");
172
173
  f_create_child = (create_child_fn)lib_sym(h, "leptris_element_create_child");
173
174
  f_append_child = (append_child_fn)lib_sym(h, "leptris_element_append_child");
175
+ f_prepend_child = (append_child_fn)lib_sym(h, "leptris_element_prepend_child");
176
+ f_insert_after = (append_child_fn)lib_sym(h, "leptris_element_insert_after");
177
+ f_insert_before = (append_child_fn)lib_sym(h, "leptris_element_insert_before");
174
178
  f_set_attr = (set_attr_fn)lib_sym(h, "leptris_element_set_attribute");
175
179
  f_set_root = (set_root_fn)lib_sym(h, "leptris_document_set_root");
176
180
  f_doc_free = (doc_free_fn)lib_sym(h, "leptris_document_free");
177
181
  if (!f_elem_name || !f_text_content || !f_attr ||
178
182
  !f_children_ex || !f_node_type || !f_next_sibling || !f_parent ||
179
183
  !f_element_text || !f_doc_create || !f_elem_create || !f_text_create ||
180
- !f_create_child || !f_append_child || !f_set_attr || !f_set_root ||
184
+ !f_create_child || !f_append_child || !f_prepend_child ||
185
+ !f_insert_after || !f_insert_before || !f_set_attr || !f_set_root ||
181
186
  !f_doc_free ||
182
187
  !f_elem_prefix || !f_xp_count || !f_xp_nodes_ex ||
183
188
  !f_xp_node_kind || !f_xp_node_name || !f_xp_node_value ||
@@ -602,6 +607,48 @@ static VALUE nf_set_binding_attribute(VALUE self, VALUE document,
602
607
  return INT2FIX(st);
603
608
  }
604
609
 
610
+ /* C-bound insert family (TODO.perf/14): prepend / insert-after /
611
+ * insert-before share append's gates + predicate + bump shape.
612
+ * mode: 1 prepend, 2 after, 3 before (anchor = receiver). */
613
+ static VALUE nf_insert_binding_child(VALUE self, VALUE document,
614
+ VALUE anchor_addr, VALUE child_addr,
615
+ VALUE mode)
616
+ {
617
+ void *anchor, *child;
618
+ const char *uri;
619
+ int st, m;
620
+
621
+ (void)self;
622
+ resolve_binding_classes();
623
+ if (NIL_P(rb_ivar_get(document, id_iv_c_address)))
624
+ rb_raise(c_use_after_free_error,
625
+ "owning document has been freed");
626
+ if (rb_ivar_get(document, id_iv_readonly) == Qtrue)
627
+ rb_raise(c_readonly_error,
628
+ "document is readonly — mutation attempted");
629
+ anchor = (void *)(uintptr_t)NUM2ULL(anchor_addr);
630
+ child = (void *)(uintptr_t)NUM2ULL(child_addr);
631
+ m = FIX2INT(mode);
632
+ if (f_node_type(child) == NT_ELEMENT) {
633
+ if (f_elem_ns_count(child) > 0)
634
+ return Qnil;
635
+ uri = f_elem_ns(child);
636
+ if (uri && *uri)
637
+ return Qnil;
638
+ }
639
+ rb_ivar_set(document, id_iv_version,
640
+ LONG2FIX(FIX2LONG(rb_ivar_get(document, id_iv_version)) + 1));
641
+ switch (m) {
642
+ case 1: st = f_prepend_child(anchor, child); break;
643
+ case 2: st = f_insert_after(anchor, child); break;
644
+ case 3: st = f_insert_before(anchor, child); break;
645
+ default:
646
+ rb_raise(rb_eArgError, "invalid insert mode %d", m);
647
+ return Qnil;
648
+ }
649
+ return INT2FIX(st);
650
+ }
651
+
605
652
  /* Builder factories (#149): create in C, wrap as NativeNode — no
606
653
  * FFI::Pointer, no Ruby wrap_fresh path. */
607
654
  static VALUE nn_create_element(VALUE klass, VALUE document, VALUE name)
@@ -726,7 +773,7 @@ static VALUE nn_address(VALUE self)
726
773
  * disappear. */
727
774
  static VALUE c_b_element = Qundef, c_b_text, c_b_comment, c_b_cdata,
728
775
  c_b_pi, c_b_node, c_b_result_text, c_b_result_attr,
729
- c_b_attr, c_ffi_pointer;
776
+ c_b_attr, c_ffi_pointer, c_b_document, c_b_freed;
730
777
  static ID id_ptr_new;
731
778
 
732
779
  /* Binding classes resolve LAZILY: Init_native can run before the
@@ -746,6 +793,8 @@ static void resolve_binding_classes(void)
746
793
  c_b_result_attr = rb_path2class("Leptris::XML::ResultAttr");
747
794
  c_ffi_pointer = rb_path2class("FFI::Pointer");
748
795
  c_b_attr = rb_path2class("Leptris::XML::Attr");
796
+ c_b_document = rb_path2class("Leptris::XML::Document");
797
+ c_b_freed = rb_path2class("Leptris::XML::Document::Freed");
749
798
  id_ptr_new = rb_intern("new");
750
799
  rb_gc_register_mark_object(c_b_element);
751
800
  rb_gc_register_mark_object(c_b_text);
@@ -757,6 +806,8 @@ static void resolve_binding_classes(void)
757
806
  rb_gc_register_mark_object(c_b_result_attr);
758
807
  rb_gc_register_mark_object(c_ffi_pointer);
759
808
  rb_gc_register_mark_object(c_b_attr);
809
+ rb_gc_register_mark_object(c_b_document);
810
+ rb_gc_register_mark_object(c_b_freed);
760
811
  }
761
812
 
762
813
  static VALUE binding_klass_for(int kind)
@@ -801,6 +852,7 @@ static VALUE bulk_children_impl(VALUE document, VALUE parent_addr,
801
852
  rb_iv_set(node, "@c_ptr", ptr);
802
853
  rb_iv_set(node, "@document", document);
803
854
  rb_iv_set(node, "@parent", Qnil);
855
+ rb_iv_set(node, "@structure_memoizable", Qtrue);
804
856
  rb_iv_set(node, "@node_type", INT2FIX(kinds[i]));
805
857
  rb_hash_aset(cache, key, node);
806
858
  }
@@ -874,6 +926,7 @@ static VALUE nf_bulk_xpath(VALUE self, VALUE document, VALUE result_ptr_val)
874
926
  rb_iv_set(node, "@c_ptr", ptr);
875
927
  rb_iv_set(node, "@document", document);
876
928
  rb_iv_set(node, "@parent", Qnil);
929
+ rb_iv_set(node, "@structure_memoizable", Qtrue);
877
930
  rb_iv_set(node, "@node_type", INT2FIX(0));
878
931
  rb_hash_aset(cache, key, node);
879
932
  }
@@ -913,6 +966,7 @@ static VALUE nf_bulk_xpath(VALUE self, VALUE document, VALUE result_ptr_val)
913
966
  rb_iv_set(node, "@c_ptr", ptr);
914
967
  rb_iv_set(node, "@document", document);
915
968
  rb_iv_set(node, "@parent", Qnil);
969
+ rb_iv_set(node, "@structure_memoizable", Qtrue);
916
970
  rb_iv_set(node, "@node_type", INT2FIX(nt));
917
971
  rb_hash_aset(cache, key, node);
918
972
  }
@@ -972,6 +1026,7 @@ static VALUE nf_create_binding_element(VALUE self, VALUE document,
972
1026
  ULL2NUM((uint64_t)(uintptr_t)ptr)));
973
1027
  rb_iv_set(node, "@document", document);
974
1028
  rb_iv_set(node, "@parent", Qnil);
1029
+ rb_iv_set(node, "@structure_memoizable", Qtrue);
975
1030
  rb_iv_set(node, "@node_type", INT2FIX(0));
976
1031
  cache = binding_cache_of(document);
977
1032
  key = ULL2NUM((uint64_t)(uintptr_t)ptr);
@@ -996,6 +1051,7 @@ static VALUE nf_create_binding_text(VALUE self, VALUE document,
996
1051
  ULL2NUM((uint64_t)(uintptr_t)ptr)));
997
1052
  rb_iv_set(node, "@document", document);
998
1053
  rb_iv_set(node, "@parent", Qnil);
1054
+ rb_iv_set(node, "@structure_memoizable", Qtrue);
999
1055
  rb_iv_set(node, "@node_type", INT2FIX(1));
1000
1056
  cache = binding_cache_of(document);
1001
1057
  key = ULL2NUM((uint64_t)(uintptr_t)ptr);
@@ -1113,6 +1169,99 @@ static VALUE nf_fast_element_xml(VALUE self, VALUE addr,
1113
1169
  * predicate answers in ONE dispatch: false when the node carries
1114
1170
  * no namespace declarations AND its name has no prefix — the
1115
1171
  * common programmatic-build shape (bare names, no namespaces). */
1172
+ /* ---- Document lifetime in C (TODO.perf/12) ----------------------
1173
+ * A TypedData handle holding the C document pointer, referenced
1174
+ * only by the binding Document's @doc_handle ivar — its lifetime
1175
+ * IS the document's. dfree releases the C document directly: no
1176
+ * Ruby finalizer, no FFI dispatch from finalizer context.
1177
+ * Document#free releases the pointer first, so dfree no-ops —
1178
+ * the same double-free protocol the Freed struct enforces on the
1179
+ * Ruby-finalizer path (which stays for LEPTRIS_NO_NATIVE). */
1180
+ struct doc_handle {
1181
+ void *doc;
1182
+ };
1183
+
1184
+ static void dh_free(void *p)
1185
+ {
1186
+ struct doc_handle *h = p;
1187
+ if (h->doc) {
1188
+ f_doc_free(h->doc);
1189
+ h->doc = NULL;
1190
+ }
1191
+ }
1192
+
1193
+ static size_t dh_size(const void *p)
1194
+ {
1195
+ (void)p;
1196
+ return sizeof(struct doc_handle);
1197
+ }
1198
+
1199
+ static const rb_data_type_t dh_type = {
1200
+ "Leptris/XML/DocHandle",
1201
+ { 0, dh_free, dh_size, },
1202
+ 0, 0, RUBY_TYPED_FREE_IMMEDIATELY
1203
+ };
1204
+
1205
+ static VALUE c_doc_handle;
1206
+ static ID id_iv_doc_handle, id_freed_new, id_alive;
1207
+
1208
+ static VALUE nf_doc_handle_attach(VALUE self, VALUE document)
1209
+ {
1210
+ struct doc_handle *h;
1211
+ VALUE handle;
1212
+
1213
+ (void)self;
1214
+ handle = TypedData_Make_Struct(c_doc_handle, struct doc_handle,
1215
+ &dh_type, h);
1216
+ h->doc = doc_ptr_of(document);
1217
+ rb_ivar_set(document, id_iv_doc_handle, handle);
1218
+ return handle;
1219
+ }
1220
+
1221
+ /* Document#free already released the C memory through the FFI
1222
+ * seam; detach so the GC-pass dfree no-ops. */
1223
+ static VALUE nf_doc_handle_release(VALUE self, VALUE document)
1224
+ {
1225
+ VALUE handle = rb_ivar_get(document, id_iv_doc_handle);
1226
+ (void)self;
1227
+ if (handle != Qnil) {
1228
+ struct doc_handle *h;
1229
+ TypedData_Get_Struct(handle, struct doc_handle, &dh_type, h);
1230
+ h->doc = NULL;
1231
+ }
1232
+ return Qnil;
1233
+ }
1234
+
1235
+ /* The full Document.create in one dispatch: engine create,
1236
+ * binding wrapper (ivar-seeded, bypassing initialize), and the
1237
+ * lifetime handle. Returns Qnil when the engine refuses. */
1238
+ static VALUE nf_create_binding_document(VALUE self)
1239
+ {
1240
+ void *doc;
1241
+ VALUE document, addr, handle, freed;
1242
+ struct doc_handle *h;
1243
+
1244
+ (void)self;
1245
+ resolve_binding_classes();
1246
+ doc = f_doc_create();
1247
+ if (!doc)
1248
+ return Qnil;
1249
+ addr = ULL2NUM((uint64_t)(uintptr_t)doc);
1250
+ document = rb_obj_alloc(c_b_document);
1251
+ rb_iv_set(document, "@c_ptr",
1252
+ rb_funcall(c_ffi_pointer, id_ptr_new, 1, addr));
1253
+ rb_iv_set(document, "@c_address", addr);
1254
+ freed = rb_funcall(c_b_freed, id_freed_new, 1, ID2SYM(id_alive));
1255
+ rb_iv_set(document, "@freed", freed);
1256
+ rb_iv_set(document, "@readonly", Qfalse);
1257
+ rb_iv_set(document, "@version", INT2FIX(0));
1258
+ handle = TypedData_Make_Struct(c_doc_handle, struct doc_handle,
1259
+ &dh_type, h);
1260
+ h->doc = doc;
1261
+ rb_ivar_set(document, id_iv_doc_handle, handle);
1262
+ return document;
1263
+ }
1264
+
1116
1265
  /* ---- Address-based fast readers (TODO.perf/01) -----------------
1117
1266
  * The DEFAULT binding classes call these when the bundle is
1118
1267
  * loaded: one C-API dispatch + rb_utf8_str_new_cstr — no FFI
@@ -1198,6 +1347,9 @@ void Init_native(void)
1198
1347
  id_iv_nn_content_ver = rb_intern("@nn_content_ver");
1199
1348
  id_iv_nn_attrs = rb_intern("@nn_attrs");
1200
1349
  id_iv_nn_attrs_ver = rb_intern("@nn_attrs_ver");
1350
+ id_iv_doc_handle = rb_intern("@doc_handle");
1351
+ id_freed_new = rb_intern("new");
1352
+ id_alive = rb_intern("alive");
1201
1353
  c_readonly_error = rb_path2class("Leptris::XML::ReadOnlyError");
1202
1354
  c_use_after_free_error =
1203
1355
  rb_path2class("Leptris::XML::UseAfterFreeError");
@@ -1240,6 +1392,16 @@ void Init_native(void)
1240
1392
  nf_append_binding_child, 3);
1241
1393
  rb_define_module_function(m_native, "set_binding_attribute",
1242
1394
  nf_set_binding_attribute, 4);
1395
+ rb_define_module_function(m_native, "insert_binding_child",
1396
+ nf_insert_binding_child, 4);
1397
+ rb_define_module_function(m_native, "doc_handle_attach",
1398
+ nf_doc_handle_attach, 1);
1399
+ rb_define_module_function(m_native, "doc_handle_release",
1400
+ nf_doc_handle_release, 1);
1401
+ rb_define_module_function(m_native, "create_binding_document",
1402
+ nf_create_binding_document, 0);
1403
+ c_doc_handle = rb_define_class_under(m_xml, "DocHandle", rb_cObject);
1404
+ rb_undef_alloc_func(c_doc_handle);
1243
1405
  rb_define_module_function(m_native, "bulk_attributes", nf_bulk_attributes, 1);
1244
1406
  rb_define_module_function(m_native, "bulk_attr_faces",
1245
1407
  nf_bulk_attr_faces, 2);
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Leptris
4
- VERSION = "1.9.163.5"
4
+ VERSION = "1.9.163.6"
5
5
  end
@@ -129,6 +129,15 @@ class Leptris::XML::Document
129
129
  # pool. Elements for the tree are created against it via
130
130
  # #create_element and friends, then attached with #root=.
131
131
  def self.create
132
+ # One C dispatch (TODO.perf/12): engine create + wrapper
133
+ # (ivar-seeded) + lifetime handle. The FFI+wrap shape stays
134
+ # for LEPTRIS_NO_NATIVE.
135
+ if defined?(Leptris::XML::NATIVE_FAST)
136
+ doc = Leptris::XML::Native.create_binding_document
137
+ raise Leptris::XML::Error,
138
+ "leptris_document_create failed" if doc.nil?
139
+ return doc
140
+ end
132
141
  raw = Leptris::XML::FFI.leptris_document_create
133
142
  raise Leptris::XML::Error,
134
143
  "leptris_document_create failed" if raw.null?
@@ -178,7 +187,14 @@ class Leptris::XML::Document
178
187
  ptr = ::FFI::Pointer.new(addr)
179
188
  freed = Freed.new(:alive)
180
189
  doc = new(ptr, freed)
181
- ObjectSpace.define_finalizer(doc, finalizer(addr, freed))
190
+ if defined?(Leptris::XML::NATIVE_FAST)
191
+ # TypedData dfree owns the release at GC (TODO.perf/12): no
192
+ # Ruby finalizer, no FFI dispatch from finalizer context.
193
+ # Freed stays the shared free-state for #freed?/#free.
194
+ Leptris::XML::Native.doc_handle_attach(doc)
195
+ else
196
+ ObjectSpace.define_finalizer(doc, finalizer(addr, freed))
197
+ end
182
198
  doc
183
199
  end
184
200
 
@@ -194,9 +210,16 @@ class Leptris::XML::Document
194
210
  def root
195
211
  raise Leptris::XML::UseAfterFreeError if @freed.state == :freed
196
212
  return nil if @c_ptr.nil?
213
+ # Version-stamped memo (TODO.perf/13): root changes only
214
+ # through mutations that advance @version (root=, unlink) or
215
+ # #free (which nils @c_ptr above) — the entry point of every
216
+ # pipeline stops paying FFI + wrap per call.
217
+ return @root if @root_version == @version
197
218
  ptr = Leptris::XML::FFI.leptris_document_root(@c_ptr)
198
- return nil if ptr.null?
199
- Leptris::XML::Node.wrap(ptr, self)
219
+ result = ptr.null? ? nil : Leptris::XML::Node.wrap(ptr, self)
220
+ @root = result
221
+ @root_version = @version
222
+ result
200
223
  end
201
224
 
202
225
  # The document node — navigation head over the whole tree chain
@@ -268,6 +291,12 @@ class Leptris::XML::Document
268
291
  Leptris::XML::FFI.check_status(
269
292
  Leptris::XML::FFI.leptris_document_set_root(@c_ptr, element.c_ptr))
270
293
  @version += 1
294
+ # Seed the root memo through wrap: a cross-document element
295
+ # must enter THIS document's identity cache with @document
296
+ # pointing here, not ride its source-document wrapper.
297
+ @root = Leptris::XML::Node.wrap(element.c_ptr, self)
298
+ @root_version = @version
299
+ Leptris::XML::Node.invalidate_cross_document!(element, self)
271
300
  element
272
301
  end
273
302
 
@@ -391,6 +420,9 @@ class Leptris::XML::Document
391
420
  @c_ptr = nil
392
421
  @c_address = nil
393
422
  @wrapper_cache&.clear
423
+ # Detach the lifetime handle (TODO.perf/12): the memory is
424
+ # already released above — the GC-pass dfree must no-op.
425
+ Leptris::XML::Native.doc_handle_release(self) if defined?(Leptris::XML::NATIVE_FAST)
394
426
  end
395
427
 
396
428
  # Enable the first-party EXSLT-style extension pack on this
@@ -87,12 +87,16 @@ class Leptris::XML::Element < Leptris::XML::Node
87
87
  values[name] = v
88
88
  return v
89
89
  end
90
- # Completely cold: one FFI, start a partial values hash.
90
+ # Completely cold: one read, start a partial values hash.
91
91
  # Drop any full-face memos from a prior version — otherwise a
92
92
  # post-mutation cold [] would stamp the new version onto a
93
93
  # stale @attributes hash and attributes would serve it.
94
94
  ensure_alive!
95
- v = Leptris::XML::FFI.leptris_element_attribute(@c_ptr, name)
95
+ v = if native_fast?
96
+ Leptris::XML::Native.fast_attribute(@c_ptr.address, name)
97
+ else
98
+ Leptris::XML::FFI.leptris_element_attribute(@c_ptr, name)
99
+ end
96
100
  @attr_values = { name => v }
97
101
  @attributes = nil
98
102
  @attribute_nodes = nil
@@ -315,32 +319,71 @@ class Leptris::XML::Element < Leptris::XML::Node
315
319
  end
316
320
 
317
321
  def prepend_child(node)
322
+ # C-bound insert (TODO.perf/14): gates + predicate + version
323
+ # bump + engine insert in one dispatch; Qnil = the child needs
324
+ # the namespace lift — the full path below handles it.
325
+ if native_fast_children?
326
+ st = Leptris::XML::Native.insert_binding_child(
327
+ @document, @c_ptr.address, node.c_ptr.address, 1)
328
+ unless st.nil?
329
+ Leptris::XML::FFI.check_status(st)
330
+ Leptris::XML::Node.invalidate_cross_document!(node, @document)
331
+ return node
332
+ end
333
+ end
318
334
  ensure_writable!
319
335
  unless Leptris::XML::Element.skip_adoption_lift?(node)
320
336
  Leptris::XML::Element.lift_namespaces_for_adoption(node, namespaces)
321
337
  end
322
338
  Leptris::XML::FFI.check_status(
323
339
  Leptris::XML::FFI.leptris_element_prepend_child(@c_ptr, node.c_ptr))
340
+ Leptris::XML::Node.invalidate_cross_document!(node, @document)
324
341
  node
325
342
  end
326
343
 
327
344
  def add_next_sibling(node)
345
+ # C-bound insert (TODO.perf/14): gates + predicate + version
346
+ # bump + engine insert in one dispatch; Qnil = the child needs
347
+ # the namespace lift — the full path below handles it.
348
+ if native_fast_children?
349
+ st = Leptris::XML::Native.insert_binding_child(
350
+ @document, @c_ptr.address, node.c_ptr.address, 2)
351
+ unless st.nil?
352
+ Leptris::XML::FFI.check_status(st)
353
+ Leptris::XML::Node.invalidate_cross_document!(node, @document)
354
+ return node
355
+ end
356
+ end
328
357
  ensure_writable!
329
358
  unless Leptris::XML::Element.skip_adoption_lift?(node)
330
359
  Leptris::XML::Element.lift_namespaces_for_adoption(node, namespaces)
331
360
  end
332
361
  Leptris::XML::FFI.check_status(
333
362
  Leptris::XML::FFI.leptris_element_insert_after(@c_ptr, node.c_ptr))
363
+ Leptris::XML::Node.invalidate_cross_document!(node, @document)
334
364
  node
335
365
  end
336
366
 
337
367
  def add_previous_sibling(node)
368
+ # C-bound insert (TODO.perf/14): gates + predicate + version
369
+ # bump + engine insert in one dispatch; Qnil = the child needs
370
+ # the namespace lift — the full path below handles it.
371
+ if native_fast_children?
372
+ st = Leptris::XML::Native.insert_binding_child(
373
+ @document, @c_ptr.address, node.c_ptr.address, 3)
374
+ unless st.nil?
375
+ Leptris::XML::FFI.check_status(st)
376
+ Leptris::XML::Node.invalidate_cross_document!(node, @document)
377
+ return node
378
+ end
379
+ end
338
380
  ensure_writable!
339
381
  unless Leptris::XML::Element.skip_adoption_lift?(node)
340
382
  Leptris::XML::Element.lift_namespaces_for_adoption(node, namespaces)
341
383
  end
342
384
  Leptris::XML::FFI.check_status(
343
385
  Leptris::XML::FFI.leptris_element_insert_before(@c_ptr, node.c_ptr))
386
+ Leptris::XML::Node.invalidate_cross_document!(node, @document)
344
387
  node
345
388
  end
346
389
 
@@ -437,6 +480,7 @@ class Leptris::XML::Element < Leptris::XML::Node
437
480
  @document, @c_ptr.address, node_or_markup.c_ptr.address)
438
481
  unless st.nil?
439
482
  Leptris::XML::FFI.check_status(st)
483
+ Leptris::XML::Node.invalidate_cross_document!(node_or_markup, @document)
440
484
  return node_or_markup
441
485
  end
442
486
  end
@@ -445,6 +489,7 @@ class Leptris::XML::Element < Leptris::XML::Node
445
489
  end
446
490
  Leptris::XML::FFI.check_status(
447
491
  Leptris::XML::FFI.leptris_element_append_child(@c_ptr, node_or_markup.c_ptr))
492
+ Leptris::XML::Node.invalidate_cross_document!(node_or_markup, @document)
448
493
  node_or_markup
449
494
  when String
450
495
  frag = Leptris::XML::DocumentFragment.parse(node_or_markup, @document)
@@ -546,7 +591,10 @@ class Leptris::XML::Element < Leptris::XML::Node
546
591
  key = ns.prefix ? "xmlns:#{ns.prefix}" : "xmlns"
547
592
  scopes[key] ||= ns.href
548
593
  end
549
- node = node.parent
594
+ # unstamped: this walk also runs inside the adoption lift,
595
+ # between the version bump and the engine move (see
596
+ # Node#unstamped_parent).
597
+ node = node.unstamped_parent
550
598
  end
551
599
  if @document
552
600
  @namespaces = scopes
@@ -14,6 +14,19 @@ class Leptris::XML::Node
14
14
  @c_ptr = c_ptr
15
15
  @document = document
16
16
  @parent = parent
17
+ # Structural-memo stamp (TODO.perf/13): a constructor-seeded
18
+ # @parent is true AS OF the document's current version; any
19
+ # mutation advances the version and forces re-derivation. A
20
+ # NIL constructor parent means UNKNOWN, not "no parent" — the
21
+ # engine may attach during creation (create_child), so an
22
+ # unstamped memo must derive rather than trust nil.
23
+ @parent_version = parent ? document&.version : nil
24
+ # Computed once: structural stamps apply to document-owned
25
+ # nodes only (scope-owned iterparse elements move without
26
+ # their scope's version advancing — see #parent). An ivar
27
+ # keeps the memo-hit path free of method dispatch.
28
+ @structure_memoizable =
29
+ !document.nil? && !document.is_a?(Leptris::XML::IterationScope)
17
30
  # wrap() already calls leptris_node_get_type for dispatch; reusing
18
31
  # the result makes every predicate and #type call FFI-free.
19
32
  @node_type = node_type
@@ -157,12 +170,60 @@ class Leptris::XML::Node
157
170
  end
158
171
  alias_method :pi?, :processing_instruction?
159
172
 
173
+ # Version-stamped structural memo (TODO.perf/13): derive once,
174
+ # re-derive after any mutation that advances the owning
175
+ # document's version. Fixes the stale seeded @parent after a
176
+ # move (FFI children walks seed it; the move never cleared it).
177
+ # Scope-owned (iterparse) elements never memoize: a scope element
178
+ # adopted into a document moves without its scope's version
179
+ # advancing, so a stamp would lie.
160
180
  def parent
161
- return @parent if @parent
181
+ if @structure_memoizable && @parent_version == @document.version
182
+ return @parent
183
+ end
162
184
  ensure_alive!
163
185
  ptr = Leptris::XML::FFI.leptris_node_parent(@c_ptr)
164
- return nil if ptr.null?
165
- Leptris::XML::Node.wrap(ptr, @document)
186
+ result = ptr.null? ? nil : Leptris::XML::Node.wrap(ptr, @document)
187
+ if @document
188
+ @parent = result
189
+ @parent_version = @document.version
190
+ end
191
+ result
192
+ end
193
+
194
+ # Derive the parent WITHOUT stamping the structural memo. The
195
+ # adoption lift reads the child's source scope BETWEEN the
196
+ # mutation gate's version bump and the engine move — a stamped
197
+ # #parent there would record post-bump versions carrying
198
+ # pre-move truth. Ancestor walks that run inside mutations use
199
+ # this; #namespaces itself is memoized, so stable trees pay the
200
+ # unstamped derivation at most once per version.
201
+ def unstamped_parent
202
+ ensure_alive!
203
+ ptr = Leptris::XML::FFI.leptris_node_parent(@c_ptr)
204
+ ptr.null? ? nil : Leptris::XML::Node.wrap(ptr, @document)
205
+ end
206
+
207
+ # Mutation sites move nodes ACROSS documents: the moved node's
208
+ # stamps reference the source document's version, which the move
209
+ # does not advance. Clear them and advance the source version
210
+ # (its other nodes hold parent/sibling stamps the move invalidates).
211
+ def invalidate_structural_stamps!
212
+ @parent_version = nil
213
+ @next_sibling_version = nil
214
+ @previous_sibling_version = nil
215
+ end
216
+
217
+ # Mutation-site helper: when +node+ moves into +target_document+
218
+ # from a different one, the move invalidates the node's own
219
+ # structural stamps (stamped against the SOURCE version) and the
220
+ # source document's sibling/parent stamps. Scope-owned nodes
221
+ # answer nil from #document and never memoize — nothing to do.
222
+ def self.invalidate_cross_document!(node, target_document)
223
+ source = node.document
224
+ return if source.nil? || source.equal?(target_document)
225
+ node.invalidate_structural_stamps!
226
+ source.advance_version
166
227
  end
167
228
 
168
229
  # Borrowed-handle lifetime: every c_ptr dereference is valid only
@@ -267,18 +328,32 @@ class Leptris::XML::Node
267
328
  end
268
329
 
269
330
  def next_sibling
331
+ if @structure_memoizable && @next_sibling_version == @document.version
332
+ return @next_sibling
333
+ end
270
334
  ensure_alive!
271
335
  ptr = Leptris::XML::FFI.leptris_node_next_sibling(@c_ptr)
272
- return nil if ptr.null?
273
- Leptris::XML::Node.wrap(ptr, @document, parent: @parent)
336
+ result = ptr.null? ? nil : Leptris::XML::Node.wrap(ptr, @document, parent: @parent)
337
+ if @document
338
+ @next_sibling = result
339
+ @next_sibling_version = @document.version
340
+ end
341
+ result
274
342
  end
275
343
  alias_method :next, :next_sibling
276
344
 
277
345
  def previous_sibling
346
+ if @structure_memoizable && @previous_sibling_version == @document.version
347
+ return @previous_sibling
348
+ end
278
349
  ensure_alive!
279
350
  ptr = Leptris::XML::FFI.leptris_node_previous_sibling(@c_ptr)
280
- return nil if ptr.null?
281
- Leptris::XML::Node.wrap(ptr, @document, parent: @parent)
351
+ result = ptr.null? ? nil : Leptris::XML::Node.wrap(ptr, @document, parent: @parent)
352
+ if @document
353
+ @previous_sibling = result
354
+ @previous_sibling_version = @document.version
355
+ end
356
+ result
282
357
  end
283
358
  alias_method :previous, :previous_sibling
284
359
 
@@ -5,6 +5,30 @@ module Leptris::XML::Searchable
5
5
  # mirroring leptris-py#105); anything else is a namespace prefix.
6
6
  VERSION_SELECTORS = ["1.0", "3.1", :xpath10, :xpath31].freeze
7
7
 
8
+ # Compiled-expression cache (TODO.perf/15): the engine caches
9
+ # compiled strings internally, but the string path still pays
10
+ # lookup + hashing per call — a direct handle eval measured 34%
11
+ # under it on repeat expressions. Bounded LRU keyed on the
12
+ # expression string; only successful compiles cache; GVL makes
13
+ # the Hash operations safe. Version-pinned and namespace-bound
14
+ # evaluations keep their dedicated string entries.
15
+ COMPILED_CACHE_LIMIT = 64
16
+
17
+ def self.compiled_expression(expr)
18
+ cache = (@compiled_expressions ||= {})
19
+ if (hit = cache[expr])
20
+ cache.delete(expr)
21
+ cache[expr] = hit # LRU refresh
22
+ return hit
23
+ end
24
+ compiled = Leptris::XML::XPath.compile(expr)
25
+ cache.shift while cache.size >= COMPILED_CACHE_LIMIT
26
+ cache[expr] = compiled
27
+ compiled
28
+ rescue Leptris::XML::XPathError
29
+ nil # fall back to the string path — its error surface is the contract
30
+ end
31
+
8
32
  def xpath(*paths)
9
33
  handler, ns, version = parse_search_args(paths)
10
34
  raise ArgumentError, "custom XPath handlers not supported" if handler
@@ -25,6 +49,8 @@ module Leptris::XML::Searchable
25
49
  Leptris::XML::Searchable.xpath_version_code(version), nil)
26
50
  elsif ns && !ns.empty?
27
51
  xpath_eval_with_namespaces(doc_ptr, context_ptr, expr, ns)
52
+ elsif (compiled = Leptris::XML::Searchable.compiled_expression(expr))
53
+ compiled.eval_ptrs(doc_ptr, context_ptr)
28
54
  else
29
55
  Leptris::XML::FFI.leptris_xpath_eval(doc_ptr, context_ptr, expr)
30
56
  end
@@ -56,6 +82,8 @@ module Leptris::XML::Searchable
56
82
  Leptris::XML::Searchable.xpath_version_code(version), nil)
57
83
  elsif ns && !ns.empty?
58
84
  xpath_eval_with_namespaces(doc_ptr, context_ptr, expr, ns)
85
+ elsif (compiled = Leptris::XML::Searchable.compiled_expression(expr))
86
+ compiled.eval_ptrs(doc_ptr, context_ptr)
59
87
  else
60
88
  Leptris::XML::FFI.leptris_xpath_eval(doc_ptr, context_ptr, expr)
61
89
  end
@@ -32,6 +32,14 @@ class Leptris::XML::XPath
32
32
  new(expression.to_s, CompiledHandle.new(raw), version_code)
33
33
  end
34
34
 
35
+ # Raw-pointer evaluation for Searchable's compiled cache
36
+ # (TODO.perf/15): the same entry the string path uses, against
37
+ # the compiled handle — no per-call parse/cache lookup.
38
+ def eval_ptrs(doc_ptr, context_ptr)
39
+ Leptris::XML::FFI.leptris_xpath_compiled_eval(
40
+ @handle, doc_ptr, context_ptr)
41
+ end
42
+
35
43
  def initialize(expression, handle, version_code = nil)
36
44
  @expression = expression
37
45
  @handle = handle
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.163.5
4
+ version: 1.9.163.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
@@ -80,6 +80,10 @@ files:
80
80
  - TODO.perf/09-adoption-lift-fast-path.md
81
81
  - TODO.perf/10-bulk-attribute-materialization.md
82
82
  - TODO.perf/11-moxml-gap-battery.md
83
+ - TODO.perf/12-document-lifetime-in-c.md
84
+ - TODO.perf/13-structural-memos.md
85
+ - TODO.perf/14-insert-family-c-bound.md
86
+ - TODO.perf/15-compiled-expression-cache.md
83
87
  - TODO.restructure/01-constraint-compliance-audit.md
84
88
  - TODO.restructure/02-deep-copy-seam.md
85
89
  - TODO.restructure/03-evaluation-context-seam.md