fix: start and stop in the topological order that was computed
make_network computes Topo for the cycle check and then discarded it. The node 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 a graceful drain only if the order really is sources-first. It held for every network in this tree because edges happen to be declared in pipeline order, so the two coincided. Declared any other way — which is legal and which make_network otherwise accepts in silence — shutdown stops a consumer before its producer and discards whatever was queued in front of it. The order now comes from Topo::topo, which is already sources-first. Fanout nodes appear there too and are skipped, since they are owned separately in fanout_storage; they are still started after the user nodes and stopped before them, so a fanout sitting between two user nodes is not staged precisely during a drain. That is a smaller gap than the one being closed and is left alone rather than restructured on the way past. The test declares the sink edge first and the source edge last, and asserts through shutdown() rather than by reading the order back — the order is private, and what it buys is the point. A sources-first shutdown lets the backlog queued in front of the slow relay reach the sink; stopping the relay first discards all of it. Worth recording how this went, because it is the more useful half: the new test segfaulted, 12 runs in 20. Not a fault in the ordering change — it was stopping sources first that finally put a live consumer behind a dead producer, which is the condition the previous commit's crash needs. The ordering fix did not introduce that bug, it made it reachable. Verified in both directions: with declaration order the sink receives nothing after shutdown begins; with topological order it receives the whole backlog.
This commit is contained in:
@@ -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