Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/fuzz.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ jobs:
cmake --build build-fuzz -j
- name: Smoke-run multislab fuzzer
run: ./build-fuzz/fuzz/fuzz_multislab -max_total_time=60 -print_final_stats=1
- name: Smoke-run pool fuzzer
run: ./build-fuzz/fuzz/fuzz_pool -max_total_time=45 -print_final_stats=1
- name: Smoke-run sparse_set fuzzer
run: ./build-fuzz/fuzz/fuzz_sparse_set -max_total_time=45 -print_final_stats=1
- name: Smoke-run sparse_map fuzzer
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ To use it from another project, see [docs/integration.md](docs/integration.md).
CI builds and runs the suite with **both GCC and Clang** on every push, in both
**`c++26` and `gnu++26`**, under **AddressSanitizer + UndefinedBehaviorSanitizer**,
with the ring handoff also run under **ThreadSanitizer**. Coverage-guided **libFuzzer** harnesses drive
`multislab`, `sparse_set`, and `sparse_map`; the two sparse ones are differential,
`multislab`, `pool`, `sparse_set`, and `sparse_map`; the two sparse ones are differential,
comparing every operation against a standard container.

This is an early-stage library, so treat that as what is exercised today rather
Expand Down
4 changes: 2 additions & 2 deletions docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,8 @@ mismatch reaching `deallocate` is undefined behaviour that neither ASan nor

## Fuzzing

