Author SHA1 Message Date
dtourolle 771b9f8593 fix: ThreadPool::start must take the lifecycle lock too
🚦 CI / changes (push) Successful in 4s
🚦 CI / docker (push) Has been skipped
🚦 CI / test (push) Successful in 5m0s
🚦 CI / tsan (push) Successful in 3m39s
🚦 CI / docs (push) Has been skipped
abbb2d4 guarded submit() against stop() with a shared/exclusive lock, because
stop() ends with queues_.clear() and submit() indexes queues_. It missed the
other writer: start() rebuilds the same vector and took no lock at all.

A submission can genuinely land while a pool is inside start(). A network
starts its nodes one at a time, and a node already started fires into the next
one's channel, whose push callback submits. ThreadSanitizer reports it as the
read at scheduler.hpp:114 against the write at :59, and the consequence is
worse than a torn read: push_back can reallocate the vector under a reader
that has already indexed it.

Surfaced by 6802328. Firing push_callback_ unconditionally is correct — the
argument in that commit holds — and it makes callbacks frequent enough during
startup to hit this window. It went from unobserved to 4 races in one run of
the unit suite.

Holding the lock across the thread spawn is safe: all queues are constructed
before any worker starts, and worker_loop never takes the lifecycle lock, so
there is nothing for it to deadlock against.

Verified with -DKPN_SANITIZER=thread: 4 races in one run of five before, 0
across five unit runs and two stress runs after. 148/148.
2026-08-06 22:53:02 +02:00
dtourolle 3b67b7e1e9 Merge origin/master into master
🚦 CI / changes (push) Successful in 4s
🚦 CI / docker (push) Has been skipped
🚦 CI / test (push) Successful in 5m1s
🚦 CI / tsan (push) Successful in 3m38s
🚦 CI / docs (push) Has been skipped
2026-08-06 21:57:32 +02:00
dtourolle 433c3b3859 Merge branch 'fix/channel-push-callback-lost-wake'
A push must wake its consumer even when the ring looked non-empty. The
edge-triggered push_callback_ was computed from a head_ sampled before
the item was published, so a concurrent pop could leave a PoolNode idle
holding work — permanently, since the edge never fired again.

Validated: 136/136 tests pass; the retargeted stress test fails at
1325/10000 against the old code. ~85k reproducer iterations and 10
consecutive full benchmark passes with no wedge, against 5/5 wedges
before the fix.
2026-08-06 21:57:15 +02:00
dtourolleandClaude Opus 5 6802328e97 fix: a push must wake its consumer, even when the ring looked non-empty
push(), try_push() and push_blocking() fired push_callback_ only on the
empty->non-empty edge, and computed that edge from a head_ sampled before
the item was published. A PoolNode consumer decides whether to run again
from the level (count_ready -> approx_size), so a pop landing in that
window left both sides standing down:

  producer (push)                    consumer (PoolNode firing)
  ------------------------           ----------------------------
  samples t=782, h=781
    -> was_empty = false, no wake
                                     pops idx 781, head_ = 782
                                     count_ready(): head_==tail_==782
                                     -> not ready, gate released to Idle
  tail_.store(783)

The item is in the ring, the node is idle, and no wake is outstanding.
The failure is absorbing: every later push then sees a non-empty ring, so
the edge never fires again and the node sleeps while its backlog grows.

Observed as a hang in bench_pipeline at (chain, depth=4, work_us=10,
shared pool): all pool workers asleep in worker_loop, the reader blocked
in pop(), and 218 items stranded in one channel with head_ stopped at
exactly the index where the edge was dropped.

Re-reading head_ after the tail_ store does not fix this. That is the
store-buffer pattern, and under acquire/release both sides may legally
read stale; forbidding it needs seq_cst on the producer's tail_ store and
head_ load *and* on the consumer's head_ store and tail_ load, a fence on
both hot paths. Firing unconditionally is correct by construction: the
callback runs after the publishing store, so a consumer that observes the
level at all observes the item. The redundant wakes are cheap --
on_input_ready re-checks the level and SubmitGate::claim() collapses a
wake arriving mid-firing into the firing already in flight.

