Files
KPN/include/kpn/main_thread_node.hpp
dtourolle a8cfe7300a fix: a lossless fanout, a node that starts awake, and the instrumentation that found them
Three changes from one debugging session on the intermittent wedge, kept
together because the instrumentation is what made the other two findable.

**Fanout was never made lossless.** 6595e6e made node outputs lossless and
28e0667 stopped them parking a worker; FanoutNode was in neither and kept
`catch (ChannelOverflowError&) {}` per output. Whichever branch fell behind
lost items, silently, by an amount that depended on timing — so two runs of
the same input could disagree. deliver() now retries each output
independently until it is taken, rechecking stop_flag_ every pass so
teardown cannot hang on a full output. A fanout owns a private thread, so
waiting costs no scheduler worker.

**A node could start with a wake already outstanding.** start() enables the
input channel several statements before it installs the push callback, and
StaticNetwork starts nodes sources-first, so an upstream node is already
firing into the gap. A push landing there is accepted by the ring but wakes
nobody: push_callback_ fires only on the empty→non-empty transition, and at
that instant the callback is null. Every later push sees a non-empty ring
and stays silent, so the node is never submitted. Asking on_input_ready()
once at the end of start() converts the missed edge into a state check.

The signature is distinctive — zero items delivered, not a stall partway.
Under `ctest -j4` on a loaded machine it reproduced 7 times in 24 and never
in 10 unloaded runs, which is almost certainly the "~1 run in 20" hang
28e0667 recorded as known-incomplete.

**NodeSnapshot now carries scheduling state and a true exec total.** queued
and wake_pending make the 9c5ce5f invariant observable at runtime; it could
previously only be inspected in a debugger, and the bug does not reproduce
under one. total_exec_us is a real sum — frames × ema_exec_us tracks the
tail of a run, not the whole of it, and diverges badly on a workload whose
per-frame cost varies. Both are exposed over the web debug JSON so a wedged
pipeline can be interrogated without attaching to it.
2026-08-05 12:40:03 +02:00

189 lines
7.0 KiB
C++

