shutdown()'s drain step was an unbounded
while (anything, anywhere, is non-empty) poll every channel
Three defects in one loop.
It drained the wrong thing. Stopping a node should wait for that node's own
outputs before moving to the next layer; this waited for the entire graph to
fall idle each time. The dynamic Network's version made it explicit — it took
a node name and ignored it. Both now track which node feeds each probe and
wait only on those.
It had no deadline, so anything wedged downstream turned a graceful shutdown
into the hang it exists to avoid. Now bounded two ways, because a stalled
consumer and a slow one fail differently: a deadline for fill that never
changes, and a no-progress counter that keeps waiting as long as the queue is
shrinking, so a slow drain is not cut short merely for taking a while.
And it could fail to terminate with nothing wedged at all. current_fill came
from a snapshot that loaded tail_ before head_. A concurrent pop between the
two reads yields a head_ past the sampled tail_, and the unsigned difference
wraps to ~2^64 — so a poll for "is it empty yet" runs forever on a channel
that is in fact empty. Both indices only ever increase, so loading head_
first can at worst under-report a concurrent push, which this loop tolerates
and a wrap does not. size() and snapshot() are both corrected; size() feeds
approx_size(), which is what node readiness checks call.
Giving up is now reported rather than silent, because undrained data at that
point is about to be discarded by the stop that follows, and a graceful
shutdown quietly dropping values is the thing worth knowing about.
Verified in both directions: with the old loop the new case is killed at a
30 s timeout; with this it returns in under a second, having reported four
items its wedged consumer never took. Full suite 138/138.
Note the drain timeout is per node and defaults to 5 s, so a graph of N
stalled nodes can still take N x 5 s to shut down. That is a deliberate
trade against cutting off legitimate slow drains, and set_drain_timeout()
exists for callers who want it tighter.
352 lines
13 KiB
C++
352 lines
13 KiB
C++
#include <catch2/catch_test_macros.hpp>
|
|
#include <kpn/kpn.hpp>
|
|
#include <chrono>
|
|
#include <thread>
|
|
#include <atomic>
|
|
|
|
using namespace kpn;
|
|
|
|
static int increment(int x) { return x + 1; }
|
|
static int multiply2(int x) { return x * 2; }
|
|
static int multiply3(int x) { return x * 3; }
|
|
static int add10(int x) { return x + 10; }
|
|
static int negate_val(int x) { return -x; }
|
|
static int square(int x) { return x * x; }
|
|
|
|
// ── Linear pipeline ───────────────────────────────────────────────────────────
|
|
|
|
TEST_CASE("static_network: linear pipeline produces correct result", "[static_network]") {
|
|
auto src = make_node<increment>(5);
|
|
auto dst = make_node<multiply2>(5);
|
|
|
|
Channel<int> final_out(5);
|
|
dst.set_output_channel<0>(&final_out);
|
|
|
|
auto net = make_network(
|
|
edge(src.output<0>(), dst.input<0>())
|
|
);
|
|
|
|
net.start();
|
|
src.input_channel<0>().push(5); // 5 → increment → 6 → multiply2 → 12
|
|
int result = final_out.pop();
|
|
net.stop();
|
|
|
|
REQUIRE(result == 12);
|
|
}
|
|
|
|
TEST_CASE("static_network: three-node pipeline", "[static_network]") {
|
|
auto a = make_node<increment>(5);
|
|
auto b = make_node<multiply2>(5);
|
|
auto c = make_node<add10>(5);
|
|
|
|
Channel<int> out(5);
|
|
c.set_output_channel<0>(&out);
|
|
|
|
auto net = make_network(
|
|
edge(a.output<0>(), b.input<0>()),
|
|
edge(b.output<0>(), c.input<0>())
|
|
);
|
|
|
|
net.start();
|
|
a.input_channel<0>().push(3); // 3 → +1=4 → *2=8 → +10=18
|
|
int result = out.pop();
|
|
net.stop();
|
|
|
|
REQUIRE(result == 18);
|
|
}
|
|
|
|
// ── Auto fan-out ──────────────────────────────────────────────────────────────
|
|
|
|
TEST_CASE("static_network: auto fanout delivers to both consumers", "[static_network]") {
|
|
// Use distinct functions so each node has a distinct type in the graph
|
|
auto src = make_node<increment>(8);
|
|
auto dstA = make_node<multiply2>(8);
|
|
auto dstB = make_node<multiply3>(8);
|
|
|
|
Channel<int> outA(8), outB(8);
|
|
dstA.set_output_channel<0>(&outA);
|
|
dstB.set_output_channel<0>(&outB);
|
|
|
|
// Two edges from the same output port — FanoutNode<int,2> is auto-inserted
|
|
auto net = make_network(
|
|
edge(src.output<0>(), dstA.input<0>()),
|
|
edge(src.output<0>(), dstB.input<0>())
|
|
);
|
|
|
|
net.start();
|
|
src.input_channel<0>().push(3); // 3 → +1=4 → *2=8 and *3=12
|
|
|
|
int a = outA.pop();
|
|
int b = outB.pop();
|
|
net.stop();
|
|
|
|
REQUIRE(a == 8);
|
|
REQUIRE(b == 12);
|
|
}
|
|
|
|
TEST_CASE("static_network: auto fanout preserves ordering across multiple items", "[static_network]") {
|
|
auto src = make_node<increment>(16);
|
|
auto dstA = make_node<multiply2>(16);
|
|
auto dstB = make_node<negate_val>(16);
|
|
|
|
Channel<int> outA(16), outB(16);
|
|
dstA.set_output_channel<0>(&outA);
|
|
dstB.set_output_channel<0>(&outB);
|
|
|
|
auto net = make_network(
|
|
edge(src.output<0>(), dstA.input<0>()),
|
|
edge(src.output<0>(), dstB.input<0>())
|
|
);
|
|
|
|
net.start();
|
|
for (int i = 0; i < 5; ++i)
|
|
src.input_channel<0>().push(i); // 0..4 → +1 → *2 or negate
|
|
|
|
for (int i = 0; i < 5; ++i) {
|
|
REQUIRE(outA.pop() == (i + 1) * 2);
|
|
REQUIRE(outB.pop() == -(i + 1));
|
|
}
|
|
net.stop();
|
|
}
|
|
|
|
// ── Stop/start lifecycle ──────────────────────────────────────────────────────
|
|
|
|
TEST_CASE("static_network: stop disables input channel", "[static_network]") {
|
|
auto src = make_node<increment>(5);
|
|
auto dst = make_node<multiply2>(5);
|
|
|
|
auto net = make_network(
|
|
edge(src.output<0>(), dst.input<0>())
|
|
);
|
|
|
|
net.start();
|
|
net.stop();
|
|
|
|
// After stop, input channel disabled — push must not throw
|
|
src.input_channel<0>().push(99);
|
|
REQUIRE(src.input_channel<0>().size() == 0);
|
|
}
|
|
|
|
// ── Compile-time cycle detection ──────────────────────────────────────────────
|
|
// Cycles fire a static_assert in make_network(), so we can only test the
|
|
// no-cycle path at runtime.
|
|
//
|
|
// To manually verify a cycle error: add
|
|
// auto bad = make_network(edge(a.output<0>(), b.input<0>()),
|
|
// edge(b.output<0>(), a.input<0>()));
|
|
// and confirm: "make_network: graph contains a directed cycle"
|
|
//
|
|
// To manually verify a duplicate-tag error: add
|
|
// auto x = make_node<increment>(5);
|
|
// auto y = make_node<increment>(5); // same type as x — UniqueTag=0 for both
|
|
// auto bad = make_network(edge(x.output<0>(), y.input<0>()));
|
|
// and confirm: "make_network: two nodes have the same (Func, UniqueTag)"
|
|
|
|
TEST_CASE("static_network: acyclic graph does not trigger static_assert", "[static_network]") {
|
|
auto a = make_node<increment>(5);
|
|
auto b = make_node<add10>(5);
|
|
|
|
Channel<int> out(5);
|
|
b.set_output_channel<0>(&out);
|
|
|
|
auto net = make_network(edge(a.output<0>(), b.input<0>()));
|
|
|
|
net.start();
|
|
a.input_channel<0>().push(5);
|
|
REQUIRE(out.pop() == 16); // 5 → +1=6 → +10=16
|
|
net.stop();
|
|
}
|
|
|
|
// ── Label and UniqueTag ───────────────────────────────────────────────────────
|
|
|
|
TEST_CASE("static_network: same function distinguished by UniqueTag", "[static_network]") {
|
|
// Two nodes wrapping the same function — only possible with distinct UniqueTag
|
|
auto a = make_node<increment, "stage1", 0>(8);
|
|
auto b = make_node<increment, "stage2", 1>(8); // same func, tag=1 → distinct type
|
|
|
|
Channel<int> out(8);
|
|
b.set_output_channel<0>(&out);
|
|
|
|
auto net = make_network(edge(a.output<0>(), b.input<0>()));
|
|
|
|
net.start();
|
|
a.input_channel<0>().push(10);
|
|
REQUIRE(out.pop() == 12); // 10 → +1=11 → +1=12
|
|
net.stop();
|
|
}
|
|
|
|
TEST_CASE("static_network: two independent fan-outs of the same element type are wired independently", "[static_network]") {
|
|
// Both src_a and src_b fan out to two consumers each.
|
|
// Without the FanoutId fix, both would produce FanoutNode<int,2> — the same
|
|
// C++ type — and find_node would wire all four consumers to the first instance.
|
|
auto src_a = make_node<add10, "src_a">(8);
|
|
auto src_b = make_node<negate_val, "src_b">(8);
|
|
auto cA = make_node<increment, "cA">(8);
|
|
auto cB = make_node<multiply2, "cB">(8);
|
|
auto cC = make_node<multiply3, "cC">(8);
|
|
auto cD = make_node<square, "cD">(8);
|
|
|
|
Channel<int> outA(8), outB(8), outC(8), outD(8);
|
|
cA.set_output_channel<0>(&outA);
|
|
cB.set_output_channel<0>(&outB);
|
|
cC.set_output_channel<0>(&outC);
|
|
cD.set_output_channel<0>(&outD);
|
|
|
|
auto net = make_network(
|
|
edge(src_a.output<0>(), cA.input<0>()), // src_a → FanoutNode<int,2,0> → cA, cB
|
|
edge(src_a.output<0>(), cB.input<0>()),
|
|
edge(src_b.output<0>(), cC.input<0>()), // src_b → FanoutNode<int,2,1> → cC, cD
|
|
edge(src_b.output<0>(), cD.input<0>())
|
|
);
|
|
|
|
net.start();
|
|
src_a.input_channel<0>().push(0); // 0 → +10=10 → {+1=11, *2=20}
|
|
src_b.input_channel<0>().push(5); // 5 → negate=-5 → {*3=-15, ^2=25}
|
|
|
|
REQUIRE(outA.pop() == 11);
|
|
REQUIRE(outB.pop() == 20);
|
|
REQUIRE(outC.pop() == -15);
|
|
REQUIRE(outD.pop() == 25);
|
|
net.stop();
|
|
}
|
|
|
|
TEST_CASE("static_network: code reuse - same function at corresponding stages of parallel branches", "[static_network]") {
|
|
// Both branches use increment and multiply2 — code reuse via distinct UniqueTag.
|
|
// Topology: src → fanout → { increment(tag=1) → multiply2(tag=1) → outA }
|
|
// → { increment(tag=2) → multiply2(tag=2) → outB }
|
|
auto src = make_node<add10, "src" >(8);
|
|
auto incA = make_node<increment, "inc", 1>(8);
|
|
auto mulA = make_node<multiply2, "mul", 1>(8);
|
|
auto incB = make_node<increment, "inc", 2>(8);
|
|
auto mulB = make_node<multiply2, "mul", 2>(8);
|
|
|
|
Channel<int> outA(8), outB(8);
|
|
mulA.set_output_channel<0>(&outA);
|
|
mulB.set_output_channel<0>(&outB);
|
|
|
|
auto net = make_network(
|
|
edge(src.output<0>(), incA.input<0>()),
|
|
edge(src.output<0>(), incB.input<0>()),
|
|
edge(incA.output<0>(), mulA.input<0>()),
|
|
edge(incB.output<0>(), mulB.input<0>())
|
|
);
|
|
|
|
net.start();
|
|
src.input_channel<0>().push(0); // 0 → +10=10 → both: +1=11 → *2=22
|
|
|
|
REQUIRE(outA.pop() == 22);
|
|
REQUIRE(outB.pop() == 22);
|
|
net.stop();
|
|
}
|
|
|
|
TEST_CASE("static_network: label is accessible as static member", "[static_network]") {
|
|
using MyNode = decltype(make_node<increment, "my_node">(5));
|
|
REQUIRE(MyNode::label() == "my_node");
|
|
REQUIRE(MyNode::unique_tag == 0);
|
|
|
|
using TaggedNode = decltype(make_node<increment, "tagged", 42>(5));
|
|
REQUIRE(TaggedNode::label() == "tagged");
|
|
REQUIRE(TaggedNode::unique_tag == 42);
|
|
}
|
|
|
|
TEST_CASE("static_network: fanout with labelled same-function consumers", "[static_network]") {
|
|
auto src = make_node<increment, "src" >(8);
|
|
auto dstA = make_node<increment, "consumer_a", 1>(8);
|
|
auto dstB = make_node<increment, "consumer_b", 2>(8);
|
|
|
|
Channel<int> outA(8), outB(8);
|
|
dstA.set_output_channel<0>(&outA);
|
|
dstB.set_output_channel<0>(&outB);
|
|
|
|
// Fan-out from src to two increment nodes — only possible because tags differ
|
|
auto net = make_network(
|
|
edge(src.output<0>(), dstA.input<0>()),
|
|
edge(src.output<0>(), dstB.input<0>())
|
|
);
|
|
|
|
net.start();
|
|
src.input_channel<0>().push(5); // 5 → +1=6 → both +1=7
|
|
|
|
REQUIRE(outA.pop() == 7);
|
|
REQUIRE(outB.pop() == 7);
|
|
net.stop();
|
|
}
|
|
|
|
// Regression: shutdown() must return even when a consumer stopped consuming.
|
|
//
|
|
// The drain step was an unbounded `while (anything anywhere is non-empty)` poll
|
|
// over *every* channel in the graph. Two defects in one loop: it waited for the
|
|
// whole network to be idle before stopping each successive layer rather than
|
|
// just the node it had stopped — the dynamic Network's version even took a node
|
|
// name and ignored it — and it had no deadline, so anything wedged downstream
|
|
// turned a graceful shutdown into the hang it exists to avoid.
|
|
//
|
|
// It could also fail to terminate with nothing wedged at all. current_fill came
|
|
// from a snapshot that loaded tail_ before head_; a concurrent pop between the
|
|
// two reads yields a head_ past the sampled tail_, and the unsigned difference
|
|
// wraps to ~2^64. Any poll for "is it empty yet" against that value runs
|
|
// forever. Both indices only ever increase, so loading head_ first can at worst
|
|
// under-report a push, which this loop tolerates and a wrap does not.
|
|
//
|
|
// Here the sink never takes anything, so its input cannot drain and the only
|
|
// correct outcome is to give up and say so. The bound asserted is deliberately
|
|
// loose: the point is that it terminates, not how fast.
|
|
namespace {
|
|
|
|
struct DrainSource {
|
|
static constexpr std::string_view label() { return "drain_source"; }
|
|
int n{0};
|
|
int operator()() {
|
|
std::this_thread::sleep_for(std::chrono::microseconds(100));
|
|
return n++;
|
|
}
|
|
};
|
|
|
|
struct NeverConsumes {
|
|
static constexpr std::string_view label() { return "never_consumes"; }
|
|
std::atomic<bool>* wedged;
|
|
void operator()(int) {
|
|
// Blocks for the duration of the test: the input channel behind it
|
|
// fills and stays full.
|
|
while (!wedged->load(std::memory_order_acquire))
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(1));
|
|
}
|
|
};
|
|
|
|
} // namespace
|
|
|
|
TEST_CASE("shutdown returns when a consumer has wedged", "[static_network][shutdown]") {
|
|
std::atomic<bool> release{false};
|
|
|
|
DrainSource src_fn;
|
|
NeverConsumes sink_fn{&release};
|
|
|
|
kpn::ObjectNode<DrainSource, kpn::in<>, kpn::out<"v">, "drain_source", 0> s(src_fn, 4);
|
|
kpn::ObjectNode<NeverConsumes, kpn::in<"v">, kpn::out<>, "never_consumes", 0> k(sink_fn, 4);
|
|
|
|
auto net = kpn::make_network(kpn::edge(s.output<"v">(), k.input<"v">()));
|
|
net.set_drain_timeout(std::chrono::milliseconds(100));
|
|
net.start();
|
|
|
|
// Let the channel fill and the sink jam.
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
|
|
|
// Unjam the sink well after the drain timeout should have expired. Stopping
|
|
// a node joins its worker, so a sink blocked forever would hang the test in
|
|
// stop() rather than in the drain loop this case is about.
|
|
std::thread unjam([&] {
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(800));
|
|
release.store(true, std::memory_order_release);
|
|
});
|
|
|
|
const auto t0 = std::chrono::steady_clock::now();
|
|
net.shutdown();
|
|
const auto elapsed = std::chrono::steady_clock::now() - t0;
|
|
|
|
unjam.join();
|
|
|
|
const auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(elapsed).count();
|
|
INFO("shutdown took " << ms << " ms");
|
|
CHECK(ms < 3000); // unbounded before; one 100 ms drain timeout after
|
|
}
|