The stress test named this exact hazard and could not detect it: it
asserted only 1 <= callbacks <= N, which a *missed* callback satisfies.
It now requires one callback per successful push, and fails at 1325/10000
against the old code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 20:50:34 +02:00
dtourolle c9aa246322 fix: a re-offered sentinel is not data loss
🚦 CI / changes (push) Successful in 5s
🚦 CI / docker (push) Has been skipped
🚦 CI / test (push) Successful in 5m0s
🚦 CI / tsan (push) Successful in 3m37s
🚦 CI / docs (push) Has been skipped
139bfbb made the channel refuse a sentinel offered while one was still
pending — correct, and the reason is a data race: overwriting wrote
eof_value_ while the consumer could be moving the previous one out of it,
which ThreadSanitizer reports as a torn refcount and a heap-use-after-free.
That part stands.

What was wrong was the accounting. The refusal recorded a drop, and PoolNode
reported it through the overflow event callback, on the theory that two
control tokens on one channel means the stream ended twice and is a caller
protocol error.

It is not. A source that has reached the end of its input keeps being polled
and keeps returning EOF — that is the normal steady state, not an error — so
the token is re-offered on every firing. Refusing a re-offer loses nothing:
the pending token carries the same meaning and is already on its way.

Found by running it. scene-actor-extraction on a 14 s clip reported

    [main] ERROR: frames were dropped (channel overflow):
      frame_source: 2
      scene_annotate: 1
    [main] The output would describe footage that was never analysed.
           Refusing to report success.

and exited 2, on a run where nothing had been dropped and every frame was
analysed. frame_source emits EOF once and then returns it forever
(frame_source_node.hpp:76), so the count grows with however many times the
source is polled after the end. The pipeline's own loss detector — which
exists because a dropped frame silently corrupts the output — was being
tripped by a clean run, which is the one thing a loss detector must not do.

So SlotBusy now records nothing and reports nothing. The cost, stated
plainly: a genuinely distinct second token would also be refused silently,
and the channel cannot tell a re-offer from a distinct token. Re-offering is
the case that actually occurs; the delivery guarantee that matters — the
first token arrives — holds either way.