Coverage-guided **libFuzzer** harnesses drive `multislab`, `sparse_set`, and
`sparse_map` under ASan + UBSan (`-DLIBMEM_BUILD_FUZZERS=ON`, Clang only, Debug
Coverage-guided **libFuzzer** harnesses drive `multislab`, `pool`, `sparse_set`,
and `sparse_map` under ASan + UBSan (`-DLIBMEM_BUILD_FUZZERS=ON`, Clang only, Debug
only). CI runs a short, time-boxed smoke pass on each. The two sparse harnesses are
differential, comparing every operation against a standard container.

Expand Down
1 change: 1 addition & 0 deletions fuzz/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,6 @@ function(add_libmem_fuzzer name)
endfunction()

add_libmem_fuzzer(fuzz_multislab)
add_libmem_fuzzer(fuzz_pool)
add_libmem_fuzzer(fuzz_sparse_set)
add_libmem_fuzzer(fuzz_sparse_map)
10 changes: 9 additions & 1 deletion fuzz/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ Requires **Clang** and a **Debug** build (so the sanitizers are active).

| Target | Exercises |
|--------------------|--------------------------------------------------------------------------|
| `fuzz_multislab` | `multislab` alloc/release op-streams across block sizes, caps, policies |
| `fuzz_multislab` | `multislab` alloc/release op-streams and mutating traversals, across caps |
| `fuzz_pool` | `pool` inserts, erases, mutating sweeps and clones, lifetime-trapping payload |
| `fuzz_sparse_set` | `sparse_set` against a `std::unordered_set` model, all three storage kinds |
| `fuzz_sparse_map` | `sparse_map` against a `std::unordered_map` model, lifetime-trapping payload |

Expand All @@ -16,6 +17,13 @@ aborts via `FUZZ_CHECK` when a structural invariant is violated, ASan/UBSan
catch memory/UB bugs, the invariants catch logic bugs (e.g. the iteration-count
check catches lost/leaked slabs).

The `multislab` and `pool` harnesses also hold iterators across mutation. A traversal
that releases and allocates as it walks must visit only live blocks, none twice, and
every block live for the whole walk exactly once; a stored `pool` iterator must still
dereference to its own element. Under a slab cap, an allocation must succeed exactly
when the cap leaves room, so a spurious `nullptr` fails. The `pool` payload carries a
magic word cleared by its destructor, like the map's.

The two sparse harnesses are differential: every operation runs against a standard
container and the results are compared, on top of the structural invariants. The
load-bearing ones are `index_of(keys()[i]) == i` (a sparse array left pointing at
Expand Down
265 changes: 150 additions & 115 deletions fuzz/fuzz_multislab.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -4,30 +4,25 @@
* @file fuzz_multislab.cpp
* @brief Coverage-guided fuzzer for `libmem::multislab`.
*
* Interprets the libFuzzer input as a config header (which compile-time
* `BlocksPerSlab` instantiation to drive, plus a runtime max-slab cap) followed
* by an alloc/release opcode stream. Only valid operations are issued (releases
* always target a currently-live block), so the allocator's defensive asserts
* are never tripped; ASan, UBSan, and the structural invariants below surface
* the bugs.
* A config header picks a compile-time `BlocksPerSlab`, a runtime slab cap and a
* hysteresis reserve; the rest is an opcode stream of allocations, releases (by
* pointer and by iterator), and traversals that mutate the allocator as they go.
* Only valid operations are issued, so the allocator's asserts never fire.
*
* Invariants (checked after every operation):
* - allocations never alias a still-live block, and are owned by the allocator;
* - the number of blocks reachable by iteration equals the number we hold live;
* - empty_slab_count() never exceeds slab_count().
* On teardown a counting memory_resource verifies destroy() returns every byte.
*
* The iteration-count invariant is what catches the lazy full-list corruption
* class of bug (a lost slab makes the live count diverge); this drives it across
* random block sizes, caps, and interleavings.
* Invariants:
* - an allocation succeeds iff the cap leaves room, and never aliases a live block;
* - `begin()` reaches exactly the live blocks; `empty_slab_count() <= slab_count()`;
* - a traversal that releases and allocates while it walks visits only live blocks,
* none twice, and every block live for the whole walk exactly once;
* - an `allocate_at` iterator held across operations still starts at its block;
* - teardown returns every byte to the resource.
*/
#include "fuzz_support.h"

#include <cstddef>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <new>
#include <ranges>
#include <vector>
#include <unordered_set>

import libmem;

Expand All @@ -36,128 +31,168 @@ using libmem::threshold_policy;

namespace {

#define FUZZ_CHECK(cond) \
do { \
if (!(cond)) { \
std::fprintf(stderr, "FUZZ INVARIANT FAILED: %s (%s:%d)\n", #cond, __FILE__, __LINE__); \
std::abort(); \
} \
} while (false)

/* Byte-stream reader: yields zero once exhausted so the harness can drain input
* with a simple `while (more())` loop. */
struct reader {
const std::uint8_t* data;
std::size_t size;
std::size_t pos{0};

std::uint8_t u8() { return pos < size ? data[pos++] : std::uint8_t{0}; }

std::uint32_t u32() {
std::uint32_t v{0};
for (int i{0}; i < 4; ++i) {
v = (v << 8) | u8();
}
return v;
}
constexpr std::size_t live_cap{1024};

std::uint32_t range(const std::uint32_t lo, const std::uint32_t hi) { return hi <= lo ? lo : lo + (u32() % (hi - lo + 1)); }
template <std::uint32_t BlocksPerSlab> class harness {
using multislab_type = multislab<libmem::cache_line_size, BlocksPerSlab, fuzz::counting_resource, threshold_policy>;

public:
harness(fuzz::reader& r, fuzz::stats& st, const std::uint32_t max_slabs, const std::uint32_t reserve)
: r_{r}, max_slabs_{max_slabs}, ms_{max_slabs, fuzz::counting_resource{&st}, threshold_policy{.max_empty_reserve = reserve}} {}

void run() {
while (r_.more()) {
switch (r_.u8() % 6u) {
case 0:
case 1:
allocate();
break;
case 2:
case 3:
release_any(nullptr);
break;
case 4:
walk();
break;
default:
hold();
break;
}
check();
}

bool more() const { return pos < size; }
};
for (void* p : live_.keys()) {
ms_.deallocate(p);
}
ms_.destroy();
}

struct stats {
std::size_t live_bytes{0};
std::size_t allocs{0};
std::size_t frees{0};
};
private:
fuzz::reader& r_;
std::uint32_t max_slabs_;
multislab_type ms_;
fuzz::live_set<void*> live_{};
/* Everything released during the current walk; a reused address is not "live throughout". */
std::unordered_set<void*> released_{};

struct counting_resource {
stats* s{};
bool has_room() const { return max_slabs_ == 0 || live_.size() < std::size_t{max_slabs_} * BlocksPerSlab; }

void* allocate(const std::size_t size) {
++s->allocs;
s->live_bytes += size;
return ::operator new(size);
void allocate() {
if (live_.size() >= live_cap) {
return;
}
const bool room{has_room()};
void* p{ms_.allocate()};
FUZZ_CHECK((p != nullptr) == room);
if (p) {
FUZZ_CHECK(live_.insert(p)); // aliasing a live block fails the insert
}
}

void deallocate(void* ptr, const std::size_t size) noexcept {
++s->frees;
s->live_bytes -= size;
::operator delete(ptr, size);
void release(void* p) {
if (r_.u8() & 1u) {
ms_.deallocate(ms_.make_iterator(p));
} else {
ms_.deallocate(p);
}
live_.erase(p);
released_.insert(p);
}

/* multislab takes its slab memory through these, so a resource backing one must be an
* aligned_memory_resource. */
void* allocate(const std::size_t size, const std::size_t align) {
++s->allocs;
s->live_bytes += size;
return ::operator new(size, std::align_val_t{align});
/** @brief Release a random live block other than `keep`. */
void release_any(const void* keep) {
if (live_.empty()) {
return;
}
void* p{live_.pick(r_)};
if (p != keep) {
release(p);
}
}

void deallocate(void* ptr, const std::size_t size, const std::size_t align) noexcept {
++s->frees;
s->live_bytes -= size;
::operator delete(ptr, size, std::align_val_t{align});
}
};

constexpr std::size_t live_cap{1024};
/* Erase-while-iterating, plus allocations and releases elsewhere, one per step. */
void walk() {
const auto before{live_.keys()};
std::unordered_set<void*> seen{};
released_.clear();

for (auto it{ms_.begin()}; it != ms_.end();) {
void* const p{*it};
FUZZ_CHECK(live_.contains(p));
FUZZ_CHECK(seen.insert(p).second);
++it;
switch (r_.u8() % 4u) {
case 0:
release(p);
break;
case 1:
allocate();
break;
case 2:
release_any(it == ms_.end() ? nullptr : *it);
break;
default:
break;
}
}

template <std::uint32_t BlocksPerSlab> void run(reader& r) {
using ms_t = multislab<libmem::cache_line_size, BlocksPerSlab, counting_resource, threshold_policy>;
for (void* p : before) {
FUZZ_CHECK(released_.contains(p) || seen.contains(p));
}
}

stats st{};
const std::uint32_t max_slabs{r.range(0, 6)}; // 0 = unlimited
const std::uint32_t reserve{r.range(0, 3)}; // hysteresis reserve
void hold() {
if (live_.size() >= live_cap) {
return;
}
const bool room{has_room()};
const auto held{ms_.allocate_at()};
FUZZ_CHECK((held.ptr != nullptr) == room);
if (!held.ptr) {
return;
}
FUZZ_CHECK(live_.insert(held.ptr));

{
ms_t ms{max_slabs, counting_resource{&st}, threshold_policy{.max_empty_reserve = reserve}};

std::vector<void*> live{};

while (r.more()) {
const std::uint8_t op{r.u8()};

if ((op & 1u) == 0u) {
if (live.size() < live_cap) {
void* p{ms.allocate()};
if (p) {
for (void* q : live) {
FUZZ_CHECK(q != p); // must not alias a live block
}
live.push_back(p);
}
}
} else if (!live.empty()) {
const std::size_t idx{r.u8() % live.size()};
ms.deallocate(live[idx]);
live[idx] = live.back();
live.pop_back();
for (std::uint32_t n{r_.range(1, 8)}; n > 0 && r_.more(); --n) {
if (r_.u8() & 1u) {
allocate();
} else {
release_any(held.ptr);
}

/* the blocks reachable by iteration must be exactly those we hold */
const auto reachable{std::ranges::distance(ms.begin(), ms.end())};
FUZZ_CHECK(static_cast<std::size_t>(reachable) == live.size());
FUZZ_CHECK(ms.empty_slab_count() <= ms.slab_count());
}

for (void* p : live) {
ms.deallocate(p);
auto it{held.it};
FUZZ_CHECK(*it == held.ptr);
std::unordered_set<void*> seen{};
for (; it != ms_.end(); ++it) {
FUZZ_CHECK(live_.contains(*it));
FUZZ_CHECK(seen.insert(*it).second);
}
ms.destroy();
}

void check() {
FUZZ_CHECK(static_cast<std::size_t>(std::ranges::distance(ms_.begin(), ms_.end())) == live_.size());
FUZZ_CHECK(ms_.empty_slab_count() <= ms_.slab_count());
FUZZ_CHECK(max_slabs_ == 0 || ms_.slab_count() <= max_slabs_);
}
};

FUZZ_CHECK(st.allocs == st.frees);
FUZZ_CHECK(st.live_bytes == 0);
template <std::uint32_t BlocksPerSlab> void run(fuzz::reader& r) {
fuzz::stats st{};
const std::uint32_t max_slabs{r.range(0, 6)}; // 0 = unlimited
const std::uint32_t reserve{r.range(0, 3)};
{
harness<BlocksPerSlab> h{r, st, max_slabs, reserve};
h.run();
}
fuzz::check_balanced(st);
}

} // namespace

extern "C" int LLVMFuzzerTestOneInput(const std::uint8_t* data, std::size_t size) {
reader r{data, size};
fuzz::reader r{data, size};

/* dispatch over a handful of compile-time block-per-slab instantiations */
switch (r.u8() % 5u) {
case 0:
run<1>(r);
Expand Down
Loading
Loading