3 Commits
Author SHA1 Message Date
dtourolleandClaude Opus 4.8 bceccc3406 fix: deliver EOF sentinel only when the ring is freshly empty
🚦 CI / changes (pull_request) Successful in 19s
🚦 CI / docker (pull_request) Has been skipped
🚦 CI / test (pull_request) Successful in 8m1s
🚦 CI / tsan (pull_request) Failing after 2m22s
🚦 CI / docs (pull_request) Has been skipped
Channel<T>::pop() surfaced the out-of-band sentinel from its empty branch
using the tail_ snapshot taken at the top of the loop. Under contention the
producer can push more values *and* the sentinel in the window between that
snapshot and take_sentinel(), so pop() could return the sentinel while real
values still sat in the ring — the sentinel jumping ahead of values pushed
before it. No value was lost (a consumer that keeps draining still receives
them, and approx_size() keeps counting them so a PoolNode reschedules), but a
consumer treating the sentinel as a hard "last message" barrier would act on
EOF early.

Re-confirm emptiness against a fresh tail_ load before taking the sentinel.
Costs one acquire-load on the empty-ring path only; never runs in steady
state. The spin and post-spin takes already reload tail_ on the line above
them; try_pop_now() already reads tail_ fresh in the same branch — both were
correct and are unchanged.

The two sentinel stress cases now assert the strict "sentinel is last, after
every value" ordering (previously relaxed to avoid the flake this fixes).
Verified TSan-clean (2606 assertions, no data races) over repeated runs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 19:35:31 +02:00
dtourolleandClaude Opus 4.8 001e192bb3 docs: rewrite SPEC.md to match the implemented library
The spec had drifted far from the code. Key corrections:

- Execution model is reactive (PoolNode submits fire_once() to a
  ThreadPool when inputs are ready), not one blocking thread per node
- Channel<T> is a lock-free SPSC ring buffer (atomic wait/notify +
  spin-before-sleep), not a mutex+CV queue
- Remove latch<> ports (never implemented)
- NodeErrorHandler returns bool (skip vs stop); per-node
- Document new subsystems: scheduler, InterruptNode, Router/FilterNode,
  MainThreadNode, SharedResource, DebugHub, diagnostics/stats layer
- Update StaticNetwork, Python auto_bind layer, examples 01-16

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 19:17:19 +02:00
dtourolleandClaude Opus 4.8 50032ffce3 test: add ThreadSanitizer verification for lock-free Channel<T>
Add a contended SPSC stress suite (tests/test_channel_stress.cpp) that
actually exercises the ring's memory-ordering pairing and spin/futex/
lost-wakeup logic, plus the CMake and CI plumbing to run it under TSan:

- KPN_SANITIZER cache var + kpn_sanitizer_flags() helper (no-op when unset)
- kpn_tests_stress executable, labelled "stress" for CTest
- reusable tsan.yaml workflow (gcc:14 builder image, already ships libtsan)
- ci.yaml gains a tsan job on the same code/dockerfile triggers as test

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 19:17:19 +02:00
7 changed files with 981 additions and 1156 deletions
+7
View File
@@ -73,6 +73,13 @@ jobs:
if: ${{ !failure() && !cancelled() && (needs.changes.outputs.code == 'true' || needs.changes.outputs.dockerfile == 'true') }}
uses: ./.gitea/workflows/test.yaml
# ThreadSanitizer run for the lock-free Channel<T>. Same trigger conditions as
# test (code or image changed); runs in parallel with test.
tsan:
needs: [changes, docker]
if: ${{ !failure() && !cancelled() && (needs.changes.outputs.code == 'true' || needs.changes.outputs.dockerfile == 'true') }}
uses: ./.gitea/workflows/tsan.yaml
docs:
needs: [changes, docker]
if: ${{ !failure() && !cancelled() && github.ref == 'refs/heads/master' && (needs.changes.outputs.docs == 'true' || needs.changes.outputs.dockerfile == 'true') }}
+67
View File
@@ -0,0 +1,67 @@
name: '🧵 ThreadSanitizer'
# Reusable workflow: builds the channel stress suite with ThreadSanitizer and
# runs it. This is the dynamic half of verifying the lock-free SPSC Channel<T>
# (the static half is the CDSChecker model-check harness in verify/).
#
# Triggering and path filtering are owned by ci.yaml (the orchestrator), which
# calls this only when code changed. workflow_dispatch is kept for manual runs.
#
# Runs in the prebuilt builder image (gcc:14), which already ships libtsan — no
# package installs at job time.
on:
workflow_call:
workflow_dispatch:
jobs:
tsan:
runs-on: linux/amd64
container:
image: gitea.tourolle.paris/dtourolle/kpnpp-builder:latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
path: tsan-${{ github.run_id }}
- name: Cache FetchContent dependencies
uses: actions/cache@v3
with:
path: ~/.cmake/fetchcontent
key: cmake-fetchcontent-${{ hashFiles('**/CMakeLists.txt') }}
restore-keys: cmake-fetchcontent-
- name: Configure (TSan)
working-directory: tsan-${{ github.run_id }}
run: |
cmake -S . -B build \
-G Ninja \
-DCMAKE_BUILD_TYPE=Debug \
-DKPN_SANITIZER=thread \
-DKPN_BUILD_TESTS=ON \
-DKPN_BUILD_EXAMPLES=OFF \
-DKPN_BUILD_PYTHON=OFF \
-DFETCHCONTENT_BASE_DIR=$HOME/.cmake/fetchcontent
- name: Build (TSan)
working-directory: tsan-${{ github.run_id }}
run: cmake --build build --parallel --target kpn_tests kpn_tests_stress
- name: Run stress suite under TSan
working-directory: tsan-${{ github.run_id }}
# halt_on_error=1 makes the first detected race fail the job; the report
# (with both stacks) is printed to the log. second_deadlock_stack gives
# the full picture for lock-order issues.
env:
TSAN_OPTIONS: "halt_on_error=1 second_deadlock_stack=1"
run: ./build/tests/kpn_tests_stress
- name: Run unit tests under TSan
working-directory: tsan-${{ github.run_id }}
env:
TSAN_OPTIONS: "halt_on_error=1 second_deadlock_stack=1"
run: ./build/tests/kpn_tests
- name: Cleanup
if: always()
run: rm -rf tsan-${{ github.run_id }}
+24
View File
@@ -10,6 +10,30 @@ option(KPN_BUILD_PYTHON "Build Python bindings (requires nanobind)" ON)
option(KPN_BUILD_EXAMPLES "Build examples" ON)
option(KPN_WEB_DEBUG "Enable web debug UI (cpp-httplib)" OFF)
# Sanitizer build. Empty = off. Accepts "thread", "address", "undefined",
# or a combination like "address,undefined". Applied to all kpn targets via
# the kpn_sanitizer_flags() helper below.
#
# The lock-free SPSC Channel<T> (include/kpn/channel.hpp) has hand-reasoned
# acquire/release ordering; -DKPN_SANITIZER=thread + the channel stress test
# (tests/test_channel_stress.cpp) is the dynamic half of verifying it. The
# static half is the CDSChecker model-check harness (see verify/).
set(KPN_SANITIZER "" CACHE STRING
"Build with sanitizer: thread | address | undefined | <combo> (empty = off)")
# Translate KPN_SANITIZER into compile/link flags. No-op when empty.
function(kpn_sanitizer_flags out_var)
if(KPN_SANITIZER)
set(${out_var}
-fsanitize=${KPN_SANITIZER}
-fno-omit-frame-pointer
-g
PARENT_SCOPE)
else()
set(${out_var} "" PARENT_SCOPE)
endif()
endfunction()
# ── Core library (header-only) ────────────────────────────────────────────────
add_library(kpn INTERFACE)
target_include_directories(kpn INTERFACE
+571 -1155
View File
File diff suppressed because it is too large Load Diff
+10 -1
View File
@@ -180,7 +180,16 @@ public:
if (h == t) {
// Ring drained — deliver any pending out-of-band sentinel (EOF)
// now, so it always arrives after the data pushed before it.
{ T s; if (take_sentinel(s)) return s; }
//
// Re-confirm emptiness against a fresh tail_ first: the snapshot
// at the top of the loop may be stale (the producer can push more
// values *and* the sentinel in the window since), and the sentinel
// must never jump ahead of ring values pushed before it. The spin
// and post-spin takes below already reload tail_ on the line above
// them; this is the one take that used the loop-top snapshot.
if (h == tail_.load(std::memory_order_acquire)) {
T s; if (take_sentinel(s)) return s;
}
if (!accepting_.load(std::memory_order_acquire))
throw ChannelClosedError{};
+21
View File
@@ -43,6 +43,27 @@ target_link_libraries(kpn_tests PRIVATE
GTest::gtest
)
# ── Channel stress suite (separate executable) ────────────────────────────────
# Contended SPSC tests for the lock-free Channel<T>. Kept out of kpn_tests
# because each case runs many reps / tens of thousands of items and is slow.
# Most valuable under -DKPN_SANITIZER=thread, but correct (and run) without it.
add_executable(kpn_tests_stress test_channel_stress.cpp)
target_link_libraries(kpn_tests_stress PRIVATE kpn Catch2::Catch2WithMain)
# ── Sanitizer flags ───────────────────────────────────────────────────────────
# kpn_sanitizer_flags() is defined in the top-level CMakeLists and is a no-op
# unless -DKPN_SANITIZER=... is set. Sanitizer must be on both compile and link.
kpn_sanitizer_flags(_kpn_san)
if(_kpn_san)
foreach(_t kpn_tests kpn_tests_stress)
target_compile_options(${_t} PRIVATE ${_kpn_san})
target_link_options(${_t} PRIVATE ${_kpn_san})
endforeach()
endif()
include(CTest)
include(Catch)
catch_discover_tests(kpn_tests)
# Register the stress suite under its own label so CI can run / time it
# separately from the fast unit tests.
catch_discover_tests(kpn_tests_stress PROPERTIES LABELS "stress")
+281
View File
@@ -0,0 +1,281 @@
// Contended stress tests for the lock-free SPSC Channel<T>.
//
// The other channel tests (test_channel.cpp) are single-threaded or use a
// single 20 ms sleep to order two threads — they never actually contend on the
// ring, so they exercise neither the memory-ordering pairing nor the
// spin/futex/lost-wakeup logic in pop().
//
// These tests are written to be run under ThreadSanitizer:
//
// cmake -B build -DKPN_SANITIZER=thread -DKPN_BUILD_EXAMPLES=OFF -DKPN_BUILD_PYTHON=OFF
// cmake --build build --target kpn_tests_tsan
// ./build/tests/kpn_tests_tsan
//
// They are also valid (and meaningful) without a sanitizer: the value/sequence
// assertions catch lost or duplicated items regardless of build flags. TSan
// adds detection of the underlying data race even on runs where the race did
// not corrupt observable state.
//
// Channel<T> is SPSC: exactly one producer thread and one consumer thread per
// channel. Every scenario below honours that contract.
#include <catch2/catch_test_macros.hpp>
#include <atomic>
#include <chrono>
#include <kpn/channel.hpp>
#include <thread>
#include <vector>
using namespace kpn;
using namespace std::chrono_literals;
namespace {
// Repeat each scenario enough times that rare interleavings (spin window just
// missing / just catching the next push, disable landing inside the futex
// wait) actually occur across a run. Kept modest so a TSan run stays minutes,
// not hours.
constexpr int kReps = 200;
} // namespace
TEST_CASE("SPSC: every pushed item is popped exactly once, in order", "[channel][stress]") {
// Small capacity forces frequent full/empty transitions, so both the
// producer's overflow-retry and the consumer's spin->futex path are hit
// many times. The producer retries on overflow rather than dropping, so
// the consumer must observe a strictly contiguous 0..N-1 sequence.
constexpr int N = 50'000;
Channel<int> ch(/*capacity=*/4, /*spin_count=*/16);
std::thread producer([&] {
for (int i = 0; i < N; ++i) {
for (;;) {
try { ch.push(i); break; }
catch (const ChannelOverflowError&) { std::this_thread::yield(); }
}
}
});
int expected = 0;
bool in_order = true;
for (int i = 0; i < N; ++i) {
int v = ch.pop();
if (v != expected) in_order = false;
++expected;
}
producer.join();
REQUIRE(in_order);
REQUIRE(expected == N);
REQUIRE(ch.size() == 0);
}
TEST_CASE("SPSC: tight empty<->non-empty transitions exercise spin/futex boundary",
"[channel][stress]") {
// spin_count=0 forces every empty pop() straight into atomic::wait, so this
// hammers the lost-wakeup guard (snapshot wake_, re-check tail_, then wait).
// The producer pushes one item then waits to go empty again, maximising the
// number of empty->non-empty edges relative to item count.
constexpr int N = 20'000;
Channel<int> ch(/*capacity=*/2, /*spin_count=*/0);
std::thread producer([&] {
for (int i = 0; i < N; ++i) {
for (;;) {
try { ch.push(i); break; }
catch (const ChannelOverflowError&) { std::this_thread::yield(); }
}
}
});
long sum = 0;
for (int i = 0; i < N; ++i) sum += ch.pop();
producer.join();
// Sum of 0..N-1 — detects any lost or duplicated item.
REQUIRE(sum == static_cast<long>(N) * (N - 1) / 2);
}
TEST_CASE("SPSC: disable() while consumer is blocked in pop() unblocks cleanly",
"[channel][stress]") {
// The data race of record: consumer blocked in pop() (spinning or parked in
// the futex) while the owner thread calls disable(). pop() must observe the
// close and throw ChannelClosedError — it must not hang and must not read
// past the ring. Repeated so disable() lands at many points in pop()'s loop.
for (int rep = 0; rep < kReps; ++rep) {
Channel<int> ch(/*capacity=*/4, /*spin_count=*/8);
std::atomic<bool> threw{false};
std::atomic<bool> finished{false};
std::thread consumer([&] {
try {
ch.pop(); // empty channel: will block
} catch (const ChannelClosedError&) {
threw.store(true, std::memory_order_relaxed);
}
finished.store(true, std::memory_order_relaxed);
});
// Give the consumer a chance to reach the wait, then close.
std::this_thread::sleep_for(50us);
ch.disable();
consumer.join();
REQUIRE(finished.load());
REQUIRE(threw.load());
}
}
TEST_CASE("SPSC: producer racing a disable() never throws and never hangs",
"[channel][stress]") {
// Mirror of the above from the producer side: push() racing disable() must
// either enqueue or silently drop, never throw ChannelClosedError and never
// wedge. Overflow is still a legal outcome (full accepting channel) and is
// tolerated here.
for (int rep = 0; rep < kReps; ++rep) {
Channel<int> ch(/*capacity=*/8, /*spin_count=*/8);
std::atomic<bool> bad{false};
std::thread producer([&] {
for (int i = 0; i < 1000; ++i) {
try { ch.push(i); }
catch (const ChannelOverflowError&) { /* legal: full */ }
catch (...) { bad.store(true, std::memory_order_relaxed); break; }
}
});
std::this_thread::sleep_for(20us);
ch.disable(); // owner closes mid-stream
producer.join();
REQUIRE_FALSE(bad.load());
}
}
TEST_CASE("SPSC: push_callback fires on each empty->non-empty transition",
"[channel][stress]") {
// The empty->non-empty callback ([channel.hpp] was_empty branch) is read by
// the consumer-side notification path. Run it under contention to make sure
// the was_empty detection isn't torn by a concurrent pop().
Channel<int> ch(/*capacity=*/4, /*spin_count=*/4);
std::atomic<int> callbacks{0};
ch.set_push_callback([&] { callbacks.fetch_add(1, std::memory_order_relaxed); });
constexpr int N = 10'000;
std::thread producer([&] {
for (int i = 0; i < N; ++i) {
for (;;) {
try { ch.push(i); break; }
catch (const ChannelOverflowError&) { std::this_thread::yield(); }
}
}
});
for (int i = 0; i < N; ++i) (void)ch.pop();
producer.join();
// At least one transition, at most one per item; mainly we assert the run
// completed without TSan flagging a race on push_callback_/was_empty.
REQUIRE(callbacks.load() >= 1);
REQUIRE(callbacks.load() <= N);
}
// Ordering contract of the out-of-band sentinel under contention.
//
// push_sentinel() publishes has_eof_ (release) after the producer's N ring
// pushes; a consumer that observes has_eof_ (acquire) therefore also observes
// every value pushed before it. Both pop() and try_pop_now() only surface the
// sentinel once the ring is *freshly* observed empty, so the sentinel is the
// strictly last item received — it never jumps ahead of a ring value pushed
// before it. These tests treat the sentinel as a hard "last message" barrier
// (the consumer stops draining the moment it sees it) and assert that all N
// values arrived, in a contiguous 0..N-1 sequence, before it.
//
// Regression guard: an earlier version of pop() checked emptiness against a
// stale tail_ snapshot from the top of its loop, so under load the sentinel
// could surface with a few real values still queued — breaking in_order /
// values==N here. Under TSan these also cover the has_eof_/eof_value_
// acquire/release handshake and the spin/futex wakeup on push_sentinel().
TEST_CASE("SPSC: sentinel is strictly last, after every value (blocking pop)",
"[channel][stress]") {
constexpr int N = 20'000;
constexpr int SENTINEL = -1;
for (int rep = 0; rep < kReps; ++rep) {
// Small ring + tiny spin window so the ring is frequently empty exactly
// when the sentinel is published — the interleaving under test.
Channel<int> ch(/*capacity=*/4, /*spin_count=*/8);
std::thread producer([&] {
for (int i = 0; i < N; ++i) {
for (;;) {
try { ch.push(i); break; }
catch (const ChannelOverflowError&) { std::this_thread::yield(); }
}
}
ch.push_sentinel(SENTINEL); // must-deliver, never overflows/blocks
});
int expected = 0;
bool in_order = true;
bool saw_sentinel = false;
// Treat the sentinel as EOF: stop draining the instant it appears.
for (;;) {
int v = ch.pop();
if (v == SENTINEL) { saw_sentinel = true; break; }
if (v != expected) in_order = false;
++expected;
}
producer.join();
REQUIRE(saw_sentinel);
REQUIRE(in_order);
REQUIRE(expected == N); // all N values received before the sentinel
REQUIRE(ch.size() == 0);
REQUIRE(ch.approx_size() == 0);
}
}
TEST_CASE("SPSC: sentinel is strictly last, after every value (try_pop_now)",
"[channel][stress]") {
// The pool-node consume path is try_pop_now(), not pop(): it must surface
// the out-of-band sentinel only once the ring is freshly observed empty.
// The consumer spins with no sleeps, racing the producer at full tilt
// across the empty-ring boundary where take_sentinel() is reached.
constexpr int N = 20'000;
constexpr int SENTINEL = -1;
for (int rep = 0; rep < kReps; ++rep) {
Channel<int> ch(/*capacity=*/4, /*spin_count=*/0);
std::thread producer([&] {
for (int i = 0; i < N; ++i) {
for (;;) {
try { ch.push(i); break; }
catch (const ChannelOverflowError&) { std::this_thread::yield(); }
}
}
ch.push_sentinel(SENTINEL);
});
int expected = 0;
bool in_order = true;
bool saw_sentinel = false;
int v;
for (;;) {
if (!ch.try_pop_now(v)) { std::this_thread::yield(); continue; }
if (v == SENTINEL) { saw_sentinel = true; break; }
if (v != expected) in_order = false;
++expected;
}
producer.join();
REQUIRE(saw_sentinel);
REQUIRE(in_order);
REQUIRE(expected == N);
// Sentinel held no ring slot; once taken the channel is fully empty.
REQUIRE(ch.size() == 0);
REQUIRE(ch.approx_size() == 0);
}
}