The test asserting the old behaviour is inverted rather than deleted, and now
also checks that the token which was accepted is the one delivered. 148/148.
2026-08-06 20:44:38 +02:00
dtourolle 27f884496d Merge pull request 'fix/kpn-wedging-audit' (#3) from fix/kpn-wedging-audit into master
🚦 CI / changes (push) Successful in 4s
🚦 CI / docker (push) Has been skipped
🚦 CI / test (push) Successful in 5m13s
🚦 CI / tsan (push) Successful in 3m36s
🚦 CI / docs (push) Has been skipped
Reviewed-on: #3
2026-08-05 16:24:01 +00:00
5 changed files with 113 additions and 39 deletions
+56 -10
View File
@@ -136,7 +136,6 @@ public:
throw ChannelOverflowError(capacity_);
}
const bool was_empty = (t == h);
buf_[t & ring_mask_] = make_storage(std::move(value));
tail_.store(t + 1, std::memory_order_release);
stats_.record_push(t - h + 1, data_bytes);
@@ -144,7 +143,8 @@ public:
wake_.fetch_add(1, std::memory_order_release);
wake_.notify_one();
if (was_empty && push_callback_)
// Level-triggered, not edge-triggered — see set_push_callback.
if (push_callback_)
push_callback_();
}
@@ -187,13 +187,13 @@ public:
if (t - h >= capacity_) return PushResult::Full;
const std::size_t data_bytes = ChannelDataSize<T>::bytes(value);
const bool was_empty = (t == h);
buf_[t & ring_mask_] = make_storage(std::move(value));
tail_.store(t + 1, std::memory_order_release);
stats_.record_push(t - h + 1, data_bytes);
wake_.fetch_add(1, std::memory_order_release);
wake_.notify_one();
if (was_empty && push_callback_) push_callback_();
// Level-triggered, not edge-triggered — see set_push_callback.
if (push_callback_) push_callback_();
return PushResult::Taken;
}
@@ -212,13 +212,13 @@ public:
const std::size_t h = head_.load(std::memory_order_acquire);
if (t - h < capacity_) { // space available → normal push
const std::size_t data_bytes = ChannelDataSize<T>::bytes(value);
const bool was_empty = (t == h);
buf_[t & ring_mask_] = make_storage(std::move(value));
tail_.store(t + 1, std::memory_order_release);
stats_.record_push(t - h + 1, data_bytes);
wake_.fetch_add(1, std::memory_order_release);
wake_.notify_one();
if (was_empty && push_callback_) push_callback_();
// Level-triggered, not edge-triggered — see set_push_callback.
if (push_callback_) push_callback_();
return true;
}
// full: yield briefly and retry (consumer will drain)
@@ -267,10 +267,22 @@ public:
// 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();
//
// Not counted as a drop, and this is the important part. A source that
// has reached the end of its input keeps being polled and keeps
// returning EOF — that is the normal steady state, not an error — so a
// token arriving while one is already pending is a *re-offer*, and
// refusing it loses nothing: the pending token carries the same
// meaning and is already on its way. Counting it as a drop made a
// clean run report data loss and exit non-zero.
//
// The cost of that choice, stated plainly: a genuinely distinct second
// token would also be refused silently, and the channel cannot tell the
// two apart. Re-offering is the case that actually occurs here, and the
// delivery guarantee that matters — the first token arrives — holds
// either way.
if (has_eof_.load(std::memory_order_acquire))
return SentinelResult::SlotBusy;
}
eof_value_ = make_storage(std::move(value));
has_eof_.store(true, std::memory_order_release);
// Wake a consumer blocked in pop(): the sentinel is now deliverable even
@@ -388,7 +400,41 @@ public:
wake_.notify_all();
}
// Register a callback fired when the queue transitions empty→non-empty.
// Register a callback fired after every successful push.
//
// It fires on every push, not on the empty→non-empty transition, and that
// is a correctness requirement rather than a simplification.
//
// The edge version tested `was_empty = (t == h)` using an `h` sampled
// *before* the item was published. A PoolNode consumer decides whether to
// run again from the level (count_ready → approx_size), so the two sides
// could each read the other as stale and both stand down:
//
// producer (push) consumer (PoolNode firing)
// ------------------------ ----------------------------
// samples t=782, h=781
// -> was_empty = false, no wake
// pops idx 781, head_ = 782
// count_ready(): head_==tail_==782
// -> not ready, gate released to Idle
// tail_.store(783)
//
// The item is in the ring, the node is idle, and no wake is outstanding.
// Worse, the failure is absorbing: every later push now sees a non-empty
// ring, so `was_empty` is false forever and the callback never fires again.
// The node sleeps while its backlog grows and its consumer waits on it.
//
// Re-reading head_ after the tail_ store does not fix it. That is the
// store-buffer pattern, and under acquire/release both sides may legally
// read stale; forbidding it needs seq_cst on the producer's tail_ store and
// head_ load *and* on the consumer's head_ store and tail_ load — a fence
// on both hot paths. Firing unconditionally is correct by construction:
// the callback runs after the publishing store, so a consumer that observes
// the level at all observes the item.
//
// The redundant wakes are cheap. on_input_ready re-checks the level, and
// SubmitGate::claim() collapses a wake arriving during a firing into the
// firing already in flight, so the cost is one CAS, not one extra run.
void set_push_callback(std::function<void()> cb) {
push_callback_ = std::move(cb);
}
+14 -16
View File
@@ -622,14 +622,13 @@ private:
// downstream pop() forever. Deliver them out-of-band (push_sentinel),
// which never overflows and never blocks this node's worker thread.
if (is_sentinel_value(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_);
// Not parked and not reported. Parking would spin against a slot
// only the consumer can free; reporting would cry data loss on the
// normal steady state, since a source at end of input keeps being
// polled and keeps returning EOF, so the token is re-offered on
// every firing. Refusing a re-offer loses nothing — the pending
// token says the same thing. See Channel::try_push_sentinel.
ch->try_push_sentinel(val);
return true;
}
// Backpressure without parking the worker. A full channel means the
@@ -1207,14 +1206,13 @@ private:
// downstream pop() forever. Deliver them out-of-band (push_sentinel),
// which never overflows and never blocks this node's worker thread.
if (is_sentinel_value(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_);
// Not parked and not reported. Parking would spin against a slot
// only the consumer can free; reporting would cry data loss on the
// normal steady state, since a source at end of input keeps being
// polled and keeps returning EOF, so the token is re-offered on
// every firing. Refusing a re-offer loses nothing — the pending
// token says the same thing. See Channel::try_push_sentinel.
ch->try_push_sentinel(val);
return true;
}
// See the note on the typed overload above: park rather than block.
+13
View File
@@ -53,6 +53,19 @@ public:
}
void start() override {
// Under the lifecycle lock for the same reason stop() is: submit()
// reads queues_ and this rebuilds it. A network starts its nodes one at
// a time, and a node already started fires into the next one's channel,
// whose push callback submits — so a submission can genuinely land
// while another pool is still inside start(). ThreadSanitizer reports
// it as a read at submit() against this write, and the consequence is
// worse than a torn read: push_back can reallocate the vector under a
// reader that has already indexed it.
//
// Queues are all constructed before any worker is spawned, which is
// what keeps worker_loop's own queues_[id] out of this — it never takes
// the lock, so holding it across the spawn cannot deadlock.
std::unique_lock lk(lifecycle_mx_);
stopped_.store(false, std::memory_order_relaxed);
queues_.clear();
for (std::size_t i = 0; i < thread_count_; ++i)
+16 -5
View File
@@ -271,15 +271,26 @@ TEST_CASE("a second sentinel is refused, not swallowed", "[channel][sentinel]")
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.
TEST_CASE("a refused sentinel is not counted as a drop", "[channel][sentinel]") {
// A refusal means a token arrived while an equivalent one was already
// pending — not that anything was lost. Counting it as a drop was wrong in
// a way that showed up immediately on real content: a source at the end of
// its input keeps being polled and keeps returning EOF, so the token is
// re-offered on every firing, and the pipeline reported hundreds of dropped
// frames on a clean run and exited non-zero.
//
// The delivery guarantee is unaffected: the first token is pending and will
// arrive. Only the accounting changed.
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);
CHECK(ch.stats().drops.load() == before);
// And the one that was accepted is still the one delivered.
int out = 0;
REQUIRE(ch.try_pop_now(out));
CHECK(out == 1);
}
TEST_CASE("try_push_sentinel leaves a refused value untouched", "[channel][sentinel]") {
+14 -8
View File
@@ -153,11 +153,18 @@ TEST_CASE("SPSC: producer racing a disable() never throws and never hangs",
}
}
TEST_CASE("SPSC: push_callback fires on each empty->non-empty transition",
TEST_CASE("SPSC: push_callback fires for every push, never missed",
"[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().
// Regression: this callback is the *only* thing that wakes a PoolNode, and
// it used to fire only on the empty->non-empty edge, computed from a head_
// sampled before the item was published. A concurrent pop() could drain the
// ring to empty in that window, so neither side saw the other: the item sat
// in the ring with the consumer idle, and because the trigger was an edge it
// never recovered. See set_push_callback in channel.hpp.
//
// The old version of this test asserted only `1 <= callbacks <= N`, which a
// *missed* callback satisfies — it named the hazard and could not detect it.
// One callback per successful push is the contract, so assert exactly that.
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); });
@@ -175,10 +182,9 @@ TEST_CASE("SPSC: push_callback fires on each empty->non-empty transition",
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);
// Exactly one callback per successful push. Fewer means a wake was dropped,
// which is the bug; more would mean a spurious wake was manufactured.
REQUIRE(callbacks.load() == N);
}
// Ordering contract of the out-of-band sentinel under contention.