Skip to content

Rewrite as Nokogiri-compatible FFI binding for libtaurus v0.5.14 (v0.1.0) - #1

Merged
ronaldtse merged 11 commits into
mainfrom
rewrite/v0.1.0
Aug 11, 2026
Merged

Rewrite as Nokogiri-compatible FFI binding for libtaurus v0.5.14 (v0.1.0)#1
ronaldtse merged 11 commits into
mainfrom
rewrite/v0.1.0

Conversation

@ronaldtse

@ronaldtse ronaldtse commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Rewrite as Nokogiri-compatible FFI binding for libtaurus v0.5.14 (v0.1.0)

Complete rewrite. The C DOM is the single source of truth; Ruby objects are thin FFI wrappers (one Ruby method = one FFI call).

What changed

Added (lib/taurus/xml/):

  • Document, Element, Node, Text, Comment, CDATA, ProcessingInstruction, Attr, Namespace, NodeSet, Searchable, ParseOptions
  • SAX::Parser + SAX::Document handler base class
  • Minimal CSS-to-XPath translator covering the common Nokogiri subset
  • C14N with all modes (canonical 1.0/1.1, exclusive, with-comments, inclusive namespaces) via taurus_c14n_canonicalize_ex / _subtree_ex
  • Document/Element#to_xml, Document#save, Document/Element#canonicalize

Removed:

  • Pure-Ruby XML tree model (lib/taurus/{document,element,node,node_set}.rb)
  • Pure-Ruby XPath engine (lib/taurus/xpath/)
  • Stale bundled C source at ext/taurus/lib/
  • taurus CLI
  • Pure-Ruby adapter framework
  • Thor runtime dependency
  • Old specs in spec/taurus/ that crashed on the new dylib

Build / CI:

  • Gemfile trimmed to ffi + rake + rspec + rubocop
  • taurus.gemspec: spec.executables = [], spec.extensions = [], description updated, thor dropped
  • .github/workflows/test.yml replaced with canon's rake.yml + release.yml (metanorma/ci and relaton/support reusable workflows)
  • lib/libtaurus.dylib removed from git index (was tracked by mistake); users install libtaurus v0.5.14+ themselves

Test results

156 examples, 0 failures, 0 pending. Run with bundle exec rake.

Required external dependency

libtaurus v0.5.14 or later, installed separately. Get it from
https://github.com/lutaml/taurus/releases and place the shared library on
your system's library search path, or set TAURUS_LIB_PATH.

Upstream issue tracker

15 issues filed against lutaml/taurus during this rewrite (all closed in v0.5.0–v0.5.14):

  • v0.5.0: #166–172 (Nokogiri-compatible C API expansion)
  • v0.5.2: #179, #180, #181, #184 (previous_sibling, exclusive C14N, namespace mutation, FlatDoc create_element)
  • v0.5.3: #194 fix path (real exclusive C14N algorithm)
  • v0.5.5: #194 (duplicate xmlns dedup)
  • v0.5.7: #201 (flat XPath dispatcher overmatch)
  • v0.5.13: #213, #216, #217 (child_count, insert_after non-element, append_child unlink)
  • v0.5.14: #222, #223 (namespace read on FlatDoc, node_line)

Review checklist

  • CI workflow (rake.yml) is canon's verbatim — confirm LUTAML_CI_PAT_TOKEN secret is set in repo settings
  • Release workflow (release.yml) is canon's verbatim — confirm LUTAML_CI_RUBYGEMS_API_KEY secret is set
  • taurus.gemspec spec.version is 0.1.0 (from lib/taurus/version.rb)
  • No taurus executable ships (CLI was removed; gem installs as library only)
  • No extconf.rb runs at install — gem assumes libtaurus is pre-installed
  • lib/libtaurus.dylib is NOT shipped (it's in .gitignore); users on Linux/macOS install libtaurus themselves
  • No AI attribution in commit message

After merge

To release v0.1.0:

  1. Merge this PR.
  2. Trigger the release workflow via gh workflow run release.yml -f next_version=0.1.0 (or via the GitHub UI), OR push a v0.1.0 tag.
  3. Verify the gem appears at https://rubygems.org/gems/taurus.

Do NOT run bundle exec rake release directly — it would bypass the release workflow and push to RubyGems without going through the canon reusable-workflow-based pipeline.

…1.0)

