The spec had drifted far from the code. Key corrections: - Execution model is reactive (PoolNode submits fire_once() to a ThreadPool when inputs are ready), not one blocking thread per node - Channel<T> is a lock-free SPSC ring buffer (atomic wait/notify + spin-before-sleep), not a mutex+CV queue - Remove latch<> ports (never implemented) - NodeErrorHandler returns bool (skip vs stop); per-node - Document new subsystems: scheduler, InterruptNode, Router/FilterNode, MainThreadNode, SharedResource, DebugHub, diagnostics/stats layer - Update StaticNetwork, Python auto_bind layer, examples 01-16 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
37 KiB
KPN++ — Kahn Process Network Library Specification
Overview
A header-only C++20 template-metaprogramming library for building Kahn Process Networks. Each node wraps a function (or callable object); its input types are inferred from the parameter list and its output types from the return type. Nodes communicate over bounded, lock-free SPSC FIFO channels.
Unlike a naive "one blocking thread per node" model, KPN++ is reactive: a node is
scheduled onto a thread pool whenever all of its input channels have data. A node that wraps a
function with Node<> owns a private single-thread pool and behaves exactly like an
independent worker; multiple nodes can instead share one ThreadPool for bounded-thread
execution. Source nodes self-resubmit; event-driven sources (InterruptNode) fire on an
external trigger.
The library ships rich runtime diagnostics (per-node exec/CPU/throughput stats, per-channel fill/bandwidth/overflow counters, pool and shared-resource utilisation), an optional in-process web debug UI, and nanobind-based Python bindings (partially implemented).
Note on accuracy. This document describes the code as it exists in
include/kpn/. Where a behaviour is subtle the relevant header is named so the source remains the ground truth.
Project Structure
kpn++/
├── CMakeLists.txt
├── include/kpn/
│ ├── fixed_string.hpp # NTTP string + in<>/out<> tags + index_of
│ ├── traits.hpp # function signature introspection, normalised_return_t, repeat_tuple
│ ├── diagnostics.hpp # NodeStats, ChannelStats, *Snapshot, IPoolProbe, IResourceProbe
│ ├── channel.hpp # lock-free SPSC ring-buffer Channel<T> + storage policy
│ ├── port.hpp # InputPort / OutputPort handles
│ ├── inode.hpp # INode interface, NodeErrorHandler, NodeEvent
│ ├── scheduler.hpp # IScheduler + work-stealing ThreadPool
│ ├── pool_node.hpp # PoolNode / PoolObjectNode (reactive, scheduler-driven)
│ ├── interrupt_node.hpp # InterruptNode (external-trigger source)
│ ├── node.hpp # Node / ObjectNode (PoolNode + private 1-thread pool) + make_node
│ ├── fanout.hpp # FanoutNode<T,N> + make_fanout
│ ├── branch.hpp # RouterNode<T,N> + FilterNode<T> + make_router / make_filter
│ ├── shared_resource.hpp # SharedResource<T> priority-arbitrated exclusive resource
│ ├── main_thread_node.hpp # MainThreadNode<> (GUI / main-thread-bound nodes)
│ ├── static_network.hpp # Edge<>, make_network(), StaticNetwork<>
│ ├── network.hpp # runtime Network builder + watchdog + diagnostics
│ ├── debug_hub.hpp # DebugHub multi-network web UI (KPN_WEB_DEBUG only)
│ ├── web_debug.hpp # single-network web debug server (KPN_WEB_DEBUG only)
│ ├── variant_node.hpp # runtime-typed nodes/channels for Python graphs
│ ├── tmp/
│ │ ├── fanout_groups.hpp # compile-time fan-out detection + edge expansion
│ │ ├── topo_sort.hpp # compile-time DFS cycle check + topological order
│ │ └── repeat_tuple.hpp # repeat_tuple_t<T,N>
│ ├── python/
│ │ ├── bindings.hpp # PyNetwork / PyNode nanobind helpers
│ │ └── auto_bind.hpp # NodeRegistry / Entry / bind_network / bind_debug
│ └── kpn.hpp # umbrella header
├── src/network.cpp
├── tests/ # Catch2 v3 + GoogleTest
├── examples/ # 01–16 (see Examples)
├── benchmarks/ # bench_pipeline (optional, KPN_BUILD_BENCHMARKS)
└── python/kpn_python.cpp # nanobind module definition
kpn.hpp is the umbrella header; including it pulls in the full C++ API (the Python layer is
included only by the binding TU).
Component 0 — fixed_string.hpp: NTTP String + Port Tags
Named ports use C++20 non-type template parameters (NTTPs). std::string_view and
const char* are not valid NTTPs, so a fixed_string literal type provides constexpr
internal storage.
template<std::size_t N>
struct fixed_string {
char data[N]{};
constexpr fixed_string(const char (&s)[N]) { std::copy_n(s, N, data); }
constexpr bool operator==(const fixed_string&) const = default;
constexpr std::string_view view() const { return {data, N - 1}; }
};
template<std::size_t N>
fixed_string(const char (&)[N]) -> fixed_string<N>; // deduction guide (required)
fixed_string<4> and fixed_string<7> are distinct types, so input<"img">() and
input<"sigma">() produce different instantiations — enabling zero-overhead compile-time
port dispatch.
Named-port lookup uses a constexpr index_of over the name pack; it returns the sentinel
npos on a miss so the static_assert fires at the input<"img">() call site, giving a
readable error at the point of use:
inline constexpr std::size_t npos = std::size_t(-1);
template<fixed_string Name, fixed_string... Names>
constexpr std::size_t index_of(); // returns position or npos
Port tags
in<...> and out<...> tag types disambiguate input vs. output name packs in the factory
API. Both are trivial empty structs; both are optional (omit to get index-only ports).
template<fixed_string... Names> struct in {};
template<fixed_string... Names> struct out {};
There is no
latch<>tag. An earlier design sketched latched (most-recent-value) input ports; this was not implemented and the only input kind is the synchronous one.
Component 1 — traits.hpp: Function Introspection
Extracts parameter and return types from any callable at compile time, for free functions,
function pointers, member function pointers (const and non-const), lambdas and std::function.
// function_traits<F>::return_t, ::args (std::tuple<...>), ::arity
template<typename F> using return_t = ...; // return type
template<typename F> using args_t = ...; // std::tuple of parameters
template<typename F> inline constexpr std::size_t arity_v = ...;
The return type is normalised to a tuple so every node has a uniform output-tuple shape:
// void → std::tuple<> (sink node, 0 outputs)
// T (non-tup) → std::tuple<T> (1 output)
// tuple<...> → tuple<...> (one output port per element)
template<typename T> using normalised_return_t = ...;
template<typename F> inline constexpr std::size_t output_count_v = ...;
repeat_tuple_t<T, N> (also surfaced via tmp/repeat_tuple.hpp) builds std::tuple<T, …, T>
with N repetitions — used by FanoutNode and RouterNode to describe their N identical
output ports.
Component 2 — diagnostics.hpp: Statistics and Snapshots
Shared timing types: clock_t = std::chrono::steady_clock, duration_t is a
double-millisecond duration.
NodeStats— atomic counters updated per fire:frames_processed, an EMA of wall-clock exec time (ema_exec_us, warmup-mean for the first 5 frames then α=0.1),max_exec_us,total_blocked_us, thread CPU time (total_cpu_usviaCLOCK_THREAD_CPUTIME_ID),queue_wait_us(pool queue latency), andexec_start_us(non-zero while executing; used by the watchdog to detect hung nodes).ChannelStats—pushes,bytes_pushed,drops,overflows,pops,peak_fill.- Snapshots — copyable plain structs taken by the watchdog / UI:
NodeSnapshot,ChannelSnapshot(withfill_pct(),peak_pct(),bandwidth_mbs()),PoolSnapshot,ResourceSnapshot, andNetworkSnapshot(used by theDebugHub). - Probe interfaces —
IPoolProbeandIResourceProbeexpose asnapshot(name)method so pools and shared resources can be registered with a network for reporting.
ChannelDataSize<T> trait
bytes_pushed is computed from a specialisable trait, defaulting to sizeof(T). Specialise it
for heap-owning payloads to get accurate bandwidth:
template<> struct kpn::ChannelDataSize<cv::Mat> {
static std::size_t bytes(const cv::Mat& m) { return m.total() * m.elemSize(); }
};
Component 3 — channel.hpp: Lock-free Bounded FIFO + Storage Policy
Storage policy
The type stored inside a channel is chosen by a specialisable trait. Small trivially-copyable
types are stored by value; everything else as std::shared_ptr<const T> so fan-out copies a
refcount, not data:
template<typename T>
struct channel_storage_policy {
static constexpr bool by_value =
std::is_trivially_copyable_v<T> && sizeof(T) <= 8;
};
template<typename T>
using channel_storage_t = std::conditional_t<
channel_storage_policy<T>::by_value, T, std::shared_ptr<const T>>;
Override it to force value semantics for a custom small type. Push wraps a value in
make_shared<const T> when needed; pop dereferences it transparently, so a function taking
const T& works naturally and immutability is compiler-enforced.
Channel — SPSC ring buffer
Channel<T> is a single-producer/single-consumer ring buffer (capacity rounded up to a power
of two). It uses C++20 std::atomic::wait/notify_one (portable futex) with a configurable
spin-before-sleep window so the common case never touches the kernel.
template<typename T>
class Channel {
public:
using storage_type = channel_storage_t<T>;
explicit Channel(std::size_t capacity = 5, std::size_t spin_count = 200);
void push(T value); // drops if disabled; throws ChannelOverflowError if full
bool push_sentinel(T value); // out-of-band, non-blocking must-deliver token (EOF)
T pop(); // blocks (spin then futex); throws ChannelClosedError if disabled+empty
bool try_pop(T& out, std::chrono::milliseconds timeout); // polling (watchdog/display)
bool try_pop_now(T& out); // immediate, non-blocking
void enable(); // accept pushes
void disable(); // stop accepting + unblock any waiting pop()
void set_push_callback(std::function<void()>); // empty→non-empty notification
std::size_t size() const; // ring occupancy (excludes any pending sentinel)
std::size_t approx_size() const; // size() + 1 if a sentinel is pending (readiness checks)
std::size_t capacity() const;
bool is_accepting() const;
const ChannelStats& stats() const;
ChannelSnapshot snapshot(const std::string& name) const;
};
class ChannelOverflowError : public std::runtime_error { /* capacity + optional context */ };
class ChannelClosedError : public std::runtime_error {};
head_ and tail_/wake_ live on separate cache lines (alignas(64)) to avoid false
sharing between producer and consumer. spin_hint() issues a pause/yield instruction (or a
compiler fence on other ISAs).
The push_callback — how reactivity works
set_push_callback registers a callback fired when a channel transitions empty→non-empty. A
consuming PoolNode installs this on each of its input channels; when an input becomes ready it
re-evaluates whether all inputs have data and, if so, submits itself to the scheduler. This
is the mechanism that replaces a dedicated blocking thread per node.
The out-of-band EOF sentinel — push_sentinel
push_sentinel(T value) delivers a must-deliver control token (a graceful-EOF marker) that
cannot be dropped by backpressure. The value is stored in a dedicated slot outside the ring,
so it consumes no capacity, never throws ChannelOverflowError, and never blocks the producer.
This matters because a node's worker cannot afford to block on a downstream push: parking that
thread would stop it draining its own input, cascading into a hold-and-wait deadlock under
backpressure. push_sentinel sets a published flag (has_eof_) and returns immediately, keeping
the worker free to keep popping.
Ordering is preserved: the consumer's pop() / try_pop_now() drain the ring first and only
surface the sentinel once the ring is observed empty — so EOF always arrives after every value
pushed before it. approx_size() (used by node readiness checks) counts a pending sentinel as one
consumable item, so a channel carrying only a sentinel still schedules its consumer's next fire
and the token is never stranded. Same SPSC contract as push() (sole producer); returns false
if the channel is already disabled (teardown in progress → the token is moot).
Backpressure and shutdown — accepting_ flag
Each channel carries std::atomic<bool> accepting_ (default true). It is the primary shutdown
mechanism; the only additional signal is the out-of-band EOF sentinel above, used for graceful
drain rather than an abrupt close.
push()on a disabled channel silently drops the value (recorded as adrop). On a full accepting channel it throwsChannelOverflowError(a sizing error).pop()blocks while empty and accepting;disable()wakes it and it throwsChannelClosedError.
The consumer node owns its input channels and flips the flag: start() calls enable(),
stop() calls disable(). Producers never touch it.
Ownership
Input channels are owned by their consumer node (held as shared_ptr<Channel<T>>). A
producer node holds a non-owning raw Channel<T>* to push into. Network/StaticNetwork are
otherwise non-owning of user nodes — see Components 8–9.
Component 4 — inode.hpp: The Node Interface
Every node implements INode:
struct INode {
virtual ~INode() = default;
virtual void start() = 0;
virtual void stop() = 0;
virtual bool running() const = 0;
virtual const NodeStats& stats() const = 0;
virtual NodeSnapshot node_snapshot(const std::string& name, double elapsed_s) const = 0;
virtual void set_name(std::string) = 0;
virtual void set_network_overflow_callback(NodeEventCallback) {} // network-injected
virtual void set_network_closed_callback(NodeEventCallback) {}
virtual void halt() { stop(); } // immediate, discard in-flight work
virtual void shutdown() { stop(); } // graceful topo-ordered drain (overridden by networks)
};
Supporting types:
// Per-node error policy: return true to skip the failed fire and keep running,
// false to stop the node (and signal closed downstream).
using NodeErrorHandler = std::function<bool(std::string_view node_name, std::exception_ptr)>;
using NodeEventCallback = std::function<void(std::chrono::steady_clock::time_point)>;
enum class NodeEvent { Overflow, Closed };
Component 5 — scheduler.hpp: Thread Pool
struct IScheduler {
virtual void submit(std::function<void()> task, float priority = 0.5f) = 0;
virtual void start() = 0;
virtual void stop() = 0; // join workers, discard pending tasks
virtual void drain() = 0; // block until in-flight tasks complete (workers keep running)
};
ThreadPool is a work-stealing pool implementing both IScheduler and IPoolProbe. Each
worker owns a priority queue (max-heap by priority, FIFO within equal priority via a sequence
counter). submit() distributes round-robin; idle workers steal from the most-loaded peer
using try_lock, then sleep on a shared condition variable. The submit/notify path takes the CV
mutex around notify to close the lost-wakeup window; drain() waits on a separate counter of
in-flight tasks. priority lets a hot node (full input, empty output) be scheduled ahead of
others — see PoolNode::compute_priority.
Component 6 — Node Types
All processing nodes share the same shape: typed input channels they own, raw output-channel
pointers set at wiring time, args_tuple / return_tuple aliases used by the connect-time
type check, and static constexpr label() / unique_tag / input_count / output_count.
PoolNode / PoolObjectNode — reactive, scheduler-driven (pool_node.hpp)
The core node. Instead of a blocked thread, it submits a fire_once() to a shared
IScheduler whenever all inputs are ready; queued_ ensures at most one fire_once() is
in flight. fire_once() pops every input (try_pop_now), runs the function, pushes each
normalised output, records stats, then resubmits if inputs remain ready. Source nodes
(input_count == 0) self-submit on start() and after each fire.
template<auto Func,
typename InputTag = in<>,
typename OutputTag = out<>,
fixed_string Label = "",
std::size_t UniqueTag = 0>
class PoolNode : public INode { ... };
auto n = make_pool_node<func>(scheduler, fifo_capacity); // index ports
auto n = make_pool_node<func, "label", 0>(scheduler, in<"a">{}, out<"b">{}, cap);
PoolObjectNode<Obj, …> is the same for a stateful callable object (introspected via
&Obj::operator()); the object must outlive the node.
Per-node configuration: set_error_handler(NodeErrorHandler), set_overflow_callback,
set_closed_callback, set_max_exec_time. Inside fire_once():
ChannelOverflowError fires the overflow callbacks; ChannelClosedError (or an error handler
returning false) fires the closed callbacks and self-stops; any other exception consults the
error handler.
Name-count contract — a static_assert requires that the number of input names is 0 or
equals arity (same for outputs):
static_assert(sizeof...(InNames) == 0 || sizeof...(InNames) == input_count,
"make_pool_node: number of input names must match function arity, or provide none");
Node / ObjectNode — convenience wrappers (node.hpp)
Node<> privately owns a ThreadPool(1) and derives from PoolNode<> with the same
template signature, so each Node is a self-contained worker with no external scheduler. Its
start()/stop() start and stop the private pool around the base. This keeps the simple API —
make_node<func>(5) — while routing all execution through the one fire_once() code path.
template<auto Func, typename InputTag = in<>, typename OutputTag = out<>,
fixed_string Label = "", std::size_t UniqueTag = 0>
class Node : public PoolNode<...> { ... };
auto src = make_node<produce>(5);
auto dbl = make_node<double_it, "dbl">(5);
auto cnt = make_node<count_words>(in<"words">{}, out<"count","words">{}, 4);
The Label NTTP gives a human-readable name for diagnostics; UniqueTag is a collision-breaker
required when the same function is used as two distinct vertices in a StaticNetwork (two
make_node<blur> would otherwise be the same type). Both default so existing code is unaffected.
To share one pool across many nodes for bounded-thread execution, use make_pool_node directly.
InterruptNode — external-trigger source (interrupt_node.hpp)
A zero-input source driven by an external event (camera frame, timer, socket) instead of
self-resubmission. get_trigger() returns a thread-safe callable to hand to the event source;
each call increments a pending_ counter and submits fire_once() on the 0→1 transition,
guaranteeing one execution per trigger even under bursts. It does not busy-loop.
auto cam = make_interrupt_node<grab_frame>(scheduler, out<"frame">{});
camera_sdk.on_frame_ready(cam.get_trigger());
FanoutNode<T, N> — explicit fan-out (fanout.hpp)
Reads one item and pushes a copy to each of N outputs (per-output overflow drops
independently). Runs on its own std::jthread blocking on pop(). Used directly in a runtime
Network via make_fanout<T,N>, and auto-inserted by make_network() for StaticNetwork.
RouterNode<T, N> / FilterNode<T> — branching (branch.hpp)
Both run on a dedicated jthread. RouterNode pushes each item to exactly one of N
outputs chosen by a selector(item) -> size_t (out-of-range index drops). FilterNode forwards
an item only when pred(item) is true. Factories: make_router<T,N>(sel), make_filter<T>(pred).
MainThreadNode<Derived, in<…>, Args…> — GUI / main-thread nodes (main_thread_node.hpp)
For work that must run on the thread owning a GUI event loop (OpenCV imshow/waitKey on
Wayland/Qt). It owns input channels and is registered as a normal INode (appears in
diagnostics) but spawns no thread. The application drives it by calling step() in a loop on
the main thread: step() does a zero-timeout try_pop on every input, and when all are ready
invokes the derived operator()(Args…) (returning false to stop). CRTP; the derived class
supplies the operator.
Component 7 — shared_resource.hpp: Priority-arbitrated Exclusive Resource
SharedResource<T> wraps a singleton-like resource (an ONNX session, a CUDA stream) shared by
nodes across one or more networks, and arbitrates access with a priority + aging waiter
queue. Priority is re-evaluated at every release (so it reflects current queue state), and each
waiter's effective score grows with wait time (kAgingPerSecond) to prevent starvation.
SharedResource<OrtSession> res(session_args...);
// inside a node functor:
auto guard = res.acquire_balanced(in_channel, out_channel); // RAII; releases on scope exit
guard->Run(...);
acquire_balanced(in, out) scores a waiter by input_fill × output_headroom — a node with a
full input queue and empty output is most urgent. acquire(fn) takes any ()->float priority;
acquire() treats all waiters equally. Implements IResourceProbe so it shows up in
diagnostics and the debug hub. The factory is make_shared_resource<T>(args…).
Component 8 — network.hpp: Runtime Graph Builder + Watchdog
Network is non-owning (nodes outlive it; add() stores INode*). A builder collects the
full topology before build(), enabling cycle detection and topological ordering.
class Network : public INode {
public:
template<typename NodeT> Network& add(std::string name, NodeT& node);
template<typename SrcNode, std::size_t SrcIdx, typename DstNode, std::size_t DstIdx>
Network& connect(const std::string& src, OutputPort<SrcNode, SrcIdx>,
const std::string& dst, InputPort<DstNode, DstIdx>);
Network& expose_input (std::string boundary_name, InputPort<NodeT, Idx>); // sub-network port
Network& expose_output(std::string boundary_name, OutputPort<NodeT, Idx>);
Network& build(); // DFS cycle check (throws NetworkCycleError) + topo sort
void start() override; // start nodes in topo order; launch watchdog (+ web UI)
void stop() override; // == halt()
void halt() override; // immediate: stop nodes in reverse topo order
void shutdown() override; // graceful: stop source layers, drain channels, descend
void set_watchdog_interval(std::chrono::milliseconds);
void set_error_handler(ErrorHandler); // void(node_name, exception_ptr)
void set_diagnostics_handler(DiagnosticsHandler); // fired each watchdog tick
void set_event_handler(EventHandler); // void(name, NodeEvent, timestamp)
void register_pool(const std::string&, IPoolProbe*);
void print_diagnostics(std::ostream& = std::cerr) const; // formatted table
};
connectstatic-asserts that the source output type equals the destination input type (via the nodes'return_tuple/args_tuple), sets the consumer's input channel as the producer's output pointer, registers aChannelProbefor diagnostics, and rejects a second connection from the same output port (usemake_fanout).buildcolours the graph DFS; a back-edge throwsNetworkCycleError. It also wires each node's network-level overflow/closed callbacks to theEventHandlerif one is set.haltvsshutdown—halt()disables channels and stops nodes in reverse order immediately;shutdown()walks source layers first, polling channel probes until they drain before stopping the next layer.- Watchdog — a
std::jthreadthat wakes onwatchdog_interval_(default 3 s), collects snapshots, warns about nodes whoseexec_start_usindicates an execution running > 5 s, and either calls the diagnostics handler or prints the formatted report. expose_input/expose_outputrecord boundary names (sub-network support is scaffolded;Networkis itself anINodeand can beadd()ed to an outerNetwork).
The formatted report includes node (frames, exec ms, max ms, blocked ms, fps, cpu ms, util%),
channel (fill%, peak%, pushes, drops, overflow, MB/s, item bytes), and pool tables, plus a
bottleneck hint (highest ema_exec_ms).
Component 9 — static_network.hpp: Compile-time Graph Builder
For C++ graphs whose full topology is known at compile time. The complete edge list is a type
pack, so fan-out arity is known up front, cycle detection is a static_assert, and start/stop
are pointer-vector traversals rather than string-map + virtual dispatch.
// edge() builds a typed Edge descriptor from two port handles.
template<typename SrcNode, std::size_t SrcIdx, typename DstNode, std::size_t DstIdx>
Edge<SrcNode, SrcIdx, DstNode, DstIdx>
edge(OutputPort<SrcNode, SrcIdx>, InputPort<DstNode, DstIdx>);
// make_network() takes all edges, expands fan-outs, wires channels, returns a StaticNetwork.
template<typename... Edges> auto make_network(Edges&&... edges);
Usage — no add/connect/build/string names; one source port feeding two destinations
auto-inserts a FanoutNode:
auto src = make_node<produce, "src">(8);
auto blur = make_node<blur_func, "blur">(8);
auto detect = make_node<detect_func,"detect">(8);
auto sink = make_node<display, "sink">(8);
auto net = make_network(
edge(src.output<0>(), blur.input<0>()),
edge(src.output<0>(), detect.input<0>()), // same source port → FanoutNode<T,2> inserted
edge(blur.output<0>(), sink.input<0>()),
edge(detect.output<0>(), sink.input<1>()));
net.start(); /* … */ net.stop();
make_network performs, at compile time: fan-out detection and edge expansion
(tmp/fanout_groups.hpp), a duplicate-(Func, UniqueTag) check
(static_assert — "add a UniqueTag"), and a cycle check + topological order
(tmp/topo_sort.hpp, static_assert — "graph contains a directed cycle"). At run time it
heap-allocates owned FanoutNode storage, collects user-node pointers in edge order, sets each
node's display name (Label, else node[UniqueTag]; fan-outs become "<src>_fanout"), wires
every expanded edge, and builds channel probes.
StaticNetwork<FanoutStorage, TopoNodeList> implements INode (so it can be embedded in a
runtime Network). It owns the fan-out nodes, holds user nodes by pointer, and provides
start/halt/shutdown, an EventHandler, register_resource / register_pool,
print_diagnostics, and network_snapshot() (consumed by the DebugHub). Compile-time labels
are read from each NodeType::label().
make_fanout<T,N>remains for explicit fan-out in a runtimeNetwork;make_networkusers never call it.
Component 10 — Web Debugging (optional, KPN_WEB_DEBUG)
Zero cost when disabled — guarded headers, no symbols, no dependency. Depends on cpp-httplib (single-header, fetched by CMake when the option is on) and loads D3.js v7 from CDN. Enable per-target:
#define KPN_WEB_DEBUG 1
#include <kpn/kpn.hpp>
Single-network server (web_debug.hpp)
When enabled, Network / StaticNetwork gain set_web_debug_port(uint16_t) (default 9090) and
auto-start an in-process HTTP server in start(). It serves an inline single-page app at / and
a JSON snapshot at /api/snapshot (nodes, channels/edges, pools, resources, elapsed). The page
renders a force-directed graph: node colour encodes ema_exec_ms, edge colour encodes fill%,
with hover tooltips for the full stat set; it polls every 500 ms.
DebugHub — multi-network UI (debug_hub.hpp)
A standalone server aggregating several networks under one endpoint:
DebugHub hub(9090);
hub.register_network("detect", detect_net); // disables that net's own server
hub.register_network("classify", classify_net);
hub.register_resource("gpu", &gpu_resource); // shows utilisation cards
hub.start();
The hub UI has one tab per registered network plus an "All Networks" tab with shared-resource
cards and a cross-network node table. register_network calls net.disable_web_server() so the
hub is the single debug endpoint; call it before net.start().
Component 11 — Python Bindings (partial)
Status: scaffolded and partially implemented. The variant machinery,
PyNetwork/PyNode, and the auto-binding layer exist; the demo module wires a hello-pipeline. Full sub-port read/write and mixed C++/Python graphs are still in progress.
Python graphs cannot resolve types at compile time, so a PyNetwork is parameterised by a
std::variant derived (at compile time, via unique_types) from the port types of a closed
list of registered C++ node types. The variant only appears at the C++/Python boundary; each
node's internal Channel<T> still stores raw T (variant_node.hpp: IVariantChannel,
VariantChannel<T,Variant>, IVariantNode, VariantNodeWrapper).
Auto-binding (python/auto_bind.hpp)
The node list is declared once with a NodeRegistry of Entry<func, "name">. bind_network
registers the PyNetwork class, a make_<name>(capacity) factory and a <Name>Node class per
entry, and auto-registers PythonConverter for each port type. bind_debug additionally exposes
each raw C++ function as a free Python callable for testing without a network. Recompiling the
extension is the registration step — there is no CMake code-gen.
using DemoNodes = kpn::python::NodeRegistry<
kpn::python::Entry<produce, "produce">,
kpn::python::Entry<double_it, "double_it">,
kpn::python::Entry<print_it, "print_it">>; // variant auto-deduced as std::variant<int>
NB_MODULE(kpn_python, m) {
bind_network<DemoNodes>(m);
bind_debug<DemoNodes>(m);
}
Custom types are supported by specialising kpn::PythonConverter<T> (to_python / from_python,
optional type_name) before bind_network.
GIL rules (non-negotiable)
- Acquire for callback — hold the GIL only for the duration of a Python callable
invocation (
nb::gil_scoped_acquirearound the call site). - Release while blocking — release the GIL before any blocking channel op
(
nb::gil_scoped_release), then re-acquire. Violating this deadlocks: a PyNode thread waiting for the GIL cannot proceed while another thread holds it and blocks on a channel waiting for that PyNode.
Error Handling Contract
| Situation | Behaviour |
|---|---|
| FIFO overflow (full, accepting) | ChannelOverflowError thrown in producer; node overflow callbacks fire |
| Push to a disabled channel | Value silently dropped (counted as a drop) |
| Node function throws | Routed to the node's NodeErrorHandler → true skips & continues, false stops the node |
| Node stopped / channel closed | ChannelClosedError → node fires closed callbacks and self-stops |
| Type mismatch (C++) | static_assert at connect() / make_network() |
| Cycle in graph (runtime) | NetworkCycleError thrown at build() |
| Cycle in graph (static) | static_assert at make_network() |
Duplicate (Func, UniqueTag) (static) |
static_assert at make_network() — add a UniqueTag |
| Hung node | Watchdog warning after threshold |
Network additionally exposes an aggregate EventHandler(name, NodeEvent, timestamp) for
overflow/closed events across all nodes.
Thread Model
KPN++ is reactive, not one-thread-per-node:
- A
PoolNodeowns no thread. It registers a push-callback on each input channel; when all inputs are ready it submitsfire_once()to a sharedIScheduler(aThreadPool). Node<>wraps aPoolNodeplus a privateThreadPool(1), recovering "independent worker" semantics with the simplemake_nodeAPI. Many nodes can instead share one pool (make_pool_node) for a bounded OS thread count.FanoutNode,RouterNode, andFilterNodedo run a dedicatedstd::jthreadblocking onpop()(they are simple, latency-sensitive routers).InterruptNodefires on an external trigger;MainThreadNoderuns on the caller's main thread viastep().
std::jthread (C++20) and its stop_token are used where a thread is owned, simplifying
cooperative shutdown. Benchmarks (benchmarks/bench_pipeline) show ~2–7 µs/hop framework
overhead for chains within the core count, rising under oversubscription.
Platform and Compiler Requirements
C++20 is required.
| Feature | Min compiler |
|---|---|
NTTP structural types (fixed_string) |
GCC 11, Clang 13, MSVC 19.29 |
std::atomic::wait/notify (channel futex) |
GCC 11, Clang 13, MSVC 19.29 |
std::jthread + stop_token |
GCC 11, Clang 14, MSVC 19.29 |
auto NTTPs, fold expressions, if constexpr, concepts |
C++20 / C++17 baseline |
CLOCK_THREAD_CPUTIME_ID (per-thread CPU stats in diagnostics.hpp) is POSIX. nanobind
requires Python 3.8+ (auto-fetched when KPN_BUILD_PYTHON=ON).
Testing Strategy
Catch2 v3 for behaviour/integration tests and GoogleTest for unit and death tests; both
are auto-fetched. Existing suites: test_fixed_string, test_traits, test_channel,
test_node, test_network, test_static_network, test_scheduler, test_pool_node,
test_shared_resource.
Cases covered explicitly include: channel blocking/unblocking and overflow; shutdown races
(stop() while blocked on pop()); try_pop_now; fan-out delivery; tuple unpacking to
sub-channels; runtime cycle detection and static cycle/duplicate-tag static_asserts; named
port lookup and wrong-name-count static_asserts; storage-policy by-value vs shared_ptr;
scheduler submit/steal/drain; PoolNode reactive scheduling; and SharedResource priority +
aging.
Examples
Self-contained programs under examples/, built by default (-DKPN_BUILD_EXAMPLES=OFF to
skip). They double as documentation and smoke tests.
| Example | What it shows |
|---|---|
01_hello_pipeline |
Linear pipeline, index-based wiring, Network builder |
02_named_ports |
in<>/out<> tags, named port access, wrong-name static_assert |
03_multi_output |
Tuple-returning node, per-element sub-port routing |
04_storage_policy |
channel_storage_policy default + specialisation |
05_error_handling |
ChannelOverflowError, diagnostics handler |
06_watchdog |
Watchdog interval, stall detection |
07_python_network |
PyNetwork with a pure-Python node (pending) |
08_python_subport |
net.read / net.write, sub-port tap (pending) |
09_opencv_cellshade |
Real-time cell-shading on webcam; named ports, fan-out, MainThreadNode display (requires OpenCV) |
10_static_hello_pipeline |
make_network() version of 01 — compile-time topology |
11_static_fanout |
Auto-inserted FanoutNode from a duplicated source port |
12_static_cellshade |
Static cell-shading with auto fan-out and Label NTTPs |
13_debug_cellshade |
One-op-per-node pipeline + variadic DebugCanvas<N> tiling node |
14_debug_hub |
Two networks sharing a SharedResource via DebugHub |
15_node_error_handler |
Per-node set_error_handler (skip-and-continue vs stop) |
16_event_callbacks |
set_overflow_callback + network set_event_handler |
Future Extension Points (Heterogeneous Execution)
Not implemented, but the design keeps these doors open:
IChannelabstract interface —Channel<T>and a futureRemoteChannel<T>(socket / shared-memory) sharing onepush/popsurface so nodes are agnostic to channel location.Serializer<T>trait — parallel tochannel_storage_policy/PythonConverter, for cross-device serialisation (MessagePack for embedded, pinned memory for GPU zero-copy).NodeKindtag — e.g.{ Local, Gpu, Remote }onINode, letting the watchdog apply per-device health-check and timeout strategies.
The IScheduler abstraction already decouples node execution from any specific thread model,
making a cooperative or device-specific executor a drop-in.
Resolved Design Decisions
| Question | Decision |
|---|---|
| Execution model | Reactive: nodes submit fire_once() to an IScheduler when inputs are ready, not one blocking thread per node |
Node<> vs PoolNode<> |
Node<> owns a private ThreadPool(1); PoolNode<> shares a pool for bounded threads |
| Channel | Lock-free SPSC ring buffer, atomic::wait/notify + spin-before-sleep |
| Shutdown | Per-channel accepting_ flag; disable() unblocks pop() (→ ChannelClosedError) |
| Overflow | ChannelOverflowError on full accepting channel; silent drop on disabled channel |
| Node error policy | Per-node NodeErrorHandler returning bool (skip vs stop) |
| Network ownership | Non-owning; user declares nodes, network stores INode* |
| Fan-out | Explicit FanoutNode<T,N> for runtime Network; auto-inserted by make_network() |
| Branching | RouterNode<T,N> (select one of N) and FilterNode<T> (predicate gate) |
| Static vs runtime graph | Both; StaticNetwork for compile-time C++ topology, Network for dynamic/Python; StaticNetwork is an INode so it embeds in Network |
| Node identity (static graphs) | Label NTTP (name) + UniqueTag NTTP (collision-breaker); both default |
| Shared device resource | SharedResource<T> with priority + aging arbitration |
| Main-thread / GUI work | MainThreadNode<> driven by step() on the main thread |
| External-event sources | InterruptNode with a thread-safe get_trigger() |
| Web debugging | Per-network server + multi-network DebugHub, behind KPN_WEB_DEBUG |
| Mixed-rate latched inputs | Not implemented — no latch<> ports |