Deliver EOF sentinels out-of-band to prevent teardown deadlock
Channel::push() drops values on overflow (the intended backpressure policy for data), and PoolNode swallows the resulting ChannelOverflowError. For a control sentinel like EOF this is fatal: a single dropped EOF under backpressure wedges every downstream pop() forever, so the pipeline never tears down. Deliver sentinels out-of-band instead. Channel::push_sentinel() stores the token in a dedicated slot that does not consume ring capacity, so it can never overflow and — crucially — never blocks the caller. That non-blocking property is essential: each KPN node has a single worker thread, so a *blocking* push would park that thread and stop it draining its own input, cascading into a hold-and-wait deadlock under backpressure. The consumer's pop()/try_pop_now() drain the ring first, then deliver the sentinel, so it always arrives after every value pushed before it. approx_size() (which node readiness checks call) counts a pending sentinel as consumable work, so a channel carrying only a sentinel still schedules its consumer's next fire — without this the token would sit undelivered and the pipeline would still deadlock at teardown. PoolNode/PoolObjectNode route values carrying an eof flag (direct .eof or nested .source.eof) through push_sentinel via a SFINAE-safe is_sentinel_value trait; all other values keep the existing lossy throwing push. The trait compiles to false for types without an eof convention, so this is a no-op for pipelines that don't use one. Verified end-to-end: scene_analyze now reaches EOF, flushes its output, and exits cleanly instead of hanging. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -175,3 +175,66 @@ TEST_CASE("bandwidth_mbs returns 0 when elapsed_s is zero or negative", "[channe
|
||||
REQUIRE(snap.bandwidth_mbs(0.0) == 0.0);
|
||||
REQUIRE(snap.bandwidth_mbs(-1.0) == 0.0);
|
||||
}
|
||||
|
||||
TEST_CASE("push_sentinel never overflows even on a full channel", "[channel][sentinel]") {
|
||||
Channel<int> ch(2);
|
||||
ch.push(1);
|
||||
ch.push(2); // channel full — a plain push(3) would throw ChannelOverflowError
|
||||
|
||||
// The sentinel is stored out-of-band, so it neither throws nor blocks the
|
||||
// caller — the exact property an EOF token needs under backpressure. This
|
||||
// returns immediately with the ring still full.
|
||||
REQUIRE(ch.push_sentinel(99));
|
||||
REQUIRE(ch.size() == 2); // sentinel did not consume ring capacity
|
||||
}
|
||||
|
||||
TEST_CASE("push_sentinel is delivered after all ring data, in order", "[channel][sentinel]") {
|
||||
Channel<int> ch(4);
|
||||
ch.push(1);
|
||||
ch.push(2);
|
||||
ch.push_sentinel(99); // enqueue EOF while data is still buffered
|
||||
|
||||
// Data drains first; the sentinel arrives only once the ring is empty.
|
||||
REQUIRE(ch.pop() == 1);
|
||||
REQUIRE(ch.pop() == 2);
|
||||
REQUIRE(ch.pop() == 99);
|
||||
}
|
||||
|
||||
TEST_CASE("push_sentinel wakes a blocked pop", "[channel][sentinel]") {
|
||||
Channel<int> ch(2); // empty
|
||||
std::thread producer([&] {
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(20));
|
||||
ch.push_sentinel(99); // must wake a consumer parked on an empty ring
|
||||
});
|
||||
REQUIRE(ch.pop() == 99);
|
||||
producer.join();
|
||||
}
|
||||
|
||||
TEST_CASE("approx_size counts a pending sentinel so consumers stay schedulable",
|
||||
"[channel][sentinel]") {
|
||||
Channel<int> ch(4);
|
||||
REQUIRE(ch.approx_size() == 0);
|
||||
ch.push_sentinel(99);
|
||||
// Node readiness checks call approx_size(); it must report the out-of-band
|
||||
// sentinel as consumable work even though it holds no ring slot.
|
||||
REQUIRE(ch.approx_size() == 1);
|
||||
REQUIRE(ch.size() == 0); // ...but the ring itself is still empty
|
||||
int out = 0;
|
||||
REQUIRE(ch.try_pop_now(out));
|
||||
REQUIRE(out == 99);
|
||||
REQUIRE(ch.approx_size() == 0);
|
||||
}
|
||||
|
||||
TEST_CASE("try_pop_now delivers a pending sentinel once the ring is empty",
|
||||
"[channel][sentinel]") {
|
||||
Channel<int> ch(2);
|
||||
ch.push(1);
|
||||
ch.push_sentinel(99);
|
||||
|
||||
int out = 0;
|
||||
REQUIRE(ch.try_pop_now(out)); // ring data first
|
||||
REQUIRE(out == 1);
|
||||
REQUIRE(ch.try_pop_now(out)); // then the sentinel
|
||||
REQUIRE(out == 99);
|
||||
REQUIRE_FALSE(ch.try_pop_now(out)); // nothing left
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user