#pragma once
#include "channel.hpp"
#include "diagnostics.hpp"
#include "fixed_string.hpp"
#include "inode.hpp"
#include "port.hpp"
#include <atomic>
#include <chrono>
#include <cstddef>
#include <memory>
#include <optional>
#include <tuple>
namespace kpn {
// ── MainThreadNode ────────────────────────────────────────────────────────────
//
// Base class for nodes that must run on the main thread (e.g. OpenCV display
// on Wayland/Qt). Registered as a normal INode in the Network so it appears
// in diagnostics and the web UI, but spawns no thread.
//
// Usage:
// class MyDisplay : public kpn::MainThreadNode<MyDisplay, in<"a","b">, TypeA, TypeB> {
// public:
// MyDisplay(...) { /* constructor runs on main thread */ }
// bool operator()(TypeA a, TypeB b) { ...; return true; /* false = stop */ }
// };
//
// MyDisplay disp(...);
// net.add("display", disp).connect(...).build();
// net.start();
// while (disp.step()) ; // drives the event loop on the main thread
// net.stop();
//
// step() behaviour:
// - try_pop on every input channel with zero timeout
// - if all inputs have data: calls operator(), records stats, returns its result
// - if any input is missing: returns true immediately (caller should yield/waitKey)
template<typename Derived, typename InputTag, typename... Args>
class MainThreadNode;
template<typename Derived, fixed_string... InNames, typename... Args>
class MainThreadNode<Derived, in<InNames...>, Args...> : public INode {
public:
static constexpr std::size_t input_count = sizeof...(Args);
static_assert(
sizeof...(InNames) == 0 || sizeof...(InNames) == input_count,
"MainThreadNode: name count must match input type count, or provide none"
);
using args_tuple = std::tuple<Args...>; // required by Network::connect type check
explicit MainThreadNode(std::size_t fifo_capacity = 8) {
init_channels(std::make_index_sequence<input_count>{}, fifo_capacity);
}
// ── INode ─────────────────────────────────────────────────────────────────
void start() override {
enable_channels(std::make_index_sequence<input_count>{});
running_.store(true, std::memory_order_relaxed);
}
void stop() override {
running_.store(false, std::memory_order_relaxed);
disable_channels(std::make_index_sequence<input_count>{});
}
bool running() const override {
return running_.load(std::memory_order_relaxed);
}
void set_name(std::string) override {}
const NodeStats& stats() const override { return stats_; }
NodeSnapshot node_snapshot(const std::string& name, double elapsed_s) const override {
uint64_t frames = stats_.frames_processed.load(std::memory_order_relaxed);
double exec_ms = stats_.ema_exec_us.load(std::memory_order_relaxed) / 1000.0;
double blocked_ms = stats_.total_blocked_us.load(std::memory_order_relaxed) / 1000.0;
double total_ms = exec_ms + blocked_ms;
return {
name, frames,
exec_ms,
stats_.max_exec_us.load(std::memory_order_relaxed) / 1000.0,
blocked_ms,
elapsed_s > 0 ? frames / elapsed_s : 0.0,
stats_.total_cpu_us.load(std::memory_order_relaxed) / 1000.0,
total_ms > 0 ? 100.0 * exec_ms / total_ms : 0.0,
0.0, // queue_wait_ms — main-thread node is not pool-scheduled
stats_.total_exec_us.load(std::memory_order_relaxed) / 1000.0,
};
}
// ── Port access (for Network::connect) ───────────────────────────────────
template<std::size_t I>
Channel<std::tuple_element_t<I, std::tuple<Args...>>>& input_channel() {
return *std::get<I>(channels_);
}
template<std::size_t I>
InputPort<Derived, I> input() {
static_assert(I < input_count, "input index out of range");
return {static_cast<Derived&>(*this)};
}
template<fixed_string Name>
auto input() {
constexpr std::size_t idx = index_of<Name, InNames...>();
static_assert(idx != npos, "unknown input port name");
return input<idx>();
}
// ── Main-thread driver ────────────────────────────────────────────────────
// Call this in a loop on the main thread instead of net.start()'s thread.
// Returns false when operator() returns false or all channels are closed.
bool step() {
if (!running_.load(std::memory_order_relaxed)) return false;
auto t0 = clock_t::now();
auto inputs = try_pop_all(std::make_index_sequence<input_count>{});
auto t1 = clock_t::now();
if (!inputs.has_value()) return true; // not all inputs ready — yield
auto cpu0 = NodeStats::cpu_now();
bool cont = std::apply(
[this](Args&&... a) {
return static_cast<Derived*>(this)->operator()(std::forward<Args>(a)...);
},
std::move(*inputs));
auto cpu1 = NodeStats::cpu_now();
auto t2 = clock_t::now();
stats_.record_exec(duration_t(t2 - t1), duration_t(t1 - t0), cpu0, cpu1);
return cont;
}
private:
template<std::size_t... Is>
void init_channels(std::index_sequence<Is...>, std::size_t cap) {
((std::get<Is>(channels_) =
std::make_unique<Channel<std::tuple_element_t<Is, args_tuple>>>(cap)), ...);
}
template<std::size_t... Is>
void enable_channels(std::index_sequence<Is...>) {
(std::get<Is>(channels_)->enable(), ...);
}
template<std::size_t... Is>
void disable_channels(std::index_sequence<Is...>) {
(std::get<Is>(channels_)->disable(), ...);
}
// Try to pop one item from every channel with zero timeout.
// Returns nullopt if any channel has no data ready.
template<std::size_t... Is>
std::optional<args_tuple> try_pop_all(std::index_sequence<Is...>) {
args_tuple result;
bool all_ready = true;
// Use a fold that short-circuits on first missing item
((all_ready = all_ready &&
std::get<Is>(channels_)->try_pop(
std::get<Is>(result), std::chrono::milliseconds(0))), ...);
if (!all_ready) return std::nullopt;
return result;
}
// Build the channel tuple type
template<typename Tup, std::size_t... Is>
static auto make_channel_tuple(std::index_sequence<Is...>)
-> std::tuple<std::unique_ptr<Channel<std::tuple_element_t<Is, Tup>>>...>;
using channels_t = decltype(make_channel_tuple<args_tuple>(
std::make_index_sequence<input_count>{}));
channels_t channels_;
std::atomic<bool> running_{false};
NodeStats stats_;
};
} // namespace kpn