Complete rewrite. The C DOM is now the single source of truth; Ruby
objects are thin FFI wrappers (one Ruby method = one FFI call).

Added (lib/taurus/xml/):
- Document, Element, Node, Text, Comment, CDATA, ProcessingInstruction,
  Attr, Namespace, NodeSet, Searchable, ParseOptions
- SAX::Parser + SAX::Document handler base class
- Minimal CSS-to-XPath translator covering the common Nokogiri subset
- C14N with all modes (canonical 1.0/1.1, exclusive, with-comments,
  inclusive namespaces) via taurus_c14n_canonicalize_ex / _subtree_ex
- Document/Element#to_xml, Document#save, Document/Element#canonicalize

Removed:
- Pure-Ruby XML tree model (lib/taurus/{document,element,node,node_set}.rb)
- Pure-Ruby XPath engine (lib/taurus/xpath/)
- Stale bundled C source at ext/taurus/lib/
- taurus CLI (lib/taurus/cli.rb, lib/taurus/commands/)
- Pure-Ruby adapter framework (lib/taurus/adapter*)
- Thor runtime dependency
- spec/taurus/* (old specs that crashed on the new dylib)
- spec/spec_helper.rb's dependency on the broken old entry point

Build / CI:
- Gemfile trimmed to ffi + rake + rspec + rubocop
- taurus.gemspec: spec.executables = [], spec.extensions = [],
  description updated, thor dropped
- .github/workflows/test.yml replaced with canon's rake.yml + release.yml
  (use metanorma/ci and relaton/support reusable workflows)
- lib/libtaurus.dylib removed from index (was tracked by mistake);
  users install libtaurus v0.5.14+ themselves

Specs: 156 passing, 0 pending. Run with 'bundle exec rake'.
…ent parsing, DOCTYPE access

libtaurus v0.6.0/v0.6.1 shipped 4 Nokogiri-compat C-API gaps:
- taurus_element_copy(src, dest_doc) — Element/Node#dup, #clone
- taurus_document_copy(src) — Document#dup, #clone
- taurus_node_get_xpath(node) — Node#path, #css_path
- taurus_parse_fragment(xml, len, dest_doc, status) — Document#fragment,
  Element#add_child with String markup
- taurus_document_internal_subset + taurus_doctype_get_* — DocType class

Ruby binding changes:
- New classes: DocType (name/root_name/public_id/system_id/internal_subset/
  external_id/to_s), DocumentFragment (children, name)
- Node#path, #css_path, #dup, #clone
- Element#dup, #clone, add_child(String) via fragment parsing
- Document#fragment, #dup, #clone, #doctype, #internal_subset

1 new upstream bug filed: #253 — DOCTYPE PUBLIC/SYSTEM/internal_subset
not exposed (only the name comes through). 4 specs marked pending
pending upstream fix.

Specs: 176 passing, 4 pending (all blocked on #253).
Run with: bundle exec ruby -Ilib benchmark/taurus_vs_nokogiri.rb

Findings (libtaurus v0.11.0 vs Nokogiri 1.19.4):
- Parse small (431 B): Taurus 1.99x faster
- Parse medium (12 KB): Taurus 6.51x faster
- XPath count() / boolean() (scalar returns): Taurus 5.45x faster
- XPath predicate (single match): Taurus 12.39x faster
- Serialize: Taurus 2.90x faster
- XPath nodeset-returning (100 nodes): Nokogiri 7x faster
- XPath union (200 nodes): Nokogiri 6x faster
- Tree traversal: Nokogiri 2x faster

Losses attributable to Ruby-side Node/NodeSet wrapper allocation:
- NodeSet.from_result eagerly materializes every match
- Node.wrap creates a new wrapper per c_ptr per call (no cache)
- traverse path allocates a wrapper per visited node

These are addressable without libtaurus work — lazy NodeSet, wrapper
cache, specialized traverse.

1 new upstream bug filed: #256 — taurus_parse_string segfaults under
tight parse loops on ~38 KB docs (memory pool reuse issue).

benchmark/README.md has the full analysis.
v0.11.1 perf is within run-to-run variance of v0.11.0 (no algorithmic
change). The v0.11.1 fix for #256 covers the GC-pressure path but not
the parse+explicit-free path; both still segfault on ~38 KB docs. Added
link to the follow-up comment on #256.
Two independent fixes combined to resolve most of the v0.11.0-era
crashes and gaps:

libtaurus v0.11.2 fixes:
- #253 (DOCTYPE): PUBLIC/SYSTEM identifiers now exposed correctly.
  3 of 4 DOCTYPE specs pass; internal_subset still nil (partial fix).
- #256 (segfault): taurus_node_freeze converted from recursive to
  iterative with fixed 256-deep explicit stack. Partial fix — addresses
  the unbounded-recursion vector but residual issue remains under
  benchmark-ips on 38 KB docs (see #256 follow-up comment).

Ruby binding lifetime refactor (lib/taurus/xml/document.rb):
- Replaced FFI::AutoPointer with plain FFI::Pointer + ObjectSpace
  finalizer sharing a Freed flag container between instance and
  finalizer. AutoPointer double-freed when Document#free was called
  explicitly and then GC ran later — that was the actual root cause
  of the parse-loop crashes attributed to libtaurus #256 in earlier
  runs, not just the C-level stale-thread-local.
- After the refactor, 5000 parse+free iterations on 38 KB docs work
  cleanly. 1000 parses with GC pressure work cleanly. Only
  benchmark-ips (which accumulates return values differently) still
  crashes inside taurus_node_freeze+0x150.

Specs: 176 passing, 1 pending (DOCTYPE internal_subset, #253 partial).
Slight numeric regression vs v0.11.0 (parse small 5.49 -> 9.02 us,
serialize 27.40 -> 33.73 us) is the cost of the explicit finalizer
pattern — ObjectSpace.define_finalizer per-Document is heavier than
FFI::AutoPointer's built-in GC hook. Worth it for correctness.

Benchmark wins preserved:
- Parse medium (12 KB): 7.09x faster than Nokogiri
- XPath count() (scalar): 5.63x faster
- XPath predicate (1 match): 14.03x faster
- Serialize: 2.94x faster
- Parse small (431 B): 1.68x faster
…on 8/9 ops

Two Ruby-side optimizations to close the nodeset-XPath gap without
waiting on libtaurus #262:

1. Lazy NodeSet (lib/taurus/xml/node_set.rb)
   NodeSet.from_result used to walk the entire TaurusXPathResult and
   call Node.wrap for each entry on construction. For a 100-node query
   that was 100x FFI calls + 100x Ruby allocations just to build the
   container. The new code keeps the TaurusXPathResult pointer alive
   via FFI::AutoPointer and materializes self[i] on demand via
   taurus_xpath_result_get.

2. Per-Document Node wrapper cache (lib/taurus/xml/document.rb,
   lib/taurus/xml/node.rb)
   Each Document now owns an ObjectSpace::WeakMap keyed on c_ptr
   address. Node.wrap checks the cache before allocating. Dies with
   the document so no stale entries pointing at freed memory.

Benchmark deltas (libtaurus v0.11.2, M1, Nokogiri 1.19.4):

| Operation                  | Before    | After    | Taurus/Nokogiri |
|----------------------------|-----------|----------|-----------------|
| XPath //book (100 nodes)   | 87.77 us  | 2.70 us  | 0.15x -> 3.43x  |
| XPath union (200 nodes)    | 188.40 us | 10.61 us | 0.14x -> 2.13x  |
| XPath complex              | 126.88 us | 64.54 us | 0.67x -> 1.13x  |
| Tree traversal             | 1203 us   | 777 us   | 0.50x -> 0.61x  |
| Parse small                | 9.02 us   | 6.24 us  | 1.68x -> 1.95x  |

Taurus now beats Nokogiri on 8 of 9 benchmarked operations. Only
tree traversal still loses (by 1.6x) — closing that needs the C-side
back-ref proposal in libtaurus #262.

Specs: 176 passing, 1 pending (DOCTYPE internal_subset, libtaurus #253).
…sues closed

libtaurus v0.12.0 shipped both #262 proposals + finished #253 and #261:
- v0.11.4: taurus_xpath_result_get_nodes (batch accessor)
- v0.12.0: taurus_node_get/set_binding_wrapper (per-node back-ref field)
- v0.12.0: #261 fully resolved (benchmark-ips on 38 KB docs no longer segfaults)
- v0.12.0: #253 internal_subset now exposed (last pending spec removed)

Ruby binding changes:
- FFI declarations added for the 3 new functions (172 total symbols)
- NodeSet#each uses taurus_xpath_result_get_nodes for batch fetch (1 FFI
  call instead of N) when iterating a lazy XPath result
- NodeSet#to_a memoizes after first materialization (subsequent
  iterations hit the cache)
- binding_wrapper field left for non-Ruby FFI bindings — Ruby's
  ObjectSpace::WeakMap cache is faster (no FFI call per lookup). The
  field is opaque to libtaurus and not used by this binding.

Specs: 176 passing, 0 pending. All upstream issues closed (#166-#262,
21 issues total).

Benchmark deltas (median of 3 runs, libtaurus v0.12.0, Nokogiri 1.19.4):
- Parse small: Taurus ~2x faster
- Parse medium (12 KB): Taurus ~6x faster
- XPath count() (scalar): Taurus ~6x faster
- XPath //book (100 nodes): Taurus ~5x faster
- XPath predicate (1 match): Taurus ~10x faster
- XPath complex: Taurus ~1.3x faster
- XPath union (200 nodes): Taurus ~3x faster
- Serialize: Taurus ~3x faster
- Tree traversal: Nokogiri ~1.8x faster (only place Taurus still loses)

Taurus beats Nokogiri on 8 of 9 benchmarked operations. Tree traversal
remains the only loss — Ruby-side wrap-on-visit cost is fundamental;
fixing it needs a specialized read-only traverse path, not libtaurus work.
Previous traverse went through Element#children which built a NodeSet
(Array + Node.wrap per child) per parent. For a tree of N nodes, that's
~N NodeSet allocations + ~N Array allocations on a full traversal.

New traverse walks via raw FFI (taurus_node_first_child +
taurus_node_next_sibling) and wraps each node directly via Node.wrap,
skipping the NodeSet allocation. Same FFI call count, fewer allocations.

Benchmark (median of multiple runs, libtaurus v0.12.0, Nokogiri 1.19.4):
- Before: ~900 µs/iter (0.55x vs Nokogiri, Nokogiri 1.8x faster)
- After:  ~600 µs/iter (0.9x-1.1x vs Nokogiri, roughly tied)
- With GC disabled (theoretical max): 459 µs vs Nokogiri 682 µs (1.49x faster)

The realistic case is noisy because Nokogiri benefits from GC pressure
(it allocates per-visit and the GC pressure happens to align well with
its allocation rate). Taurus with the WeakMap cache allocates once per
unique node, so GC pressure hurts less.

To CONSISTENTLY beat Nokogiri (not just match), needs libtaurus #273
(C-side traverse with callback). Tracked upstream.

176 passing specs, 0 pending.
CI runs on macos-latest, ubuntu-latest, windows-latest × Ruby 3.3/3.4/4.0.
Gemfile.lock was generated locally on macOS arm64 only and rejected by
Bundler on other platforms ('Your bundle only supports arm64-darwin,
x86_64-darwin but your local platform is x86_64-linux'). Adding the
matrix platforms lets bundle install succeed on every CI runner.
The metanorma/ci reusable workflow supports a 'before-setup-ruby' command
input. Using it to download + build libtaurus v0.12.0 on each runner
(macos/ubuntu/windows), then export TAURUS_LIB_PATH so the FFI binding
finds the shared library.

Build flags: shared only, no CLI/tests/benchmarks/man-pages, no optional
deps (utf8proc/iconv) — keeps the install fast and dependency-free.
…ink)

Previous find -type f skipped libtaurus.so on Linux because it's a
symlink to libtaurus.so.0.12.0. Use -type f -o -type l to catch both
real files and symlinks.
@ronaldtse
ronaldtse merged commit 0d9c9f9 into main Aug 11, 2026
9 of 14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant