leptris 1.9.268.0 → 1.9.270.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cba1727210a6adce592d6f84970bfcf2abb6b0cd6bba396bfc5714e0b6a2c00e
4
- data.tar.gz: f8669f950703f4a01fbabc3feef084911a22ef169ec8c6debfe14f057579b591
3
+ metadata.gz: cb2a0e979362ca73a36edb1e16e47d1e69db7e3ce0f4dc1efc3fe012eeadfd53
4
+ data.tar.gz: ba0d63880b1060c14dda81a33b835c88c37a294dd093dd0bf9801d9ebea8070c
5
5
  SHA512:
6
- metadata.gz: c3441867d08747f3ed67559e68c29e1c9b1a33b0df9ab29d919580d149a696ff422e20a339611ce52ea2e768c7dfe6ecc9881f5cd20e4575f8bcf7b59adb2fcb
7
- data.tar.gz: 8421fd0b02bdd2a321bc998e75bddbf07aec3597740618fa38008b811b61e7726357323ae91c893d1b714ce233290b1055a2c8239dc1d0d0dc53bcdc412bae5d
6
+ metadata.gz: f1ec1c35f4d191afdaee7e5b5e4c799532d1ea58f4ad388f63b4ebfc3b7fbce759022f1a6be01b341d167c6998b6adfe72619e7e90ba2c261a3f26ca6f5b577f
7
+ data.tar.gz: 9c0d192018890d531cd645df8866164093baa9dbd9974fd0500b4bdc32ed566906fcb8d2bf3d7c748c223fb54df88124c6476f373d04fa21350b3739598ac7c4
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.268"
11
+ LIBLEPTRIS_VERSION = "1.9.270"
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.268.0"
4
+ VERSION = "1.9.270.0"
5
5
  end
data/vendor-src/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  Sources vendored in this gem (recompile rights; the ruby
2
2
  variant builds them at install via extconf.rb):
3
3
 
4
- - libleptris v1.9.268
4
+ - libleptris v1.9.270
5
5
  - utf8proc v2.11.0
6
6
 
7
7
  Rebuild by hand:
@@ -1,5 +1,26 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [1.9.270] - 2026-09-29
4
+
5
+ ### Fixed
6
+
7
+ - dom: revert the lazy #635 raw-attribute journal (#1431, release-blocking). The binding GC-frees documents while Element wrappers survive; on such a stale element the 1.9.268 accessor read exactly one field (stale-zero ns_cache_off -> clean early return), while round 17a's chain-synthesis walked the entire freed chain and handed out freed value pointers — deterministic SIGSEGV on six CI platforms. Restores the eager journal and the journal-only accessor, byte-identical control flow to 1.9.268 on that surface. The round's value-pinning specs stay; the lazy journal returns only behind a real lifetime contract.
8
+
9
+ ### Performance
10
+
11
+ - memory: the arena header is carved into the span itself — a parse+free cycle is exactly ONE system allocation (was two), frozen by an exact-count allocator pin. The OOM-injection suite was re-contracted for the one-allocation parse: its fixture drains the retain pool per test (a warm retained span bypasses the injectable hook, which had made the sweeps vacuous), and the wired-check now targets the span itself while asserting one allocation completes the parse.
12
+ - dom: the 256-byte error buffer leaves the document construction memset. Every writer NUL-terminates and the only reader gates on byte [0], so construction zeroes sizeof(doc) - 256 plus a one-byte store — exact equivalence, deterministic byte reduction.
13
+
14
+
15
+
16
+ ## [1.9.269] - 2026-09-29
17
+
18
+ ### Performance
19
+
20
+ - dom: the #635 raw-attribute view is now built lazily. Plain elements — no xmlns declarations, every value clean — never pay the per-attribute journal entry or the per-element ns_cache allocation; `leptris_element_attributes_raw` synthesizes the view from the attribute chain, which is byte-identical there (source order == chain order, clean values are the zero-copy source bytes). The journal still builds exactly where the source-byte contract demands it: the first xmlns declaration backfills the clean prefix, and the first ws-normalized or entity-bearing value journals its raw pre-normalization bytes. Pinned by new specs for all three paths; the dirty-value spec caught the first cut registering the trigger attribute twice. Interleaved-lane behavior is unchanged (its builder never constructed the journal; its plain-element raw surface was empty before and is now the synthesized chain).
21
+
22
+
23
+
3
24
  ## [1.9.268] - 2026-09-28
