leptris 1.9.204.0-aarch64-linux → 1.9.208.0-aarch64-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.
Files changed (30) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +19 -19
  3. data/Rakefile +1 -1
  4. data/lib/leptris/version.rb +1 -1
  5. data/lib/leptris/xml/ffi.rb +28 -0
  6. data/lib/libleptris.so +0 -0
  7. data/vendor-src/libleptris/CHANGELOG.md +76 -0
  8. data/vendor-src/libleptris/CMakeLists.txt +1 -1
  9. data/vendor-src/libleptris/TODO.max-perf/00-INDEX.md +3 -3
  10. data/vendor-src/libleptris/TODO.max-perf/2-3-oneshot-tokenizer-design.md +7 -1
  11. data/vendor-src/libleptris/src/include/leptris/error.h +56 -0
  12. data/vendor-src/libleptris/src/include/leptris/types.h +29 -1
  13. data/vendor-src/libleptris/src/leptris/dom/diag.h +2 -13
  14. data/vendor-src/libleptris/src/leptris/dom/digest.c +5 -1
  15. data/vendor-src/libleptris/src/leptris/dom/element_query.c +43 -1
  16. data/vendor-src/libleptris/src/leptris/dom/root_doc_map.c +31 -9
  17. data/vendor-src/libleptris/src/leptris/dom/root_doc_map.h +2 -0
  18. data/vendor-src/libleptris/src/leptris/error.c +22 -0
  19. data/vendor-src/libleptris/src/leptris/flat/direct_parse.c +61 -6
  20. data/vendor-src/libleptris/src/leptris/html/html_parse.c +125 -39
  21. data/vendor-src/libleptris/src/leptris/leptris.c +20 -3
  22. data/vendor-src/libleptris/src/leptris/leptris_internal.h +23 -1
  23. data/vendor-src/libleptris/src/leptris/sax/pull.c +52 -17
  24. data/vendor-src/libleptris/src/leptris/sax/streaming.c +90 -5
  25. data/vendor-src/libleptris/test/dom/test_digest.cpp +36 -0
  26. data/vendor-src/libleptris/test/dom/test_dom.cpp +120 -0
  27. data/vendor-src/libleptris/test/parser/test_parser.cpp +43 -0
  28. data/vendor-src/libleptris/test/sax/test_pull.cpp +32 -0
  29. data/vendor-src/libleptris/vcpkg.json +1 -1
  30. metadata +1 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5fee38974dcf6dec6e9cddf97d4eceef296be7f5ae89ad99b19f839fa352c79b
4
- data.tar.gz: 78c413374da054b8caffc0d83dc64b79f0fec653c662e09100c03967cdf439b0
3
+ metadata.gz: 1fbc5d6ee665722d780cb6f4df91a5a6d9480b4383f57bf6e96da136ac83a9f9
4
+ data.tar.gz: 502302cc31c62cc3c3376084e54ca2c172b63df886f0cbed4454937734129f60
5
5
  SHA512:
6
- metadata.gz: 6f9739606cb33c4238a3cd0abd13a60f00158d063b0e8044983b9ee41c396bc89aec70b9946b72ec99372b5ed3a848527d6e29e8da8b72721f7837c5eea49fb7
7
- data.tar.gz: dd3c0922cb78244ba8d0474bdf7f57d19a0ef85cb89d519d81e7f4f6344824a31c14bc20b0bb73860677682ddc63839507d440214a3e0d817afe97df0ab41e56
6
+ metadata.gz: cb39852ab3c7ace6a69f7195dcd49bcfb82315967d6a697479ac6144b0c895eaef4eabb7f61deb687e3818efd86aa0949d075c7b69173b4f08c45187ce84248d
7
+ data.tar.gz: cfec2fc118e64ea6a0fee7f95053e0a99b6fccc940c43c606b1e1ca0dd207e7affc0a5a7a9c714c009efa6f75ff2e81b7b6aa12c2c356070eb6fbd20048d3ad9
data/CHANGELOG.md CHANGED
@@ -54,25 +54,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
54
54
  ### Not fixed despite upstream closes
55
55
 
56
56
  - **Iterparse attribute reads: all-nil runs on ubuntu-latest Ruby
