fix/kpn-wedging-audit #3
@@ -374,29 +374,6 @@ auto make_network(Edges&&... edges) {
|
||||
// 4. Construct owned fanout storage on the heap (FanoutNode has jthread — not moveable)
|
||||
auto fanout_storage = std::make_unique<FanoutSto>();
|
||||
|
||||
// 5. Collect unique user node pointers + their display names, in edge-declaration order
|
||||
std::vector<INode*> user_node_ptrs;
|
||||
std::vector<std::string> user_node_names;
|
||||
auto collect = [&](auto& e) {
|
||||
using SrcT = std::decay_t<decltype(e.src)>;
|
||||
using DstT = std::decay_t<decltype(e.dst)>;
|
||||
auto* s = static_cast<INode*>(&e.src);
|
||||
auto* d = static_cast<INode*>(&e.dst);
|
||||
if (std::find(user_node_ptrs.begin(), user_node_ptrs.end(), s) == user_node_ptrs.end()) {
|
||||
auto sname = node_display_name<SrcT>();
|
||||
user_node_ptrs.push_back(s);
|
||||
user_node_names.push_back(sname);
|
||||
s->set_name(sname);
|
||||
}
|
||||
if (std::find(user_node_ptrs.begin(), user_node_ptrs.end(), d) == user_node_ptrs.end()) {
|
||||
auto dname = node_display_name<DstT>();
|
||||
user_node_ptrs.push_back(d);
|
||||
user_node_names.push_back(dname);
|
||||
d->set_name(dname);
|
||||
}
|
||||
};
|
||||
(collect(edges), ...);
|
||||
|
||||
// 5. Wire all expanded SimpleEdges.
|
||||
// find_node<NodeT>: searches fanout storage then user edge pack, returns NodeT*.
|
||||
// Uses if constexpr in a fold so mismatched types never reach assignment.
|
||||
@@ -421,6 +398,38 @@ auto make_network(Edges&&... edges) {
|
||||
return ptr;
|
||||
};
|
||||
|
||||
// 5. Collect user node pointers + display names in *topological* order.
|
||||
//
|
||||
// Topo is computed above for the cycle check and used to be discarded,
|
||||
// while this vector was filled in edge-declaration order — and then named
|
||||
// user_nodes_topo_ and relied upon as if it were sorted. halt() stops in
|
||||
// its reverse, and shutdown() walks it forwards stopping each node and
|
||||
// draining its outputs before the next, which is only a graceful drain if
|
||||
// the order really is sources-first. It held for every network in the tree
|
||||
// because edges happen to be declared in pipeline order, and would have
|
||||
// broken silently for one that was not.
|
||||
//
|
||||
// Fanout nodes appear in Topo too; they are skipped here because they are
|
||||
// owned separately, in fanout_storage.
|
||||
std::vector<INode*> user_node_ptrs;
|
||||
std::vector<std::string> user_node_names;
|
||||
[&]<typename... Ns>(tmp::TypeList<Ns...>) {
|
||||
([&]<typename NodeT>() {
|
||||
if constexpr (!requires { NodeT::is_fanout_node; }) {
|
||||
if (auto* p = find_node.template operator()<NodeT>()) {
|
||||
auto* n = static_cast<INode*>(p);
|
||||
if (std::find(user_node_ptrs.begin(), user_node_ptrs.end(), n)
|
||||
== user_node_ptrs.end()) {
|
||||
auto nm = node_display_name<NodeT>();
|
||||
user_node_ptrs.push_back(n);
|
||||
user_node_names.push_back(nm);
|
||||
n->set_name(nm);
|
||||
}
|
||||
}
|
||||
}
|
||||
}.template operator()<Ns>(), ...);
|
||||
}(typename Topo::topo{});
|
||||
|
||||
// Pre-pass: build fanout_id → source display name map so fanout nodes
|
||||
// can be named after the node feeding them (e.g. "capture_fanout").
|
||||
std::map<std::size_t, std::string> fanout_src_name;
|
||||
|
||||
@@ -349,3 +349,86 @@ TEST_CASE("shutdown returns when a consumer has wedged", "[static_network][shutd
|
||||
INFO("shutdown took " << ms << " ms");
|
||||
CHECK(ms < 3000); // unbounded before; one 100 ms drain timeout after
|
||||
}
|
||||
|
||||
// Regression: node order must come from the topological sort, not from the
|
||||
// order the edges happened to be written in.
|
||||
//
|
||||
// make_network computes Topo for the cycle check and then dropped it, filling
|
||||
// the node vector in edge-declaration order — and named it user_nodes_topo_.
|
||||
// halt() stops in its reverse, and shutdown() walks it forwards stopping each
|
||||
// node and draining its outputs before moving to the next, which is a graceful
|
||||
// drain only if the order really is sources-first.
|
||||
//
|
||||
// Every network in this tree declares edges in pipeline order, so the two
|
||||
// coincided and nothing failed. This case declares them backwards, which is
|
||||
// legal and which make_network otherwise accepts silently.
|
||||
//
|
||||
// Asserted through shutdown() rather than by reading the order back, because
|
||||
// the order is private and the ordering is not the point — what it buys is.
|
||||
// A sources-first shutdown lets the values already in flight reach the sink;
|
||||
// stopping the sink first strands them, and the drain step then has nobody
|
||||
// left to take them.
|
||||
namespace {
|
||||
|
||||
struct OrderSource {
|
||||
static constexpr std::string_view label() { return "order_source"; }
|
||||
std::atomic<int>* made;
|
||||
int operator()() {
|
||||
std::this_thread::sleep_for(std::chrono::microseconds(20));
|
||||
return made->fetch_add(1, std::memory_order_relaxed);
|
||||
}
|
||||
};
|
||||
|
||||
// Deliberately slower than the source, so a deep backlog builds up in its input
|
||||
// channel. That backlog is what a sources-first shutdown preserves and a
|
||||
// sink-first one throws away, and it needs to be big enough that the difference
|
||||
// cannot be mistaken for one value in flight.
|
||||
struct OrderRelay {
|
||||
static constexpr std::string_view label() { return "order_relay"; }
|
||||
int operator()(int v) {
|
||||
std::this_thread::sleep_for(std::chrono::microseconds(300));
|
||||
return v;
|
||||
}
|
||||
};
|
||||
|
||||
struct OrderSink {
|
||||
static constexpr std::string_view label() { return "order_sink"; }
|
||||
std::atomic<int>* seen;
|
||||
void operator()(int) { seen->fetch_add(1, std::memory_order_relaxed); }
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("edges declared out of order still start and stop sources-first",
|
||||
"[static_network][shutdown]") {
|
||||
std::atomic<int> seen{0}, made{0};
|
||||
|
||||
OrderSource src_fn{&made};
|
||||
OrderRelay relay_fn;
|
||||
OrderSink sink_fn{&seen};
|
||||
|
||||
kpn::ObjectNode<OrderSource, kpn::in<>, kpn::out<"v">, "order_source", 0> s(src_fn, 8);
|
||||
kpn::ObjectNode<OrderRelay, kpn::in<"v">, kpn::out<"w">, "order_relay", 0> r(relay_fn, 64);
|
||||
kpn::ObjectNode<OrderSink, kpn::in<"w">, kpn::out<>, "order_sink", 0> k(sink_fn, 64);
|
||||
|
||||
// Sink edge first, source edge last — the reverse of pipeline order.
|
||||
auto net = kpn::make_network(
|
||||
kpn::edge(r.output<"w">(), k.input<"w">()),
|
||||
kpn::edge(s.output<"v">(), r.input<"v">())
|
||||
);
|
||||
net.start();
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(300));
|
||||
|
||||
const int before = seen.load(std::memory_order_relaxed);
|
||||
REQUIRE(before > 0); // the pipeline ran at all
|
||||
|
||||
net.shutdown();
|
||||
|
||||
// Sources stop first and each layer drains before the next stops, so the
|
||||
// backlog queued in front of the relay still reaches the sink. Stopping in
|
||||
// declaration order stops the relay first and discards all of it.
|
||||
const int after = seen.load(std::memory_order_relaxed);
|
||||
INFO("made " << made.load() << ", delivered " << before
|
||||
<< " before shutdown, " << after << " after");
|
||||
CHECK(after - before >= 20);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user