diff --git a/.github/workflows/fuzzing.yml b/.github/workflows/fuzzing.yml new file mode 100644 index 0000000000..26e22460bd --- /dev/null +++ b/.github/workflows/fuzzing.yml @@ -0,0 +1,59 @@ +name: Fuzzing + +on: + schedule: + # Weekly, Mondays at 07:17 UTC (off the top-of-hour to spread CI load). + - cron: '17 7 * * 1' + workflow_dispatch: + inputs: + max_total_time: + description: 'Seconds to run each fuzz target' + required: false + default: '120' + +permissions: + contents: read + +jobs: + fuzz: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y clang + + # The fuzz harness is header-only (it includes the queue headers directly + # and links nothing from the library), so we compile it directly with + # Clang + libFuzzer rather than configuring the whole project. This keeps + # the weekly job fast and independent of vcpkg / common_system. + - name: Build fuzz target + run: | + mkdir -p build-fuzz/bin + clang++ -std=c++20 -g -O1 \ + -fsanitize=fuzzer,address -fno-omit-frame-pointer \ + -I include \ + fuzz/concurrent_queue_fuzzer.cpp \ + -o build-fuzz/bin/concurrent_queue_fuzzer + + - name: Run concurrent_queue fuzzer + run: | + MAX_TIME="${{ github.event.inputs.max_total_time || '120' }}" + ./build-fuzz/bin/concurrent_queue_fuzzer \ + -max_total_time="${MAX_TIME}" \ + -print_final_stats=1 \ + fuzz/corpus + + - name: Upload crash artifacts + if: failure() + uses: actions/upload-artifact@v4 + with: + name: fuzz-crashes + path: | + crash-* + oom-* + timeout-* + if-no-files-found: ignore diff --git a/CMakeLists.txt b/CMakeLists.txt index 822ccbf398..9e8fe9edd1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -38,6 +38,10 @@ option(THREAD_BUILD_INTEGRATION_TESTS "Build integration tests" ON) option(BUILD_DOCUMENTATION "Build Doxygen documentation" ON) option(ENABLE_COVERAGE "Enable code coverage" OFF) +# libFuzzer targets (requires Clang -fsanitize=fuzzer,address; off by default, +# excluded from the default build and the regular CI matrix). See fuzz/. +option(BUILD_FUZZERS "Build libFuzzer targets (requires Clang)" OFF) + # Lock-free queue option (TICKET-001 RESOLVED - Now safe with Hazard Pointers) option(THREAD_ENABLE_LOCKFREE_QUEUE "Enable lock-free queue (safe with Hazard Pointers)" ON) @@ -161,6 +165,11 @@ if(NOT BUILD_THREADSYSTEM_AS_SUBMODULE) # Add benchmarks add_benchmarks_subdirectory() + # Add fuzzers (libFuzzer; off by default, requires Clang) + if(BUILD_FUZZERS AND EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/fuzz/CMakeLists.txt) + add_subdirectory(fuzz) + endif() + # Setup documentation setup_documentation() @@ -278,6 +287,7 @@ message(STATUS " Submodule: ${BUILD_THREADSYSTEM_AS_SUBMODULE}") message(STATUS " common_system: ${BUILD_WITH_COMMON_SYSTEM}") message(STATUS " std::format: ${USE_STD_FORMAT}") message(STATUS " Coverage: ${ENABLE_COVERAGE}") +message(STATUS " Fuzzers: ${BUILD_FUZZERS}") message(STATUS " Lock-free queue: ${THREAD_ENABLE_LOCKFREE_QUEUE}") if(THREAD_ENABLE_LOCKFREE_QUEUE) message(STATUS " TICKET-001 resolved: Safe with Hazard Pointers") diff --git a/fuzz/CMakeLists.txt b/fuzz/CMakeLists.txt new file mode 100644 index 0000000000..22f69bc5c4 --- /dev/null +++ b/fuzz/CMakeLists.txt @@ -0,0 +1,41 @@ +################################################## +# thread_system Fuzzing CMakeLists.txt +# +# libFuzzer-based fuzz targets, gated behind the BUILD_FUZZERS option +# (default OFF). Requires a Clang toolchain with the fuzzer and address +# sanitizers: +# +# cmake -B build-fuzz -G Ninja \ +# -DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++ \ +# -DBUILD_FUZZERS=ON -DTHREAD_BUILD_INTEGRATION_TESTS=OFF \ +# -DBUILD_DOCUMENTATION=OFF +# cmake --build build-fuzz +# ./build-fuzz/bin/concurrent_queue_fuzzer -max_total_time=60 fuzz/corpus +# +# The targets are excluded from the default build and from the regular CI +# matrix; they are exercised by .github/workflows/fuzzing.yml on a weekly +# schedule. The harness here is header-only (it includes the queue headers +# directly), so it does not need the full thread_system library link or vcpkg. +################################################## + +if(NOT CMAKE_CXX_COMPILER_ID MATCHES "Clang") + message(WARNING + "BUILD_FUZZERS is ON but the compiler is '${CMAKE_CXX_COMPILER_ID}'. " + "libFuzzer requires Clang; skipping fuzz targets.") + return() +endif() + +set(FUZZER_FLAGS -g -O1 -fsanitize=fuzzer,address -fno-omit-frame-pointer) + +function(add_fuzz_target target_name source_file) + add_executable(${target_name} ${source_file}) + target_include_directories(${target_name} PRIVATE + ${CMAKE_SOURCE_DIR}/include) + target_compile_features(${target_name} PRIVATE cxx_std_20) + target_compile_options(${target_name} PRIVATE ${FUZZER_FLAGS}) + target_link_options(${target_name} PRIVATE ${FUZZER_FLAGS}) +endfunction() + +add_fuzz_target(concurrent_queue_fuzzer concurrent_queue_fuzzer.cpp) + +message(STATUS "Fuzz targets enabled: concurrent_queue_fuzzer") diff --git a/fuzz/README.md b/fuzz/README.md new file mode 100644 index 0000000000..296072ebaf --- /dev/null +++ b/fuzz/README.md @@ -0,0 +1,67 @@ +# Fuzzing + +libFuzzer-based fuzz harnesses for thread_system's lock-free / concurrent queue +and resilience paths. + +## Targets + +| Target | Area | Source | +|--------|------|--------| +| `concurrent_queue_fuzzer` | The thread-safe queue reachable through the lock-free compatibility include path (`kcenon::thread::detail::concurrent_queue`, the backing for ``): enqueue (copy/move), `try_dequeue` including the empty path, `wait_dequeue(timeout)`, `empty()`/`size()` queries, `shutdown()`/`is_shutdown()` resilience signalling, and FIFO round-trip integrity. | `concurrent_queue_fuzzer.cpp` | + +The harness encodes the fuzzer input as an opcode stream: each byte drives one +queue operation (opcode in the high 3 bits, payload in the low 5 bits). + +## Requirements + +- Clang with the fuzzer and address sanitizers (`-fsanitize=fuzzer,address`). + GCC is not supported for these targets — the CMake config skips them with a + warning under non-Clang compilers. + +The targets are header-only: they include the queue headers directly and do not +need the full thread_system library link or vcpkg. + +## Building and running locally + +```bash +cmake -B build-fuzz -G Ninja \ + -DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++ \ + -DBUILD_FUZZERS=ON \ + -DTHREAD_BUILD_INTEGRATION_TESTS=OFF \ + -DBUILD_DOCUMENTATION=OFF +cmake --build build-fuzz + +# Run for a bounded time, seeded from the checked-in corpus: +./build-fuzz/bin/concurrent_queue_fuzzer -max_total_time=60 fuzz/corpus +``` + +A crash reproducer (if any) is written to the working directory as +`crash-`; replay it with: + +```bash +./build-fuzz/bin/concurrent_queue_fuzzer crash- +``` + +## Seed corpus + +`corpus/` contains a few hand-crafted seeds that exercise the major code paths +(enqueue/dequeue round-trips, fill-then-drain, and query/shutdown mixes). Add +interesting inputs discovered during fuzzing here. + +## CI + +`.github/workflows/fuzzing.yml` builds these targets with Clang+libFuzzer and +runs them for a short bounded time on a weekly schedule and on manual dispatch. +The targets are excluded from the default build and from the regular CI matrix +(`BUILD_FUZZERS` defaults to `OFF`). + +## Follow-ups (issue #697) + +This PR adds the harness scaffolding only. Still to do under the same issue: + +- Phased line-coverage raise (40% -> 50% -> 60%) with the codecov floor bumped + accordingly. +- A dedicated target for the true lock-free `kcenon::thread::lockfree_job_queue` + (Michael-Scott + hazard pointers). That one needs the full library link + (`src/lockfree`, `job`, and the common_system `result` chain), so it is + deferred until the harness can link the library cleanly in CI. diff --git a/fuzz/concurrent_queue_fuzzer.cpp b/fuzz/concurrent_queue_fuzzer.cpp new file mode 100644 index 0000000000..d4084e846f --- /dev/null +++ b/fuzz/concurrent_queue_fuzzer.cpp @@ -0,0 +1,136 @@ +// BSD 3-Clause License +// Copyright (c) 2024, 🍀☀🌕🌥 🌊 +// See the LICENSE file in the project root for full license information. + +/** + * @file concurrent_queue_fuzzer.cpp + * @brief libFuzzer harness for the lock-free / resilience queue surface. + * + * This harness drives kcenon::thread::detail::concurrent_queue with a + * fuzzer-controlled opcode stream. concurrent_queue is the implementation that + * backs the legacy include path, and + * it is the queue's input-handling and resilience surface (enqueue/dequeue, + * empty/size queries, the wait-with-timeout path, and shutdown signalling). + * + * The harness explores: + * - enqueue (copy and move overloads), + * - try_dequeue including the empty-queue resilience path, + * - wait_dequeue(timeout) with a tiny timeout (non-blocking probe), + * - empty()/size() state queries, + * - shutdown()/is_shutdown() resilience signalling, + * - FIFO round-trip integrity against a shadow model. + * + * It is intentionally single-threaded: libFuzzer requires deterministic replay, + * so this targets input handling and state-machine edge cases rather than + * multi-threaded interleavings. Concurrency stress remains the job of the unit + * tests under tests/unit/lockfree_test/. + * + * The true lock-free queue (kcenon::thread::lockfree_job_queue, Michael-Scott + + * hazard pointers) requires the full library link (src/impl/lockfree + job + + * common_system result); a dedicated target for it is tracked as a follow-up + * under issue #697 (see fuzz/README.md). + * + * Build via the BUILD_FUZZERS CMake option (clang -fsanitize=fuzzer,address). + */ + +// Silence the legacy-include deprecation pragma; we deliberately fuzz the +// queue reachable through the lock-free compatibility path. +#define THREAD_SUPPRESS_LEGACY_LOCKFREE_QUEUE_WARNING 1 +#include + +#include +#include +#include +#include + +namespace { + +using kcenon::thread::detail::concurrent_queue; + +// Opcodes encoded in the high bits of each input byte; the low bits feed the +// payload (the value to enqueue), so a single byte fully drives one step. +enum class op : std::uint8_t { + enqueue_copy = 0, + enqueue_move = 1, + try_dequeue = 2, + wait_dequeue = 3, + query = 4, + shutdown = 5, +}; + +} // namespace + +extern "C" int LLVMFuzzerTestOneInput(const std::uint8_t* data, std::size_t size) { + concurrent_queue queue; + + // Shadow model of values enqueued but not yet dequeued. For a FIFO queue, + // successful dequeues must return values in enqueue order, so we can assert + // round-trip integrity on the dequeue paths. + std::vector shadow; + bool shut = false; + + for (std::size_t i = 0; i < size; ++i) { + const std::uint8_t byte = data[i]; + const auto code = static_cast((byte >> 5) % 6u); + const std::uint32_t payload = static_cast(byte & 0x1Fu); + + switch (code) { + case op::enqueue_copy: { + queue.enqueue(payload); + shadow.push_back(payload); + break; + } + case op::enqueue_move: { + std::uint32_t movable = payload; + queue.enqueue(std::move(movable)); + shadow.push_back(payload); + break; + } + case op::try_dequeue: { + auto maybe = queue.try_dequeue(); + if (maybe.has_value()) { + if (shadow.empty() || *maybe != shadow.front()) { + __builtin_trap(); // FIFO integrity violation + } + shadow.erase(shadow.begin()); + } + break; + } + case op::wait_dequeue: { + // Tiny timeout so the harness stays deterministic and fast even + // when the queue is empty (exercises the timeout resilience + // path without actually blocking the fuzzer for long). + auto maybe = queue.wait_dequeue(std::chrono::milliseconds(0)); + if (maybe.has_value()) { + if (shadow.empty() || *maybe != shadow.front()) { + __builtin_trap(); + } + shadow.erase(shadow.begin()); + } + break; + } + case op::query: { + // Exercise the read-only surface; sink results so the optimizer + // cannot elide the calls. + volatile bool e = queue.empty(); + volatile std::size_t n = queue.size(); + volatile bool s = queue.is_shutdown(); + (void)e; + (void)n; + (void)s; + break; + } + case op::shutdown: { + queue.shutdown(); + shut = true; + if (!queue.is_shutdown()) { + __builtin_trap(); // shutdown must be observable + } + break; + } + } + } + + (void)shut; + return 0; +} diff --git a/fuzz/corpus/seed_enqueue_dequeue b/fuzz/corpus/seed_enqueue_dequeue new file mode 100644 index 0000000000..3d8f165c4e --- /dev/null +++ b/fuzz/corpus/seed_enqueue_dequeue @@ -0,0 +1 @@ +CDef \ No newline at end of file diff --git a/fuzz/corpus/seed_fill_then_drain b/fuzz/corpus/seed_fill_then_drain new file mode 100644 index 0000000000..8c8aafaa0e --- /dev/null +++ b/fuzz/corpus/seed_fill_then_drain @@ -0,0 +1 @@ +@ABC \ No newline at end of file diff --git a/fuzz/corpus/seed_query_shutdown b/fuzz/corpus/seed_query_shutdown new file mode 100644 index 0000000000..f5b1b2ac0b --- /dev/null +++ b/fuzz/corpus/seed_query_shutdown @@ -0,0 +1 @@ +`@ \ No newline at end of file