57
- 3.4 CI legs** — filed upstream as leptris/leptris#1242
58
- (leptris-ruby#279 tracks the investigation). Six strikes; the
59
- last three were consecutive reruns of one job, suggesting
60
- per-machine determinism. Unreproducible across ~75k local reads
61
- on darwin/arm64 (native + FFI, GC.stress) and x86_64 linux
62
- (qemu; native + FFI; threaded and not). The leg sets
63
- `LEPTRIS_SKIP_1242=1` to quarantine exactly that one spec on
64
- that one leg until the engine ships the fix; every other leg
65
- keeps the full guard.
66
-
67
- - **#1220 reopened with the 1.9.203 repro**: HTML-document
68
- mutations are still lost on serialization (the exact original
69
- repro stands; node reads back mutated, serializer sees the old
70
- tree). Tracking continues upstream.
71
- - **#1197 reopened with fresh 1.9.203 evidence**: the s390x qemu
72
- leg failed 442/742 with the identical `malformed input`
73
- signature at ASCII offsets; the reopen quotes the close
74
- comment's own terms. (1.9.204's notes carry no BE fix; the leg
75
- stays experimental until the engine ships one.)
57
+ ## [1.9.207.0] - 2026-09-19
58
+
59
+ ### Fixed — engine sync: big-endian close-tag parse (#1197 upstream)
60
+
61
+ - Vendored libleptris 1.9.207: the close-tag fast-path compare
62
+ masked the wrong end of its 8-byte name load on big-endian hosts,
63
+ rejecting ordinary paired-tag documents with "malformed input"
64
+ (434/727 binding-suite failures under qemu s390x). The mask now
65
+ follows memory order per byte dialect; the s390x-linux-musl
66
+ platform gem becomes viable with this release.
67
+
68
+ ### Added — FFI mirror adopts the 1.9.202–1.9.207 engine surface
69
+
70
+ - audit:symbols lockstep attachments: recover diagnostics
71
+ (leptris_document_parse_diag[_count]), the standalone DTD family
72
+ (leptris_dtd_*), declaration/doctype removal, engine-heap buffer
73
+ allocation, and one-shot SAX. leptris_set_error stays internal —
74
+ the engine now hides it on ELF too (leptris#1243), so the export
75
+ surface agrees across Linux/macOS/Windows.
76
76
 
77
77
  ## [1.9.201.3] - 2026-09-19
78
78
 
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.204"
11
+ LIBLEPTRIS_VERSION = "1.9.208"
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.204.0"
4
+ VERSION = "1.9.208.0"
5
5
  end
@@ -137,6 +137,7 @@ module Leptris
137
137
  typedef :pointer, :leptris_iterparse
138
138
  typedef :pointer, :leptris_xpath_var_set
139
139
  typedef :pointer, :leptris_sax_parser
140
+ typedef :pointer, :leptris_dtd
140
141
  typedef :int, :leptris_status
141
142
 
142
143
  class SAXHandler < ::FFI::Struct
@@ -1156,6 +1157,33 @@ attach_function :leptris_parse_string,
1156
1157
  [:leptris_document, :pointer, :pointer], :leptris_status
1157
1158
  attach_function :leptris_status_string, [:leptris_status], :string
1158
1159
  attach_function :leptris_error_message, [:leptris_status], :string
1160
+ # Engine surface adopted with libleptris 1.9.206 (the FFI
1161
+ # mirror audit keeps attachments and exports in lockstep):
1162
+ # recover diagnostics (#1200), the standalone DTD family
1163
+ # (leptris_dtd_*), declaration/doctype removal (#1094), the
1164
+ # engine-heap buffer (#1219), one-shot SAX (#1178), and the
1165
+ # exported error-recorder entry point.
1166
+ attach_function :leptris_alloc_buffer, [:size_t], :pointer
1167
+ attach_function :leptris_document_clear_declaration,
1168
+ [:leptris_document], :leptris_status
1169
+ attach_function :leptris_document_remove_doctype,
1170
+ [:leptris_document], :leptris_status
1171
+ attach_function :leptris_document_parse_diag_count,
1172
+ [:leptris_document], :size_t
1173
+ attach_function :leptris_document_parse_diag,
1174
+ [:leptris_document, :size_t, :pointer, :pointer, :size_t], :int
1175
+ attach_function :leptris_dtd_parse,
1176
+ [:string, :size_t], :leptris_dtd
1177
+ attach_function :leptris_dtd_free, [:leptris_dtd], :void
1178
+ attach_function :leptris_dtd_parse_external_subset,
1179
+ [:leptris_dtd, :string, :size_t], :int
1180
+ attach_function :leptris_dtd_set_pe_loader,
1181
+ [:leptris_dtd, :pointer, :pointer], :void
1182
+ attach_function :leptris_dtd_validate,
1183
+ [:leptris_document, :leptris_dtd, :pointer], :int
1184
+ attach_function :leptris_dtd_error_free, [:pointer], :void
1185
+ attach_function :leptris_sax_parser_set_one_shot,
1186
+ [:leptris_sax_parser, :int], :void
1159
1187
  # Thread-global last-failure position (error.h): companion to
1160
1188
  # leptris_last_error; populated by recover parses (#547).
1161
1189
  attach_function :leptris_last_error_position,
data/lib/libleptris.so CHANGED
Binary file
@@ -1,5 +1,81 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [1.9.208] - 2026-09-19
4
+
5
+ ### Fixed
6
+
7
+ - Per-machine process-global corruption (#1242): the TLS (root, doc)
8
+ last-hit memo in `leptris_element_get_document` trusted the root
9
+ ADDRESS and was cleared only by document identity — an element-level
10
+ unregistration left `(freed_address, doc)` behind, and a
11
+ recycled address resolved the WRONG document (poisoning attribute
12
+ ownership: whole-run all-NULL `leptris_element_attribute` reads;
13
+ and tripping set_root's cross-document check). The memo is now
14
+ cleared whenever the outgoing root address matches, in both the
15
+ element-level unregister and the document sweep — closing the
16
+ same recycled-address class #1038 fixed for the root-doc map.
17
+ - `leptris_document_set_root` now sets a fresh thread-local error on
18
+ every rejection (NULL_ARG + both EINVAL paths) — previously the
19
+ previous operation's message leaked through (the reported "EINVAL
20
+ with a stale XPath parse error").
21
+ - New diagnostic: `LEPTRIS_DEBUG_ATTR_MISS=1` dumps attribute-chain
22
+ state on lookup misses (chain length, per-attr name bytes/hash15)
23
+ — the discriminator for any residual binding-side corruption
24
+ report.
25
+
26
+
27
+
28
+ ## [1.9.207] - 2026-09-19
29
+
30
+ ### Fixed
31
+
32
+ - stop leptris_set_error leaking into the ELF dynamic table (build)
33
+
34
+
35
+
36
+ ## [1.9.206] - 2026-09-19
37
+
38
+ ### Fixed
39
+
40
+ - endian-neutral close-tag name compare (#1197) (parse)
41
+
42
+ ### Performance
43
+
44
+ - memchr finds the text span end (TODO.max-perf/2-3 slice 3) (sax)
45
+ - zero-copy slice emission for one-shot pulls (#1178 tail) (sax)
46
+
47
+
48
+
49
+ ## [1.9.205] - 2026-09-19
50
+
51
+ ### Added
52
+
53
+ - Parse recover diagnostics (#1200): the DOM parse now records
54
+ duplicate-attribute events on the document — `LeptrisDiagKind`
55
+ (with the new `LEPTRIS_DIAG_RECOVER` kind) is public in
56
+ types.h, and the diag list reads through
57
+ `leptris_document_parse_diag_count` /
58
+ `leptris_document_parse_diag` (error.h). First definition wins,
59
+ matching the SAX lane's libxml2-recover surface; the digest
60
+ gate no longer needs a SAX probe pass.
61
+ - `LEPTRIS_DIGEST_ATTR_ORDER` (#1200 part 1): opt-in digest flag
62
+ that makes attribute order identity-bearing instead of sorted
63
+ away.
64
+
65
+ ### Fixed
66
+
67
+ - MSVC C2027 in `leptris_internal.h`: the `LeptrisDiag` forward
68
+ declaration moved to file scope (broke the windows-dll-exports
69
+ leg).
70
+
71
+ ### Performance
72
+
73
+ - First-char-bucketed tag classifier for the WHATWG HTML path:
74
+ the tokenizer's name-list strcmp scans become one-load bucket
75
+ lookups — parse +31-37% on the #1218 bench shapes.
76
+
77
+
78
+
3
79
  ## [1.9.204] - 2026-09-19
4
80
 
5
81
  ### Added
@@ -3,7 +3,7 @@
3
3
  cmake_minimum_required(VERSION 3.20)
4
4
 
5
5
  project(leptris
6
- VERSION 1.9.204
6
+ VERSION 1.9.208
7
7
  DESCRIPTION "Fast XML parser and XPath evaluator in pure C"
8
8
  LANGUAGES C CXX
9
9
  )
@@ -5,12 +5,12 @@ RED bench first where perf claims are made.
5
5
 
6
6
  | # | Lever | Type | Effort | Status |
7
7
  |---|-------|------|--------|--------|
8
- | 3 | iterparse fixed-cost (6.9µs) + error channel (#563) | perf | bench landed #1195; remaining = zero-copy one-shot emission (design with lever 2) | half done |
8
+ | 3 | iterparse fixed-cost (6.9µs) + error channel (#563) | perf | bench #1195; zero-copy emission landed #1236 (295->52us full-doc); remaining = iterparse per-event DOM construction (0.45->0.15us/event stretch) | mostly done |
9
9
  | 1 | SIMD/memchr HTML tokenizer scans | perf | memchr ×2 (#1176/#1190) + LUT (#1199, +30%/+51%) | DONE v1.9.199 |
10
10
  | 7 | leptris validate CLI + bindings | feature | engine half merged #1198 (--dtd + DTD engine fixes); bindings remain | half done |
11
- | 8 | native XML diff consumer surface | feature | small | next |
11
+ | 8 | native XML diff consumer surface | feature | CLI diff text/JSON/--summary + engine API shipped | DONE (#1184 closed) |
12
12
  | 4 | compact-node micro-opts (non-layout) | perf | audited: TODOs 172/195/Lane18-P3 already in; residual = node-creation cost (node-layout fork, owner call) | parked on fork |
13
- | 2 | compiled-dispatch tokenizer | perf | medium; design together with lever 3's zero-copy emission | queued |
13
+ | 2 | compiled-dispatch tokenizer | perf | slices 1+3 landed #1236; slice 2's class LUT + dense switch pre-existed; residual = iterparse consumer (row 3) | mostly done |
14
14
  | 5 | XSLT 2x per-bench profile rounds (#682) | perf | medium, ongoing | in progress |
15
15
  | 6 | language tails (QT3/dates/fn-items/lane 15) | correctness | medium-large | queued |
16
16
  | 9 | prebuilt-gem 12-platform matrix | release | small, BLOCKED on owner go | blocked |
@@ -1,6 +1,12 @@
1
1
  # Levers 2+3 — compiled-dispatch + zero-copy one-shot emission (design)
2
2
 
3
- Status: DESIGNED (2026-09-19). Implementation slices below.
3
+ Status: LANDED (2026-09-19, PR #1236). Slices 1+3 implemented;
4
+ slice 2's class LUT (`sxs_chartype`) + dense state switch already
5
+ existed. Measured: bench_iterparse full-doc 295->52-63us wall,
6
+ 15.9->74-91 MB/s, 2.54->0.45-0.55us/event. Residual to the 0.15
7
+ stretch = iterparse per-event DOM construction (element create +
8
+ namespace resolution through the public element API), a consumer
9
+ lever, not string copies.
4
10
  Owners: engine repo. Bench: `benchmarks/sax/bench_iterparse.c` (#1195).
5
11
 
6
12
  ## Problem
@@ -96,6 +96,62 @@ LEPTRIS_API void leptris_last_error_position(int* line, int* column);
96
96
  */
97
97
  LEPTRIS_API const char* leptris_document_last_error(LeptrisDocument doc);
98
98
 
99
+ /* ============================================================================
100
+ * Parse diagnostics (#1200): recover-class events recorded on the
101
+ * DOM parse (duplicate attributes today) — the SAX lane's recover
102
+ * error list, unified engine-side so both lanes report the same
103
+ * surface. Documents stay usable; the diagnostics are advisory.
104
+ * ============================================================================ */
105
+
106
+ #ifndef LEPTRIS_API
107
+ # ifdef _WIN32
108
+ # ifdef LEPTRIS_BUILD_SHARED
109
+ # define LEPTRIS_API __declspec(dllexport)
110
+ # elif defined(LEPTRIS_BUILDING_DLL)
111
+ # define LEPTRIS_API __declspec(dllexport)
112
+ # elif defined(LEPTRIS_USE_SHARED)
113
+ # define LEPTRIS_API __declspec(dllimport)
114
+ # else
115
+ # define LEPTRIS_API
116
+ # endif
117
+ # else
118
+ # if defined(__GNUC__) && !defined(__clang__)
119
+ # define LEPTRIS_API __attribute__((visibility("default"), used, \
120
+ externally_visible))
121
+ # else
122
+ # define LEPTRIS_API __attribute__((visibility("default"), used))
123
+ # endif
124
+ # endif
125
+ #endif
126
+
127
+ /**
128
+ * Number of recover-class diagnostics recorded during the
129
+ * document's parse.
130
+ *
131
+ * @param doc Document handle
132
+ * @return Diagnostic count (0 for NULL doc or a clean parse)
133
+ */
134
+ LEPTRIS_API size_t leptris_document_parse_diag_count(LeptrisDocument doc);
135
+
136
+ /**
137
+ * Read one parse diagnostic by index.
138
+ *
139
+ * @param doc Document handle
140
+ * @param index Diagnostic index (< leptris_document_parse_diag_count)
141
+ * @param kind Out: diagnostic kind (may be NULL)
142
+ * @param message Out: NUL-terminated message buffer (may be NULL)
143
+ * @param message_cap Capacity of message (ignored when message NULL)
144
+ * @return 1 on success; 0 for NULL doc or out-of-range index
145
+ *
146
+ * Memory: Message is copied into the caller's buffer; diagnostics
147
+ * are owned by the document until leptris_document_free.
148
+ */
149
+ LEPTRIS_API int leptris_document_parse_diag(LeptrisDocument doc,
150
+ size_t index,
151
+ LeptrisDiagKind* kind,
152
+ char* message,
153
+ size_t message_cap);
154
+
99
155
  #ifdef __cplusplus
100
156
  }
101
157
  #endif
@@ -246,6 +246,28 @@ typedef enum {
246
246
  LEPTRIS_DIFF_IGNORE_WS_TEXT = 1u
247
247
  } LeptrisDiffFlags;
248
248
 
249
+ /* Unified narration record kinds (#1126). One structured record
250
+ * per validation/recover event, shared by every validator (RNG
251
+ * today; XSD/Schematron follow) and the DOM parse's recover
252
+ * channel (#1200). The kind selects the reporting convention;
253
+ * append-only. */
254
+ typedef enum {
255
+ LEPTRIS_DIAG_INVALID = 0,
256
+ LEPTRIS_DIAG_NOT_ALLOWED_ANYWHERE, /* name never valid in model */
257
+ LEPTRIS_DIAG_NOT_ALLOWED_HERE, /* valid name, wrong position */
258
+ LEPTRIS_DIAG_NOT_ALLOWED_YET, /* later; missing required first */
259
+ LEPTRIS_DIAG_INCOMPLETE, /* content ends before required */
260
+ LEPTRIS_DIAG_MISSING_REQUIRED_ATTR,
261
+ LEPTRIS_DIAG_ATTR_NOT_ALLOWED,
262
+ LEPTRIS_DIAG_ATTR_VALUE_INVALID,
263
+ LEPTRIS_DIAG_CHAR_CONTENT_INVALID, /* data/value/list mismatch */
264
+ /* Recover-class parse event: the document is usable, the input
265
+ * was not conformant (duplicate attribute, ...). Not a model
266
+ * error; SAX-lane "recover" parity (#1200). */
267
+ LEPTRIS_DIAG_RECOVER,
268
+ LEPTRIS_DIAG_COUNT_
269
+ } LeptrisDiagKind;
270
+
249
271
  /* Subtree digest flags (issue #869). The digest is content-defined
250
272
  * (no pointers/addresses — stable across processes); equality
251
273
  * implies subtree equivalence under the flag semantics. */
@@ -253,7 +275,13 @@ typedef enum {
253
275
  LEPTRIS_DIGEST_DEFAULT = 0,
254
276
  /* Skip whitespace-only text nodes entirely (comparator's
255
277
  * strip-profile default). */
256
- LEPTRIS_DIGEST_DROP_WS_TEXT = 1u
278
+ LEPTRIS_DIGEST_DROP_WS_TEXT = 1u,
279
+ /* Hash attributes in DOCUMENT order instead of the sorted
280
+ * canonical order — documents differing only in attribute
281
+ * order digest DIFFERENT under this flag (canon's verbose
282
+ * diff gate, #1200). Equality under this flag additionally
283
+ * implies attribute-order identity. */
284
+ LEPTRIS_DIGEST_ATTR_ORDER = 2u
257
285
  } LeptrisDigestFlags;
258
286
 
259
287
  typedef struct {
@@ -17,20 +17,9 @@
17
17
  extern "C" {
18
18
  #endif
19
19
 
20
- typedef enum {
21
- LEPTRIS_DIAG_INVALID = 0,
22
- LEPTRIS_DIAG_NOT_ALLOWED_ANYWHERE, /* name never valid in model */
23
- LEPTRIS_DIAG_NOT_ALLOWED_HERE, /* valid name, wrong position */
24
- LEPTRIS_DIAG_NOT_ALLOWED_YET, /* later; missing required first */
25
- LEPTRIS_DIAG_INCOMPLETE, /* content ends before required */
26
- LEPTRIS_DIAG_MISSING_REQUIRED_ATTR,
27
- LEPTRIS_DIAG_ATTR_NOT_ALLOWED,
28
- LEPTRIS_DIAG_ATTR_VALUE_INVALID,
29
- LEPTRIS_DIAG_CHAR_CONTENT_INVALID, /* data/value/list mismatch */
30
- LEPTRIS_DIAG_COUNT_
31
- } LeptrisDiagKind;
20
+ /* LeptrisDiagKind lives in leptris/types.h (public, #1200). */
32
21
 
33
- typedef struct {
22
+ typedef struct LeptrisDiag {
34
23
  LeptrisDiagKind kind;
35
24
  char* offender_name; /* captured at emit time (strdup'd);
36
25
  * NULL when the diag has no offender.
@@ -181,7 +181,11 @@ static uint64_t digest_element(LeptrisElement e, LeptrisDigestFlags flags) {
181
181
  attrs[n].value_len = leptris_attr_value_sv(a).length;
182
182
  n++;
183
183
  }
184
- qsort(attrs, n, sizeof(DigestAttr), digest_attr_cmp);
184
+ /* ATTR_ORDER (#1200): document order is canonical — the
185
+ * walk above already collected in document order, so
186
+ * skip the sort and attribute order feeds the hash. */
187
+ if (!(flags & LEPTRIS_DIGEST_ATTR_ORDER))
188
+ qsort(attrs, n, sizeof(DigestAttr), digest_attr_cmp);
185
189
  }
186
190
  h = digest_mix_u64(h, n);
187
191
  for (size_t i = 0; i < n; i++) {
@@ -178,7 +178,49 @@ LEPTRIS_API const char* leptris_element_attribute(LeptrisElement elem, const cha
178
178
  if (!uri) return NULL; /* (4) */
179
179
  attr = find_attr_expanded(elem, uri, colon + 1); /* (2) */
180
180
  }
181
- if (!attr) return NULL;
181
+ if (!attr) {
182
+ /* #1242 diagnostic: LEPTRIS_DEBUG_ATTR_MISS=1 dumps the
183
+ * element's attribute-chain state for the first 5 misses in
184
+ * the process — distinguishes construction loss
185
+ * (chain_len=0) from storage corruption (garbage name
186
+ * bytes) from hash-discipline drift (correct bytes,
187
+ * mismatching hash15). Off by default; fprintf only on
188
+ * miss. */
189
+ static int dumped = 0;
190
+ if (dumped < 5 && getenv("LEPTRIS_DEBUG_ATTR_MISS")) {
191
+ dumped++;
192
+ size_t chain = 0;
193
+ for (struct leptris_attribute* a =
194
+ leptris_element_get_first_attribute(elem);
195
+ a; a = leptris_attr_next(a))
196
+ chain++;
197
+ fprintf(stderr,
198
+ "[leptris-attr-miss] elem=%p needle='%s' "
199
+ "chain_len=%zu\n",
200
+ (void*)elem, name, chain);
201
+ size_t i = 0;
202
+ for (struct leptris_attribute* a =
203
+ leptris_element_get_first_attribute(elem);
204
+ a && i < 8; a = leptris_attr_next(a), i++) {
205
+ char nb[17];
206
+ size_t nl = a->name_view.length < 16
207
+ ? a->name_view.length : 16;
208
+ for (size_t k = 0; k < nl; k++) {
209
+ unsigned char c = (unsigned char)a->name_view.data[k];
210
+ nb[k] = (c >= 32 && c < 127) ? (char)c : '.';
211
+ }
212
+ nb[nl] = '\0';
213
+ fprintf(stderr,
214
+ " attr[%zu] addr=%p name_ptr=%p len=%zu "
215
+ "bytes='%s' hash15=%u entities=%d\n",
216
+ i, (void*)a, (void*)a->name_view.data,
217
+ a->name_view.length, nb,
218
+ (unsigned)(a->name_hash & 0x7FFFu),
219
+ (int)((a->name_hash & 0x8000u) != 0));
220
+ }
221
+ }
222
+ return NULL;
223
+ }
182
224
 
183
225
  /* Single representation (round 4): entity values expand lazily
184
226
  * INTO the view (owned copy); no-entity views are already
@@ -102,6 +102,15 @@ static size_t bucket_index(LeptrisElement root) {
102
102
  return (size_t)(v & (ROOT_DOC_BUCKETS - 1));
103
103
  }
104
104
 
105
+ /* #682 2x lever (sound): last-hit (root, doc) memo. The climb to
106
+ * the root is unavoidable without a per-element field, but the TLS
107
+ * bucket-array walk is not — consecutive queries in a transform
108
+ * hit the same root, so one pointer compare replaces the hash
109
+ * bucket chain. One TLS access total (the memo pair), and the
110
+ * 1M-loop sentinel becomes a plain NULL check. */
111
+ static LEPTRIS_THREAD_LOCAL LeptrisElement g_memo_root;
112
+ static LEPTRIS_THREAD_LOCAL struct leptris_document* g_memo_doc;
113
+
105
114
  void leptris_root_doc_register(LeptrisElement root, struct leptris_document* doc) {
106
115
  if (!root || !doc) return;
107
116
  size_t idx = bucket_index(root);
@@ -160,6 +169,17 @@ void leptris_root_doc_drain_thread_caches(void) {
160
169
  void leptris_root_doc_unregister(LeptrisElement root) {
161
170
  if (!root) return;
162
171
  if (!rootmap_marked(root)) return; /* never registered: O(1) out */
172
+ /* #1242: the (root, doc) memo trusts the root ADDRESS. An
173
+ * element-level unregister frees that address while the doc
174
+ * lives — the doc-keyed invalidation never fires, and the
175
+ * allocator recycling the address into another element's climb
176
+ * target made get_document return the WRONG document. Same
177
+ * exposure class #1038 closed for the map; the memo needs it
178
+ * at element granularity. */
179
+ if (g_memo_root == root) {
180
+ g_memo_root = NULL;
181
+ g_memo_doc = NULL;
182
+ }
163
183
  size_t idx = bucket_index(root);
164
184
  RootDocEntry** pp = &g_root_doc_buckets[idx];
165
185
  while (*pp) {
@@ -202,6 +222,13 @@ size_t leptris_root_doc_unregister_doc(struct leptris_document* doc) {
202
222
  doc->map_entries = NULL;
203
223
  while (e) {
204
224
  RootDocEntry* next = e->doc_next;
225
+ /* #1242: a swept root address must not survive in the memo
226
+ * even when the memo's doc differs (a previously poisoned
227
+ * pair). */
228
+ if (g_memo_root == e->root) {
229
+ g_memo_root = NULL;
230
+ g_memo_doc = NULL;
231
+ }
205
232
  size_t idx = bucket_index(e->root);
206
233
  RootDocEntry** pp = &g_root_doc_buckets[idx];
207
234
  while (*pp) {
@@ -234,15 +261,6 @@ struct leptris_document* leptris_root_doc_lookup(LeptrisElement root) {
234
261
  * are skipped entirely (get_document was 3.5% of the light bench).
235
262
  * Hint is thread-local-scoped by construction (one transform per
236
263
  * thread), validated by the root-walk fallback when it misses. */
237
- /* #682 2x lever (sound): last-hit (root, doc) memo. The climb to
238
- * the root is unavoidable without a per-element field, but the TLS
239
- * bucket-array walk is not — consecutive queries in a transform
240
- * hit the same root, so one pointer compare replaces the hash
241
- * bucket chain. One TLS access total (the memo pair), and the
242
- * 1M-loop sentinel becomes a plain NULL check. */
243
- static LEPTRIS_THREAD_LOCAL LeptrisElement g_memo_root;
244
- static LEPTRIS_THREAD_LOCAL struct leptris_document* g_memo_doc;
245
-
246
264
  /* #904: prime the TLS last-root memo from a driver that already
247
265
  * knows the (root, doc) pair — the iterparse yield path hands out
248
266
  * a subtree that is released before the next, so the memo misses
@@ -263,6 +281,10 @@ void leptris_root_doc_memo_invalidate(const struct leptris_document* doc) {
263
281
  }
264
282
  }
265
283
 
284
+ LeptrisElement leptris_root_doc_memo_root_for_tests(void) {
285
+ return g_memo_root;
286
+ }
287
+
266
288
  struct leptris_document* leptris_element_get_document(LeptrisElement elem) {
267
289
  if (!elem) return NULL;
268
290
  LeptrisElement cur = elem;
@@ -15,6 +15,8 @@ void leptris_root_doc_register(LeptrisElement root, struct leptris_document* doc
15
15
  void leptris_root_doc_unregister(LeptrisElement root);
16
16
  struct leptris_document* leptris_root_doc_lookup(LeptrisElement root);
17
17
  void leptris_root_doc_memo_invalidate(const struct leptris_document* doc);
18
+ /* Test hook (#1242): the memo's current root, or NULL. */
19
+ LeptrisElement leptris_root_doc_memo_root_for_tests(void);
18
20
  /* #904: prime the TLS (root, doc) memo from a driver that knows
19
21
  * the pair (iterparse yields). Root must be registered. */
20
22
  void leptris_root_doc_memo_prime(LeptrisElement root,
@@ -7,6 +7,7 @@
7
7
  #include "common/port.h" /* LEPTRIS_THREAD_LOCAL (TODO.concurrency/01) */
8
8
  #include <stdio.h>
9
9
  #include <string.h>
10
+ #include "dom/diag.h" /* LeptrisDiag layout (#1200) */
10
11
 
11
12
  /* Error state — THREAD-LOCAL since TODO.concurrency/01: concurrent
12
13
  * parses (one document per thread) no longer race the channel. The
@@ -131,4 +132,25 @@ LEPTRIS_API const char* leptris_status_string(LeptrisStatus status) {
131
132
  case LEPTRIS_ERROR_IO: return "I/O error";
132
133
  default: return "Unknown error";
133
134
  }
135
+ }
136
+
137
+ /* ---- #1200: recover-class parse diagnostics ------------------- */
138
+
139
+ LEPTRIS_API size_t leptris_document_parse_diag_count(LeptrisDocument doc) {
140
+ return doc ? (size_t)doc->parse_diag_count : 0;
141
+ }
142
+
143
+ LEPTRIS_API int leptris_document_parse_diag(LeptrisDocument doc,
144
+ size_t index,
145
+ LeptrisDiagKind* kind,
146
+ char* message,
147
+ size_t message_cap) {
148
+ if (!doc || index >= (size_t)doc->parse_diag_count) return 0;
149
+ struct LeptrisDiag* d = (struct LeptrisDiag*)doc->parse_diags + index;
150
+ if (kind) *kind = d->kind;
151
+ if (message && message_cap) {
152
+ strncpy(message, d->message, message_cap - 1);
153
+ message[message_cap - 1] = '\0';
154
+ }
155
+ return 1;
134
156
  }
@@ -437,7 +437,47 @@ static inline int dp_add_attr_inline(DParser* p, LeptrisElement elem,
437
437
  * Careful: first_attribute_off == 0 means empty list. We can't decode
438
438
  * the pointer and check for NULL — at offset 0 the decoded pointer
439
439
  * is `elem` itself, which is non-NULL. Check the offset field. */
440
+ /* #1200: duplicate-attribute detection. Prefilter on (len,
441
+ * first byte) — a mismatching pair costs two loads; the memcmp
442
+ * only runs on genuine candidates. First definition wins
443
+ * (libxml2 recover semantics, and the digest's first-wins
444
+ * becomes official); the duplicate never enters the chain, it
445
+ * costs its already-carved slot. */
440
446
  if (elem->first_attribute_off != 0) {
447
+ int dup = 0;
448
+ for (struct leptris_attribute* a =
449
+ (struct leptris_attribute*)((char*)elem +
450
+ elem->first_attribute_off);
451
+ a; a = leptris_attr_next(a)) {
452
+ if (a->name_view.length == attr->name_view.length &&
453
+ a->name_view.data[0] == attr->name_view.data[0] &&
454
+ memcmp(a->name_view.data, attr->name_view.data,
455
+ attr->name_view.length) == 0) {
456
+ dup = 1;
457
+ break;
458
+ }
459
+ }
460
+ if (dup) {
461
+ if (p->doc) {
462
+ extern struct LeptrisDiag* leptris_diag_emit(
463
+ struct LeptrisDiag**, int*, int*,
464
+ LeptrisDiagKind, LeptrisNodeRef,
465
+ const char*, ...);
466
+ char qname[130];
467
+ size_t ql = attr->name_view.length < 129
468
+ ? attr->name_view.length : 129;
469
+ memcpy(qname, attr->name_view.data, ql);
470
+ qname[ql] = '\0';
471
+ leptris_diag_emit(&p->doc->parse_diags,
472
+ &p->doc->parse_diag_count,
473
+ &p->doc->parse_diag_cap,
474
+ LEPTRIS_DIAG_RECOVER, NULL,
475
+ "Attribute %s redefined", qname);
476
+ }
477
+ /* 1 = duplicate skipped (keep first): the caller skips
478
+ * the raw-attr registration so BOTH surfaces agree. */
479
+ return 1;
480
+ }
441
481
  /* Cache should always be valid mid-parse. Fall back to walk
442
482
  * only if cache is NULL (defensive — shouldn't happen). */
443
483
  struct leptris_attribute* tail = p->current_elem_last_attr;
@@ -845,13 +885,16 @@ static int dp_parse_attrs(DParser* p, LeptrisElement elem) {
845
885
  continue;
846
886
  }
847
887
 
848
- /* Regular attribute — zero-copy name/value, bulk-allocated struct. */
849
- if (dp_add_attr_inline(p, elem, name_start, name_len,
850
- val_start, val_len, has_amp, has_ws,
851
- attr_colon) != 0)
888
+ /* Regular attribute — zero-copy name/value, bulk-allocated
889
+ * struct. 1 = duplicate skipped: neither surface sees it. */
890
+ int arc = dp_add_attr_inline(p, elem, name_start, name_len,
891
+ val_start, val_len, has_amp,
892
+ has_ws, attr_colon);
893
+ if (arc < 0) return -1;
894
+ if (arc == 0 &&
895
+ dp_raw_attr(p, elem, name_start, name_len,
896
+ val_start, val_len) != 0)
852
897
  return -1;
853
- if (dp_raw_attr(p, elem, name_start, name_len,
854
- val_start, val_len) != 0) return -1;
855
898
  }
856
899
  /* for (;;): every exit is a return inside the loop (0 = tag
857
900
  * closed, 1 = self-closing, -1 = error) — there is no
@@ -1571,9 +1614,21 @@ static struct leptris_document* direct_parse_internal(char* buf, size_t len,
1571
1614
  if (close_local_len <= 8 &&
1572
1615
  (p.probe_slack ||
1573
1616
  close_local + 8 <= p.end + 1)) {
1617
+ /* The mask follows MEMORY order: memcpy fills the low
1618
+ * bytes on little-endian and the high bytes on
1619
+ * big-endian, so each dialect must mask the end its
1620
+ * load populated. The unconditional low-bytes mask
1621
+ * compared bytes past the name on big-endian and
1622
+ * failed every close tag (#1197). */
1623
+ #if defined(__BYTE_ORDER__) && __BYTE_ORDER__ == __ORDER_BIG_ENDIAN__
1624
+ uint64_t mask = (close_local_len == 8)
1625
+ ? ~(uint64_t)0
1626
+ : (~(uint64_t)0 << (64 - close_local_len * 8));
1627
+ #else
1574
1628
  uint64_t mask = (close_local_len == 8)
1575
1629
  ? ~(uint64_t)0
1576
1630
  : ((1ull << (close_local_len * 8)) - 1);
1631
+ #endif
1577
1632
  uint64_t a, b;
1578
1633
  memcpy(&a, open_name, 8);
1579
1634
  memcpy(&b, close_local, 8);