KPN/tests/test_channel.cpp
Duncan Tourolle 19f5a2b0ae
Some checks failed
🚦 CI / changes (push) Successful in 18s
🚦 CI / docker (push) Has been skipped
🚦 CI / docs (push) Has been cancelled
🚦 CI / test (push) Has been cancelled
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>
2026-07-04 00:30:40 +02:00

241 lines
7.6 KiB
C++

#include <catch2/catch_test_macros.hpp>
#include <catch2/catch_approx.hpp>
#include <kpn/channel.hpp>
#include <thread>
#include <vector>
using namespace kpn;
TEST_CASE("channel storage policy: small trivial type by value", "[channel]") {
STATIC_REQUIRE(channel_storage_policy<int>::by_value);
STATIC_REQUIRE(channel_storage_policy<float>::by_value);
}
TEST_CASE("channel storage policy: large type by shared_ptr", "[channel]") {
struct Big { char data[64]; };
STATIC_REQUIRE(!channel_storage_policy<Big>::by_value);
}
TEST_CASE("push and pop single value", "[channel]") {
Channel<int> ch(5);
ch.push(42);
REQUIRE(ch.pop() == 42);
}
TEST_CASE("channel respects capacity", "[channel]") {
Channel<int> ch(2);
ch.push(1);
ch.push(2);
REQUIRE_THROWS_AS(ch.push(3), ChannelOverflowError);
}
TEST_CASE("pop blocks until value available", "[channel]") {
Channel<int> ch(5);
int result = 0;
std::thread producer([&] {
std::this_thread::sleep_for(std::chrono::milliseconds(20));
ch.push(99);
});
result = ch.pop();
producer.join();
REQUIRE(result == 99);
}
TEST_CASE("try_pop returns false on timeout", "[channel]") {
Channel<int> ch(5);
int out = 0;
REQUIRE_FALSE(ch.try_pop(out, std::chrono::milliseconds(10)));
}
TEST_CASE("try_pop succeeds when value present", "[channel]") {
Channel<int> ch(5);
ch.push(7);
int out = 0;
REQUIRE(ch.try_pop(out, std::chrono::milliseconds(10)));
REQUIRE(out == 7);
}
TEST_CASE("disable unblocks waiting pop", "[channel]") {
Channel<int> ch(5);
std::thread disabler([&] {
std::this_thread::sleep_for(std::chrono::milliseconds(20));
ch.disable();
});
REQUIRE_THROWS_AS(ch.pop(), ChannelClosedError);
disabler.join();
}
TEST_CASE("push to disabled channel is silently dropped", "[channel]") {
Channel<int> ch(5);
ch.disable();
ch.push(99); // must not throw, must not enqueue
REQUIRE(ch.size() == 0);
}
TEST_CASE("disable stops accepting and unblocks pop", "[channel]") {
// With the SPSC lock-free ring, disable() does not drain the ring immediately;
// items are freed when the Channel is destroyed. What it must do is:
// 1. reject further pushes (drops silently)
// 2. unblock any waiting pop() (throws ChannelClosedError)
Channel<int> ch(5);
ch.push(1);
ch.push(2);
REQUIRE(ch.size() == 2);
ch.disable();
// Further pushes are dropped
ch.push(3);
REQUIRE(ch.size() <= 2);
// pop() must throw even though items remain in the ring
REQUIRE_THROWS_AS(ch.pop(), ChannelClosedError);
}
TEST_CASE("enable re-accepts pushes after disable", "[channel]") {
Channel<int> ch(5);
ch.disable();
ch.push(1);
REQUIRE(ch.size() == 0);
ch.enable();
ch.push(42);
REQUIRE(ch.pop() == 42);
}
TEST_CASE("large type stored as shared_ptr — no copy on pop", "[channel]") {
struct Big { char data[64]; int tag; };
Channel<Big> ch(5);
Big b{}; b.tag = 123;
ch.push(b);
auto out = ch.pop();
REQUIRE(out.tag == 123);
}
// ── ChannelDataSize / bytes_pushed tests ─────────────────────────────────────
TEST_CASE("bytes_pushed uses sizeof(T) by default for POD type", "[channel][bandwidth]") {
Channel<int> ch(10);
ch.push(1);
ch.push(2);
ch.push(3);
ch.pop(); ch.pop(); ch.pop();
auto snap = ch.snapshot("test");
REQUIRE(snap.pushes == 3);
REQUIRE(snap.bytes_pushed == 3 * sizeof(int));
}
TEST_CASE("bytes_pushed uses sizeof(T) by default for large struct", "[channel][bandwidth]") {
struct Blob { char data[256]; };
Channel<Blob> ch(10);
ch.push(Blob{});
ch.push(Blob{});
auto snap = ch.snapshot("test");
REQUIRE(snap.bytes_pushed == 2 * sizeof(Blob));
}
// A fake heap-owning type whose logical payload size differs from sizeof.
struct FakeFrame {
std::vector<uint8_t> pixels;
};
// Specialise ChannelDataSize so the channel counts actual pixel bytes.
template<>
struct kpn::ChannelDataSize<FakeFrame> {
static std::size_t bytes(const FakeFrame& f) { return f.pixels.size(); }
};
TEST_CASE("bytes_pushed uses ChannelDataSize specialisation for heap-owning type", "[channel][bandwidth]") {
Channel<FakeFrame> ch(10);
ch.push(FakeFrame{std::vector<uint8_t>(1000)});
ch.push(FakeFrame{std::vector<uint8_t>(2000)});
auto snap = ch.snapshot("test");
REQUIRE(snap.pushes == 2);
REQUIRE(snap.bytes_pushed == 3000);
// item_bytes is still sizeof(FakeFrame) — the struct header
REQUIRE(snap.item_bytes == sizeof(FakeFrame));
}
TEST_CASE("bandwidth_mbs is non-zero and correct for heap-owning type", "[channel][bandwidth]") {
Channel<FakeFrame> ch(10);
// Push 10 frames of 1 MB each
for (int i = 0; i < 10; ++i)
ch.push(FakeFrame{std::vector<uint8_t>(1'000'000)});
auto snap = ch.snapshot("test");
// 10 MB over 1 second => 10.0 MB/s
REQUIRE(snap.bandwidth_mbs(1.0) == Catch::Approx(10.0).epsilon(1e-6));
// Without the fix (using sizeof), this would have been ~40 bytes/s ≈ 0.00004 MB/s.
REQUIRE(snap.bandwidth_mbs(1.0) > 1.0);
}
TEST_CASE("bandwidth_mbs returns 0 when elapsed_s is zero or negative", "[channel][bandwidth]") {
Channel<int> ch(5);
ch.push(42);
auto snap = ch.snapshot("test");
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
}