From f7192d3c816e9e5dd5f81e42134606fd208c3bab Mon Sep 17 00:00:00 2001 From: Andrei Pechkurov Date: Wed, 27 May 2026 15:32:38 +0300 Subject: [PATCH 1/3] fix(jit_allocator): bound BitVectorRangeIterator range_start to _end BitVectorRangeIterator::next_range() did not clamp *range_start against _end. The iterator's init() only masks bits before start, so the underlying BitWord can carry free bits past end. When ctz picked such a bit, the caller would compute range_size = range_end - range_start as size_t (range_end gets clamped to _end), the subtraction would underflow to a huge value, and a range that lies entirely past the search region would be accepted. Inside JitAllocator::alloc this produced an area_index outside the block: the returned Span reported its requested size even though the underlying memory ran past the block boundary into unmapped pages. QuestDB caught this as a SIGSEGV inside JitRuntime::_add's memcpy: a production core dump shows a block with _area_size=4096, _search_end=4083 and 3 free bits at the tail (areas 4093-4095). A 7-area request let the iterator pick range_start=4093 with range_end clamped to 4083, _area_used over-incremented by 4, the Span claimed 448 bytes when only 192 were valid, and the JIT runtime memcpy walked off the end of the block. The fix adds a single bound check: if *range_start >= _end after the ctz, the iterator reports end-of-iteration. Two regression tests cover the path: - test_bit_vector_range_iterator_bounds() exercises the iterator directly with two hand-crafted bitmaps (same-word and multi-word search regions). Without the fix EXPECT_LT(range_start, end) fails on the first iterator call. - test_jit_allocator_search_end_bounds() drives JitAllocator into the production state (area_used = area_size - 14, free bits both inside the search range and at the tail past _search_end). With the bug the alloc trips the existing offset <= block_size - size assertion at line ~999; with the fix the search reports no fit and a fresh block is allocated. --- asmjit/core/jitallocator.cpp | 133 +++++++++++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) diff --git a/asmjit/core/jitallocator.cpp b/asmjit/core/jitallocator.cpp index ad75ffd5..5662566a 100644 --- a/asmjit/core/jitallocator.cpp +++ b/asmjit/core/jitallocator.cpp @@ -108,6 +108,11 @@ class BitVectorRangeIterator { size_t i = Support::ctz(_bit_word); *range_start = _idx + i; + if (ASMJIT_UNLIKELY(*range_start >= _end)) { + // `init()` only masks bits before `start`, so the current BitWord can + // carry free bits past `_end` and `ctz` would return such a position. + return false; + } _bit_word = ~(_bit_word ^ ~(Support::bit_ones << i)); if (_bit_word == 0) { @@ -1450,6 +1455,132 @@ static void BitVectorRangeIterator_testRandom(TestUtils::Random& rnd, size_t cou } } +// Regression test for BitVectorRangeIterator returning ranges past `end`. +// +// `init()` only masks bits before `start`, so a BitWord can carry free bits +// past `end`. Without bounding `range_start` against `end`, `ctz` could pick +// such a bit, the caller would then compute `range_size = range_end - range_start` +// as `size_t` (range_end gets clamped to `end`), the subtraction underflows, +// and a range that lies entirely past the search region would be accepted. +// Inside JitAllocator that produced an area_index outside the block, an +// oversized Span, and an eventual write to unmapped memory. +static void test_bit_vector_range_iterator_bounds() noexcept { + using Bw = Support::BitWord; + constexpr Bw all_ones = Support::bit_ones; + constexpr size_t kBwBits = Support::bit_size_of; + + // Case 1: start and end share the same BitWord; free bits live past end-in-word. + // bitmap = [used: bits 0..(kBwBits/2)-1, free: bits (kBwBits/2)..kBwBits-1] + // search range = [8, 16). Without the fix, ctz picks bit kBwBits/2 -> range_start = kBwBits/2 > 16. + { + Bw bitmap[1]; + bitmap[0] = (Bw(1) << (kBwBits / 2u)) - Bw(1); + BitVectorRangeIterator it(bitmap, 1u, 8u, 16u); + size_t s = 0, e = 0; + while (it.next_range(Out(s), Out(e))) { + EXPECT_LT(s, 16u); + EXPECT_LE(e, 16u); + EXPECT_LE(s, e); + } + } + + // Case 2: end mid-word in the last BitWord scanned; free bits live past end. + // This mirrors a JitAllocator state observed in production: a 64-BitWord + // bitmap, search range [72, 4083), and 3 free bits at positions 61-63 of + // word 63 (absolute 4093-4095). Without the fix the iterator returned + // range_start = 4093, range_end clamped to 4083. + { + constexpr size_t kWordCount = 64; + Bw bitmap[kWordCount]; + for (size_t i = 0; i < kWordCount; i++) { + bitmap[i] = all_ones; + } + bitmap[kWordCount - 1u] = ~(Bw(0x7) << (kBwBits - 3u)); + + BitVectorRangeIterator it(bitmap, kWordCount, 72u, 4083u); + size_t s = 0, e = 0; + while (it.next_range(Out(s), Out(e))) { + EXPECT_LT(s, 4083u); + EXPECT_LE(e, 4083u); + EXPECT_LE(s, e); + } + } +} + +// Regression test for JitAllocator returning an oversized Span when bitmap +// search finds a range past the block boundary. Drives the allocator into the +// exact state captured in a QuestDB production core dump: an almost-full +// block where the iterator can match the last few free areas at the tail +// even though the search-region end is set below them. +// +// The hand-crafted state cannot be reached purely via the public API in a +// few calls, so this test directly manipulates the block bookkeeping it has +// access to. It relies on the block being the cursor block of the default +// pool, which is true right after the first allocation lands. +static void test_jit_allocator_search_end_bounds() noexcept { + JitAllocator allocator; + + // Force a block to come into existence and capture an allocation we can + // build the rest of the state around. + JitAllocator::Span anchor_span; + EXPECT_EQ(allocator.alloc(Out(anchor_span), 64u), Error::kOk); + EXPECT_NOT_NULL(anchor_span.rx()); + + JitAllocatorBlock* block = static_cast(anchor_span._block); + EXPECT_NOT_NULL(block); + + JitAllocatorPool* pool = block->pool(); + uint32_t area_size = block->area_size(); + uint32_t granularity = pool->granularity; + + // Mirror the production state captured in a QuestDB core dump: + // - area_used = area_size - 14 (14 free total), so area_available >= 7 + // and alloc enters the bitmap-search path; + // - 11 of those free areas are fragmented inside [_search_start, _search_end), + // none of them a contiguous run >= 7, so the search runs to the last + // BitWord without taking the fast match; + // - the remaining 3 free areas sit at the very tail (area_size - 3 .. + // area_size - 1), past _search_end. The buggy iterator picks those up + // and the caller treats the underflowed range_size as oversize fit. + Support::bit_vector_fill(block->_used_bit_vector, 0u, area_size); + + // Fragmented free areas inside the search range, none contiguous >= 7. + Support::bit_vector_clear(block->_used_bit_vector, 1232u, 5u); + Support::bit_vector_clear(block->_used_bit_vector, 1575u, 1u); + Support::bit_vector_clear(block->_used_bit_vector, 2843u, 5u); + // 3 free areas past _search_end. + Support::bit_vector_clear(block->_used_bit_vector, area_size - 3u, 3u); + + // Anchor sentinels - one per "live" allocation. The exact placement doesn't + // matter for triggering the bug, it just keeps mark_released_area's + // bookkeeping reachable when we tear the test down. + Support::bit_vector_set_bit(block->_stop_bit_vector, area_size - 4u, true); + + block->_area_used = area_size - 14u; + block->_largest_unused_area = 5u; + block->_search_start = 72u; + block->_search_end = area_size - 13u; + block->add_flags(JitAllocatorBlock::kFlagDirty); + block->clear_flags(JitAllocatorBlock::kFlagIncremental); + + // Ask for 7 areas. With the bug, alloc accepts area_index = area_size - 3 + // (start of the free tail past _search_end), reports a Span of 7 areas + // worth of memory, and that Span extends 4 areas past the block end. With + // the fix, the bitmap search reports no fit and a fresh block is allocated. + JitAllocator::Span span; + EXPECT_EQ(allocator.alloc(Out(span), size_t(7u) * granularity), Error::kOk); + EXPECT_NOT_NULL(span.rx()); + + uint8_t* span_rx = static_cast(span.rx()); + uint8_t* span_end = span_rx + span.size(); + uint8_t* block_end = block->rx_ptr() + block->block_size(); + + if (span_rx >= block->rx_ptr() && span_rx < block_end) { + EXPECT_LE(span_end, block_end) + .message("Span [%p:%p] extends past block end %p", span_rx, span_end, block_end); + } +} + static void test_jit_allocator_reset_empty() noexcept { JitAllocator allocator; allocator.reset(ResetPolicy::kSoft); @@ -1635,6 +1766,8 @@ static void test_jit_allocator_query() noexcept { } UNIT(jit_allocator) { + test_bit_vector_range_iterator_bounds(); + test_jit_allocator_search_end_bounds(); test_jit_allocator_reset_empty(); test_jit_allocator_alloc_release(); test_jit_allocator_query(); From 33992601c95847a9aaefffd69fd502aab25e966a Mon Sep 17 00:00:00 2001 From: Andrei Pechkurov Date: Wed, 27 May 2026 15:51:57 +0300 Subject: [PATCH 2/3] Minor clean-up --- asmjit/core/jitallocator.cpp | 72 +++++++++++++++--------------------- 1 file changed, 30 insertions(+), 42 deletions(-) diff --git a/asmjit/core/jitallocator.cpp b/asmjit/core/jitallocator.cpp index 5662566a..e54dfd04 100644 --- a/asmjit/core/jitallocator.cpp +++ b/asmjit/core/jitallocator.cpp @@ -107,12 +107,13 @@ class BitVectorRangeIterator { size_t i = Support::ctz(_bit_word); - *range_start = _idx + i; - if (ASMJIT_UNLIKELY(*range_start >= _end)) { + size_t start = _idx + i; + if (ASMJIT_UNLIKELY(start >= _end)) { // `init()` only masks bits before `start`, so the current BitWord can // carry free bits past `_end` and `ctz` would return such a position. return false; } + *range_start = start; _bit_word = ~(_bit_word ^ ~(Support::bit_ones << i)); if (_bit_word == 0) { @@ -1456,22 +1457,16 @@ static void BitVectorRangeIterator_testRandom(TestUtils::Random& rnd, size_t cou } // Regression test for BitVectorRangeIterator returning ranges past `end`. -// // `init()` only masks bits before `start`, so a BitWord can carry free bits -// past `end`. Without bounding `range_start` against `end`, `ctz` could pick -// such a bit, the caller would then compute `range_size = range_end - range_start` -// as `size_t` (range_end gets clamped to `end`), the subtraction underflows, -// and a range that lies entirely past the search region would be accepted. -// Inside JitAllocator that produced an area_index outside the block, an -// oversized Span, and an eventual write to unmapped memory. +// past `end` and ctz could pick one - see next_range() for the bound check. static void test_bit_vector_range_iterator_bounds() noexcept { using Bw = Support::BitWord; constexpr Bw all_ones = Support::bit_ones; constexpr size_t kBwBits = Support::bit_size_of; // Case 1: start and end share the same BitWord; free bits live past end-in-word. - // bitmap = [used: bits 0..(kBwBits/2)-1, free: bits (kBwBits/2)..kBwBits-1] - // search range = [8, 16). Without the fix, ctz picks bit kBwBits/2 -> range_start = kBwBits/2 > 16. + // bitmap: bits 0..kBwBits/2-1 used, rest free; search [8, 16). Without the + // fix, ctz picks bit kBwBits/2 (16 or 32) -> range_start >= end. { Bw bitmap[1]; bitmap[0] = (Bw(1) << (kBwBits / 2u)) - Bw(1); @@ -1484,11 +1479,9 @@ static void test_bit_vector_range_iterator_bounds() noexcept { } } - // Case 2: end mid-word in the last BitWord scanned; free bits live past end. - // This mirrors a JitAllocator state observed in production: a 64-BitWord - // bitmap, search range [72, 4083), and 3 free bits at positions 61-63 of - // word 63 (absolute 4093-4095). Without the fix the iterator returned - // range_start = 4093, range_end clamped to 4083. + // Case 2: end mid-word in the last BitWord; mirrors a production state. + // 64-word bitmap, search [72, 4083), free bits at 4093-4095. Without the + // fix, iterator returns range_start = 4093, range_end clamped to 4083. { constexpr size_t kWordCount = 64; Bw bitmap[kWordCount]; @@ -1508,20 +1501,17 @@ static void test_bit_vector_range_iterator_bounds() noexcept { } // Regression test for JitAllocator returning an oversized Span when bitmap -// search finds a range past the block boundary. Drives the allocator into the -// exact state captured in a QuestDB production core dump: an almost-full -// block where the iterator can match the last few free areas at the tail -// even though the search-region end is set below them. +// search finds a range past the block boundary. Mirrors a QuestDB production +// core dump: an almost-full block where the iterator can match the last few +// free areas at the tail even though the search-region end is set below them. // -// The hand-crafted state cannot be reached purely via the public API in a -// few calls, so this test directly manipulates the block bookkeeping it has -// access to. It relies on the block being the cursor block of the default -// pool, which is true right after the first allocation lands. +// The state isn't reachable through the public API in a few calls, so this +// test pokes JitAllocatorBlock internals directly and must move with any +// block-bookkeeping refactor. static void test_jit_allocator_search_end_bounds() noexcept { JitAllocator allocator; - // Force a block to come into existence and capture an allocation we can - // build the rest of the state around. + // Force a block to exist so we can hand-craft state on it. JitAllocator::Span anchor_span; EXPECT_EQ(allocator.alloc(Out(anchor_span), 64u), Error::kOk); EXPECT_NOT_NULL(anchor_span.rx()); @@ -1533,15 +1523,11 @@ static void test_jit_allocator_search_end_bounds() noexcept { uint32_t area_size = block->area_size(); uint32_t granularity = pool->granularity; - // Mirror the production state captured in a QuestDB core dump: - // - area_used = area_size - 14 (14 free total), so area_available >= 7 - // and alloc enters the bitmap-search path; - // - 11 of those free areas are fragmented inside [_search_start, _search_end), - // none of them a contiguous run >= 7, so the search runs to the last - // BitWord without taking the fast match; - // - the remaining 3 free areas sit at the very tail (area_size - 3 .. - // area_size - 1), past _search_end. The buggy iterator picks those up - // and the caller treats the underflowed range_size as oversize fit. + // Production state: 14 free areas total. 11 are fragmented inside the + // search range with no contiguous run >= 7 (forces the search to the last + // BitWord). The other 3 sit at the tail past _search_end - the buggy + // iterator picks them up and the caller takes the underflowed range_size + // as an oversize fit. Support::bit_vector_fill(block->_used_bit_vector, 0u, area_size); // Fragmented free areas inside the search range, none contiguous >= 7. @@ -1551,9 +1537,9 @@ static void test_jit_allocator_search_end_bounds() noexcept { // 3 free areas past _search_end. Support::bit_vector_clear(block->_used_bit_vector, area_size - 3u, 3u); - // Anchor sentinels - one per "live" allocation. The exact placement doesn't - // matter for triggering the bug, it just keeps mark_released_area's - // bookkeeping reachable when we tear the test down. + // Anchor sentinel - placement doesn't matter for triggering the bug. The + // inconsistent _used/_stop state is safe at teardown: ~JitAllocator -> + // reset(kHard) walks pool.blocks and frees each without traversing sentinels. Support::bit_vector_set_bit(block->_stop_bit_vector, area_size - 4u, true); block->_area_used = area_size - 14u; @@ -1563,10 +1549,9 @@ static void test_jit_allocator_search_end_bounds() noexcept { block->add_flags(JitAllocatorBlock::kFlagDirty); block->clear_flags(JitAllocatorBlock::kFlagIncremental); - // Ask for 7 areas. With the bug, alloc accepts area_index = area_size - 3 - // (start of the free tail past _search_end), reports a Span of 7 areas - // worth of memory, and that Span extends 4 areas past the block end. With - // the fix, the bitmap search reports no fit and a fresh block is allocated. + // Ask for 7 areas. With the bug, alloc picks the tail past _search_end and + // hands back a Span extending past the block end. With the fix, the search + // reports no fit and a fresh block is allocated. JitAllocator::Span span; EXPECT_EQ(allocator.alloc(Out(span), size_t(7u) * granularity), Error::kOk); EXPECT_NOT_NULL(span.rx()); @@ -1575,6 +1560,9 @@ static void test_jit_allocator_search_end_bounds() noexcept { uint8_t* span_end = span_rx + span.size(); uint8_t* block_end = block->rx_ptr() + block->block_size(); + // With the fix, span lands in a fresh block and this guard is false; the + // primary signal is the absence of the in-block-bound assert in alloc. + // Kept as a guard against regressions that return the buggy in-block span. if (span_rx >= block->rx_ptr() && span_rx < block_end) { EXPECT_LE(span_end, block_end) .message("Span [%p:%p] extends past block end %p", span_rx, span_end, block_end); From 3613030a48c54317937363c37ff6833201e8700b Mon Sep 17 00:00:00 2001 From: Andrei Pechkurov Date: Wed, 27 May 2026 15:58:16 +0300 Subject: [PATCH 3/3] Fix tests on 32-bit platforms --- asmjit/core/jitallocator.cpp | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/asmjit/core/jitallocator.cpp b/asmjit/core/jitallocator.cpp index e54dfd04..0952df27 100644 --- a/asmjit/core/jitallocator.cpp +++ b/asmjit/core/jitallocator.cpp @@ -1480,21 +1480,24 @@ static void test_bit_vector_range_iterator_bounds() noexcept { } // Case 2: end mid-word in the last BitWord; mirrors a production state. - // 64-word bitmap, search [72, 4083), free bits at 4093-4095. Without the - // fix, iterator returns range_start = 4093, range_end clamped to 4083. + // Last 3 bits of the bitmap are free, end is placed 13 bits before total + // (mid last word). Without the fix, iterator returns range_start at the + // tail free bits >= end. { constexpr size_t kWordCount = 64; + constexpr size_t kTotalBits = kWordCount * kBwBits; + constexpr size_t kEnd = kTotalBits - 13u; Bw bitmap[kWordCount]; for (size_t i = 0; i < kWordCount; i++) { bitmap[i] = all_ones; } bitmap[kWordCount - 1u] = ~(Bw(0x7) << (kBwBits - 3u)); - BitVectorRangeIterator it(bitmap, kWordCount, 72u, 4083u); + BitVectorRangeIterator it(bitmap, kWordCount, 72u, kEnd); size_t s = 0, e = 0; while (it.next_range(Out(s), Out(e))) { - EXPECT_LT(s, 4083u); - EXPECT_LE(e, 4083u); + EXPECT_LT(s, kEnd); + EXPECT_LE(e, kEnd); EXPECT_LE(s, e); } } @@ -1523,6 +1526,12 @@ static void test_jit_allocator_search_end_bounds() noexcept { uint32_t area_size = block->area_size(); uint32_t granularity = pool->granularity; + // Need enough room to place a search range with fragmented free bits inside + // and 3 free bits at the tail past _search_end. + if (area_size < 256u) { + return; + } + // Production state: 14 free areas total. 11 are fragmented inside the // search range with no contiguous run >= 7 (forces the search to the last // BitWord). The other 3 sit at the tail past _search_end - the buggy @@ -1531,9 +1540,10 @@ static void test_jit_allocator_search_end_bounds() noexcept { Support::bit_vector_fill(block->_used_bit_vector, 0u, area_size); // Fragmented free areas inside the search range, none contiguous >= 7. - Support::bit_vector_clear(block->_used_bit_vector, 1232u, 5u); - Support::bit_vector_clear(block->_used_bit_vector, 1575u, 1u); - Support::bit_vector_clear(block->_used_bit_vector, 2843u, 5u); + uint32_t mid = area_size / 2u; + Support::bit_vector_clear(block->_used_bit_vector, mid - 64u, 5u); + Support::bit_vector_clear(block->_used_bit_vector, mid, 1u); + Support::bit_vector_clear(block->_used_bit_vector, mid + 64u, 5u); // 3 free areas past _search_end. Support::bit_vector_clear(block->_used_bit_vector, area_size - 3u, 3u);