spec-and-tsan #1

Merged
dtourolle merged 4 commits from spec-and-tsan into master 2026-07-17 18:11:51 +00:00
2 changed files with 51 additions and 49 deletions
Showing only changes of commit a0c4bf580e - Show all commits
+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{};
+41 -48
View File
@@ -180,28 +180,24 @@ TEST_CASE("SPSC: push_callback fires on each empty->non-empty transition",
REQUIRE(callbacks.load() <= N);
}
// What the out-of-band sentinel guarantees under contention — and what it does
// not. push_sentinel() publishes has_eof_ (release) after the producer's N ring
// 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. What these tests assert:
// 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.
//
// * Losslessness — every value 0..N-1 is delivered exactly once (contiguous,
// no gaps, no duplicates) and the sentinel is delivered exactly once. This
// is the invariant that must hold on every run; a broken acquire/release
// pairing would surface as a lost/duplicated value or (under TSan) a data
// race on has_eof_/eof_value_.
//
// What they deliberately do NOT assert is that the sentinel is the *strictly
// last* item popped. pop() checks emptiness (h == t) using a tail_ snapshot
// taken at the top of its loop; the producer can push more values *and* the
// sentinel in the window before take_sentinel() runs, so the consumer may
// surface the sentinel with a few real values still queued behind it. Those
// values are not lost — a consumer that keeps draining still receives them —
// but "sentinel arrives dead last" is not a property the channel promises, so
// asserting it would be flaky. We track how many values trailed the sentinel
// for visibility without failing on 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 and all values survive contention (blocking pop)",
TEST_CASE("SPSC: sentinel is strictly last, after every value (blocking pop)",
"[channel][stress]") {
constexpr int N = 20'000;
constexpr int SENTINEL = -1;
@@ -221,34 +217,32 @@ TEST_CASE("SPSC: sentinel and all values survive contention (blocking pop)",
ch.push_sentinel(SENTINEL); // must-deliver, never overflows/blocks
});
std::vector<bool> seen(N, false);
int values = 0;
int sentinels = 0;
bool duplicate = false;
// Drain until the sentinel AND all N values have been received; the
// sentinel may arrive before the last few values (see note above).
while (values < N || sentinels == 0) {
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) { ++sentinels; continue; }
if (seen[v]) duplicate = true; else seen[v] = true;
++values;
if (v == SENTINEL) { saw_sentinel = true; break; }
if (v != expected) in_order = false;
++expected;
}
producer.join();
REQUIRE_FALSE(duplicate);
REQUIRE(values == N); // every value delivered exactly once
REQUIRE(sentinels == 1); // sentinel delivered exactly once
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 and all values survive contention (try_pop_now)",
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 once the ring is observed empty. The consumer
// spins with no sleeps, racing the producer at full tilt across the
// empty-ring boundary where take_sentinel() is reached.
// 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;
@@ -265,23 +259,22 @@ TEST_CASE("SPSC: sentinel and all values survive contention (try_pop_now)",
ch.push_sentinel(SENTINEL);
});
std::vector<bool> seen(N, false);
int values = 0;
int sentinels = 0;
bool duplicate = false;
int expected = 0;
bool in_order = true;
bool saw_sentinel = false;
int v;
while (values < N || sentinels == 0) {
for (;;) {
if (!ch.try_pop_now(v)) { std::this_thread::yield(); continue; }
if (v == SENTINEL) { ++sentinels; continue; }
if (seen[v]) duplicate = true; else seen[v] = true;
++values;
if (v == SENTINEL) { saw_sentinel = true; break; }
if (v != expected) in_order = false;
++expected;
}
producer.join();
REQUIRE_FALSE(duplicate);
REQUIRE(values == N);
REQUIRE(sentinels == 1);
// Sentinel held no ring slot; once drained the channel is fully empty.
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);
}