fix: the sentinel slot holds one token and refuses a second
push_sentinel wrote eof_value_ unconditionally. Offering a second token
before the first was taken did two wrong things at once.
It lost the first silently, and a lost EOF is not a lost frame — it is the
token every downstream node is waiting for in order to shut down, so losing
it wedges the pipeline.
And it wrote the storage while the consumer could be moving the previous
value out of it. I expected that to be a stale read; ThreadSanitizer shows
it is worse. On the shared_ptr storage that non-trivial types use, the
racing write tears the refcount, and the stress case added here reports
heap-use-after-free in extract() alongside the data race.
try_push_sentinel now refuses when the slot is occupied, which turns the
slot into 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 it false is what licenses the write. Refusal is
recorded as a drop, and PoolNode reports it through the overflow event
callback, because a refused control token going unnoticed is the failure
this commit exists to stop.
Refusing rather than queueing is deliberate. Two control tokens on one
channel means the stream ended twice, which is a caller protocol error and
not backpressure; parking and retrying would spin against a slot only the
consumer can free, and there is no sensible second value to deliver after
the end of a stream. The non-consuming try_push_sentinel exists so a refused
token is still the caller's to report — the consuming push_sentinel cannot
offer that, since the value has already been moved into its parameter.
Single-shot EOF is what every current caller does, so this is latent for
them today. It stops being latent the moment a pipeline is reused for a
second input, which is what the persistent-pipeline work in 4b6e498 sets up.
Verified in both directions under -DKPN_SANITIZER=thread: the new contended
case reports three data races and a heap-use-after-free against the old
overwrite, and is clean with the handshake. Full suite 137/137, TSan clean
across unit and stress suites.
This commit is contained in:
@@ -19,6 +19,7 @@
|
||||
// Channel<T> is SPSC: exactly one producer thread and one consumer thread per
|
||||
// channel. Every scenario below honours that contract.
|
||||
|
||||
#include <string>
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
@@ -279,3 +280,51 @@ TEST_CASE("SPSC: sentinel is strictly last, after every value (try_pop_now)",
|
||||
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user