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
5 changed files with 71 additions and 16 deletions
Showing only changes of commit 80c2b1fb2f - Show all commits
+16 -8
View File
@@ -52,16 +52,24 @@ bool deliver_one(Channel<T>* ch, T& val, const std::atomic<bool>& stop_flag,
} }
const auto park_from = clock_t::now(); const auto park_from = clock_t::now();
for (;;) { for (;;) {
if (ch->try_push(val)) { switch (ch->try_push(val)) {
parked = duration_t(clock_t::now() - park_from); case Channel<T>::PushResult::Taken:
return true; parked = duration_t(clock_t::now() - park_from);
return true;
case Channel<T>::PushResult::Closed:
// Nobody is listening any more; the channel has recorded the
// drop. Retrying would spin until teardown noticed.
parked = duration_t(clock_t::now() - park_from);
return false;
case Channel<T>::PushResult::Full:
break; // fall through to the retry logic
} }
if (stop_flag.load(std::memory_order_relaxed)) { if (stop_flag.load(std::memory_order_relaxed)) {
// Teardown with work in hand. One last throwing push, purely so the // Teardown with work in hand and the output still full. One last
// channel's own stats record the loss (drop if it is disabled, // throwing push, purely so the channel's own stats record the
// overflow if it is merely full). The point of the lossless path is // overflow — the point of the lossless path is that a loss is never
// that a loss is never invisible, and a silent return here would // invisible, and a silent return here would reintroduce exactly the
// reintroduce exactly the hole this function exists to close. // hole this function exists to close.
try { ch->push(std::move(val)); } try { ch->push(std::move(val)); }
catch (const ChannelOverflowError&) {} catch (const ChannelOverflowError&) {}
parked = duration_t(clock_t::now() - park_from); parked = duration_t(clock_t::now() - park_from);
+18 -5
View File
@@ -165,13 +165,26 @@ public:
head_.load(std::memory_order_acquire) < capacity_; head_.load(std::memory_order_acquire) < capacity_;
} }
/// Non-blocking, lossless push. Returns false when the ring is full, having /// Outcome of a non-blocking push.
///
/// try_push used to return bool, and returned *true* for a closed channel —
/// so "delivered" and "discarded because nobody is listening" were the same
/// answer. Both mean "stop trying", which is why the callers were correct,
/// but neither they nor the producer's own accounting could tell a value
/// that arrived from one that was thrown away. Only the channel's drop
/// counter knew.
enum class PushResult { Taken, Full, Closed };
/// Non-blocking, lossless push. Returns Full when the ring is full, having
/// changed nothing — the caller keeps the value and retries when woken. /// changed nothing — the caller keeps the value and retries when woken.
bool try_push(T& value) { PushResult try_push(T& value) {
if (!accepting_.load(std::memory_order_acquire)) { stats_.record_drop(); return true; } if (!accepting_.load(std::memory_order_acquire)) {
stats_.record_drop();
return PushResult::Closed;
}
const std::size_t t = tail_.load(std::memory_order_relaxed); const std::size_t t = tail_.load(std::memory_order_relaxed);
const std::size_t h = head_.load(std::memory_order_acquire); const std::size_t h = head_.load(std::memory_order_acquire);
if (t - h >= capacity_) return false; if (t - h >= capacity_) return PushResult::Full;
const std::size_t data_bytes = ChannelDataSize<T>::bytes(value); const std::size_t data_bytes = ChannelDataSize<T>::bytes(value);
const bool was_empty = (t == h); const bool was_empty = (t == h);
@@ -181,7 +194,7 @@ public:
wake_.fetch_add(1, std::memory_order_release); wake_.fetch_add(1, std::memory_order_release);
wake_.notify_one(); wake_.notify_one();
if (was_empty && push_callback_) push_callback_(); if (was_empty && push_callback_) push_callback_();
return true; return PushResult::Taken;
} }
// Lossless push with BACKPRESSURE: if the ring is full, wait for the consumer to // Lossless push with BACKPRESSURE: if the ring is full, wait for the consumer to
+4 -1
View File
@@ -160,7 +160,10 @@ private:
for (;;) { for (;;) {
for (std::size_t i = 0; i < N; ++i) { for (std::size_t i = 0; i < N; ++i) {
if (!pending[i]) continue; if (!pending[i]) continue;
if (out_channels_[i]->try_push(*pending[i])) { // Taken or Closed both mean "stop trying" — delivered, or gone
// with the drop recorded. Only Full is worth another pass.
if (out_channels_[i]->try_push(*pending[i])
!= Channel<T>::PushResult::Full) {
pending[i].reset(); pending[i].reset();
--outstanding; --outstanding;
} }
+8 -2
View File
@@ -641,7 +641,10 @@ private:
// to run the consumer that would drain the channel. That is the // to run the consumer that would drain the channel. That is the
// hold-and-wait deadlock channel.hpp warns about for sentinels; it // hold-and-wait deadlock channel.hpp warns about for sentinels; it
// applies to data pushes just as much. // applies to data pushes just as much.
return ch->try_push(val); // Closed counts as "stop trying", not as delivered: the value is gone
// and the channel has recorded the drop. Only Full means park and retry.
return ch->try_push(val) != Channel<std::tuple_element_t<I, return_tuple>>
::PushResult::Full;
} }
template<std::size_t I> template<std::size_t I>
@@ -1215,7 +1218,10 @@ private:
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.
return ch->try_push(val); // Closed counts as "stop trying", not as delivered: the value is gone
// and the channel has recorded the drop. Only Full means park and retry.
return ch->try_push(val) != Channel<std::tuple_element_t<I, return_tuple>>
::PushResult::Full;
} }
Obj& obj_; Obj& obj_;
+25
View File
@@ -298,3 +298,28 @@ TEST_CASE("try_push_sentinel leaves a refused value untouched", "[channel][senti
CHECK(ch.try_push_sentinel(third) == Channel<std::string>::SentinelResult::Closed); CHECK(ch.try_push_sentinel(third) == Channel<std::string>::SentinelResult::Closed);
CHECK(third == "eof-3"); CHECK(third == "eof-3");
} }
// Regression: try_push must distinguish delivered from discarded.
//
// It returned bool, and returned *true* for a closed channel — so "the value
// arrived" and "the value was thrown away because nobody is listening" were the
// same answer. Every caller was nonetheless correct, because both cases mean
// "stop trying"; but nothing above the channel could tell the two apart, and a
// producer counting successful pushes counted discards among them. Only the
// channel's own drop counter knew, and only if someone read the diagnostics.
TEST_CASE("try_push distinguishes taken, full and closed", "[channel]") {
Channel<int> ch(2);
int v = 1;
CHECK(ch.try_push(v) == Channel<int>::PushResult::Taken);
CHECK(ch.try_push(v) == Channel<int>::PushResult::Taken);
// Ring is full: the value is untouched and the caller keeps it.
CHECK(ch.try_push(v) == Channel<int>::PushResult::Full);
CHECK(v == 1);
ch.disable();
const auto drops_before = ch.stats().drops.load();
CHECK(ch.try_push(v) == Channel<int>::PushResult::Closed);
// Discarded, and recorded as such rather than reported as a delivery.
CHECK(ch.stats().drops.load() == drops_before + 1);
}