From fae375b7ef5d7506bb41eb0018505f491d4910df Mon Sep 17 00:00:00 2001 From: Aoto Date: Tue, 29 Sep 2026 21:21:22 +0200 Subject: [PATCH] slab/multislab/pool: reject bad releases, fix limits, add LIBMEM_ASSERT --- CMakeLists.txt | 3 ++ docs/integration.md | 10 +++++ src/libmem/allocator.cppm | 2 +- src/libmem/arena.cppm | 10 ++--- src/libmem/assert.hpp | 17 ++++++++ src/libmem/concepts.cppm | 17 +++++--- src/libmem/multislab.cppm | 51 +++++++++++++++--------- src/libmem/pool.cppm | 11 ++++-- src/libmem/slab.cppm | 77 ++++++++++++++++++++---------------- src/libmem/sparse_index.cppm | 10 ++--- src/libmem/sparse_set.cppm | 20 +++++----- src/libmem/storage.cppm | 20 +++++----- src/libmem/typed_arena.cppm | 10 ++--- src/libmem/vector.cppm | 18 ++++----- tests/src/multislab_test.cpp | 63 +++++++++++++++++++++++++++++ tests/src/pool_test.cpp | 27 +++++++++++++ tests/src/slab_test.cpp | 56 ++++++++++++++++++++++++++ 17 files changed, 316 insertions(+), 106 deletions(-) create mode 100644 src/libmem/assert.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 832ba4b..6adaf54 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -257,6 +257,9 @@ if(LIBMEM_INSTALL) FILE_SET CXX_MODULES DESTINATION "${CMAKE_INSTALL_DATADIR}/libmem/modules" ) + # Next to the .cppm files: they include it by a relative quoted path. + install(FILES src/libmem/assert.hpp DESTINATION "${CMAKE_INSTALL_DATADIR}/libmem/modules/libmem") + install(EXPORT libmem-targets FILE libmem-targets.cmake NAMESPACE libmem:: diff --git a/docs/integration.md b/docs/integration.md index f440645..4eb3a9c 100644 --- a/docs/integration.md +++ b/docs/integration.md @@ -132,6 +132,16 @@ set options like `-fno-rtti`, LTO, and sanitizers project-wide, and have libmem the rest of the build agree. A mismatch forks the std BMI at best and is ill-formed at worst. +## Precondition checks + +Library checks are `assert`. Defining `LIBMEM_CONTRACTS` project-wide turns them +into `contract_assert` where the compiler has contracts, governed by +`-fcontract-evaluation-semantic` instead of `NDEBUG`. Not usable yet: GCC 16 fails +to compile or link importers of such modules. + +A bad release (foreign or misaligned pointer, double free) is rejected without +touching allocator state, even with the check compiled out. + ## Option naming `USE_SANITIZERS` and `THREAD_SANITIZER` are intentionally **not** prefixed: diff --git a/src/libmem/allocator.cppm b/src/libmem/allocator.cppm index 513c3e6..d9a4597 100644 --- a/src/libmem/allocator.cppm +++ b/src/libmem/allocator.cppm @@ -26,7 +26,7 @@ */ module; -#include +#include "assert.hpp" export module libmem:allocator; diff --git a/src/libmem/arena.cppm b/src/libmem/arena.cppm index f6ee2e4..a49d477 100644 --- a/src/libmem/arena.cppm +++ b/src/libmem/arena.cppm @@ -25,7 +25,7 @@ */ module; -#include +#include "assert.hpp" export module libmem:arena; @@ -65,7 +65,7 @@ public: */ constexpr arena(const std::span buffer, const std::size_t align = default_alignment) noexcept : begin_{buffer.data()}, end_{buffer.data() + buffer.size()}, cursor_{buffer.data()}, default_alignment_{align}, owns_buffer_{false} { - assert(align > 0 && (align & (align - 1)) == 0 && "alignment must be a power of two"); + LIBMEM_ASSERT(align > 0 && (align & (align - 1)) == 0 && "alignment must be a power of two"); } /** @@ -76,8 +76,8 @@ public: */ inline explicit arena(const std::size_t size, const std::size_t align = default_alignment) : begin_{new std::byte[size]}, end_{begin_ + size}, cursor_{begin_}, default_alignment_{align}, owns_buffer_{true} { - assert(size > 0); - assert(align > 0 && (align & (align - 1)) == 0 && "alignment must be a power of two"); + LIBMEM_ASSERT(size > 0); + LIBMEM_ASSERT(align > 0 && (align & (align - 1)) == 0 && "alignment must be a power of two"); } arena(const arena&) = delete; @@ -110,7 +110,7 @@ public: * @return Pointer to the freshly bumped region, or `nullptr` if exhausted. */ [[nodiscard]] inline void* allocate(const std::size_t bytes, const std::size_t alignment) noexcept { - assert(alignment > 0 && (alignment & (alignment - 1)) == 0 && "alignment must be a power of two"); + LIBMEM_ASSERT(alignment > 0 && (alignment & (alignment - 1)) == 0 && "alignment must be a power of two"); const auto current{reinterpret_cast(cursor_)}; const auto aligned{(current + alignment - 1) & ~static_cast(alignment - 1)}; diff --git a/src/libmem/assert.hpp b/src/libmem/assert.hpp new file mode 100644 index 0000000..35fc6cd --- /dev/null +++ b/src/libmem/assert.hpp @@ -0,0 +1,17 @@ +// Copyright (c) 2026 Aoto +// SPDX-License-Identifier: MIT +/** + * @file assert.hpp + * @brief `LIBMEM_ASSERT`: `contract_assert` when opted in and supported, `assert` otherwise. + * + * Include in a module's global fragment. Opt in by defining `LIBMEM_CONTRACTS` + * project-wide; see docs/integration.md for what a consumer then needs. + */ +#pragma once + +#if defined(LIBMEM_CONTRACTS) && defined(__cpp_contracts) +#define LIBMEM_ASSERT(...) contract_assert(__VA_ARGS__) // NOLINT(cppcoreguidelines-macro-usage) +#else +#include +#define LIBMEM_ASSERT(...) assert(__VA_ARGS__) // NOLINT(cppcoreguidelines-macro-usage) +#endif diff --git a/src/libmem/concepts.cppm b/src/libmem/concepts.cppm index 01ec2de..e4f72f1 100644 --- a/src/libmem/concepts.cppm +++ b/src/libmem/concepts.cppm @@ -21,7 +21,7 @@ */ module; -#include +#include "assert.hpp" export module libmem:concepts; @@ -77,12 +77,19 @@ concept valid_alignment = power_of_two; export template concept valid_block_geometry = (Size > 0) && valid_alignment && (Size % Align == 0); +/** @brief A valid block geometry for `Count > 0` blocks whose total size fits in `std::size_t`. */ +export template +concept valid_slab_geometry = valid_block_geometry && (Count > 0) && (Count <= std::numeric_limits::max() / Size); + static_assert(valid_block_geometry); static_assert(valid_block_geometry<4, 4>); static_assert(!valid_block_geometry<4, 8>); static_assert(!valid_block_geometry<24, 16>); static_assert(!valid_alignment<0>); static_assert(!valid_alignment<24>); +static_assert(valid_slab_geometry<64, 64, 64>); +static_assert(!valid_slab_geometry<64, 0, 64>); +static_assert(!valid_slab_geometry::max() / 2 + 1, 2, 1>); /* ============================================================================ * Memory resource concept & default implementation @@ -190,26 +197,26 @@ public: constexpr explicit resource_ref(R& resource) noexcept : resource_{std::addressof(resource)} {} void* allocate(const std::size_t size) { - assert(resource_ != nullptr && "resource_ref: no referent"); + LIBMEM_ASSERT(resource_ != nullptr && "resource_ref: no referent"); return resource_->allocate(size); } void deallocate(void* ptr, const std::size_t size) noexcept { - assert(resource_ != nullptr && "resource_ref: no referent"); + LIBMEM_ASSERT(resource_ != nullptr && "resource_ref: no referent"); resource_->deallocate(ptr, size); } void* allocate(const std::size_t size, const std::size_t align) requires aligned_memory_resource { - assert(resource_ != nullptr && "resource_ref: no referent"); + LIBMEM_ASSERT(resource_ != nullptr && "resource_ref: no referent"); return resource_->allocate(size, align); } void deallocate(void* ptr, const std::size_t size, const std::size_t align) noexcept requires aligned_memory_resource { - assert(resource_ != nullptr && "resource_ref: no referent"); + LIBMEM_ASSERT(resource_ != nullptr && "resource_ref: no referent"); resource_->deallocate(ptr, size, align); } diff --git a/src/libmem/multislab.cppm b/src/libmem/multislab.cppm index ceed3a1..ac1f082 100644 --- a/src/libmem/multislab.cppm +++ b/src/libmem/multislab.cppm @@ -22,7 +22,7 @@ */ module; -#include +#include "assert.hpp" export module libmem:multislab; @@ -83,7 +83,7 @@ template - requires valid_block_geometry && (BlocksPerSlab > 0) + requires valid_slab_geometry class multislab { using node_type = detail::slab_node; using slab_type = typename node_type::slab_type; @@ -155,16 +155,18 @@ public: /** * @brief Release a block previously obtained from `allocate()`. - * @pre `ptr` was allocated from this multislab and has not been double-freed. + * @pre `ptr` is a live block of this multislab. A violation is rejected + * without touching any state: a foreign or misaligned pointer, or a double free. * @note O(S): finds the owning slab by scanning. Prefer `deallocate(iterator)` * when a position is at hand. */ void deallocate(void* ptr) noexcept { - assert(ptr != nullptr); - node_type* node{find_owner(ptr)}; - assert(node != nullptr && "pointer not owned by this allocator"); - + const bool ours{node != nullptr && node->allocator.owns(ptr)}; + LIBMEM_ASSERT(ours && "multislab: pointer is not a block of this allocator"); + if (!ours) [[unlikely]] { + return; + } release(node, node->allocator.index_of(ptr)); } @@ -307,10 +309,14 @@ public: /** * @brief Release the block `pos` points at, in O(1). - * @pre `pos` is dereferenceable. Iterators to other blocks stay valid. + * @pre `pos` is dereferenceable and its block live; rejected like `deallocate(ptr)` otherwise. + * Iterators to other blocks stay valid. */ void deallocate(const iterator& pos) noexcept { - assert(pos != end()); + LIBMEM_ASSERT(pos != end()); + if (pos == end()) [[unlikely]] { + return; + } release(pos.node_, pos.slab_iter_.index()); } @@ -358,7 +364,7 @@ private: node_type* node{active_}; const auto alloc{node->allocator.allocate_at()}; - assert(alloc.ptr != nullptr && "a slab on the active list has no free slot"); + LIBMEM_ASSERT(alloc.ptr != nullptr && "a slab on the active list has no free slot"); if (node->used++ == 0) { --empty_count_; @@ -370,11 +376,13 @@ private: } void release(node_type* node, const std::uint32_t index) noexcept { + /* First: a rejected release must not move the node or count it down. */ + if (!node->allocator.deallocate_at(index)) [[unlikely]] { + return; + } if (node->full()) [[unlikely]] { move_to_active(node); } - - node->allocator.deallocate_at(index); node->used--; /* Became empty: apply shrink policy. */ @@ -428,20 +436,27 @@ private: return false; } - /* Allocate the node. */ - void* node_mem{resource_.allocate(sizeof(node_type))}; - if (!node_mem) [[unlikely]] { + /* Owns the node memory until the node is built, so a failing or throwing + * slab allocation cannot leak it. */ + struct node_memory { + multislab& owner; + void* ptr; + ~node_memory() { + if (ptr) { + owner.resource_.deallocate(ptr, sizeof(node_type)); + } + } + } node_mem{*this, resource_.allocate(sizeof(node_type))}; + if (!node_mem.ptr) [[unlikely]] { return false; } - /* Allocate the slab backing memory. */ void* slab_mem{allocate_slab_memory()}; if (!slab_mem) [[unlikely]] { - resource_.deallocate(node_mem, sizeof(node_type)); return false; } - auto* node{::new (node_mem) node_type{slab_mem, slab_memory_size}}; + auto* node{::new (std::exchange(node_mem.ptr, nullptr)) node_type{slab_mem, slab_memory_size}}; push_front<&node_type::space>(active_, node); push_front<&node_type::all>(nodes_, node); diff --git a/src/libmem/pool.cppm b/src/libmem/pool.cppm index dd66f1e..d45ebbf 100644 --- a/src/libmem/pool.cppm +++ b/src/libmem/pool.cppm @@ -27,7 +27,7 @@ */ module; -#include +#include "assert.hpp" export module libmem:pool; @@ -81,10 +81,12 @@ template consteval std::uint32_t default_pool_blocks_per_slab() noe export struct slab_limit { std::uint32_t value{}; - /* Non-explicit and integral-taking, so `slab_limit{4}` needs no `u` suffix. */ constexpr slab_limit() noexcept = default; - template constexpr slab_limit(const N count) noexcept : value{static_cast(count)} {} // NOLINT(google-explicit-constructor) + /* Explicit, so `pool p(4)` does not compile. Any integral, so `slab_limit{4}` needs no `u` suffix. */ + template constexpr explicit slab_limit(const N count) noexcept : value{static_cast(count)} { + LIBMEM_ASSERT(std::in_range(count) && "slab_limit: count must fit in std::uint32_t"); + } constexpr bool operator==(const slab_limit&) const noexcept = default; }; @@ -381,13 +383,14 @@ public: /** * @brief Destroy the element referenced by `it` and free its slot. + * @pre `it` is dereferenceable. * @return Iterator pointing to the element following the erased one, * or `end()` if `it` referenced the last live element. * * Iterators and pointers to other elements remain valid. */ iterator erase(const_iterator it) noexcept(std::is_nothrow_destructible_v) { - assert(size_ > 0); + LIBMEM_ASSERT(it != end() && "pool: erase(end())"); pool_iterator inner{it.inner_}; pool_iterator next{inner}; diff --git a/src/libmem/slab.cppm b/src/libmem/slab.cppm index aad2b0a..80ae8ca 100644 --- a/src/libmem/slab.cppm +++ b/src/libmem/slab.cppm @@ -16,7 +16,7 @@ */ module; -#include +#include "assert.hpp" export module libmem:slab; @@ -42,24 +42,22 @@ namespace detail { /** @brief Compute the number of bitmap words needed for `block_count` blocks. */ consteval std::size_t words_for(const std::uint32_t block_count) noexcept { - return (block_count + bitmap_word_bits - 1) / bitmap_word_bits; + return (std::size_t{block_count} + bitmap_word_bits - 1) / bitmap_word_bits; } /** * @brief Find the first free (zero) bit across `Words` bitmap words. - * @return Index of the free bit, or -1 if all bits are set. + * @return Index of the free bit, or `capacity` if none is below it. + * @pre Bits at or above `capacity` are clear. */ -template constexpr std::int32_t bitmap_find_free(const std::array& bitmap, const std::uint32_t capacity) noexcept { +template constexpr std::uint32_t bitmap_find_free(const std::array& bitmap, const std::uint32_t capacity) noexcept { for (std::size_t i{0}; i < Words; ++i) { - if (~bitmap[i]) { - const auto bit = static_cast(std::countr_zero(static_cast(~bitmap[i]))); - const std::uint32_t index{static_cast(i) * bitmap_word_bits + bit}; - if (index < capacity) { - return static_cast(index); - } + if (const std::uint64_t free{~bitmap[i]}) { + const std::size_t index{i * bitmap_word_bits + static_cast(std::countr_zero(free))}; + return index < capacity ? static_cast(index) : capacity; } } - return -1; + return capacity; } /** @brief Test whether a bit is set. */ @@ -94,7 +92,7 @@ template constexpr void bitmap_clear(std::array - requires valid_block_geometry && (MaxBlocks > 0) + requires valid_slab_geometry class slab { static constexpr std::size_t bitmap_words{detail::words_for(MaxBlocks)}; @@ -107,8 +105,8 @@ public: /** * @brief Construct from a user-provided memory region. * @param memory Base pointer to the backing storage. - * @param memory_size Size of the backing storage (must be >= `required_memory`). - * @pre `memory != nullptr && memory_size >= required_memory`. + * @param memory_size Size of the backing storage; blocks past `MaxBlocks` go unused. + * @pre `memory != nullptr && memory_size >= BlockSize`. * @pre `memory` is at least `BlockAlign`-aligned. * * @warning Blocks are `memory + index * BlockSize`, so they inherit the base's alignment; @@ -116,13 +114,12 @@ public: * storage. */ constexpr slab(void* memory, const std::size_t memory_size) noexcept - : memory_{static_cast(memory)}, block_count_{static_cast(memory_size / BlockSize)} { - assert(memory != nullptr); - assert(memory_size >= BlockSize); - assert(block_count_ <= MaxBlocks); + : memory_{static_cast(memory)}, block_count_{static_cast(std::min(memory_size / BlockSize, std::size_t{MaxBlocks}))} { + LIBMEM_ASSERT(memory != nullptr); + LIBMEM_ASSERT(block_count_ > 0 && "slab: memory_size is smaller than one block"); /* Short-circuited: reinterpret_cast is ill-formed in constant evaluation. */ - assert((std::is_constant_evaluated() || reinterpret_cast(memory) % BlockAlign == 0) && - "slab: backing memory must be aligned to BlockAlign"); + LIBMEM_ASSERT((std::is_constant_evaluated() || reinterpret_cast(memory) % BlockAlign == 0) && + "slab: backing memory must be aligned to BlockAlign"); } /** @@ -150,11 +147,10 @@ public: * @return `{ptr, index}` on success, `{nullptr, 0}` when the slab is full. */ [[nodiscard]] constexpr allocation allocate_at() noexcept { - const auto found{detail::bitmap_find_free(bitmap_, block_count_)}; - if (found < 0) [[unlikely]] { + const auto index{detail::bitmap_find_free(bitmap_, block_count_)}; + if (index == block_count_) [[unlikely]] { return {}; } - const auto index{static_cast(found)}; detail::bitmap_set(bitmap_, index); return {index_to_ptr(index), index}; } @@ -162,34 +158,41 @@ public: /** * @brief Release a previously allocated block. * @param ptr Pointer previously returned by `allocate()`. - * @pre `ptr` was returned by this slab's `allocate()` and has not been double-freed. + * @pre `ptr` is an allocated block of this slab. + * @return `false`, leaving the slab untouched, when the precondition does not hold. */ - constexpr void deallocate(void* ptr) noexcept { - assert(ptr != nullptr); - deallocate_at(index_of(ptr)); + bool deallocate(void* ptr) noexcept { + const bool ours{owns(ptr)}; + LIBMEM_ASSERT(ours && "slab: pointer is not a block of this slab"); + return ours && deallocate_at(unchecked_index_of(ptr)); } /** * @brief Release the block at bit-index `index`, as reported by `allocate_at()` or an iterator. * @pre The block at `index` is allocated. + * @return `false`, leaving the slab untouched, when the precondition does not hold. */ - constexpr void deallocate_at(const std::uint32_t index) noexcept { - assert(index < block_count_); - assert(detail::bitmap_test(bitmap_, index) && "double-free detected"); + constexpr bool deallocate_at(const std::uint32_t index) noexcept { + const bool live{index < block_count_ && detail::bitmap_test(bitmap_, index)}; + LIBMEM_ASSERT(live && "slab: block is not allocated (double free?)"); + if (!live) [[unlikely]] { + return false; + } detail::bitmap_clear(bitmap_, index); + return true; } /** * @brief Bit-index of the block at `ptr`. * @pre `owns(ptr)`. */ - constexpr std::uint32_t index_of(const void* ptr) const noexcept { - assert(owns(ptr)); - return static_cast((reinterpret_cast(ptr) - reinterpret_cast(memory_)) / BlockSize); + std::uint32_t index_of(const void* ptr) const noexcept { + LIBMEM_ASSERT(owns(ptr)); + return unchecked_index_of(ptr); } - /** @brief Test whether `ptr` belongs to this slab's memory region. */ - constexpr bool owns(const void* ptr) const noexcept { + /** @brief Test whether `ptr` is a block of this slab: in range and on a block boundary. */ + bool owns(const void* ptr) const noexcept { const auto p{reinterpret_cast(ptr)}; const auto base{reinterpret_cast(memory_)}; const auto end{base + static_cast(block_count_) * BlockSize}; @@ -308,6 +311,10 @@ private: constexpr void* index_to_ptr(const std::uint32_t index) const noexcept { return static_cast(memory_ + static_cast(index) * BlockSize); } + std::uint32_t unchecked_index_of(const void* ptr) const noexcept { + return static_cast((reinterpret_cast(ptr) - reinterpret_cast(memory_)) / BlockSize); + } + /** @brief Index of the lowest set bit of `word`, the `word_idx`-th bitmap word. */ static constexpr std::uint32_t first_in(const std::size_t word_idx, const std::uint64_t word) noexcept { return static_cast(word_idx * bitmap_word_bits + static_cast(std::countr_zero(word))); diff --git a/src/libmem/sparse_index.cppm b/src/libmem/sparse_index.cppm index 0468c40..0574ec5 100644 --- a/src/libmem/sparse_index.cppm +++ b/src/libmem/sparse_index.cppm @@ -25,7 +25,7 @@ */ module; -#include +#include "assert.hpp" export module libmem:sparse_index; @@ -162,7 +162,7 @@ public: constexpr size_type get(const size_type at) const noexcept { return at < slots_.capacity() ? slots_.data()[at] : npos; } constexpr void set(const size_type at, const size_type index) noexcept { - assert(at < slots_.capacity() && "flat_sparse_index: subscript not reserved"); + LIBMEM_ASSERT(at < slots_.capacity() && "flat_sparse_index: subscript not reserved"); slots_.data()[at] = index; } @@ -172,7 +172,7 @@ public: * index untouched. Always `false` past a fixed extent. */ constexpr bool reserve_for(const size_type at) { - assert(at != npos && "flat_sparse_index: npos is the reserved tombstone, not a subscript"); + LIBMEM_ASSERT(at != npos && "flat_sparse_index: npos is the reserved tombstone, not a subscript"); if (at < slots_.capacity()) { return true; @@ -303,7 +303,7 @@ public: void set(const size_type at, const size_type index) noexcept { const size_type page{at >> page_shift}; - assert(page < pages_ && directory_.data()[page] != nullptr && "paged_sparse_index: subscript not reserved"); + LIBMEM_ASSERT(page < pages_ && directory_.data()[page] != nullptr && "paged_sparse_index: subscript not reserved"); directory_.data()[page][at & page_mask] = index; } @@ -317,7 +317,7 @@ public: * @return `false` when the resource could not supply the space. */ bool reserve_for(const size_type at) { - assert(at != npos && "paged_sparse_index: npos is the reserved tombstone, not a subscript"); + LIBMEM_ASSERT(at != npos && "paged_sparse_index: npos is the reserved tombstone, not a subscript"); const size_type page{at >> page_shift}; diff --git a/src/libmem/sparse_set.cppm b/src/libmem/sparse_set.cppm index f22a381..6658314 100644 --- a/src/libmem/sparse_set.cppm +++ b/src/libmem/sparse_set.cppm @@ -82,7 +82,7 @@ */ module; -#include +#include "assert.hpp" export module libmem:sparse_set; @@ -323,7 +323,7 @@ public: */ constexpr bool reserve_for(const Id& id) { const size_type at{to_index(id)}; - assert(at != npos && "sparse_set: id maps to the reserved npos subscript"); + LIBMEM_ASSERT(at != npos && "sparse_set: id maps to the reserved npos subscript"); return sparse_.reserve_for(at); } @@ -340,7 +340,7 @@ public: /** @brief The id at dense position `index`. */ constexpr const Id& operator[](const size_type index) const noexcept { - assert(index < size_ && "sparse_set: dense index out of range"); + LIBMEM_ASSERT(index < size_ && "sparse_set: dense index out of range"); return dense_.data()[index]; } @@ -376,10 +376,10 @@ public: * @pre `id != null_id_v`. */ constexpr insert_result insert(const Id& id) { - assert(!(id == null_id_v) && "sparse_set: the null id is reserved and not insertable"); + LIBMEM_ASSERT(!(id == null_id_v) && "sparse_set: the null id is reserved and not insertable"); const size_type at{to_index(id)}; - assert(at != npos && "sparse_set: id maps to the reserved npos subscript"); + LIBMEM_ASSERT(at != npos && "sparse_set: id maps to the reserved npos subscript"); if (const size_type existing{sparse_.get(at)}; existing != npos) { return {existing, false}; @@ -419,7 +419,7 @@ public: return {}; } - assert(size_ > 0); + LIBMEM_ASSERT(size_ > 0); Id* dense{dense_.data()}; const size_type last{size_ - 1}; @@ -539,7 +539,7 @@ private: * @pre This dense array holds no live ids, and `other.sparse_` has already been drained. */ constexpr void adopt_dense(sparse_set& other) noexcept { - assert(other.size_ <= dense_.capacity() && "sparse_set: source does not fit the destination slots"); + LIBMEM_ASSERT(other.size_ <= dense_.capacity() && "sparse_set: source does not fit the destination slots"); detail::relocate_n(other.dense_.data(), other.size_, dense_.data()); size_ = std::exchange(other.size_, 0); @@ -771,7 +771,7 @@ public: */ constexpr auto& at(this auto&& self, const Id& id) noexcept { auto* found{self.find(id)}; - assert(found != nullptr && "sparse_map: no entry for this id"); + LIBMEM_ASSERT(found != nullptr && "sparse_map: no entry for this id"); return *found; } @@ -847,7 +847,7 @@ public: } } - assert(keys_.size() == at + 1 && "sparse_map: key insert did not land where the payload did"); + LIBMEM_ASSERT(keys_.size() == at + 1 && "sparse_map: key insert did not land where the payload did"); return {slot, true}; } @@ -967,7 +967,7 @@ private: */ constexpr void adopt_values(sparse_map& other) noexcept { const size_type count{other.keys_.size()}; - assert(count <= values_.capacity() && "sparse_map: source does not fit the destination slots"); + LIBMEM_ASSERT(count <= values_.capacity() && "sparse_map: source does not fit the destination slots"); detail::relocate_n(other.values_.data(), count, values_.data()); std::destroy_n(other.values_.data(), count); diff --git a/src/libmem/storage.cppm b/src/libmem/storage.cppm index 6fac98f..61ffbc4 100644 --- a/src/libmem/storage.cppm +++ b/src/libmem/storage.cppm @@ -45,7 +45,7 @@ */ module; -#include +#include "assert.hpp" export module libmem:storage; @@ -515,11 +515,11 @@ public: fixed_storage() requires std::default_initializable : slots_{detail::allocate_slots(resource_, N)} { - assert(slots_ != nullptr && "fixed_storage: resource could not supply the slots"); + LIBMEM_ASSERT(slots_ != nullptr && "fixed_storage: resource could not supply the slots"); } explicit fixed_storage(Resource resource) : resource_{std::move(resource)}, slots_{detail::allocate_slots(resource_, N)} { - assert(slots_ != nullptr && "fixed_storage: resource could not supply the slots"); + LIBMEM_ASSERT(slots_ != nullptr && "fixed_storage: resource could not supply the slots"); } fixed_storage(const fixed_storage&) = delete; @@ -644,7 +644,7 @@ public: * and no live object remains in the current block. */ void adopt(const storage_block block) noexcept { - assert(block.data != nullptr && "dynamic_storage: cannot adopt an empty block"); + LIBMEM_ASSERT(block.data != nullptr && "dynamic_storage: cannot adopt an empty block"); detail::free_slots(resource_, slots_, capacity_); slots_ = block.data; capacity_ = block.capacity; @@ -745,7 +745,7 @@ public: */ [[nodiscard]] storage_block reserve_block(const size_type n) { const size_type target{detail::next_capacity(capacity_, n)}; - assert(target > N && "small_storage: a reserved block must be larger than the inline array"); + LIBMEM_ASSERT(target > N && "small_storage: a reserved block must be larger than the inline array"); T* ptr{detail::allocate_slots(resource_, target)}; if (!ptr) { @@ -759,8 +759,8 @@ public: * @pre The container has already relocated its live elements into `block`. */ void adopt(const storage_block block) noexcept { - assert(block.data != nullptr && "small_storage: cannot adopt an empty block"); - assert(block.data != inline_data() && "small_storage: cannot adopt the inline array"); + LIBMEM_ASSERT(block.data != nullptr && "small_storage: cannot adopt an empty block"); + LIBMEM_ASSERT(block.data != inline_data() && "small_storage: cannot adopt the inline array"); release(); slots_ = block.data; @@ -772,7 +772,7 @@ public: * @pre `block` holds no live objects and came from `reserve_block`. */ void discard(const storage_block block) noexcept { - assert(block.data != inline_data() && "small_storage: the inline array is not a reserved block"); + LIBMEM_ASSERT(block.data != inline_data() && "small_storage: the inline array is not a reserved block"); detail::free_slots(resource_, block.data, block.capacity); } @@ -818,6 +818,8 @@ private: alignas(Align) std::byte inline_slots_[N * sizeof(T)]; T* inline_data() noexcept { return reinterpret_cast(inline_slots_); } + /* Contract predicates see `this` as const. */ + const T* inline_data() const noexcept { return reinterpret_cast(inline_slots_); } /** @brief Free the spilled block, if any, and fall back to the inline slots. */ void release() noexcept { @@ -850,7 +852,7 @@ private: export template bool relocate_grow(S& store, const typename S::size_type min_capacity, const typename S::size_type live) { using T = typename S::value_type; - assert(live <= store.capacity() && "relocate_grow: more live elements than slots"); + LIBMEM_ASSERT(live <= store.capacity() && "relocate_grow: more live elements than slots"); if (store.capacity() >= min_capacity) { return true; diff --git a/src/libmem/typed_arena.cppm b/src/libmem/typed_arena.cppm index 7a31e3e..5888436 100644 --- a/src/libmem/typed_arena.cppm +++ b/src/libmem/typed_arena.cppm @@ -22,7 +22,7 @@ */ module; -#include +#include "assert.hpp" export module libmem:typed_arena; @@ -63,7 +63,7 @@ public: */ constexpr typed_arena(const std::span buffer, const std::size_t align = default_alignment) noexcept : begin_{buffer.data()}, end_{buffer.data() + buffer.size()}, cursor_{buffer.data()}, default_alignment_{align} { - assert(align > 0 && (align & (align - 1)) == 0 && "alignment must be a power of two"); + LIBMEM_ASSERT(align > 0 && (align & (align - 1)) == 0 && "alignment must be a power of two"); } /** @@ -73,8 +73,8 @@ public: */ inline explicit typed_arena(const std::size_t size, const std::size_t align = default_alignment) : begin_{new std::byte[size]}, end_{begin_ + size}, cursor_{begin_}, default_alignment_{align}, owns_buffer_{true} { - assert(size > 0); - assert(align > 0 && (align & (align - 1)) == 0 && "alignment must be a power of two"); + LIBMEM_ASSERT(size > 0); + LIBMEM_ASSERT(align > 0 && (align & (align - 1)) == 0 && "alignment must be a power of two"); } typed_arena(const typed_arena&) = delete; @@ -115,7 +115,7 @@ public: * any required cleanup. */ [[nodiscard]] inline void* allocate(const std::size_t bytes, const std::size_t alignment) noexcept { - assert(alignment > 0 && (alignment & (alignment - 1)) == 0 && "alignment must be a power of two"); + LIBMEM_ASSERT(alignment > 0 && (alignment & (alignment - 1)) == 0 && "alignment must be a power of two"); const auto current{reinterpret_cast(cursor_)}; const auto aligned{(current + alignment - 1) & ~static_cast(alignment - 1)}; diff --git a/src/libmem/vector.cppm b/src/libmem/vector.cppm index a6859e0..81d9d96 100644 --- a/src/libmem/vector.cppm +++ b/src/libmem/vector.cppm @@ -38,7 +38,7 @@ */ module; -#include +#include "assert.hpp" export module libmem:vector; @@ -271,19 +271,19 @@ public: * @pre `index < size()`. */ constexpr auto& operator[](this auto&& self, const size_type index) noexcept { - assert(index < self.size_ && "vector: index out of range"); + LIBMEM_ASSERT(index < self.size_ && "vector: index out of range"); return self.data()[index]; } /** @pre `!empty()`. */ constexpr auto& front(this auto&& self) noexcept { - assert(self.size_ > 0 && "vector: front() on an empty vector"); + LIBMEM_ASSERT(self.size_ > 0 && "vector: front() on an empty vector"); return *self.data(); } /** @pre `!empty()`. */ constexpr auto& back(this auto&& self) noexcept { - assert(self.size_ > 0 && "vector: back() on an empty vector"); + LIBMEM_ASSERT(self.size_ > 0 && "vector: back() on an empty vector"); return self.data()[self.size_ - 1]; } @@ -433,7 +433,7 @@ public: * @pre `!empty()`. */ constexpr void pop_back() noexcept { - assert(size_ > 0 && "vector: pop_back() on an empty vector"); + LIBMEM_ASSERT(size_ > 0 && "vector: pop_back() on an empty vector"); std::destroy_at(store_.data() + --size_); } @@ -445,7 +445,7 @@ public: constexpr iterator erase(const const_iterator pos) requires std::is_move_assignable_v { - assert(pos >= cbegin() && pos < cend() && "vector: erase position out of range"); + LIBMEM_ASSERT(pos >= cbegin() && pos < cend() && "vector: erase position out of range"); return erase(pos, pos + 1); } @@ -453,7 +453,7 @@ public: constexpr iterator erase(const const_iterator first, const const_iterator last) requires std::is_move_assignable_v { - assert(first >= cbegin() && last <= cend() && first <= last && "vector: erase range out of range"); + LIBMEM_ASSERT(first >= cbegin() && last <= cend() && first <= last && "vector: erase range out of range"); value_type* slots{store_.data()}; const size_type at{static_cast(first - cbegin())}; @@ -479,7 +479,7 @@ public: constexpr iterator erase_unordered(const const_iterator pos) requires std::is_move_assignable_v { - assert(pos >= cbegin() && pos < cend() && "vector: erase position out of range"); + LIBMEM_ASSERT(pos >= cbegin() && pos < cend() && "vector: erase position out of range"); value_type* slots{store_.data()}; const size_type at{static_cast(pos - cbegin())}; @@ -526,7 +526,7 @@ private: /* Either storage is inline-only, or `other` had not spilled. Both arrays * have the same static extent, so the elements fit by construction. */ - assert(other.size_ <= store_.capacity() && "vector: source does not fit the destination slots"); + LIBMEM_ASSERT(other.size_ <= store_.capacity() && "vector: source does not fit the destination slots"); detail::relocate_n(other.data(), other.size_, store_.data()); size_ = other.size_; diff --git a/tests/src/multislab_test.cpp b/tests/src/multislab_test.cpp index 729f4bf..51a370b 100644 --- a/tests/src/multislab_test.cpp +++ b/tests/src/multislab_test.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include @@ -509,3 +510,65 @@ TEST(MultislabTest, deallocate_through_the_allocate_at_iterator) { ms.deallocate(kept.it); EXPECT_EQ(ms.empty_slab_count(), 1u); } + +namespace { + +/* counting_resource whose slab-memory (aligned) allocation throws, after the node header was taken. */ +struct slab_throwing_resource : counting_resource { + using counting_resource::allocate; + void* allocate(const std::size_t, const std::size_t) { throw std::bad_alloc{}; } +}; + +} // namespace + +TEST(MultislabTest, grow_releases_the_node_when_the_slab_allocation_throws) { + stats st{}; + { + multislab ms{slab_throwing_resource{{&st}}}; + EXPECT_THROW(static_cast(ms.allocate()), std::bad_alloc); + EXPECT_EQ(ms.slab_count(), 0u); + } + EXPECT_EQ(st.live_bytes, 0u); + EXPECT_EQ(st.allocs, st.frees); +} + +/* + * A double free, a foreign or misaligned pointer, and end() are rejected without + * touching the bookkeeping. Before, a double free counted the slab down to empty + * with a block still live and released it. + */ +TEST(MultislabTest, bad_deallocate_is_rejected) { + multislab ms{threshold_policy{.max_empty_reserve = 0}}; + std::array blocks{}; + for (auto& b : blocks) { + b = ms.allocate(); + ASSERT_NE(b, nullptr); + } + std::int32_t foreign{}; + void* const misaligned{static_cast(blocks[1]) + 1}; + +#ifdef NDEBUG + ms.deallocate(blocks[0]); + ms.deallocate(blocks[0]); + ms.deallocate(&foreign); + ms.deallocate(misaligned); + ms.deallocate(decltype(ms.begin()){}); + ms.deallocate(blocks[1]); + ms.deallocate(blocks[2]); + + /* blocks[3] and blocks[4] are still live, so no slab was released. */ + EXPECT_EQ(ms.slab_count(), 2u); + EXPECT_EQ(std::ranges::distance(ms.begin(), ms.end()), 2); + ms.deallocate(blocks[3]); + ms.deallocate(blocks[4]); +#else + ms.deallocate(blocks[0]); + EXPECT_DEATH(ms.deallocate(blocks[0]), "double free"); + EXPECT_DEATH(ms.deallocate(&foreign), "not a block of this allocator"); + EXPECT_DEATH(ms.deallocate(misaligned), "not a block of this allocator"); + EXPECT_DEATH(ms.deallocate(decltype(ms.begin()){}), "end"); + for (std::size_t i{1}; i < blocks.size(); ++i) { + ms.deallocate(blocks[i]); + } +#endif +} diff --git a/tests/src/pool_test.cpp b/tests/src/pool_test.cpp index 71de8ac..fac0111 100644 --- a/tests/src/pool_test.cpp +++ b/tests/src/pool_test.cpp @@ -346,3 +346,30 @@ TEST(PoolTest, emplace_rollback_releases_the_slot) { EXPECT_EQ(reused->v, 2); EXPECT_EQ(p.slab_count(), 1u); } + +TEST(PoolTest, erase_end_is_a_precondition_violation) { +#ifdef NDEBUG + GTEST_SKIP() << "checks compiled out"; +#else + pool p{}; + p.insert(1); + EXPECT_DEATH(static_cast(p.erase(pool::const_iterator{})), "erase\\(end\\(\\)\\)"); +#endif +} + +/* `pool p(4)` would otherwise be a slab cap spelled like a size. */ +static_assert(!std::constructible_from, std::int32_t>); +static_assert(!std::convertible_to); +static_assert(std::constructible_from, libmem::slab_limit>); +static_assert(libmem::slab_limit{4}.value == 4u); + +TEST(PoolTest, slab_limit_rejects_counts_outside_uint32) { +#ifdef NDEBUG + GTEST_SKIP() << "checks compiled out"; +#else + const std::int64_t negative{-1}; + const std::int64_t too_large{std::int64_t{1} << 32}; + EXPECT_DEATH(static_cast(libmem::slab_limit{negative}), "must fit"); + EXPECT_DEATH(static_cast(libmem::slab_limit{too_large}), "must fit"); +#endif +} diff --git a/tests/src/slab_test.cpp b/tests/src/slab_test.cpp index 049149b..a35778a 100644 --- a/tests/src/slab_test.cpp +++ b/tests/src/slab_test.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include @@ -220,3 +221,58 @@ TEST(SlabTest, index_of_and_deallocate_at_round_trip) { EXPECT_EQ(*s.begin(), second.ptr); EXPECT_EQ(s.allocate_at().index, first.index); } + +TEST(SlabTest, constructor_clamps_memory_past_max_blocks) { + alignas(block) std::array storage{}; + slab s{storage.data(), storage.size()}; + + EXPECT_EQ(s.block_count(), 8u); + for (std::uint32_t i{0}; i < 8; ++i) { + ASSERT_NE(s.allocate(), nullptr); + } + EXPECT_EQ(s.allocate(), nullptr); +} + +TEST(SlabTest, constructor_accepts_memory_for_fewer_blocks) { + alignas(block) std::array storage{}; + slab s{storage.data(), storage.size()}; + + EXPECT_EQ(s.block_count(), 3u); + for (std::uint32_t i{0}; i < 3; ++i) { + ASSERT_NE(s.allocate(), nullptr); + } + EXPECT_EQ(s.allocate(), nullptr); +} + +namespace { + +template +concept slab_instantiable = requires { typename slab; }; + +} // namespace + +TEST(SlabTest, geometry_whose_size_overflows_is_rejected) { + constexpr std::size_t huge{std::numeric_limits::max() / 2 + 1}; + static_assert(slab_instantiable<1, 64>); + static_assert(!slab_instantiable); + static_assert(!slab_instantiable<1, 0>); +} + +/* With checks compiled out the release is rejected and the slab left as it was. */ +TEST(SlabTest, bad_deallocate_is_rejected) { + alignas(block) std::array storage{}; + slab s{storage.data(), storage.size()}; + const auto a{s.allocate_at()}; + std::byte* const misaligned{static_cast(a.ptr) + 1}; + +#ifdef NDEBUG + EXPECT_FALSE(s.deallocate(misaligned)); + EXPECT_FALSE(s.deallocate_at(a.index + 1)); + EXPECT_TRUE(s.deallocate_at(a.index)); + EXPECT_FALSE(s.deallocate_at(a.index)); + EXPECT_EQ(s.used_count(), 0u); +#else + EXPECT_DEATH(static_cast(s.deallocate(misaligned)), "not a block of this slab"); + EXPECT_DEATH(static_cast(s.deallocate_at(a.index + 1)), "double free"); +#endif +}