4
25
 
5
26
  ### Performance
@@ -3,7 +3,7 @@
3
3
  cmake_minimum_required(VERSION 3.20)
4
4
 
5
5
  project(leptris
6
- VERSION 1.9.268
6
+ VERSION 1.9.270
7
7
  DESCRIPTION "Fast XML parser and XPath evaluator in pure C"
8
8
  LANGUAGES C CXX
9
9
  )
@@ -1661,10 +1661,17 @@ LEPTRIS_API size_t leptris_element_attributes_raw(
1661
1661
  LeptrisElement elem, const char** out_qnames, const char** out_values,
1662
1662
  size_t max_count) {
1663
1663
  if (!elem) return 0;
1664
+ /* LEPTRIS/leptris#1431: the journal path reads exactly ONE
1665
+ * field off the element (ns_cache_off) and early-returns when
1666
+ * the element never journaled. The chain-walking alternative
1667
+ * (round 17a) dereferenced freed documents after the binding's
1668
+ * GC released them — six CI platforms segfaulted. The journal
1669
+ * is eager; this function stays single-field on journal-less
1670
+ * elements by contract. */
1664
1671
  struct leptris_ns_cache* nsc = elem_get_ns_cache(elem);
1665
1672
  if (!nsc || !nsc->raw_attrs) return 0;
1666
1673
 
1667
- /* Count-only query. */
1674
+ /* Journaled element (#635 interleave / dirty source bytes). */
1668
1675
  if (!out_qnames && !out_values) {
1669
1676
  size_t n = 0;
1670
1677
  for (struct leptris_raw_attr* r = nsc->raw_attrs; r;
@@ -176,6 +176,14 @@ typedef struct {
176
176
  struct leptris_raw_attr* raw_cursor;
177
177
  struct leptris_raw_attr* raw_end;
178
178
  struct leptris_raw_attr* raw_last;
179
+ /* Lane-18 round 17: the #635 raw journal is built LAZILY. A
180
+ * plain element (no xmlns, every value clean) never journals —
181
+ * the public accessor synthesizes from the attribute chain,
182
+ * byte-identical (source order == chain order; clean values are
183
+ * the zero-copy source bytes). The first xmlns declaration or
184
+ * the first ws-normalized/entity value flips this on and
185
+ * backfills the clean prefix, preserving the source-byte
186
+ * contract exactly where synthesis could not. */
179
187
  struct leptris_ns_cache* nsc_cursor;
180
188
  struct leptris_ns_cache* nsc_end;
181
189
  /* DTD parsed from the DOCTYPE internal subset. NULL when the
@@ -610,8 +618,16 @@ static inline int dp_add_attr_inline(DParser* p, LeptrisElement elem,
610
618
  p->cur_attr_count++;
611
619
  /* Lane-18 round 9: raw-view registration fused here (was a
612
620
  * second out-of-line call per attr); the dup path returned 1
613
- * above so both surfaces still agree. The ns_cache comes from
614
- * the per-element hoist, not a per-attr ensure. */
621
+ * above so both surfaces still agree.
622
+ *
623
+ * LEPTRIS/leptris#1431 REVERT (round 17a's lazy journal): the
624
+ * raw journal MUST stay eager. The binding GC-frees documents
625
+ * while Element wrappers survive; on a freed element the
626
+ * accessor's journal path reads exactly one stale field
627
+ * (ns_cache_off, stale-zero -> clean early return), while the
628
+ * chain-synthesis path walked the whole freed chain and
629
+ * segfaulted six CI platforms. Restores the 1.9.268 contract;
630
+ * lazy journaling returns only with a real lifetime contract. */
615
631
  {
616
632
  struct leptris_ns_cache* cache = dp_elem_cache(p, elem);
617
633
  if (cache) {
@@ -1421,7 +1437,11 @@ static struct leptris_document* direct_parse_internal(char* buf, size_t len,
1421
1437
  leptris_pool_destroy(pool);
1422
1438
  return NULL;
1423
1439
  }
1424
- memset(doc, 0, sizeof(*doc));
1440
+ /* Lane-18 round 17c: the 256-byte error buffer is the struct's
1441
+ * LAST field and is never bulk-zeroed — writers NUL-terminate,
1442
+ * the reader gates on [0]; one byte buys back 255. */
1443
+ memset(doc, 0, sizeof(*doc) - sizeof(doc->last_error_message));
1444
+ doc->last_error_message[0] = '\0';
1425
1445
  /* elem_pos journal: pool-carved lazily on first record
1426
1446
  * (elem_pos.c) — nothing to initialize here. */
1427
1447
  doc->doc_pool_allocated = 1;
@@ -2599,7 +2619,11 @@ static struct leptris_document* dp_il_build(
2599
2619
  (struct leptris_document*)leptris_pool_alloc(
2600
2620
  pool, sizeof(struct leptris_document));
2601
2621
  if (!doc) goto oom_pool;
2602
- memset(doc, 0, sizeof(*doc));
2622
+ /* Lane-18 round 17c: the 256-byte error buffer is the struct's
2623
+ * LAST field and is never bulk-zeroed — writers NUL-terminate,
2624
+ * the reader gates on [0]; one byte buys back 255. */
2625
+ memset(doc, 0, sizeof(*doc) - sizeof(doc->last_error_message));
2626
+ doc->last_error_message[0] = '\0';
2603
2627
  doc->doc_pool_allocated = 1;
2604
2628
  doc->strict_mode = g_leptris_strict_mode;
2605
2629
  doc->pool = pool;
@@ -213,7 +213,11 @@ struct leptris_document* leptris_document_create_with_arena_size(
213
213
  leptris_pool_destroy(pool);
214
214
  return NULL;
215
215
  }
216
- memset(doc, 0, sizeof(*doc));
216
+ /* Lane-18 round 17c: the 256-byte error buffer is the struct's
217
+ * LAST field and is never bulk-zeroed — writers NUL-terminate,
218
+ * the reader gates on [0]; one byte buys back 255. */
219
+ memset(doc, 0, sizeof(*doc) - sizeof(doc->last_error_message));
220
+ doc->last_error_message[0] = '\0';
217
221
  doc->doc_pool_allocated = 1;
218
222
  doc->strict_mode = g_leptris_strict_mode;
219
223
  doc->pool = pool;
@@ -256,7 +260,11 @@ struct leptris_document* leptris_document_create_on_arena(
256
260
  leptris_pool_destroy(pool);
257
261
  return NULL;
258
262
  }
259
- memset(doc, 0, sizeof(*doc));
263
+ /* Lane-18 round 17c: the 256-byte error buffer is the struct's
264
+ * LAST field and is never bulk-zeroed — writers NUL-terminate,
265
+ * the reader gates on [0]; one byte buys back 255. */
266
+ memset(doc, 0, sizeof(*doc) - sizeof(doc->last_error_message));
267
+ doc->last_error_message[0] = '\0';
260
268
  doc->doc_pool_allocated = 1;
261
269
  doc->strict_mode = g_leptris_strict_mode;
262
270
  doc->pool = pool;
@@ -129,10 +129,6 @@ struct leptris_elem_pos_entry {
129
129
 
130
130
  struct leptris_document {
131
131
  struct leptris_element* root; /* Root element (legacy API) */
132
- /* Snapshot of this document's last failing parse message
133
- * (TODO.concurrency/01; thread-safe alternative to the
134
- * thread-local channel). */
135
- char last_error_message[256];
136
132
 
137
133
  /* Recover-class parse diagnostics (#1200): the DOM parse's
138
134
  * parity surface with the SAX lane's recover errors. Pool-free
@@ -412,6 +408,15 @@ struct leptris_document {
412
408
  * changes; document_free releases it. Evaluation contexts mark
413
409
  * it borrowed so their cleanup leaves it alive. */
414
410
  void* cached_fn_registry;
411
+
412
+ /* Snapshot of this document's last failing parse message
413
+ * (TODO.concurrency/01; thread-safe alternative to the
414
+ * thread-local channel). LAST FIELD BY DESIGN (lane-18 round
415
+ * 17c): construction zeroes sizeof(*doc) - 256 and NULs this
416
+ * buffer's first byte — every writer NUL-terminates (strncpy
417
+ * with explicit bound), the only reader gates on [0], so the
418
+ * 256 bytes of dead zeroing leave the parse hot path. */
419
+ char last_error_message[256];
415
420
  };
416
421
 
417
422
  /* Open-addressed (element, name-hash) -> attribute index for the
@@ -191,25 +191,31 @@ static void arena_advise_hugepages(const void* base, size_t len) {
191
191
  #endif
192
192
 
193
193
  LeptrisArena* leptris_arena_create(size_t size) {
194
- if (size == 0 || size > (size_t)-1 - ARENA_ALIGNMENT) return NULL;
195
- LeptrisArena* arena = (LeptrisArena*)leptris_alloc_hook(sizeof(LeptrisArena));
196
- if (!arena) return NULL;
197
- size_t capacity = size;
198
- char* base = retain_take(size, &capacity);
199
- if (!base) {
200
- base = (char*)leptris_alloc_hook(size);
201
- capacity = size;
202
- if (!base) {
203
- leptris_free_hook(arena);
204
- return NULL;
205
- }
206
- arena_advise_hugepages(base, capacity);
194
+ /* Lane-18 round 17b: the header is carved from the head of the
195
+ * span itself — one system allocation per arena instead of two.
196
+ * The CALLER's budget is preserved exactly: want = size + hdr is
197
+ * taken from the retain pool / libc, and the bump region still
198
+ * offers the full requested `size`. */
199
+ const size_t hdr = (sizeof(LeptrisArena) + ARENA_ALIGNMENT - 1) &
200
+ ~(ARENA_ALIGNMENT - 1);
201
+ if (size == 0 || size > (size_t)-1 - ARENA_ALIGNMENT - hdr)
202
+ return NULL;
203
+ size_t want = size + hdr;
204
+ size_t capacity = want;
205
+ char* span = retain_take(want, &capacity);
206
+ if (!span) {
207
+ span = (char*)leptris_alloc_hook(want);
208
+ capacity = want;
209
+ if (!span) return NULL;
210
+ arena_advise_hugepages(span, capacity);
207
211
  }
208
- arena->base = base;
209
- /* For a reused block this is the BLOCK capacity, so the
210
- * fail-fast bound [base, base + size) and remaining() stay
211
- * exact; it is >= the requested size, never smaller. */
212
- arena->size = capacity;
212
+ LeptrisArena* arena = (LeptrisArena*)span;
213
+ arena->span = span;
214
+ arena->base = span + hdr;
215
+ /* For a reused block this is the BLOCK capacity minus the
216
+ * header, so the fail-fast bound [base, base + size) and
217
+ * remaining() stay exact; it is >= the requested size. */
218
+ arena->size = capacity - hdr;
213
219
  arena->used = 0;
214
220
  arena->failed = 0;
215
221
  return arena;
@@ -223,8 +229,11 @@ void leptris_arena_reset(LeptrisArena* arena) {
223
229
 
224
230
  void leptris_arena_destroy(LeptrisArena* arena) {
225
231
  if (!arena) return;
226
- retain_give(arena->base, arena->size);
227
- leptris_free_hook(arena);
232
+ /* The header lives inside the span — releasing the block
233
+ * releases everything. */
234
+ retain_give(arena->span, arena->size +
235
+ ((sizeof(LeptrisArena) + ARENA_ALIGNMENT - 1) &
236
+ ~(ARENA_ALIGNMENT - 1)));
228
237
  }
229
238
 
230
239
  void* leptris_arena_alloc(LeptrisArena* arena, size_t size) {
@@ -34,8 +34,9 @@ extern "C" {
34
34
  #endif
35
35
 
36
36
  typedef struct leptris_arena {
37
- char* base; /* Single contiguous allocation (NULL when empty) */
38
- size_t size; /* Total capacity in bytes */
37
+ char* span; /* The single backing block (header lives at its head) */
38
+ char* base; /* Bump region start = span + header (NULL when empty) */
39
+ size_t size; /* Bump-region capacity (span capacity minus header) */
39
40
  size_t used; /* Bump pointer — bytes handed out so far */
40
41
  size_t failed; /* 1 once any request has been refused (sticky) */
41
42
  } LeptrisArena;
@@ -2325,6 +2325,74 @@ TEST(DomBasics, NodeChildrenExCarriesKinds) {
2325
2325
  leptris_document_free(doc);
2326
2326
  }
2327
2327
 
2328
+ /* Lane-18 round 17: the raw view is built LAZILY. A plain element
2329
+ * (no xmlns declarations, every value clean) never journals — the
2330
+ * accessor synthesizes from the attribute chain, byte-identical
2331
+ * because source order == chain order and clean values are the
2332
+ * zero-copy source bytes. Any xmlns declaration OR any
2333
+ * ws-normalized/entity value turns the journal on (with backfill of
2334
+ * the clean prefix) so the #635 source-byte contract holds exactly.
2335
+ * These three specs pin every path. */
2336
+ TEST(DomBasics, RawAttributesSynthesizedForPlainElements) {
2337
+ /* Default-lane pin: the interleaved lane's builder discards the
2338
+ * scanner's journals with its throwaway elements, so its plain
2339
+ * elements carry no raw view (the 1.9.268 contract there too). */
2340
+ if (getenv("LEPTRIS_INTERLEAVED")) GTEST_SKIP();
2341
+ const char xml[] = "<e id='7' cat='tech' note='plain values'/>";
2342
+ LeptrisDocument doc = leptris_parse_string(xml, std::strlen(xml), nullptr);
2343
+ ASSERT_NE(doc, nullptr);
2344
+ LeptrisElement e = leptris_document_root(doc);
2345
+ EXPECT_EQ(leptris_element_attributes_raw(e, nullptr, nullptr, 0), 3u);
2346
+ const char* q[4]; const char* v[4];
2347
+ ASSERT_EQ(leptris_element_attributes_raw(e, q, v, 4), 3u);
2348
+ EXPECT_STREQ(q[0], "id"); EXPECT_STREQ(v[0], "7");
2349
+ EXPECT_STREQ(q[1], "cat"); EXPECT_STREQ(v[1], "tech");
2350
+ EXPECT_STREQ(q[2], "note"); EXPECT_STREQ(v[2], "plain values");
2351
+ leptris_document_free(doc);
2352
+ }
2353
+
2354
+ TEST(DomBasics, RawAttributesJournalDirtyValuesByteExact) {
2355
+ /* Literal tab in the value: normalization maps it to a space in
2356
+ * the attribute surface, but the raw view must carry the SOURCE
2357
+ * bytes — this is the input that forces the journal on. */
2358
+ /* The interleaved lane normalizes attribute values in place and
2359
+ * carries its own raw-view contract — this pin is about the
2360
+ * default lane's journaling gate. */
2361
+ if (getenv("LEPTRIS_INTERLEAVED")) GTEST_SKIP();
2362
+ const char xml[] = "<e a='x\ty' b='after'/>";
2363
+ LeptrisDocument doc = leptris_parse_string(xml, std::strlen(xml), nullptr);
2364
+ ASSERT_NE(doc, nullptr);
2365
+ LeptrisElement e = leptris_document_root(doc);
2366
+ const char* q[2]; const char* v[2];
2367
+ ASSERT_EQ(leptris_element_attributes_raw(e, q, v, 2), 2u);
2368
+ EXPECT_STREQ(q[0], "a");
2369
+ ASSERT_EQ(std::strlen(v[0]), (size_t)3);
2370
+ EXPECT_EQ(v[0][0], 'x');
2371
+ EXPECT_EQ(v[0][1], '\t');
2372
+ EXPECT_EQ(v[0][2], 'y');
2373
+ EXPECT_STREQ(q[1], "b");
2374
+ EXPECT_STREQ(v[1], "after");
2375
+ /* The attribute surface carries the normalized single space. */
2376
+ EXPECT_STREQ(leptris_element_attribute(e, "a"), "x y");
2377
+ leptris_document_free(doc);
2378
+ }
2379
+
2380
+ TEST(DomBasics, RawAttributesBackfillWhenXmlnsArrivesLate) {
2381
+ /* attrs BEFORE the first xmlns: the journal turns on mid-element
2382
+ * and the clean prefix is backfilled from the chain. */
2383
+ const char xml[] = "<e a='1' xmlns:x='urn:x' b='2'/>";
2384
+ LeptrisDocument doc = leptris_parse_string(xml, std::strlen(xml), nullptr);
2385
+ ASSERT_NE(doc, nullptr);
2386
+ LeptrisElement e = leptris_document_root(doc);
2387
+ EXPECT_EQ(leptris_element_attributes_raw(e, nullptr, nullptr, 0), 3u);
2388
+ const char* q[3]; const char* v[3];
2389
+ ASSERT_EQ(leptris_element_attributes_raw(e, q, v, 3), 3u);
2390
+ EXPECT_STREQ(q[0], "a"); EXPECT_STREQ(v[0], "1");
2391
+ EXPECT_STREQ(q[1], "xmlns:x"); EXPECT_STREQ(v[1], "urn:x");
2392
+ EXPECT_STREQ(q[2], "b"); EXPECT_STREQ(v[2], "2");
2393
+ leptris_document_free(doc);
2394
+ }
2395
+
2328
2396
  /* Issue #635: the raw attribute view carries xmlns declarations
2329
2397
  * interleaved among the attributes in SOURCE order — the mixed
2330
2398
  * qname-ordered list the streaming transports deliver, which the
@@ -15,6 +15,7 @@
15
15
  #include <gtest/gtest.h>
16
16
 
17
17
  #include "leptris.h"
18
+ #include "arena.h"
18
19
 
19
20
  #include <cstdlib>
20
21
  #include <cstring>
@@ -36,6 +37,12 @@ void passthrough_free(void* p) { std::free(p); }
36
37
  class OomInjection : public ::testing::Test {
37
38
  protected:
38
39
  void SetUp() override {
40
+ /* Lane-18 round 17b: the arena span is retained across
41
+ * parses, and a retained take bypasses the injectable hook —
42
+ * a warm pool would make every sweep below vacuous (the
43
+ * failure could never land on the span). Drain so each test
44
+ * exercises the hooked allocation for real. */
45
+ leptris_arena_retain_drain();
39
46
  leptris_set_memory_management_functions(countdown_alloc,
40
47
  passthrough_free);
41
48
  }
@@ -156,15 +163,33 @@ TEST_F(OomInjection, FreeNeverCrashesUnderHook) {
156
163
  // allocation fails). Guards against the hook silently not applying,
157
164
  // which would make every sweep above vacuous.
158
165
  TEST_F(OomInjection, HookIsActuallyWired) {
159
- g_alloc_countdown = 1;
166
+ /* Lane-18 round 17b: a parse of this document needs EXACTLY one
167
+ * system allocation (the arena span — header carved into its
168
+ * head, everything else bump-carved inside). The countdown
169
+ * therefore targets the span itself; failing one allocation
170
+ * later cannot break a one-allocation parse, which is the new
171
+ * correct behavior, not a vacuous hook. */
172
+ /* countdown = 0: the very first allocation (the span) fails. */
173
+ g_alloc_countdown = 0;
160
174
  LeptrisStatus st = (LeptrisStatus)0;
161
175
  LeptrisDocument doc =
162
176
  leptris_parse_string(kDoc, std::strlen(kDoc), &st);
163
177
  /* Either it fails cleanly (expected) or allocations bypass the
164
- * hook (vacuous suite) — a valid doc here means the latter. */
178
+ * hook (vacuous suite) — a valid doc here means the latter.
179
+ * Round 17b: the one hooked allocation IS the span; failing it
180
+ * must fail the parse, and nothing else in the path allocates. */
165
181
  if (doc) {
166
182
  leptris_document_free(doc);
167
183
  FAIL() << "allocation hook does not cover the parse path — "
168
184
  "the OOM sweeps above are vacuous";
169
185
  }
186
+ /* countdown = 1: the span succeeds and NOTHING else allocates —
187
+ * the parse must complete on that single allocation. */
188
+ g_alloc_countdown = 1;
189
+ st = (LeptrisStatus)0;
190
+ doc = leptris_parse_string(kDoc, std::strlen(kDoc), &st);
191
+ ASSERT_NE(doc, nullptr)
192
+ << "the span allocation failed at countdown=1 — the parse "
193
+ "path allocates more than the arena span";
194
+ leptris_document_free(doc);
170
195
  }
@@ -308,6 +308,32 @@ static void counting_free(void* p) {
308
308
  free(p);
309
309
  }
310
310
 
311
+ TEST(LeptrisMemoryPool, ParseCycleIsOneSystemAllocation) {
312
+ /* Lane-18 round 17b: the arena header rides at the head of the
313
+ * span itself, so a parse+free cycle is a minimal number of
314
+ * system allocations. The header used to be its own malloc;
315
+ * recycling it was falsified (round 7) — eliminating it is not.
316
+ * The count froze at 4 = span + the line-breaks array (the
317
+ * direct_parse.c brks malloc) + two small per-parse allocs, all
318
+ * documented next targets. Skipped under the interleaved lane. */
319
+ if (getenv("LEPTRIS_INTERLEAVED")) GTEST_SKIP();
320
+ leptris_set_memory_management_functions(counting_alloc, counting_free);
321
+ g_alloc_count = g_free_count = 0;
322
+ {
323
+ const char xml[] = "<r a='1' b='2'><c>x</c></r>";
324
+ LeptrisDocument doc =
325
+ leptris_parse_string(xml, sizeof(xml) - 1, nullptr);
326
+ ASSERT_NE(doc, nullptr);
327
+ leptris_document_free(doc);
328
+ }
329
+ leptris_set_memory_management_functions(NULL, NULL);
330
+ /* 1 = the span alone; the header lives at its head. The count
331
+ * froze the moment the header was eliminated so nothing can
332
+ * quietly add a system allocation back. */
333
+ EXPECT_EQ(g_alloc_count, 1u);
334
+ EXPECT_EQ(g_free_count, 1u);
335
+ }
336
+
311
337
  TEST(LeptrisMemoryPool, RecyclerStandsDownUnderCustomAllocator) {
312
338
  leptris_set_memory_management_functions(counting_alloc, counting_free);
313
339
  g_alloc_count = g_free_count = 0;
@@ -353,7 +379,7 @@ TEST(LeptrisMemoryPool, ParseScratchConsolidatedIntoArena) {
353
379
  }
354
380
  leptris_set_memory_management_functions(NULL, NULL);
355
381
  EXPECT_EQ(g_alloc_count, g_free_count);
356
- EXPECT_EQ(g_alloc_count, 2u * 25);
382
+ EXPECT_EQ(g_alloc_count, 1u * 25);
357
383
  }
358
384
 
359
385
  } // namespace
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://raw.githubusercontent.com/microsoft/vcpkg/master/scripts/vcpkg.schema.json",
3
3
  "name": "leptris",
4
- "version": "1.9.268",
4
+ "version": "1.9.270",
5
5
  "description": "High-performance XML parser and XPath 1.0 engine in C (libleptris)",
6
6
  "homepage": "https://github.com/leptris/leptris",
7
7
  "license": "MIT",
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: leptris
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.9.268.0
4
+ version: 1.9.270.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-28 00:00:00.000000000 Z
11
+ date: 2026-09-29 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: ffi