fix/kpn-wedging-audit #3

Merged
dtourolle merged 20 commits from fix/kpn-wedging-audit into master 2026-08-05 16:24:02 +00:00
4 changed files with 161 additions and 7 deletions
Showing only changes of commit 139bfbb794 - Show all commits
+36 -5
View File
@@ -226,12 +226,37 @@ public:
// preserving ordering (EOF arrives after all data pushed before it). // preserving ordering (EOF arrives after all data pushed before it).
// //
// Only the sole producer may call it (SPSC contract, same as push()). // Only the sole producer may call it (SPSC contract, same as push()).
// Returns false if the channel is already disabled (token discarded — //
// teardown is in progress, so the sentinel is moot). // The slot holds exactly one undelivered token. A second offered before the
bool push_sentinel(T value) { // first is taken is refused, not queued and not overwritten: two control
// tokens on one channel means the stream ended twice, which is a caller
// protocol error rather than backpressure, and silently coalescing them
// would hide it.
/// Outcome of offering a sentinel. SlotBusy is a protocol error, not
/// backpressure: it means a second control token was offered while the
/// first was still undelivered, and a channel carries at most one.
enum class SentinelResult { Taken, Closed, SlotBusy };
/// Non-consuming form. `value` is left untouched unless the result is
/// Taken, so a refused token is still the caller's to report.
SentinelResult try_push_sentinel(T& value) {
if (!accepting_.load(std::memory_order_acquire)) { if (!accepting_.load(std::memory_order_acquire)) {
stats_.record_drop(); stats_.record_drop();
return false; return SentinelResult::Closed;
}
// Refuse rather than overwrite. Overwriting lost the first token
// silently, and worse, wrote eof_value_ while the consumer could be
// moving the previous one out of it — a data race on the storage, which
// for a shared_ptr payload is a torn refcount rather than a stale read.
//
// Checking here is what makes the slot a correct SPSC handshake: the
// producer is the only writer of eof_value_ and the only one that sets
// has_eof_, the consumer is the only one that clears it, so observing
// false here means the consumer has finished with the storage and will
// not touch it again until this store publishes the next token.
if (has_eof_.load(std::memory_order_acquire)) {
stats_.record_drop();
return SentinelResult::SlotBusy;
} }
eof_value_ = make_storage(std::move(value)); eof_value_ = make_storage(std::move(value));
has_eof_.store(true, std::memory_order_release); has_eof_.store(true, std::memory_order_release);
@@ -240,7 +265,13 @@ public:
wake_.fetch_add(1, std::memory_order_release); wake_.fetch_add(1, std::memory_order_release);
wake_.notify_one(); wake_.notify_one();
if (push_callback_) push_callback_(); if (push_callback_) push_callback_();
return true; return SentinelResult::Taken;
}
/// Consuming convenience form. Returns false when the token was not stored,
/// whether because the channel is closed or because one is already pending.
bool push_sentinel(T value) {
return try_push_sentinel(value) == SentinelResult::Taken;
} }
// Blocking pop. Returns when an item is available. // Blocking pop. Returns when an item is available.
+16 -2
View File
@@ -570,7 +570,14 @@ private:
// downstream pop() forever. Deliver them out-of-band (push_sentinel), // downstream pop() forever. Deliver them out-of-band (push_sentinel),
// which never overflows and never blocks this node's worker thread. // which never overflows and never blocks this node's worker thread.
if (is_sentinel_value(val)) { if (is_sentinel_value(val)) {
ch->push_sentinel(std::move(val)); // A refused sentinel is a protocol error, not backpressure, so it
// is reported rather than parked and retried — retrying would spin
// forever against a slot only the consumer can free, and there is
// no correct value to deliver second anyway. Closed is normal
// during teardown and stays quiet.
if (ch->try_push_sentinel(val) == Channel<std::tuple_element_t<I, return_tuple>>
::SentinelResult::SlotBusy)
fire_callbacks(event_callbacks_);
return true; return true;
} }
// Backpressure without parking the worker. A full channel means the // Backpressure without parking the worker. A full channel means the
@@ -1087,7 +1094,14 @@ private:
// downstream pop() forever. Deliver them out-of-band (push_sentinel), // downstream pop() forever. Deliver them out-of-band (push_sentinel),
// which never overflows and never blocks this node's worker thread. // which never overflows and never blocks this node's worker thread.
if (is_sentinel_value(val)) { if (is_sentinel_value(val)) {
ch->push_sentinel(std::move(val)); // A refused sentinel is a protocol error, not backpressure, so it
// is reported rather than parked and retried — retrying would spin
// forever against a slot only the consumer can free, and there is
// no correct value to deliver second anyway. Closed is normal
// during teardown and stays quiet.
if (ch->try_push_sentinel(val) == Channel<std::tuple_element_t<I, return_tuple>>
::SentinelResult::SlotBusy)
fire_callbacks(event_callbacks_);
return true; return true;
} }
// See the note on the typed overload above: park rather than block. // See the note on the typed overload above: park rather than block.
+60
View File
@@ -1,3 +1,4 @@
#include <string>
#include <catch2/catch_test_macros.hpp> #include <catch2/catch_test_macros.hpp>
#include <catch2/catch_approx.hpp> #include <catch2/catch_approx.hpp>
#include <kpn/channel.hpp> #include <kpn/channel.hpp>
@@ -238,3 +239,62 @@ TEST_CASE("try_pop_now delivers a pending sentinel once the ring is empty",
REQUIRE(out == 99); REQUIRE(out == 99);
REQUIRE_FALSE(ch.try_pop_now(out)); // nothing left REQUIRE_FALSE(ch.try_pop_now(out)); // nothing left
} }
// Regression: the sentinel slot holds one token and refuses a second.
//
// push_sentinel used to write eof_value_ unconditionally. Offering a second
// token before the first was taken therefore did two wrong things at once: it
// lost the first silently — and a lost EOF wedges every downstream pop forever
// — and it wrote the storage while the consumer could be moving the previous
// value out of it. For the shared_ptr storage that non-trivial types use, that
// is a torn refcount, not merely a stale read.
//
// Refusing is correct rather than queueing: two control tokens on one channel
// means the stream ended twice, which is a caller protocol error. Coalescing
// them would hide it, and there is no second value that could sensibly follow
// the end of a stream.
TEST_CASE("a second sentinel is refused, not swallowed", "[channel][sentinel]") {
Channel<int> ch(4);
REQUIRE(ch.push_sentinel(1));
// Slot occupied: the first token is still undelivered.
REQUIRE_FALSE(ch.push_sentinel(2));
// The first survives intact — the overwrite is what used to lose it.
int out = 0;
REQUIRE(ch.try_pop_now(out));
CHECK(out == 1);
// And the slot is reusable once drained.
REQUIRE(ch.push_sentinel(3));
REQUIRE(ch.try_pop_now(out));
CHECK(out == 3);
}
TEST_CASE("a refused sentinel is counted as a drop", "[channel][sentinel]") {
// Visibility matters more here than for a dropped value: the refusal means
// a control token went nowhere, and the only alternative to a counter is
// for it to vanish.
Channel<int> ch(4);
REQUIRE(ch.push_sentinel(1));
const auto before = ch.stats().drops.load();
REQUIRE_FALSE(ch.push_sentinel(2));
CHECK(ch.stats().drops.load() == before + 1);
}
TEST_CASE("try_push_sentinel leaves a refused value untouched", "[channel][sentinel]") {
// The non-consuming form exists so a refused token is still the caller's to
// report. The consuming push_sentinel cannot offer that, since the value is
// already moved into its parameter.
Channel<std::string> ch(4);
std::string first = "eof-1", second = "eof-2";
REQUIRE(ch.try_push_sentinel(first) == Channel<std::string>::SentinelResult::Taken);
REQUIRE(ch.try_push_sentinel(second) == Channel<std::string>::SentinelResult::SlotBusy);
CHECK(second == "eof-2"); // not moved from
ch.disable();
std::string third = "eof-3";
CHECK(ch.try_push_sentinel(third) == Channel<std::string>::SentinelResult::Closed);
CHECK(third == "eof-3");
}
+49
View File
@@ -19,6 +19,7 @@
// Channel<T> is SPSC: exactly one producer thread and one consumer thread per // Channel<T> is SPSC: exactly one producer thread and one consumer thread per
// channel. Every scenario below honours that contract. // channel. Every scenario below honours that contract.
#include <string>
#include <catch2/catch_test_macros.hpp> #include <catch2/catch_test_macros.hpp>
#include <atomic> #include <atomic>
#include <chrono> #include <chrono>
@@ -279,3 +280,51 @@ TEST_CASE("SPSC: sentinel is strictly last, after every value (try_pop_now)",
REQUIRE(ch.approx_size() == 0); REQUIRE(ch.approx_size() == 0);
} }
} }
// Contended: a producer offering sentinels while the consumer takes them.
//
// The old push_sentinel wrote eof_value_ with no regard for whether the
// consumer was reading it, so a second offer racing a take was a data race on
// the storage — for the shared_ptr form used by non-trivial types, on the
// refcount. Under TSan the old code reports it; the handshake added alongside
// this test makes the producer's write conditional on observing the slot free,
// which is what serialises the two.
//
// Payload is a std::string so the storage is the shared_ptr path rather than
// the trivially-copyable one, and each token carries its own identity so a torn
// value shows up as a mismatch rather than as a plausible-looking result.
TEST_CASE("SPSC: offering sentinels concurrently with takes is race-free",
"[channel][stress][sentinel]") {
constexpr int kRounds = 20000;
Channel<std::string> ch(4);
std::atomic<int> taken{0};
std::atomic<bool> torn{false};
std::atomic<bool> done{false};
std::thread consumer([&] {
std::string out;
while (!done.load(std::memory_order_acquire) || ch.approx_size() > 0) {
if (ch.try_pop_now(out)) {
if (out.rfind("eof-", 0) != 0) torn.store(true, std::memory_order_relaxed);
taken.fetch_add(1, std::memory_order_relaxed);
}
}
});
int accepted = 0;
for (int i = 0; i < kRounds; ++i) {
std::string tok = "eof-" + std::to_string(i);
if (ch.try_push_sentinel(tok) == Channel<std::string>::SentinelResult::Taken)
++accepted;
}
done.store(true, std::memory_order_release);
consumer.join();
INFO("accepted " << accepted << " taken " << taken.load());
CHECK_FALSE(torn.load(std::memory_order_relaxed));
// Every accepted token must be delivered: the slot is refused while full,
// so acceptance and delivery are one-to-one.
CHECK(taken.load(std::memory_order_relaxed) == accepted);
CHECK(accepted > 0);
}