Compare commits

...

19 Commits

Author SHA1 Message Date
66feb91821 ci: pass docker push input as a string, not a boolean
All checks were successful
🚦 CI / changes (push) Successful in 6s
🚦 CI / docker (push) Successful in 2m57s
🚦 CI / test (push) Successful in 8m7s
🚦 CI / docs (push) Successful in 6s
Gitea's act_runner mangles boolean workflow_call/dispatch inputs passed
from an expression -- they arrive as false regardless of value. Declare
`push` as a string ("true"/"false") and compare with == 'true' so the
builder image is pushed on non-PR events again.
2026-07-04 15:07:27 +02:00
903dd4eea5 docs: update README examples table and add documentation link
07_python_network and 08_python_subport now work and run as CI smoke
tests, so drop their "(pending)" markers and describe what they actually
demonstrate. Also surface the hosted documentation link at the top and
re-render README.md from README.md.in.
2026-07-04 15:07:12 +02:00
298c9e770b examples: run Python examples 07 and 08 as CTest smoke tests
Neither Python example was registered as a test, so `ctest -L examples`
in CI skipped them entirely -- which is how 08's missing Python node
went unnoticed.

Add a kpn_python_example() helper (gated on KPN_BUILD_PYTHON) that runs
each script with PYTHONPATH pointed at the freshly-built module, so it
does not depend on cwd or a hard-coded build/python path, and register
07 and 08. Also del the network in 07 for deterministic teardown.
2026-07-04 14:44:30 +02:00
2b0873b61b examples: make 08_python_subport run a real Python node
The example was named "python subport" but its graph was entirely C++
(ProduceNode -> DoubleItNode); Python only tapped the output, leaving a
dangling "#todo: return value to network".

Rewrite so the only node in the graph is a pure-Python py_triple, driven
from both ends via the subport taps: net.write() injects inputs and
net.read() pulls results back, closing the round trip. Also del the
network at the end so its callable cycle is reclaimed deterministically.
2026-07-04 14:44:24 +02:00
c4538f03ca python: fix nanobind Network reference leak via GC type slots
The Python Network holds each PyNode's callable, forming an
uncollectable instance -> callable -> globals() -> instance cycle that
tripped nanobind's leak check at interpreter shutdown.

Implement tp_traverse/tp_clear type slots on the Network binding so
Python's cyclic collector can see through the C++-held callables and
break the cycle. PyNode exposes its callable; PyNetwork visits and
clears them. Wired into both binding sites (auto_bind and the legacy
register_py_network).
2026-07-04 14:43:47 +02:00
949c8134ef Ignore build_test/ (out-of-tree test build dir)
All checks were successful
🚦 CI / changes (push) Successful in 3s
🚦 CI / docker (push) Has been skipped
🚦 CI / test (push) Has been skipped
🚦 CI / docs (push) Has been skipped
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 11:02:54 +02:00
19f5a2b0ae Deliver EOF sentinels out-of-band to prevent teardown deadlock
Some checks failed
🚦 CI / changes (push) Successful in 18s
🚦 CI / docker (push) Has been skipped
🚦 CI / docs (push) Has been cancelled
🚦 CI / test (push) Has been cancelled
Channel::push() drops values on overflow (the intended backpressure
policy for data), and PoolNode swallows the resulting ChannelOverflowError.
For a control sentinel like EOF this is fatal: a single dropped EOF under
backpressure wedges every downstream pop() forever, so the pipeline never
tears down.

Deliver sentinels out-of-band instead. Channel::push_sentinel() stores the
token in a dedicated slot that does not consume ring capacity, so it can
never overflow and — crucially — never blocks the caller. That non-blocking
property is essential: each KPN node has a single worker thread, so a
*blocking* push would park that thread and stop it draining its own input,
cascading into a hold-and-wait deadlock under backpressure. The consumer's
pop()/try_pop_now() drain the ring first, then deliver the sentinel, so it
always arrives after every value pushed before it.

approx_size() (which node readiness checks call) counts a pending sentinel
as consumable work, so a channel carrying only a sentinel still schedules
its consumer's next fire — without this the token would sit undelivered and
the pipeline would still deadlock at teardown.

PoolNode/PoolObjectNode route values carrying an eof flag (direct .eof or
nested .source.eof) through push_sentinel via a SFINAE-safe is_sentinel_value
trait; all other values keep the existing lossy throwing push. The trait
compiles to false for types without an eof convention, so this is a no-op
for pipelines that don't use one.

Verified end-to-end: scene_analyze now reaches EOF, flushes its output, and
exits cleanly instead of hanging.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 00:30:40 +02:00
a4de64ea04 Add liscence and prepare for OSS release
All checks were successful
🚦 CI / changes (push) Successful in 39s
🚦 CI / docker (push) Has been skipped
🚦 CI / test (push) Has been skipped
🚦 CI / docs (push) Successful in 7s
2026-06-28 12:05:56 +02:00
7c6a8be2b7 ci: provoke pipeline (touch Dockerfile + code)
All checks were successful
🚦 CI / changes (push) Successful in 32s
🚦 CI / docker (push) Successful in 1m0s
🚦 CI / test (push) Successful in 4m47s
🚦 CI / docs (push) Successful in 13s
Trivial comment changes to exercise the new CI orchestration: the
docker job should rebuild+push the builder image first, then test
and docs run against the fresh image.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 09:46:35 +02:00
4c0f1f6923 fix CI
All checks were successful
🚦 CI / changes (push) Successful in 33s
🚦 CI / docker (push) Has been skipped
🚦 CI / test (push) Has been skipped
🚦 CI / docs (push) Has been skipped
2026-06-20 09:38:26 +02:00
6b52526e44 Auto-build docker when docker file changes.
Some checks failed
🚦 CI / changes (push) Failing after 5s
🚦 CI / docker (push) Has been skipped
🚦 CI / test (push) Has been skipped
🚦 CI / docs (push) Has been skipped
2026-06-20 09:16:27 +02:00
7cb92a4091 Build docs with a pre-configured docker
All checks were successful
🧪 Test / test (push) Successful in 4m41s
2026-06-20 08:55:48 +02:00
20668d6955 Fix docker image
All checks were successful
🧪 Test / test (push) Successful in 7m31s
2026-06-19 22:29:46 +02:00
6f384dc4b5 Added callbacks for node errors and fifo overflow
Some checks failed
📚 Docs / deploy (push) Failing after 7s
🧪 Test / test (push) Has been cancelled
Add new doc system which should/might deploy to pages.
2026-06-19 22:26:39 +02:00
79916f1da1 Set node names in make_network for user and fanout nodes
All checks were successful
🧪 Test / test (push) Successful in 6m22s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 15:20:50 +02:00
f6bcaa15b0 Performance improvements, better readme and complete python bindings
Some checks failed
🧪 Test / test (push) Failing after 28m30s
2026-05-12 21:23:33 +02:00
c39db82763 Add a per node error handler possibility
All checks were successful
🧪 Test / test (push) Successful in 6m7s
2026-05-10 19:51:23 +02:00
1e9ba5ee66 Add a unified observability interface for applications with multiple networks
All checks were successful
🧪 Test / test (push) Successful in 6m9s
2026-05-10 19:08:40 +02:00
278c122e8f Add shared reasource tag to allow coordination of usage
All checks were successful
🧪 Test / test (push) Successful in 6m8s
2026-05-09 15:22:27 +02:00
67 changed files with 7698 additions and 885 deletions

80
.gitea/workflows/ci.yaml Normal file
View File

@ -0,0 +1,80 @@
name: '🚦 CI'
# Single orchestrator. This is the only workflow that triggers on push/PR.
# It decides which reusable sub-workflows to run and in what order:
# changes ─┬─> docker (only if the Dockerfile/requirements changed) ─┬─> test
# │ └─> docs
# When the builder image is rebuilt it MUST finish (and push) before test/docs
# run, so they validate against the fresh image.
on:
push:
branches:
- master
- develop
pull_request:
branches:
- master
- develop
workflow_dispatch:
jobs:
# Detect which parts of the repo changed in this push/PR.
changes:
runs-on: linux/amd64
# Runs in the builder image because the host has no Node, which the
# JS-based checkout/paths-filter actions require.
container:
image: gitea.tourolle.paris/dtourolle/kpnpp-builder:latest
outputs:
dockerfile: ${{ steps.filter.outputs.dockerfile }}
code: ${{ steps.filter.outputs.code }}
docs: ${{ steps.filter.outputs.docs }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Detect changed paths
id: filter
uses: dorny/paths-filter@v3
with:
filters: |
dockerfile:
- 'Dockerfile.builder'
- 'docs/requirements.txt'
docs:
- 'docs/**'
- 'mkdocs.yml'
- 'examples/**/*.cpp'
code:
- 'src/**'
- 'include/**'
- 'tests/**'
- 'examples/**'
- 'python/**'
- 'CMakeLists.txt'
- '**/*.cpp'
- '**/*.hpp'
- '**/*.h'
# Rebuild the builder image first, but only when it actually changed.
# On pull requests we build to validate the Dockerfile but do not push.
docker:
needs: changes
if: ${{ needs.changes.outputs.dockerfile == 'true' }}
uses: ./.gitea/workflows/docker.yaml
with:
# Explicit string, not a boolean expression (act_runner mangles bools).
push: ${{ github.event_name == 'pull_request' && 'false' || 'true' }}
# Runs after docker (if docker ran). A skipped docker job is fine; a failed
# one blocks this via !failure(). Re-run tests when code OR the image changed.
test:
needs: [changes, docker]
if: ${{ !failure() && !cancelled() && (needs.changes.outputs.code == 'true' || needs.changes.outputs.dockerfile == 'true') }}
uses: ./.gitea/workflows/test.yaml
docs:
needs: [changes, docker]
if: ${{ !failure() && !cancelled() && github.ref == 'refs/heads/master' && (needs.changes.outputs.docs == 'true' || needs.changes.outputs.dockerfile == 'true') }}
uses: ./.gitea/workflows/docs.yaml
secrets: inherit

View File

@ -0,0 +1,57 @@
name: '🐳 Builder Image'
# Reusable workflow: builds (and optionally pushes) the kpnpp-builder image.
# It is called by ci.yaml only when Dockerfile.builder or docs/requirements.txt
# change. It runs on the host runner (NOT inside the builder container) because
# it needs the Docker CLI/daemon.
# Note: `push` is a STRING ("true"/"false"), not a boolean. Gitea's act_runner
# mangles boolean inputs passed from an expression (they arrive as false), so we
# pass an explicit string and compare with == 'true' below.
on:
workflow_call:
inputs:
push:
description: 'Push the built image to the registry ("true"/"false")'
type: string
default: 'true'
workflow_dispatch:
inputs:
push:
description: 'Push the built image to the registry ("true"/"false")'
type: string
default: 'true'
jobs:
build:
runs-on: linux/amd64
steps:
# This job runs on the host (not in a container) so it can reach the
# host Docker daemon and reuse the cached registry credentials. The host
# has no Node, so the JS-based actions/checkout can't run here; do a
# minimal shallow fetch of this commit with plain git instead.
- name: Checkout repository
run: |
git init -q .
git remote add origin "${{ github.server_url }}/${{ github.repository }}.git"
git -c http.extraheader="AUTHORIZATION: basic $(printf '%s' '${{ github.actor }}:${{ github.token }}' | base64 -w0)" \
fetch --depth 1 origin "${{ github.sha }}"
git checkout -q FETCH_HEAD
# No docker login step: the host runner was authenticated to
# gitea.tourolle.paris with `docker login` during setup, so its cached
# credentials in ~/.docker/config.json cover the push below.
- name: Build builder image
# Context is the repo root because Dockerfile.builder COPYs
# docs/requirements.txt during the build.
run: |
docker build \
-f Dockerfile.builder \
-t gitea.tourolle.paris/dtourolle/kpnpp-builder:latest \
-t gitea.tourolle.paris/dtourolle/kpnpp-builder:${{ github.sha }} \
.
- name: Push builder image
if: ${{ inputs.push == 'true' }}
run: |
docker push gitea.tourolle.paris/dtourolle/kpnpp-builder:latest
docker push gitea.tourolle.paris/dtourolle/kpnpp-builder:${{ github.sha }}

View File

@ -0,0 +1,34 @@
name: '📚 Docs'
# Triggering and path filtering are owned by ci.yaml (the orchestrator), which
# calls this as a reusable workflow. workflow_dispatch is kept for manual runs.
on:
workflow_call:
workflow_dispatch:
jobs:
deploy:
runs-on: linux/amd64
container:
image: gitea.tourolle.paris/dtourolle/kpnpp-builder:latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0 # full history needed for mkdocs gh-deploy
- name: Configure git identity
run: |
git config user.name "Gitea Actions"
git config user.email "actions@gitea.tourolle.paris"
- name: Build and deploy to gitea-pages branch
env:
GH_TOKEN: ${{ github.token }}
run: |
mkdocs gh-deploy \
--force \
--remote-branch gitea-pages \
--remote-name origin \
--message "docs: deploy from ${{ github.sha }}"

View File

@ -1,18 +1,9 @@
name: '🧪 Test'
# Triggering and path filtering are owned by ci.yaml (the orchestrator), which
# calls this as a reusable workflow. workflow_dispatch is kept for manual runs.
on:
push:
branches:
- master
- develop
paths-ignore:
- '**/*.md'
pull_request:
branches:
- master
- develop
paths-ignore:
- '**/*.md'
workflow_call:
workflow_dispatch:
jobs:
@ -41,7 +32,7 @@ jobs:
-G Ninja \
-DCMAKE_BUILD_TYPE=Debug \
-DKPN_BUILD_TESTS=ON \
-DKPN_BUILD_EXAMPLES=OFF \
-DKPN_BUILD_EXAMPLES=ON \
-DKPN_BUILD_PYTHON=ON \
-DFETCHCONTENT_BASE_DIR=$HOME/.cmake/fetchcontent
@ -49,18 +40,26 @@ jobs:
working-directory: test-${{ github.run_id }}
run: cmake --build build --parallel
- name: Run tests
- name: Run unit tests
working-directory: test-${{ github.run_id }}
run: |
cd build
ctest --output-on-failure --output-junit test-results.xml
ctest --output-on-failure --output-junit test-results.xml --label-exclude examples
- name: Run example smoke tests
working-directory: test-${{ github.run_id }}
run: |
cd build
ctest --output-on-failure --output-junit example-results.xml -L examples
- name: Upload test results
if: always()
uses: actions/upload-artifact@v3
with:
name: test-results
path: test-${{ github.run_id }}/build/test-results.xml
path: |
test-${{ github.run_id }}/build/test-results.xml
test-${{ github.run_id }}/build/example-results.xml
retention-days: 7
- name: Cleanup

4
.gitignore vendored
View File

@ -1,6 +1,8 @@
# Build output
build/
build_test/
build_debug/
site/
# Python
__pycache__/
*.py[cod]

View File

@ -48,6 +48,12 @@ if(KPN_BUILD_TESTS)
add_subdirectory(tests)
endif()
# Benchmarks
option(KPN_BUILD_BENCHMARKS "Build benchmarks" OFF)
if(KPN_BUILD_BENCHMARKS)
add_subdirectory(benchmarks)
endif()
# Python bindings
if(KPN_BUILD_PYTHON)
find_package(Python 3.8 COMPONENTS Interpreter Development.Module REQUIRED)
@ -69,3 +75,14 @@ endif()
if(KPN_BUILD_EXAMPLES)
add_subdirectory(examples)
endif()
# Docs (README generation)
find_package(Python3 QUIET COMPONENTS Interpreter)
if(Python3_FOUND)
add_custom_target(docs
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/scripts/render_readme.py
WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}
COMMENT "Rendering README.md from README.md.in"
VERBATIM
)
endif()

View File

@ -1,4 +1,4 @@
# KPN++ Builder Image
# KPN++ Builder Image (CI: pipeline trigger v2)
# Pre-built image with GCC, CMake, Ninja, and Python dev headers for building and testing KPN++
# Build: docker build -f Dockerfile.builder -t gitea.tourolle.paris/dtourolle/kpnpp-builder:latest .
# Push: docker push gitea.tourolle.paris/dtourolle/kpnpp-builder:latest
@ -16,4 +16,12 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
nodejs \
&& rm -rf /var/lib/apt/lists/*
# Pre-install MkDocs dependencies so the docs workflow does not need to pip
# install at runtime. --break-system-packages is required because the Debian
# base marks the environment as externally managed (PEP 668); this is safe in
# a dedicated container image.
COPY docs/requirements.txt /tmp/docs-requirements.txt
RUN pip install --no-cache-dir --break-system-packages -r /tmp/docs-requirements.txt \
&& rm /tmp/docs-requirements.txt
WORKDIR /src

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Duncan Tourolle
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

427
README.md
View File

@ -2,6 +2,8 @@
A C++20 Kahn Process Network (KPN) library. Each node wraps a function and runs in its own thread, communicating with downstream nodes via bounded FIFO channels. Includes Python bindings via nanobind.
📖 **[Documentation](https://pages.tourolle.paris/dtourolle/kpn/)**
---
## Requirements
@ -50,31 +52,55 @@ A node wraps any callable. Its input types are taken from the function's paramet
```cpp
#include <kpn/kpn.hpp>
using namespace kpn;
```
// Single input, single output
int double_it(int x) { return x * 2; }
Source, transform, and sink — from [`examples/01_hello_pipeline/main.cpp`](examples/01_hello_pipeline/main.cpp):
// Multi-output — must return std::tuple
std::tuple<cv::Mat, cv::Mat> split(cv::Mat frame) { ... }
```cpp
static int produce() { return 42; }
static int double_it(int x) { return x * 2; }
static void print_it(int x) { std::cout << "result: " << x << '\n'; }
```
// Sink — void return, no output ports
void display(cv::Mat frame) { cv::imshow("out", frame); }
Multi-output node returning a tuple — from [`examples/03_multi_output/main.cpp`](examples/03_multi_output/main.cpp):
```cpp
// Multi-output: returns (key, value) as a tuple — KPN++ routes each element
// to its own output port automatically.
static std::tuple<std::string, std::string> parse(std::string kv) {
auto sep = kv.find('=');
if (sep == std::string::npos) return {kv, ""};
return {kv.substr(0, sep), kv.substr(sep + 1)};
}
```
### Creating Nodes
**Index-only ports** (from [`examples/01_hello_pipeline/main.cpp`](examples/01_hello_pipeline/main.cpp)):
```cpp
// No port names (index-only access)
auto node = make_node<double_it>(/*fifo_capacity=*/5);
auto src = make_node<produce>(5);
auto dbl = make_node<double_it>(5);
auto sink = make_node<print_it>(5);
```
// Named input ports only
auto node = make_node<double_it>(in<"value">{}, 5);
**Named ports** (from [`examples/02_named_ports/main.cpp`](examples/02_named_ports/main.cpp)):
// Named input and output ports
auto node = make_node<double_it>(in<"value">{}, out<"result">{}, 5);
```cpp
// tokenise: no inputs, one named output "words"
auto tok = make_node<tokenise>(out<"words">{}, 4);
// Named output ports only (e.g. a source with no inputs)
auto node = make_node<capture>(out<"colour","grey">{}, 5);
// count_words: named input "words", named outputs "count" and "words"
auto cnt = make_node<count_words>(in<"words">{}, out<"count", "words">{}, 4);
// report: two named inputs
auto snk = make_node<report>(in<"count", "words">{}, 4);
```
**Multi-named output source** (from [`examples/09_opencv_cellshade/main.cpp`](examples/09_opencv_cellshade/main.cpp)):
```cpp
auto src = make_node<capture>(out<"colour","grey">{}, 8);
```
Port names are NTTP `fixed_string` values — resolved entirely at compile time, zero runtime cost.
@ -83,22 +109,25 @@ Port names are NTTP `fixed_string` values — resolved entirely at compile time,
`Network` is **non-owning** — declare nodes first, then register them. Nodes must outlive the network.
```cpp
auto src = make_node<produce>(in<"x">{}, out<"value">{}, 5);
auto proc = make_node<double_it>(in<"value">{}, out<"result">{}, 5);
From [`examples/02_named_ports/main.cpp`](examples/02_named_ports/main.cpp):
```cpp
Network net;
net.add("src", src)
.add("proc", proc)
.connect("src", src.output<0>(), "proc", proc.input<0>()) // by index
.connect("src", src.template output<"value">(), "proc", proc.template input<"value">()) // by name
.build(); // runs cycle detection — throws NetworkCycleError on cycles
net.add("tok", tok)
.add("cnt", cnt)
.add("snk", snk)
.connect("tok", tok.template output<"words">(), "cnt", cnt.template input<"words">())
.connect("cnt", cnt.template output<"count">(), "snk", snk.template input<"count">())
.connect("cnt", cnt.template output<"words">(), "snk", snk.template input<"words">())
.build();
net.start();
// ... do work ...
std::this_thread::sleep_for(std::chrono::milliseconds(500));
net.stop();
```
`.build()` runs cycle detection — throws `NetworkCycleError` on cycles.
> **Named port syntax in template context:** when the node variable is `auto`-deduced, use `.template output<"name">()` and `.template input<"name">()` to help the parser.
### Channel Semantics
@ -113,14 +142,36 @@ net.stop();
Large types (`sizeof > 8` or non-trivially-copyable) are stored as `std::shared_ptr<const T>` inside the channel — no copies, shared immutable ownership. Small trivially-copyable types are stored by value.
Override the policy for a specific type:
Override the policy for a specific type (from [`examples/04_storage_policy/main.cpp`](examples/04_storage_policy/main.cpp)):
```cpp
template<> struct kpn::channel_storage_policy<MyType> {
// Override: store Tag by value despite being a struct
// (it's trivially copyable and small — this just makes the policy explicit)
template<>
struct kpn::channel_storage_policy<Tag> {
static constexpr bool by_value = true;
};
```
### Diagnostics & Error Handling
Custom diagnostics handler — fires on the watchdog interval (from [`examples/05_error_handling/main.cpp`](examples/05_error_handling/main.cpp)):
```cpp
// Custom diagnostics handler — fires on the watchdog interval.
// Print a concise one-liner rather than the full table.
net.set_diagnostics_handler([](const std::vector<NodeSnapshot>& nodes,
const std::vector<ChannelSnapshot>& channels) {
std::cout << "[diag] ";
for (auto& n : nodes)
std::cout << n.name << "=" << n.throughput_fps << "fps ";
for (auto& c : channels)
std::cout << "channel fill=" << static_cast<int>(c.fill_pct()) << "% "
<< "overflows=" << c.overflows;
std::cout << '\n';
});
```
### Shutdown
`node.stop()` / `net.stop()`:
@ -171,20 +222,155 @@ top.start();
## Display / GUI Nodes
**Do not wrap `imshow`/`waitKey` as a KPN node.** Qt and Wayland require these to run on the main thread (the thread that owns the event loop). Instead, wire the final output channel to a `Channel<cv::Mat>` and pop it on the main thread:
**Do not wrap `imshow`/`waitKey` as a KPN node.** Qt and Wayland require these to run on the main thread (the thread that owns the event loop). Instead, derive from `MainThreadNode<>` — it owns the input channels, implements `INode`, and exposes a `step()` method to call on the main thread.
`DisplayNode` from [`examples/09_opencv_cellshade/main.cpp`](examples/09_opencv_cellshade/main.cpp):
```cpp
Channel<cv::Mat> result_ch(8);
comp.set_output_channel<0>(&result_ch);
// ... build and start network ...
class DisplayNode : public kpn::MainThreadNode<DisplayNode,
kpn::in<"composite", "edges">,
cv::Mat, cv::Mat> {
public:
DisplayNode() : MainThreadNode(8) {
cv::namedWindow("Cell Shade", cv::WINDOW_NORMAL);
cv::namedWindow("Edge Mask", cv::WINDOW_NORMAL);
cv::resizeWindow("Cell Shade", 1280, 720);
cv::resizeWindow("Edge Mask", 640, 360);
}
~DisplayNode() { cv::destroyAllWindows(); }
bool operator()(cv::Mat composite, cv::Mat edges) {
cv::imshow("Cell Shade", composite);
cv::Mat edges_bgr;
cv::cvtColor(edges, edges_bgr, cv::COLOR_GRAY2BGR);
cv::imshow("Edge Mask", edges_bgr);
int key = cv::waitKey(1);
if (key == 'q' || key == 27) return false;
return window_open("Cell Shade") && window_open("Edge Mask");
}
private:
static bool window_open(const char* name) {
try { return cv::getWindowProperty(name, cv::WND_PROP_VISIBLE) >= 1; }
catch (const cv::Exception&) { return false; }
}
};
```
Wire it into the network and drive it from the main thread:
```cpp
net.start();
// Main thread drives display — imshow/waitKey stay on the GUI thread.
// step() returns false when operator() returns false (q pressed / window closed).
while (disp.step())
cv::waitKey(8); // yield event loop when no frame ready
net.stop();
```
---
## OpenCV Cell-Shading Example
Real-time cell-shading pipeline from [`examples/09_opencv_cellshade/main.cpp`](examples/09_opencv_cellshade/main.cpp).
**Source node** — returns two frames (colour + grey) as a tuple, routing them to separate downstream branches:
```cpp
static std::tuple<cv::Mat, cv::Mat> capture() {
constexpr int W = 640, H = 480;
static cv::VideoCapture cap;
static bool opened = false;
if (!opened) {
opened = true;
cap.open(0, cv::CAP_V4L2);
if (cap.isOpened()) {
cap.set(cv::CAP_PROP_FRAME_WIDTH, W);
cap.set(cv::CAP_PROP_FRAME_HEIGHT, H);
} else {
std::cerr << "[capture] no webcam — using synthetic animated pattern\n";
}
}
// Main thread display loop
while (true) {
cv::Mat frame;
if (!result_ch.try_pop(frame, std::chrono::milliseconds(100))) continue;
cv::imshow("output", frame);
if (cv::waitKey(1) == 'q') break;
if (cap.isOpened()) {
auto t0 = std::chrono::steady_clock::now();
cap >> frame;
auto elapsed = std::chrono::steady_clock::now() - t0;
if (elapsed < std::chrono::milliseconds(20))
std::this_thread::sleep_for(std::chrono::milliseconds(33) - elapsed);
if (frame.empty()) frame = cv::Mat::zeros(H, W, CV_8UC3);
} else {
static int tick = 0;
static cv::Mat grad = make_gradient(W, H);
++tick;
frame = grad.clone();
int r = 150 + (tick % 80) * 4;
cv::circle(frame, {W/2, H/2}, r, {255, 200, 0}, -1);
cv::circle(frame, {W/2, H/2}, r / 2, { 0, 128, 255}, -1);
cv::circle(frame, {W*2/5, H*2/5}, r / 3, {200, 0, 200}, -1);
std::this_thread::sleep_for(std::chrono::milliseconds(33));
}
return {frame.clone(), frame.clone()};
}
```
**Full network wiring:**
```cpp
auto src = make_node<capture> (out<"colour","grey">{}, 8);
auto gray_node = make_node<to_gray> (in<"bgr">{}, out<"gray">{}, 8);
auto edge_node = make_node<edges_fn> (in<"gray">{}, out<"edges">{}, 8);
auto quant = make_node<quantise> (in<"bgr">{}, out<"quantised">{}, 8);
auto comp = make_node<composite>(in<"edges","colour">{}, out<"result","edges">{}, 8);
// DisplayNode: two windows opened in constructor, step() drives main thread.
DisplayNode disp;
Network net;
net.add("src", src)
.add("gray", gray_node)
.add("edges", edge_node)
.add("quant", quant)
.add("comp", comp)
.add("display", disp)
.connect("src", src.template output<"colour">(), "quant", quant.template input<"bgr">())
.connect("quant", quant.template output<"quantised">(), "comp", comp.template input<"colour">())
.connect("src", src.template output<"grey">(), "gray", gray_node.template input<"bgr">())
.connect("gray", gray_node.template output<"gray">(), "edges", edge_node.template input<"gray">())
.connect("edges", edge_node.template output<"edges">(), "comp", comp.template input<"edges">())
.connect("comp", comp.template output<"result">(), "display", disp.template input<"composite">())
.connect("comp", comp.template output<"edges">(), "display", disp.template input<"edges">())
.build();
```
---
## Fan-Out (Multi-Output)
From [`examples/03_multi_output/main.cpp`](examples/03_multi_output/main.cpp) — one node fans out to two independent sinks via a tuple return:
```cpp
auto gen = make_node<generate>(out<"kv">{}, 4);
auto par = make_node<parse> (in<"kv">{}, out<"key", "value">{}, 4);
auto keys = make_node<print_key> (in<"key">{}, 4);
auto vals = make_node<print_value>(in<"value">{}, 4);
Network net;
net.add("gen", gen)
.add("par", par)
.add("keys", keys)
.add("vals", vals)
.connect("gen", gen.template output<"kv">(), "par", par.template input<"kv">())
.connect("par", par.template output<"key">(), "keys", keys.template input<"key">())
.connect("par", par.template output<"value">(), "vals", vals.template input<"value">())
.build();
net.start();
std::this_thread::sleep_for(std::chrono::milliseconds(600));
net.stop();
```
@ -214,8 +400,8 @@ Violating the second rule deadlocks.
| `04_storage_policy` | `channel_storage_policy` default and specialisation |
| `05_error_handling` | `ChannelOverflowError`, `ErrorHandler` |
| `06_watchdog` | Watchdog interval, stall detection |
| `07_python_network` | PyNetwork, pure Python node *(pending)* |
| `08_python_subport` | `net.read`, `net.write`, sub-port tap *(pending)* |
| `07_python_network` | PyNetwork with a pure-Python node between a C++ source and sink |
| `08_python_subport` | Drive a Python node from Python via `net.write`/`net.read` sub-port taps |
| `09_opencv_cellshade` | Real-time cell-shading on webcam/pattern; requires OpenCV ≥ 4 |
Run the cell-shading example:
@ -228,6 +414,143 @@ Run the cell-shading example:
---
## Performance
Measured on Linux (x86-64, `-O3 -march=native`) with `benchmarks/bench_pipeline`.
Each topology pushes N items through the graph; `overhead_us/item` strips out the
per-node compute time to isolate framework cost.
Overhead formula: `(elapsed (N + depth 1) × work_us) / N` removes the expected
pipeline-fill cost so the number reflects pure framework latency.
### Baseline overhead (private pools, 100 µs/node)
| Topology | items/sec | overhead µs/item |
|---|---|---|
| chain depth-1 | 9 797 | ~2 |
| chain depth-4 | 9 448 | ~4 |
| chain depth-8 | 9 078 | ~7 |
| chain depth-16 | 7 004 | ~13 ← oversubscription |
| chain depth-32 | 4 179 | ~77 ← oversubscription |
| wide fanout-1 | 9 751 | ~3 |
| wide fanout-4 | 9 668 | ~3 |
| diamond (2×2) | 9 607 | ~4 |
Chain overhead is flat at **~27 µs/hop** for depths within the machine's core count,
then rises once threads compete for CPU. Wide and diamond topologies add no measurable
overhead as fanout increases — all branches run in parallel.
### Scheduling modes
`Node<>` gives each node a private `ThreadPool(1)`. `PoolNode<>` lets multiple
nodes share one pool. The right choice depends on the graph shape:
| Scenario | Recommended |
|---|---|
| Work per node < 100 µs, deep chain | Private pools lower per-hop latency |
| Work per node ≥ 100 µs, wide/diamond | Shared pool, `threads = hardware_concurrency` |
| Any graph, bounded thread count required | Shared pool, `threads ≥ max parallel nodes` |
A shared single-thread pool (`threads=1`) fully serialises the graph — throughput
divides by depth for chains and by width for fanout topologies. A shared pool with
`threads ≥ max_concurrent_nodes` matches private-pool throughput while keeping the
OS thread count bounded.
### vs. TBB flow graph
Benchmarked against `tbb::flow::function_node<int,int>` (serial concurrency) with
`tbb::flow::broadcast_node<int>` for fanout. Run with `cmake -DKPN_BUILD_BENCHMARKS=ON`
— TBB benchmarks are included automatically when `find_package(TBB)` succeeds.
**Channel implementation:** lock-free SPSC ring buffer with `std::atomic::wait/notify_one`
(C++20 portable futex) plus a configurable spin-before-sleep window (default ~4 µs).
Large types are stored as `shared_ptr<const T>` — fanout copies reference counts,
not data.
Overhead µs/item at **work_us = 10** (framework overhead dominates):
| Topology | KPN++ | TBB |
|---|---|---|
| chain depth-1 | 1.7 | **1.4** |
| chain depth-4 | 2.5 | **2.2** |
| chain depth-8 | **3.0** | 3.6 |
| chain depth-16 | **9.3** | 13.0 |
| chain depth-32 | 23.2 | **14.2** |
| wide fanout-4 | 2.5 | **1.4** |
| diamond (2×2) | 3.4 | **1.9** |
Overhead µs/item at **work_us = 100** (moderate compute, KPN wins):
| Topology | KPN++ | TBB |
|---|---|---|
| chain depth-1 | **2.1** | 3.5 |
| chain depth-4 | **4.3** | 5.2 |
| chain depth-8 | **6.7** | 8.5 |
| chain depth-16 | **12.8** | 17.4 |
| chain depth-32 | **77** | 81 |
| wide fanout-4 | 3.4 | **1.9** |
| diamond (2×2) | **4.1** | 6.1 |
KPN++ pools beat TBB for every chain and diamond topology at 100 µs/node, and
match TBB within ~20% at 10 µs/node for shallow chains. TBB retains an edge on wide
fanout (serial dispatch loop vs. work-stealing pool) and at extreme oversubscription
depths (chain-32 at 10 µs). The remaining gap at light work is the cost of
`atomic::wait` vs. TBB's continuously-spinning worker threads.
### vs. TBB — API
The function signature is the node. KPN infers input and output types automatically;
there is no graph object to manage.
**Single-output node:**
```cpp
// KPN — 1 line
int scale(int x) { return x * 2; }
// TBB — must state types, concurrency policy, and carry a graph reference
tbb::flow::function_node<int,int> n(g, tbb::flow::serial, [](int x){ return x*2; });
```
**Multi-output node:**
```cpp
// KPN — return a tuple
std::tuple<cv::Mat,cv::Mat> split(cv::Mat f) { return {f, f}; }
// TBB — multifunction_node + explicit try_put per port
tbb::flow::multifunction_node<cv::Mat, std::tuple<cv::Mat,cv::Mat>> n(
g, tbb::flow::serial,
[](cv::Mat f, auto& ports) {
std::get<0>(ports).try_put(f);
std::get<1>(ports).try_put(f);
});
```
**Named ports** — compile-time checked, zero runtime cost, not available in TBB:
```cpp
auto node = make_node<split>(in<"frame">{}, out<"colour","grey">{}, 5);
net.connect("cam", cam.output<"frame">(), "split", node.input<"frame">());
// ^^^^^^^ typo → compile error
```
| | KPN | TBB |
|---|---|---|
| Node definition | plain function | `function_node<In,Out>` + explicit types |
| Multi-output | `return std::tuple<A,B>` | `multifunction_node` + `try_put` × N |
| Named ports | `in<"name">` / `out<"name">` compile-time | none |
| Graph lifetime | none | `graph g` must outlive all nodes |
| Shutdown | `net.stop()` | `g.wait_for_all()` + manual |
| Python bindings | designed-in | none |
Build the benchmarks with:
```bash
cmake -B build -DKPN_BUILD_BENCHMARKS=ON
cmake --build build --target bench_pipeline
./build/benchmarks/bench_pipeline | tee results.csv
```
---
## Project Structure
```
@ -254,4 +577,36 @@ python/
kpn_python.cpp — nanobind module entry point
examples/
01_hello_pipeline/ … 09_opencv_cellshade/
scripts/
render_readme.py — regenerates README.md from README.md.in
```
---
## Contributing
Contributions are welcome. This project is hosted on a self-hosted Gitea
instance that accepts sign-in and registration with a GitHub account, so you
can log in with your existing GitHub identity to open issues and pull requests.
If you change any code that appears in a README snippet, edit `README.md.in`
(the template) rather than `README.md` directly, then regenerate:
```bash
cmake --build build --target readme # or: python scripts/render_readme.py
```
---
## Acknowledgments
AI tooling was used heavily throughout the development of this project,
including the design, implementation, tests, and documentation. All output
has been reviewed, but please keep this in mind when reading or building on the
code.
---
## License
Released under the [MIT License](LICENSE). Copyright (c) 2026 Duncan Tourolle.

434
README.md.in Normal file
View File

@ -0,0 +1,434 @@
# KPN++
A C++20 Kahn Process Network (KPN) library. Each node wraps a function and runs in its own thread, communicating with downstream nodes via bounded FIFO channels. Includes Python bindings via nanobind.
📖 **[Documentation](https://pages.tourolle.paris/dtourolle/kpn/)**
---
## Requirements
| Dependency | Version | Notes |
|---|---|---|
| CMake | ≥ 3.21 | |
| C++ compiler | GCC ≥ 11, Clang ≥ 13, MSVC 19.29 | C++20 required |
| Threads | system | `find_package(Threads)` |
| nanobind | ≥ 2.1 | auto-fetched if not installed; Python ≥ 3.8 |
| Catch2 | v3 | auto-fetched for tests |
| Google Test | v1.14 | auto-fetched for tests |
| OpenCV | ≥ 4 | optional; only for example 09 |
---
## Build
```bash
cmake -B build -DKPN_BUILD_PYTHON=OFF # core + tests + C++ examples
cmake --build build --parallel
ctest --test-dir build
```
Enable Python bindings (requires nanobind and Python dev headers):
```bash
cmake -B build -DKPN_BUILD_PYTHON=ON
cmake --build build --parallel
```
Disable examples:
```bash
cmake -B build -DKPN_BUILD_EXAMPLES=OFF
```
---
## Core Concepts
### Nodes
A node wraps any callable. Its input types are taken from the function's parameter list; its output types from the return type. Multi-output nodes return `std::tuple<...>`.
```cpp
#include <kpn/kpn.hpp>
using namespace kpn;
```
Source, transform, and sink — from [`examples/01_hello_pipeline/main.cpp`](examples/01_hello_pipeline/main.cpp):
<!-- @snippet examples/01_hello_pipeline/main.cpp basic_node_fns -->
Multi-output node returning a tuple — from [`examples/03_multi_output/main.cpp`](examples/03_multi_output/main.cpp):
<!-- @snippet examples/03_multi_output/main.cpp multi_output_fn -->
### Creating Nodes
**Index-only ports** (from [`examples/01_hello_pipeline/main.cpp`](examples/01_hello_pipeline/main.cpp)):
<!-- @snippet examples/01_hello_pipeline/main.cpp index_only_nodes -->
**Named ports** (from [`examples/02_named_ports/main.cpp`](examples/02_named_ports/main.cpp)):
<!-- @snippet examples/02_named_ports/main.cpp named_port_creation -->
**Multi-named output source** (from [`examples/09_opencv_cellshade/main.cpp`](examples/09_opencv_cellshade/main.cpp)):
```cpp
auto src = make_node<capture>(out<"colour","grey">{}, 8);
```
Port names are NTTP `fixed_string` values — resolved entirely at compile time, zero runtime cost.
### Building a Network
`Network` is **non-owning** — declare nodes first, then register them. Nodes must outlive the network.
From [`examples/02_named_ports/main.cpp`](examples/02_named_ports/main.cpp):
<!-- @snippet examples/02_named_ports/main.cpp named_port_network -->
`.build()` runs cycle detection — throws `NetworkCycleError` on cycles.
> **Named port syntax in template context:** when the node variable is `auto`-deduced, use `.template output<"name">()` and `.template input<"name">()` to help the parser.
### Channel Semantics
- **Bounded FIFO**: default capacity 5, configurable per-node at construction.
- **Blocking `pop()`**: consumer blocks until data is available (KPN semantics).
- **Throwing `push()`**: throws `ChannelOverflowError` if the channel is full and accepting.
- **Silent drop on disabled channel**: after `node.stop()`, its input channels are disabled — producers that push into them have the value silently dropped. No exception, no blocking.
- **Source throttling**: source nodes (no inputs) must sleep or yield to avoid overflowing downstream FIFOs. See example 09.
### Storage Policy
Large types (`sizeof > 8` or non-trivially-copyable) are stored as `std::shared_ptr<const T>` inside the channel — no copies, shared immutable ownership. Small trivially-copyable types are stored by value.
Override the policy for a specific type (from [`examples/04_storage_policy/main.cpp`](examples/04_storage_policy/main.cpp)):
<!-- @snippet examples/04_storage_policy/main.cpp storage_policy_spec -->
### Diagnostics & Error Handling
Custom diagnostics handler — fires on the watchdog interval (from [`examples/05_error_handling/main.cpp`](examples/05_error_handling/main.cpp)):
<!-- @snippet examples/05_error_handling/main.cpp diagnostics_handler -->
### Shutdown
`node.stop()` / `net.stop()`:
1. Sets `accepting_ = false` on all input channels (drops in-flight pushes silently).
2. Clears any queued items from those channels.
3. Unblocks any thread blocked on `pop()` (throws `ChannelClosedError` inside `run_loop`, which exits cleanly).
4. Joins the node thread.
---
## Named Ports — Design Notes
Port names use C++20 NTTP `fixed_string`. The deduction guide is required:
```cpp
template<std::size_t N>
fixed_string(const char (&)[N]) -> fixed_string<N>;
```
`fixed_string<4>` and `fixed_string<7>` are distinct types — `input<"img">()` and `input<"sigma">()` resolve to different template instantiations at compile time. Wrong names produce a `static_assert` at the call site with a readable message.
---
## Sub-Networks
`Network` implements `INode`, so it can be nested inside a larger `Network`:
```cpp
// Inner sub-network
Network pipe;
pipe.add("pre", pre_node)
.add("enh", enh_node)
.connect("pre", pre_node.output<0>(), "enh", enh_node.input<0>())
.expose_input("img", pre_node.input<0>())
.expose_output("result", enh_node.output<0>())
.build();
// Outer network
Network top;
top.add("pipe", pipe)
.add("sink", sink_node)
.connect("pipe", pipe.output<"result">(), "sink", sink_node.input<0>())
.build();
top.start();
```
---
## Display / GUI Nodes
**Do not wrap `imshow`/`waitKey` as a KPN node.** Qt and Wayland require these to run on the main thread (the thread that owns the event loop). Instead, derive from `MainThreadNode<>` — it owns the input channels, implements `INode`, and exposes a `step()` method to call on the main thread.
`DisplayNode` from [`examples/09_opencv_cellshade/main.cpp`](examples/09_opencv_cellshade/main.cpp):
<!-- @snippet examples/09_opencv_cellshade/main.cpp display_node -->
Wire it into the network and drive it from the main thread:
<!-- @snippet examples/09_opencv_cellshade/main.cpp main_thread_step -->
---
## OpenCV Cell-Shading Example
Real-time cell-shading pipeline from [`examples/09_opencv_cellshade/main.cpp`](examples/09_opencv_cellshade/main.cpp).
**Source node** — returns two frames (colour + grey) as a tuple, routing them to separate downstream branches:
<!-- @snippet examples/09_opencv_cellshade/main.cpp capture_fn -->
**Full network wiring:**
<!-- @snippet examples/09_opencv_cellshade/main.cpp opencv_network -->
---
## Fan-Out (Multi-Output)
From [`examples/03_multi_output/main.cpp`](examples/03_multi_output/main.cpp) — one node fans out to two independent sinks via a tuple return:
<!-- @snippet examples/03_multi_output/main.cpp fanout_network -->
---
## Python Bindings
> Python bindings are scaffolded but not yet fully implemented. See `python/kpn_python.cpp` and `include/kpn/python/bindings.hpp`.
A `PyNetwork` is constructed from a closed list of C++ node types. The variant of all port types is derived at compile time — no runtime type registration needed.
**GIL rules (non-negotiable):**
- Acquire the GIL only for the duration of a Python callable invocation.
- Release the GIL before any blocking channel operation (`pop()`, `push()`, `net.read()`, `net.write()`).
Violating the second rule deadlocks.
---
## Examples
| Example | What it shows |
|---|---|
| `01_hello_pipeline` | Linear pipeline, index-based port wiring |
| `02_named_ports` | `in<>`/`out<>` name tags, named port access |
| `03_multi_output` | Tuple-returning node, per-element sub-port routing |
| `04_storage_policy` | `channel_storage_policy` default and specialisation |
| `05_error_handling` | `ChannelOverflowError`, `ErrorHandler` |
| `06_watchdog` | Watchdog interval, stall detection |
| `07_python_network` | PyNetwork with a pure-Python node between a C++ source and sink |
| `08_python_subport` | Drive a Python node from Python via `net.write`/`net.read` sub-port taps |
| `09_opencv_cellshade` | Real-time cell-shading on webcam/pattern; requires OpenCV ≥ 4 |
Run the cell-shading example:
```bash
./build/examples/09_opencv_cellshade
# Press 'q' or close the window to stop.
# Falls back to an animated synthetic pattern if no webcam is found.
```
---
## Performance
Measured on Linux (x86-64, `-O3 -march=native`) with `benchmarks/bench_pipeline`.
Each topology pushes N items through the graph; `overhead_us/item` strips out the
per-node compute time to isolate framework cost.
Overhead formula: `(elapsed (N + depth 1) × work_us) / N` removes the expected
pipeline-fill cost so the number reflects pure framework latency.
### Baseline overhead (private pools, 100 µs/node)
| Topology | items/sec | overhead µs/item |
|---|---|---|
| chain depth-1 | 9 797 | ~2 |
| chain depth-4 | 9 448 | ~4 |
| chain depth-8 | 9 078 | ~7 |
| chain depth-16 | 7 004 | ~13 ← oversubscription |
| chain depth-32 | 4 179 | ~77 ← oversubscription |
| wide fanout-1 | 9 751 | ~3 |
| wide fanout-4 | 9 668 | ~3 |
| diamond (2×2) | 9 607 | ~4 |
Chain overhead is flat at **~27 µs/hop** for depths within the machine's core count,
then rises once threads compete for CPU. Wide and diamond topologies add no measurable
overhead as fanout increases — all branches run in parallel.
### Scheduling modes
`Node<>` gives each node a private `ThreadPool(1)`. `PoolNode<>` lets multiple
nodes share one pool. The right choice depends on the graph shape:
| Scenario | Recommended |
|---|---|
| Work per node < 100 µs, deep chain | Private pools lower per-hop latency |
| Work per node ≥ 100 µs, wide/diamond | Shared pool, `threads = hardware_concurrency` |
| Any graph, bounded thread count required | Shared pool, `threads ≥ max parallel nodes` |
A shared single-thread pool (`threads=1`) fully serialises the graph — throughput
divides by depth for chains and by width for fanout topologies. A shared pool with
`threads ≥ max_concurrent_nodes` matches private-pool throughput while keeping the
OS thread count bounded.
### vs. TBB flow graph
Benchmarked against `tbb::flow::function_node<int,int>` (serial concurrency) with
`tbb::flow::broadcast_node<int>` for fanout. Run with `cmake -DKPN_BUILD_BENCHMARKS=ON`
— TBB benchmarks are included automatically when `find_package(TBB)` succeeds.
**Channel implementation:** lock-free SPSC ring buffer with `std::atomic::wait/notify_one`
(C++20 portable futex) plus a configurable spin-before-sleep window (default ~4 µs).
Large types are stored as `shared_ptr<const T>` — fanout copies reference counts,
not data.
Overhead µs/item at **work_us = 10** (framework overhead dominates):
| Topology | KPN++ | TBB |
|---|---|---|
| chain depth-1 | 1.7 | **1.4** |
| chain depth-4 | 2.5 | **2.2** |
| chain depth-8 | **3.0** | 3.6 |
| chain depth-16 | **9.3** | 13.0 |
| chain depth-32 | 23.2 | **14.2** |
| wide fanout-4 | 2.5 | **1.4** |
| diamond (2×2) | 3.4 | **1.9** |
Overhead µs/item at **work_us = 100** (moderate compute, KPN wins):
| Topology | KPN++ | TBB |
|---|---|---|
| chain depth-1 | **2.1** | 3.5 |
| chain depth-4 | **4.3** | 5.2 |
| chain depth-8 | **6.7** | 8.5 |
| chain depth-16 | **12.8** | 17.4 |
| chain depth-32 | **77** | 81 |
| wide fanout-4 | 3.4 | **1.9** |
| diamond (2×2) | **4.1** | 6.1 |
KPN++ pools beat TBB for every chain and diamond topology at 100 µs/node, and
match TBB within ~20% at 10 µs/node for shallow chains. TBB retains an edge on wide
fanout (serial dispatch loop vs. work-stealing pool) and at extreme oversubscription
depths (chain-32 at 10 µs). The remaining gap at light work is the cost of
`atomic::wait` vs. TBB's continuously-spinning worker threads.
### vs. TBB — API
The function signature is the node. KPN infers input and output types automatically;
there is no graph object to manage.
**Single-output node:**
```cpp
// KPN — 1 line
int scale(int x) { return x * 2; }
// TBB — must state types, concurrency policy, and carry a graph reference
tbb::flow::function_node<int,int> n(g, tbb::flow::serial, [](int x){ return x*2; });
```
**Multi-output node:**
```cpp
// KPN — return a tuple
std::tuple<cv::Mat,cv::Mat> split(cv::Mat f) { return {f, f}; }
// TBB — multifunction_node + explicit try_put per port
tbb::flow::multifunction_node<cv::Mat, std::tuple<cv::Mat,cv::Mat>> n(
g, tbb::flow::serial,
[](cv::Mat f, auto& ports) {
std::get<0>(ports).try_put(f);
std::get<1>(ports).try_put(f);
});
```
**Named ports** — compile-time checked, zero runtime cost, not available in TBB:
```cpp
auto node = make_node<split>(in<"frame">{}, out<"colour","grey">{}, 5);
net.connect("cam", cam.output<"frame">(), "split", node.input<"frame">());
// ^^^^^^^ typo → compile error
```
| | KPN | TBB |
|---|---|---|
| Node definition | plain function | `function_node<In,Out>` + explicit types |
| Multi-output | `return std::tuple<A,B>` | `multifunction_node` + `try_put` × N |
| Named ports | `in<"name">` / `out<"name">` compile-time | none |
| Graph lifetime | none | `graph g` must outlive all nodes |
| Shutdown | `net.stop()` | `g.wait_for_all()` + manual |
| Python bindings | designed-in | none |
Build the benchmarks with:
```bash
cmake -B build -DKPN_BUILD_BENCHMARKS=ON
cmake --build build --target bench_pipeline
./build/benchmarks/bench_pipeline | tee results.csv
```
---
## Project Structure
```
include/kpn/
fixed_string.hpp — NTTP string, in<>/out<> tags, index_of
traits.hpp — function_traits, normalised_return_t, output_count_v
channel.hpp — Channel<T>, channel_storage_policy, exceptions
port.hpp — InputPort<N,I>, OutputPort<N,I>
node.hpp — Node<Func,in<...>,out<...>>, make_node, INode
network.hpp — Network (builder, cycle detection, watchdog)
variant_node.hpp — VariantNode, PythonConverter<T>, unique_types (Python layer)
python/
bindings.hpp — nanobind helpers, GIL rule documentation
kpn.hpp — umbrella header
src/
network.cpp — non-template Network implementation
tests/
test_fixed_string.cpp
test_traits.cpp
test_channel.cpp
test_node.cpp
test_network.cpp
python/
kpn_python.cpp — nanobind module entry point
examples/
01_hello_pipeline/ … 09_opencv_cellshade/
scripts/
render_readme.py — regenerates README.md from README.md.in
```
---
## Contributing
Contributions are welcome. This project is hosted on a self-hosted Gitea
instance that accepts sign-in and registration with a GitHub account, so you
can log in with your existing GitHub identity to open issues and pull requests.
If you change any code that appears in a README snippet, edit `README.md.in`
(the template) rather than `README.md` directly, then regenerate:
```bash
cmake --build build --target readme # or: python scripts/render_readme.py
```
---
## Acknowledgments
AI tooling was used heavily throughout the development of this project,
including the design, implementation, tests, and documentation. All output
has been reviewed, but please keep this in mind when reading or building on the
code.
---
## License
Released under the [MIT License](LICENSE). Copyright (c) 2026 Duncan Tourolle.

14
benchmarks/CMakeLists.txt Normal file
View File

@ -0,0 +1,14 @@
cmake_minimum_required(VERSION 3.21)
add_executable(bench_pipeline bench_pipeline.cpp)
target_link_libraries(bench_pipeline PRIVATE kpn)
target_compile_options(bench_pipeline PRIVATE -O3 -march=native)
find_package(TBB QUIET)
if(TBB_FOUND)
target_link_libraries(bench_pipeline PRIVATE TBB::tbb)
target_compile_definitions(bench_pipeline PRIVATE KPN_BENCH_TBB=1)
message(STATUS "TBB found — enabling TBB benchmarks")
else()
message(STATUS "TBB not found — TBB benchmarks disabled")
endif()

View File

@ -0,0 +1,526 @@
// Throughput benchmark: items/second vs. graph topology and size.
//
// Topologies:
// chain — linear depth D: push → n[0..D-1] → pop
// wide — fanout<W>: push → fanout → W parallel nodes → W pops
// diamond — push → fanout<2> → 2×2 nodes → 2 pops
//
// Two scheduling modes for each topology:
// private — each node owns a private ThreadPool(1) [Node<>]
// pool — all nodes share one ThreadPool(T) [PoolNode<> + shared pool]
//
// Usage: ./bench_pipeline | tee results.csv
#include <kpn/kpn.hpp>
#ifdef KPN_BENCH_TBB
#include <oneapi/tbb/flow_graph.h>
namespace tbb_flow = oneapi::tbb::flow;
#endif
#include <array>
#include <atomic>
#include <chrono>
#include <cstdio>
#include <memory>
#include <string>
#include <thread>
#include <vector>
using namespace kpn;
using namespace std::chrono_literals;
using sclock = std::chrono::steady_clock;
// ── configurable work ─────────────────────────────────────────────────────────
static std::atomic<int> g_work_us{0};
static int chain_fn(int x) {
int us = g_work_us.load(std::memory_order_relaxed);
if (us > 0) {
auto end = sclock::now() + std::chrono::microseconds(us);
while (sclock::now() < end);
}
return x;
}
using ChainNode = Node<chain_fn, in<>, out<>>;
using PoolChainNode = PoolNode<chain_fn, in<>, out<>>;
// ── push helper: yield-spin on overflow (no artificial sleep latency) ─────────
static void push_retry(Channel<int>& ch, int val) {
while (true) {
try { ch.push(val); return; }
catch (const ChannelOverflowError&) { std::this_thread::yield(); }
catch (const ChannelClosedError&) { return; }
}
}
// ── result ────────────────────────────────────────────────────────────────────
struct Result {
const char* topology;
int size;
int work_us;
int threads; // 0 = private (1 thread per node), N = shared pool size
double items_per_sec;
double overhead_us;
};
// ── chain ─────────────────────────────────────────────────────────────────────
static int items_for(int work_us, int depth = 1) {
int effective = std::max(1, work_us) * std::max(1, depth);
if (effective <= 1) return 5000;
if (effective <= 10) return 3000;
if (effective <= 100) return 1000;
if (effective <= 1000) return 200;
return 50;
}
static Result bench_chain(int depth, int work_us) {
const int N = items_for(work_us, depth);
const int CAP = N;
std::vector<std::shared_ptr<Channel<int>>> chs;
for (int i = 0; i <= depth; ++i)
chs.push_back(std::make_shared<Channel<int>>(CAP));
std::vector<std::unique_ptr<ChainNode>> nodes;
for (int i = 0; i < depth; ++i) {
nodes.push_back(std::make_unique<ChainNode>(CAP));
nodes.back()->set_input_channel<0>(chs[i]);
nodes.back()->set_output_channel<0>(chs[i + 1].get());
}
for (auto& n : nodes) n->start();
std::atomic<sclock::time_point> t1;
std::thread reader([&] {
for (int i = 0; i < N; ++i) chs.back()->pop();
t1.store(sclock::now(), std::memory_order_release);
});
auto t0 = sclock::now();
std::thread pusher([&] {
for (int i = 0; i < N; ++i) push_retry(*chs[0], i);
});
pusher.join();
reader.join();
for (auto& n : nodes) n->stop();
double elapsed = std::chrono::duration<double>(
t1.load(std::memory_order_acquire) - t0).count();
// Subtract theoretical pipeline fill cost (depth-1)*W so that overhead
// reflects only framework latency, not the expected pipeline startup time.
double pipeline_us = static_cast<double>(work_us) * (N + depth - 1);
double wus = (elapsed * 1e6 - pipeline_us) / N;
return {"chain", depth, work_us, 0, N / elapsed, wus};
}
static Result bench_chain_pool(int depth, int work_us, int pool_threads) {
const int N = items_for(work_us, depth);
const int CAP = N;
auto pool = std::make_shared<ThreadPool>(pool_threads);
std::vector<std::shared_ptr<Channel<int>>> chs;
for (int i = 0; i <= depth; ++i)
chs.push_back(std::make_shared<Channel<int>>(CAP));
std::vector<std::unique_ptr<PoolChainNode>> nodes;
for (int i = 0; i < depth; ++i) {
nodes.push_back(std::make_unique<PoolChainNode>(pool, CAP));
nodes.back()->set_input_channel<0>(chs[i]);
nodes.back()->set_output_channel<0>(chs[i + 1].get());
}
pool->start();
for (auto& n : nodes) n->start();
std::atomic<sclock::time_point> t1;
std::thread reader([&] {
for (int i = 0; i < N; ++i) chs.back()->pop();
t1.store(sclock::now(), std::memory_order_release);
});
auto t0 = sclock::now();
std::thread pusher([&] {
for (int i = 0; i < N; ++i) push_retry(*chs[0], i);
});
pusher.join();
reader.join();
for (auto& n : nodes) n->stop();
pool->stop();
double elapsed = std::chrono::duration<double>(
t1.load(std::memory_order_acquire) - t0).count();
double pipeline_us = static_cast<double>(work_us) * (N + depth - 1);
double wus = (elapsed * 1e6 - pipeline_us) / N;
return {"chain", depth, work_us, pool_threads, N / elapsed, wus};
}
// ── wide (fanout<W>) ──────────────────────────────────────────────────────────
template<std::size_t W>
static Result bench_wide(int work_us) {
const int N = items_for(work_us);
const int CAP = N;
auto src_ch = std::make_shared<Channel<int>>(CAP);
auto fan = std::make_unique<FanoutNode<int, W>>(CAP);
fan->template set_input_channel<0>(src_ch);
std::array<std::unique_ptr<ChainNode>, W> nodes;
std::array<std::shared_ptr<Channel<int>>, W> sink_chs;
for (std::size_t i = 0; i < W; ++i) {
nodes[i] = std::make_unique<ChainNode>(CAP);
sink_chs[i] = std::make_shared<Channel<int>>(CAP);
nodes[i]->template set_output_channel<0>(sink_chs[i].get());
}
[&]<std::size_t... Is>(std::index_sequence<Is...>) {
(fan->template set_output_channel<Is>(
&nodes[Is]->template input_channel<0>()), ...);
}(std::make_index_sequence<W>{});
fan->start();
for (auto& n : nodes) n->start();
std::array<std::thread, W> readers;
std::atomic<sclock::time_point> t1;
std::atomic<int> readers_done{0};
for (std::size_t w = 0; w < W; ++w) {
readers[w] = std::thread([&, w] {
for (int i = 0; i < N; ++i) sink_chs[w]->pop();
if (readers_done.fetch_add(1, std::memory_order_acq_rel) + 1
== static_cast<int>(W))
t1.store(sclock::now(), std::memory_order_release);
});
}
auto t0 = sclock::now();
std::thread pusher([&] {
for (int i = 0; i < N; ++i) push_retry(*src_ch, i);
});
pusher.join();
for (auto& r : readers) r.join();
fan->stop();
for (auto& n : nodes) n->stop();
double elapsed = std::chrono::duration<double>(
t1.load(std::memory_order_acquire) - t0).count();
double wus = (elapsed * 1e6) / N - static_cast<double>(work_us);
return {"wide", static_cast<int>(W), work_us, 0, N / elapsed, wus};
}
template<std::size_t W>
static Result bench_wide_pool(int work_us, int pool_threads) {
const int N = items_for(work_us);
const int CAP = N;
auto pool = std::make_shared<ThreadPool>(pool_threads);
auto src_ch = std::make_shared<Channel<int>>(CAP);
auto fan = std::make_unique<FanoutNode<int, W>>(CAP);
fan->template set_input_channel<0>(src_ch);
std::array<std::unique_ptr<PoolChainNode>, W> nodes;
std::array<std::shared_ptr<Channel<int>>, W> sink_chs;
for (std::size_t i = 0; i < W; ++i) {
nodes[i] = std::make_unique<PoolChainNode>(pool, CAP);
sink_chs[i] = std::make_shared<Channel<int>>(CAP);
nodes[i]->template set_output_channel<0>(sink_chs[i].get());
}
[&]<std::size_t... Is>(std::index_sequence<Is...>) {
(fan->template set_output_channel<Is>(
&nodes[Is]->template input_channel<0>()), ...);
}(std::make_index_sequence<W>{});
fan->start();
pool->start();
for (auto& n : nodes) n->start();
std::array<std::thread, W> readers;
std::atomic<sclock::time_point> t1;
std::atomic<int> readers_done{0};
for (std::size_t w = 0; w < W; ++w) {
readers[w] = std::thread([&, w] {
for (int i = 0; i < N; ++i) sink_chs[w]->pop();
if (readers_done.fetch_add(1, std::memory_order_acq_rel) + 1
== static_cast<int>(W))
t1.store(sclock::now(), std::memory_order_release);
});
}
auto t0 = sclock::now();
std::thread pusher([&] {
for (int i = 0; i < N; ++i) push_retry(*src_ch, i);
});
pusher.join();
for (auto& r : readers) r.join();
fan->stop();
for (auto& n : nodes) n->stop();
pool->stop();
double elapsed = std::chrono::duration<double>(
t1.load(std::memory_order_acquire) - t0).count();
double wus = (elapsed * 1e6) / N - static_cast<double>(work_us);
return {"wide", static_cast<int>(W), work_us, pool_threads, N / elapsed, wus};
}
// ── diamond ───────────────────────────────────────────────────────────────────
static Result bench_diamond(int work_us) {
const int N = items_for(work_us, 2);
const int CAP = N;
auto src_ch = std::make_shared<Channel<int>>(CAP);
auto fan = std::make_unique<FanoutNode<int, 2>>(CAP);
fan->template set_input_channel<0>(src_ch);
auto nL = std::make_unique<ChainNode>(CAP);
auto nR = std::make_unique<ChainNode>(CAP);
auto nL2 = std::make_unique<ChainNode>(CAP);
auto nR2 = std::make_unique<ChainNode>(CAP);
auto chL = std::make_shared<Channel<int>>(CAP);
auto chR = std::make_shared<Channel<int>>(CAP);
auto snkL = std::make_shared<Channel<int>>(CAP);
auto snkR = std::make_shared<Channel<int>>(CAP);
fan->template set_output_channel<0>(&nL->template input_channel<0>());
fan->template set_output_channel<1>(&nR->template input_channel<0>());
nL->set_output_channel<0>(chL.get());
nR->set_output_channel<0>(chR.get());
nL2->set_input_channel<0>(chL);
nR2->set_input_channel<0>(chR);
nL2->set_output_channel<0>(snkL.get());
nR2->set_output_channel<0>(snkR.get());
fan->start(); nL->start(); nR->start(); nL2->start(); nR2->start();
std::atomic<sclock::time_point> t1;
std::atomic<int> done{0};
auto make_reader = [&](Channel<int>& ch) {
return std::thread([&] {
for (int i = 0; i < N; ++i) ch.pop();
if (done.fetch_add(1, std::memory_order_acq_rel) + 1 == 2)
t1.store(sclock::now(), std::memory_order_release);
});
};
auto rL = make_reader(*snkL);
auto rR = make_reader(*snkR);
auto t0 = sclock::now();
std::thread pusher([&] {
for (int i = 0; i < N; ++i) push_retry(*src_ch, i);
});
pusher.join(); rL.join(); rR.join();
fan->stop(); nL->stop(); nR->stop(); nL2->stop(); nR2->stop();
double elapsed = std::chrono::duration<double>(
t1.load(std::memory_order_acquire) - t0).count();
double wus = (elapsed * 1e6) / N - static_cast<double>(work_us);
return {"diamond", 4, work_us, 0, N / elapsed, wus};
}
static Result bench_diamond_pool(int work_us, int pool_threads) {
const int N = items_for(work_us, 2);
const int CAP = N;
auto pool = std::make_shared<ThreadPool>(pool_threads);
auto src_ch = std::make_shared<Channel<int>>(CAP);
auto fan = std::make_unique<FanoutNode<int, 2>>(CAP);
fan->template set_input_channel<0>(src_ch);
auto nL = std::make_unique<PoolChainNode>(pool, CAP);
auto nR = std::make_unique<PoolChainNode>(pool, CAP);
auto nL2 = std::make_unique<PoolChainNode>(pool, CAP);
auto nR2 = std::make_unique<PoolChainNode>(pool, CAP);
auto chL = std::make_shared<Channel<int>>(CAP);
auto chR = std::make_shared<Channel<int>>(CAP);
auto snkL = std::make_shared<Channel<int>>(CAP);
auto snkR = std::make_shared<Channel<int>>(CAP);
fan->template set_output_channel<0>(&nL->template input_channel<0>());
fan->template set_output_channel<1>(&nR->template input_channel<0>());
nL->set_output_channel<0>(chL.get());
nR->set_output_channel<0>(chR.get());
nL2->set_input_channel<0>(chL);
nR2->set_input_channel<0>(chR);
nL2->set_output_channel<0>(snkL.get());
nR2->set_output_channel<0>(snkR.get());
fan->start();
pool->start();
nL->start(); nR->start(); nL2->start(); nR2->start();
std::atomic<sclock::time_point> t1;
std::atomic<int> done{0};
auto make_reader = [&](Channel<int>& ch) {
return std::thread([&] {
for (int i = 0; i < N; ++i) ch.pop();
if (done.fetch_add(1, std::memory_order_acq_rel) + 1 == 2)
t1.store(sclock::now(), std::memory_order_release);
});
};
auto rL = make_reader(*snkL);
auto rR = make_reader(*snkR);
auto t0 = sclock::now();
std::thread pusher([&] {
for (int i = 0; i < N; ++i) push_retry(*src_ch, i);
});
pusher.join(); rL.join(); rR.join();
fan->stop();
nL->stop(); nR->stop(); nL2->stop(); nR2->stop();
pool->stop();
double elapsed = std::chrono::duration<double>(
t1.load(std::memory_order_acquire) - t0).count();
double wus = (elapsed * 1e6) / N - static_cast<double>(work_us);
return {"diamond", 4, work_us, pool_threads, N / elapsed, wus};
}
// ── TBB flow graph ────────────────────────────────────────────────────────────
#ifdef KPN_BENCH_TBB
static Result bench_chain_tbb(int depth, int work_us) {
const int N = items_for(work_us, depth);
tbb_flow::graph g;
using FN = tbb_flow::function_node<int, int>;
std::vector<std::unique_ptr<FN>> nodes;
nodes.reserve(depth);
for (int i = 0; i < depth; ++i)
nodes.push_back(std::make_unique<FN>(g, tbb_flow::serial,
[](int x) -> int { return chain_fn(x); }));
for (int i = 0; i + 1 < depth; ++i)
tbb_flow::make_edge(*nodes[i], *nodes[i + 1]);
auto t0 = sclock::now();
for (int i = 0; i < N; ++i) nodes[0]->try_put(i);
g.wait_for_all();
auto t1 = sclock::now();
double elapsed = std::chrono::duration<double>(t1 - t0).count();
double pipeline_us = static_cast<double>(work_us) * (N + depth - 1);
double wus = (elapsed * 1e6 - pipeline_us) / N;
return {"chain_tbb", depth, work_us, -1, N / elapsed, wus};
}
template<std::size_t W>
static Result bench_wide_tbb(int work_us) {
const int N = items_for(work_us);
tbb_flow::graph g;
tbb_flow::broadcast_node<int> fan(g);
using FN = tbb_flow::function_node<int, int>;
std::array<std::unique_ptr<FN>, W> nodes;
for (auto& n : nodes) {
n = std::make_unique<FN>(g, tbb_flow::serial,
[](int x) -> int { return chain_fn(x); });
tbb_flow::make_edge(fan, *n);
}
auto t0 = sclock::now();
for (int i = 0; i < N; ++i) fan.try_put(i);
g.wait_for_all();
auto t1 = sclock::now();
double elapsed = std::chrono::duration<double>(t1 - t0).count();
double wus = (elapsed * 1e6) / N - static_cast<double>(work_us);
return {"wide_tbb", static_cast<int>(W), work_us, -1, N / elapsed, wus};
}
static Result bench_diamond_tbb(int work_us) {
const int N = items_for(work_us, 2);
tbb_flow::graph g;
tbb_flow::broadcast_node<int> fan(g);
using FN = tbb_flow::function_node<int, int>;
auto fn = [](int x) -> int { return chain_fn(x); };
FN nL(g, tbb_flow::serial, fn), nR(g, tbb_flow::serial, fn);
FN nL2(g, tbb_flow::serial, fn), nR2(g, tbb_flow::serial, fn);
tbb_flow::make_edge(fan, nL); tbb_flow::make_edge(fan, nR);
tbb_flow::make_edge(nL, nL2); tbb_flow::make_edge(nR, nR2);
auto t0 = sclock::now();
for (int i = 0; i < N; ++i) fan.try_put(i);
g.wait_for_all();
auto t1 = sclock::now();
double elapsed = std::chrono::duration<double>(t1 - t0).count();
double wus = (elapsed * 1e6) / N - static_cast<double>(work_us);
return {"diamond_tbb", 4, work_us, -1, N / elapsed, wus};
}
#endif // KPN_BENCH_TBB
// ── main ──────────────────────────────────────────────────────────────────────
int main() {
const int work_amts[] = {10, 100, 1000};
const int pool_sizes[] = {1, 2, 4};
std::fprintf(stderr, "%-12s %-8s %-10s %-8s %-18s %-20s\n",
"topology", "size", "work_us", "threads", "items/sec", "overhead_us/item");
std::fprintf(stderr, "%s\n", std::string(78, '-').c_str());
std::printf("topology,size,work_us,threads,items_per_sec,overhead_us_per_item\n");
auto emit = [](const Result& r) {
std::string sched = r.threads < 0 ? "tbb"
: r.threads == 0 ? "priv"
: std::to_string(r.threads);
std::fprintf(stderr, "%-12s %-8d %-10d %-8s %-18.0f %-20.1f\n",
r.topology, r.size, r.work_us, sched.c_str(),
r.items_per_sec, r.overhead_us);
std::printf("%s,%d,%d,%s,%.0f,%.2f\n",
r.topology, r.size, r.work_us, sched.c_str(),
r.items_per_sec, r.overhead_us);
std::fflush(stdout);
};
for (int w : work_amts) {
g_work_us.store(w, std::memory_order_relaxed);
std::fprintf(stderr, "\n── work_us=%-4d private pools ───────────────────────────────────────\n", w);
for (int d : {1, 2, 4, 8, 16, 32}) emit(bench_chain(d, w));
emit(bench_wide<1>(w));
emit(bench_wide<2>(w));
emit(bench_wide<3>(w));
emit(bench_wide<4>(w));
emit(bench_diamond(w));
for (int pt : pool_sizes) {
std::fprintf(stderr, "\n── work_us=%-4d shared pool (%d thread%s) ─────────────────────────────\n",
w, pt, pt == 1 ? "" : "s");
for (int d : {1, 2, 4, 8, 16, 32}) emit(bench_chain_pool(d, w, pt));
emit(bench_wide_pool<1>(w, pt));
emit(bench_wide_pool<2>(w, pt));
emit(bench_wide_pool<3>(w, pt));
emit(bench_wide_pool<4>(w, pt));
emit(bench_diamond_pool(w, pt));
}
#ifdef KPN_BENCH_TBB
std::fprintf(stderr, "\n── work_us=%-4d TBB flow graph ──────────────────────────────────────\n", w);
for (int d : {1, 2, 4, 8, 16, 32}) emit(bench_chain_tbb(d, w));
emit(bench_wide_tbb<1>(w));
emit(bench_wide_tbb<2>(w));
emit(bench_wide_tbb<3>(w));
emit(bench_wide_tbb<4>(w));
emit(bench_diamond_tbb(w));
#endif
}
}

55
docs/channels.md Normal file
View File

@ -0,0 +1,55 @@
# Channels
A `Channel<T>` is a lock-free SPSC (single-producer, single-consumer) ring buffer with atomic wait/notify.
## Semantics
- **Bounded**: fixed capacity set at construction. Default is 5 items.
- **Backpressure**: when full, `push()` throws `ChannelOverflowError` immediately — no blocking, no spin.
- **Blocking consumer**: `pop()` blocks until an item is available or the channel is disabled.
- **Disable**: `channel.disable()` stops accepting pushes and unblocks any waiting `pop()` with `ChannelClosedError`.
## Storage policy
Small trivially-copyable types (≤ 8 bytes) are stored by value. Larger types are heap-allocated and passed via `shared_ptr<const T>` — one allocation per push, zero-copy fan-out:
```cpp
--8<-- "examples/04_storage_policy/main.cpp:storage_policy_spec"
```
Specialize `kpn::ChannelDataSize<T>` for accurate bandwidth reporting on heap-owning types:
```cpp
template<>
struct kpn::ChannelDataSize<cv::Mat> {
static std::size_t bytes(const cv::Mat& m) { return m.total() * m.elemSize(); }
};
```
## Named ports
`in<"name">` and `out<"name">` tag nodes for readable wiring:
```cpp
--8<-- "examples/02_named_ports/main.cpp:named_port_creation"
```
Named ports are checked at compile time — a typo in a port name is a compile error.
## Capacity tuning
Set capacity per node at construction:
```cpp
auto node = make_node<my_func>(/*capacity=*/20);
```
Capacity is rounded up internally to the next power of two. Monitor fill levels via diagnostics to tune for your workload — a too-small capacity causes overflows; a too-large one wastes memory and hides producer/consumer speed mismatches.
## Spin count
`Channel` spins for up to ~4 µs (200 `pause` hints at ~20 ns each on x86) before sleeping on a futex. Set to 0 for power-constrained or predominantly-idle pipelines:
```cpp
Channel<int> ch(/*capacity=*/5, /*spin_count=*/0);
```

84
docs/error-handling.md Normal file
View File

@ -0,0 +1,84 @@
# Error Handling & Events
KPN++ provides three complementary layers for observing and reacting to failures.
---
## 1. Per-node error handler
Called when a node's function throws an unhandled exception. Return `true` to skip the failed invocation and keep running; `false` to stop the node.
```cpp
--8<-- "examples/15_node_error_handler/main.cpp:error_handler"
```
When a node stops (either from `false` return or no handler installed), it:
1. Disables its **input** channels — upstream stops pushing into dead queues.
2. Disables its **output** channels — downstream nodes receive `ChannelClosedError` on their next pop, propagating the shutdown naturally through the graph.
---
## 2. Per-node overflow callback
Fired with a timestamp each time an output push is dropped because the channel is full. The node name is known at registration so it is not included — keeping the callback zero-overhead when unused.
```cpp
--8<-- "examples/16_event_callbacks/main.cpp:per_node_callback"
```
!!! note
The callback is purely informational — the node always continues after an overflow. To stop the node on overflow, call `node.stop()` from inside the callback.
A matching `set_closed_callback()` fires (also with just a timestamp) when the node stops due to a closed upstream channel:
```cpp
node.set_closed_callback([](std::chrono::steady_clock::time_point ts) {
std::cerr << "node stopped at t=" << ts.time_since_epoch().count() << '\n';
});
```
Each node holds two callback slots per event type — one user-set (registered above) and one injected by the network (see below). Both fire independently.
---
## 3. Network-level event handler
One callback for the whole network. Receives the node name (captured in a closure by the network at `build()` / `start()`), a `NodeEvent`, and a timestamp:
```cpp
--8<-- "examples/16_event_callbacks/main.cpp:network_event_handler"
```
`NodeEvent` values:
| Value | Meaning |
|---|---|
| `NodeEvent::Overflow` | An output push was dropped (channel full) |
| `NodeEvent::Closed` | The node stopped (crash or upstream close cascade) |
The network handler and any per-node callbacks are **independent** — both fire when set.
---
## Complete example
`examples/16_event_callbacks/main.cpp` shows a fast producer overflowing a slow consumer, with both a per-node overflow callback and a network-level event handler active simultaneously.
Node functions:
```cpp
--8<-- "examples/16_event_callbacks/main.cpp:node_fns"
```
Per-node overflow callback:
```cpp
--8<-- "examples/16_event_callbacks/main.cpp:per_node_callback"
```
Network-level event handler:
```cpp
--8<-- "examples/16_event_callbacks/main.cpp:network_event_handler"
```

40
docs/examples.md Normal file
View File

@ -0,0 +1,40 @@
# Examples
All C++ examples are built by default and registered as CTest smoke tests. Run them all with:
```bash
ctest --test-dir build -L examples
```
## Index
| Example | What it shows |
|---|---|
| `01_hello_pipeline` | Linear pipeline, index-based port wiring |
| `02_named_ports` | `in<>`/`out<>` name tags, named port access |
| `03_multi_output` | Tuple-returning node, per-element routing |
| `04_storage_policy` | `channel_storage_policy` specialisation |
| `05_error_handling` | Diagnostics handler, overflow channel stats |
| `06_watchdog` | Watchdog interval, stall detection |
| `10_static_hello_pipeline` | `StaticNetwork` + `make_network()` |
| `11_static_fanout` | `StaticNetwork` with `FanoutNode` |
| `15_node_error_handler` | `set_error_handler()` — skip or stop on exception |
| `16_event_callbacks` | `set_overflow_callback()`, `set_event_handler()` |
## OpenCV examples (optional)
Built only when OpenCV ≥ 4 is found:
| Example | What it shows |
|---|---|
| `09_opencv_cellshade` | Real-time cell-shading on webcam; `MainThreadNode` for display |
| `12_static_cellshade` | Same pipeline as a `StaticNetwork` |
| `13_debug_cellshade` | Web debug UI overlay on the cell-shading pipeline |
Run the cell-shading example:
```bash
./build/examples/09_opencv_cellshade
# Press 'q' or close the window to stop.
# Falls back to an animated synthetic pattern if no webcam is found.
```

44
docs/fanout.md Normal file
View File

@ -0,0 +1,44 @@
# Fan-out & Routing
## FanoutNode
Reads one item and pushes a copy to each of N output channels. All downstream nodes receive every item.
```cpp
auto fan = make_fanout<Image, 2>(/*capacity=*/8);
net.connect("src", src.output<0>(), "fan", fan.input<0>())
.connect("fan", fan.output<0>(), "nodeA", nodeA.input<0>())
.connect("fan", fan.output<1>(), "nodeB", nodeB.input<0>());
```
If one downstream channel overflows, that output drops the item independently — the other outputs are unaffected.
See `examples/11_static_fanout`.
## RouterNode
Reads one item and pushes it to exactly one of N outputs, chosen by a selector function:
```cpp
auto router = make_router<Frame, 3>(
[](const Frame& f) -> std::size_t { return f.stream_id % 3; });
net.connect("src", src.output<0>(), "router", router.input<0>())
.connect("router", router.output<0>(), "nodeA", nodeA.input<0>())
.connect("router", router.output<1>(), "nodeB", nodeB.input<0>())
.connect("router", router.output<2>(), "nodeC", nodeC.input<0>());
```
If the selector returns `>= N` the item is silently dropped.
## FilterNode
Reads one item and passes it downstream only when a predicate returns `true`:
```cpp
auto filt = make_filter<Frame>([](const Frame& f) { return f.valid; });
net.connect("src", src.output<0>(), "filt", filt.input<0>())
.connect("filt", filt.output<0>(), "dst", dst.input<0>());
```

76
docs/getting-started.md Normal file
View File

@ -0,0 +1,76 @@
# Getting Started
## Requirements
| Dependency | Version | Notes |
|---|---|---|
| CMake | ≥ 3.21 | |
| C++ compiler | GCC ≥ 11, Clang ≥ 13 | C++20 required |
| nanobind | ≥ 2.1 | auto-fetched; Python ≥ 3.8 |
| Catch2 | v3 | auto-fetched for tests |
| OpenCV | ≥ 4 | optional; only for examples 09/12/13 |
## Build
```bash
cmake -B build # core + tests + C++ examples
cmake --build build --parallel
ctest --test-dir build # run all tests including example smoke tests
```
Enable Python bindings:
```bash
cmake -B build -DKPN_BUILD_PYTHON=ON
cmake --build build --parallel
```
Skip examples:
```bash
cmake -B build -DKPN_BUILD_EXAMPLES=OFF
```
## Your first pipeline
Three functions — source, transform, sink — wired into a `Network`:
```cpp
--8<-- "examples/01_hello_pipeline/main.cpp:basic_node_fns"
```
Create nodes, connect them, build and run:
```cpp
--8<-- "examples/01_hello_pipeline/main.cpp:network_build"
```
That's it. Types are inferred from function signatures. The channel between `src` and `dbl` carries `int`; the channel between `dbl` and `prn` also carries `int`. A type mismatch is a compile error.
## Named ports
For nodes with multiple inputs or outputs, name the ports for clarity:
```cpp
--8<-- "examples/02_named_ports/main.cpp:named_port_creation"
```
Wire by name instead of index:
```cpp
--8<-- "examples/02_named_ports/main.cpp:named_port_network"
```
## Multi-output nodes
Return a `std::tuple` to fan out to multiple downstream nodes:
```cpp
--8<-- "examples/03_multi_output/main.cpp:multi_output_fn"
```
Wire each tuple element to its own downstream node:
```cpp
--8<-- "examples/03_multi_output/main.cpp:fanout_network"
```

39
docs/index.md Normal file
View File

@ -0,0 +1,39 @@
# KPN++
A C++20 [Kahn Process Network](https://en.wikipedia.org/wiki/Kahn_process_networks) library. Each node wraps a plain function and runs concurrently, communicating with downstream nodes via bounded FIFO channels. Includes Python bindings via nanobind.
---
## Why KPN++?
- **Zero boilerplate** — wrap any callable as a node; types flow automatically from the function signature
- **Bounded channels** — backpressure is structural, not bolted on
- **Observable** — per-node and network-level callbacks for overflow and stop events; diagnostics snapshots; optional web UI
- **Composable**`Network` for runtime wiring, `StaticNetwork` for compile-time topology with zero overhead
---
## Quick example
```cpp
#include <kpn/kpn.hpp>
using namespace kpn;
--8<-- "examples/01_hello_pipeline/main.cpp:basic_node_fns"
int main() {
--8<-- "examples/01_hello_pipeline/main.cpp:network_build"
}
```
---
## Install & build
```bash
cmake -B build
cmake --build build --parallel
ctest --test-dir build # unit tests + example smoke tests
```
See [Getting Started](getting-started.md) for full build options.

67
docs/network.md Normal file
View File

@ -0,0 +1,67 @@
# Networks
A `Network` wires nodes together at runtime using a builder chain.
## Building a network
```cpp
--8<-- "examples/01_hello_pipeline/main.cpp:network_build"
```
The builder chain:
| Method | Purpose |
|---|---|
| `.add(name, node)` | Register a node; assigns its name |
| `.connect(src, port, dst, port)` | Wire one output port to one input port |
| `.build()` | Compute topological order; inject network callbacks |
| `.start()` | Start nodes in topological order |
| `.stop()` | Stop all nodes immediately |
| `.shutdown()` | Graceful drain: stop sources first, wait for channels to empty, then stop downstream |
## Port access
Ports are accessed by index or by name:
```cpp
// By index
net.connect("src", src.output<0>(), "dst", dst.input<0>());
// By name (requires named ports)
--8<-- "examples/02_named_ports/main.cpp:named_port_network"
```
## Diagnostics
Install a diagnostics handler to receive periodic snapshots of every node and channel:
```cpp
--8<-- "examples/05_error_handling/main.cpp:diagnostics_handler"
```
Or print a full report at any time:
```cpp
net.print_diagnostics(); // writes to stderr by default
net.print_diagnostics(std::cout);
```
## Network-level event handler
Observe overflow and node-stop events across the entire network in one place:
```cpp
--8<-- "examples/16_event_callbacks/main.cpp:network_event_handler"
```
`NodeEvent` is either `NodeEvent::Overflow` (item dropped on full channel) or `NodeEvent::Closed` (node stopped due to crash or closed upstream channel). See [Error Handling & Events](error-handling.md).
## Shutdown
`net.stop()` halts immediately — all nodes stop in reverse topological order.
`net.shutdown()` drains gracefully: source nodes stop first; their output channels are polled until empty; then the next layer stops, and so on. This ensures no items are lost if downstream nodes are still consuming.
## StaticNetwork
For zero-overhead compile-time topology, see [Static Networks](static-network.md).

81
docs/nodes.md Normal file
View File

@ -0,0 +1,81 @@
# Nodes
A node wraps any callable. Its input types are inferred from the function's parameter list; its output types from the return type.
## Node types
| Type | Thread model | Use case |
|---|---|---|
| `Node<Func>` | Dedicated thread per node | Default — simplest, most isolated |
| `PoolNode<Func>` | Shared `ThreadPool` | Many nodes, resource-bounded execution |
| `InterruptNode<Func>` | Event-driven, no thread | Camera frame ready, timer tick, socket |
| `FanoutNode<T, N>` | Dedicated thread | Broadcast one item to N outputs |
| `RouterNode<T, N>` | Dedicated thread | Route one item to one of N outputs |
| `FilterNode<T>` | Dedicated thread | Pass items matching a predicate |
## Creating nodes
All node types are created via factory functions that infer types from the callable:
```cpp
// Free function — simplest case
auto node = make_node<my_func>();
// Stateful functor (operator() is the function)
MyProcessor proc;
auto node = make_node(proc);
// Pool node — shares a ThreadPool with other nodes
auto pool = std::make_shared<ThreadPool>(4);
auto node = make_pool_node<my_func>(pool);
// Interrupt node — triggered externally
auto sched = std::make_shared<ThreadPool>(2);
auto node = make_interrupt_node<produce_frame>(sched, out<"frame">{});
camera_sdk.on_frame_ready(node.get_trigger());
```
## Channel capacity
Each node's input FIFO has a configurable capacity (default 5):
```cpp
auto node = make_node<my_func>(/*capacity=*/20);
auto node = make_pool_node<my_func>(pool, /*capacity=*/20);
```
When an upstream push would exceed capacity, `ChannelOverflowError` is thrown and the item is dropped. See [Error Handling & Events](error-handling.md) to observe and react to this.
## Source nodes
A node with no inputs is a source. It self-submits immediately on `start()` and re-submits after each execution:
```cpp
static int produce() {
std::this_thread::sleep_for(std::chrono::milliseconds(10));
return ++counter;
}
auto src = make_node<produce>();
```
!!! tip
Source nodes must sleep or yield to avoid overflowing their output channel. The channel capacity provides the only bound.
## Sink nodes
A node with a `void` return is a sink — it consumes items without producing output:
```cpp
static void print_it(int x) { std::cout << x << '\n'; }
auto snk = make_node<print_it>();
```
## Error handler
When a node's function throws an unhandled exception, the default behaviour is to stop the node (disabling its channels so the shutdown cascades downstream). Install a handler to override:
```cpp
--8<-- "examples/15_node_error_handler/main.cpp:error_handler"
```
See [Error Handling & Events](error-handling.md) for the full picture.

3
docs/requirements.txt Normal file
View File

@ -0,0 +1,3 @@
mkdocs>=1.5
mkdocs-material>=9.5
pymdown-extensions>=10.0

42
docs/shared-resource.md Normal file
View File

@ -0,0 +1,42 @@
# Shared Resources
`SharedResource<T>` arbitrates exclusive access to a resource (ONNX session, CUDA stream, serial port) across multiple nodes using a priority-based waiter queue with starvation prevention.
## Usage
```cpp
#include <kpn/shared_resource.hpp>
using namespace kpn;
SharedResource<OnnxSession> model(session_args...);
static cv::Mat run_inference(cv::Mat frame) {
// Acquires the model; releases automatically on scope exit.
auto guard = model.acquire_balanced(in_channel, out_channel);
return guard->Run(frame);
}
```
## Acquire modes
| Method | Priority |
|---|---|
| `acquire()` | Equal (fair FIFO) |
| `acquire(fn)` | Custom — `fn()` returns `float` in `[0, 1]` |
| `acquire_balanced(in_ch, out_ch)` | `input_fill × output_headroom` — highest urgency wins |
`acquire_balanced` favours nodes with full input queues and empty output queues — the node that has the most work to do and nowhere to stall wins the resource next.
## Starvation prevention
Each waiter's effective score grows with elapsed wait time (`0.05` per second by default), ensuring a low-priority node eventually gets served regardless of how frequently higher-priority nodes compete.
## Diagnostics
Register with the network for snapshot reporting:
```cpp
net.register_resource("model", &model);
```
The diagnostics table then shows acquisition count, mean wait time, and current waiter count.

46
docs/static-network.md Normal file
View File

@ -0,0 +1,46 @@
# Static Networks
`StaticNetwork` encodes the entire topology at compile time using a `make_network()` builder. Nodes and channel types are verified statically with zero runtime overhead.
## Usage
```cpp
#include <kpn/kpn.hpp>
using namespace kpn;
static int produce() { return 42; }
static int double_it(int x) { return x * 2; }
static void print_it(int x) { std::cout << x << '\n'; }
int main() {
auto src = make_node<produce> ();
auto dbl = make_node<double_it>();
auto prn = make_node<print_it> ();
auto net = make_network(
edge(src, src.output<0>(), dbl, dbl.input<0>()),
edge(dbl, dbl.output<0>(), prn, prn.input<0>())
);
net.set_event_handler([](std::string_view name, NodeEvent ev, auto ts) {
// same API as Network
});
net.start();
std::this_thread::sleep_for(std::chrono::milliseconds(100));
net.stop();
}
```
See `examples/10_static_hello_pipeline` and `examples/11_static_fanout`.
## When to use
| | `Network` | `StaticNetwork` |
|---|---|---|
| Topology known at | Runtime | Compile time |
| Type checking | Runtime (`dynamic_cast`) | Compile time |
| Overhead | Minimal | Zero |
| Flexibility | Add nodes dynamically | Fixed at compile time |
For most applications `Network` is sufficient. Use `StaticNetwork` when you need the absolute minimum overhead or want compile-time topology verification.

View File

@ -7,16 +7,20 @@
//
// [produce] --int--> [double_it] --int--> [print_it]
// --8<-- [start:basic_node_fns]
static int produce() { return 42; }
static int double_it(int x) { return x * 2; }
static void print_it(int x) { std::cout << "result: " << x << '\n'; }
// --8<-- [end:basic_node_fns]
int main() {
using namespace kpn;
// --8<-- [start:index_only_nodes]
auto src = make_node<produce>(5);
auto dbl = make_node<double_it>(5);
auto sink = make_node<print_it>(5);
// --8<-- [end:index_only_nodes]
// Wire channels
auto& dbl_in = dbl.input_channel<0>();
@ -24,6 +28,7 @@ int main() {
src.set_output_channel<0>(&dbl_in);
dbl.set_output_channel<0>(&sink_in);
// --8<-- [start:network_build]
Network net;
net.add("src", src)
.add("dbl", dbl)
@ -35,4 +40,5 @@ int main() {
net.start();
std::this_thread::sleep_for(std::chrono::milliseconds(100));
net.stop();
// --8<-- [end:network_build]
}

View File

@ -54,17 +54,18 @@ static void report(int count, std::vector<std::string> words) {
int main() {
using namespace kpn;
// --8<-- [start:named_port_creation]
// tokenise: no inputs, one named output "words"
auto tok = make_node<tokenise>(out<"words">{}, 4);
// count_words: named input "words", named outputs "count" and "words"
auto cnt = make_node<count_words>(in<"words">{}, out<"count", "words">{}, 4);
// report: two named inputs — note the function takes (int, vector<string>)
// so we need two separate input ports wired independently
// For a two-input sink we wire each output of cnt to a different input of report
// report: two named inputs
auto snk = make_node<report>(in<"count", "words">{}, 4);
// --8<-- [end:named_port_creation]
// --8<-- [start:named_port_network]
Network net;
net.add("tok", tok)
.add("cnt", cnt)
@ -77,4 +78,5 @@ int main() {
net.start();
std::this_thread::sleep_for(std::chrono::milliseconds(500));
net.stop();
// --8<-- [end:named_port_network]
}

View File

@ -33,6 +33,7 @@ static std::string generate() {
return pairs[gen_index++ % 5];
}
// --8<-- [start:multi_output_fn]
// Multi-output: returns (key, value) as a tuple — KPN++ routes each element
// to its own output port automatically.
static std::tuple<std::string, std::string> parse(std::string kv) {
@ -40,6 +41,7 @@ static std::tuple<std::string, std::string> parse(std::string kv) {
if (sep == std::string::npos) return {kv, ""};
return {kv.substr(0, sep), kv.substr(sep + 1)};
}
// --8<-- [end:multi_output_fn]
static void print_key(std::string key) {
std::cout << "KEY → " << key << '\n';
@ -54,6 +56,7 @@ static void print_value(std::string value) {
int main() {
using namespace kpn;
// --8<-- [start:fanout_network]
auto gen = make_node<generate>(out<"kv">{}, 4);
auto par = make_node<parse> (in<"kv">{}, out<"key", "value">{}, 4);
auto keys = make_node<print_key> (in<"key">{}, 4);
@ -72,4 +75,5 @@ int main() {
net.start();
std::this_thread::sleep_for(std::chrono::milliseconds(600));
net.stop();
// --8<-- [end:fanout_network]
}

View File

@ -34,12 +34,14 @@ struct Tag {
int value = 0;
};
// --8<-- [start:storage_policy_spec]
// Override: store Tag by value despite being a struct
// (it's trivially copyable and small — this just makes the policy explicit)
template<>
struct kpn::channel_storage_policy<Tag> {
static constexpr bool by_value = true;
};
// --8<-- [end:storage_policy_spec]
// ── Node functions ────────────────────────────────────────────────────────────

View File

@ -45,6 +45,7 @@ int main() {
Network net;
// --8<-- [start:diagnostics_handler]
// Custom diagnostics handler — fires on the watchdog interval.
// Print a concise one-liner rather than the full table.
net.set_diagnostics_handler([](const std::vector<NodeSnapshot>& nodes,
@ -57,6 +58,7 @@ int main() {
<< "overflows=" << c.overflows;
std::cout << '\n';
});
// --8<-- [end:diagnostics_handler]
net.set_watchdog_interval(std::chrono::milliseconds(200));

View File

@ -30,3 +30,8 @@ net.build()
net.start()
time.sleep(0.1)
net.stop()
# Drop the network deterministically: it holds the Python callable, which forms
# a reference cycle via globals(). Deleting the global breaks it so the network
# is reclaimed now rather than lingering to interpreter shutdown.
del net

View File

@ -1,38 +1,55 @@
"""
08_python_subport tap a C++ node's output from Python using net.read().
08_python_subport drive a *Python* node from Python via write()/read() taps.
Graph:
[ProduceNode] --int--> [DoubleItNode] --int--> (tapped by net.read())
(fed by net.write()) --int--> [py_triple] --int--> (tapped by net.read())
The sink is Python: instead of connecting a PrintItNode, we call net.read()
to pull values out of DoubleItNode's output directly into Python.
We also demonstrate net.write() by injecting a value into DoubleItNode's input.
Unlike 07, there is no C++ source or sink here: the only node in the network is
a pure-Python function, py_triple. Python plays *both* the producer and the
consumer by using the subport taps:
* net.write("py", 0, v) injects v into py_triple's input (Python -> network)
* net.read("py", 0) pulls py_triple's output back out (network -> Python)
This closes the loop the old version left as a "#todo": a value flows from
Python, through a Python node running inside the network, and back to Python.
"""
import sys
import time
import threading
sys.path.insert(0, "build/python")
sys.path.insert(0, "build/python") # for `python examples/.../example.py` from repo root
import kpn_python as kpn
def py_triple(x: int) -> int:
return x * 3
net = kpn.Network()
net.add("src", kpn.make_produce())
net.add("dbl", kpn.make_double_it())
# The whole network is a single Python node with a tapped input and output.
net.add_node("py", py_triple, inputs=["int"], outputs=["int"])
net.connect("src", 0, "dbl", 0)
net.build()
net.start()
# Collect a few values from DoubleItNode's output via Python tap
# Push values in from Python and read the Python node's results back out.
inputs = [1, 2, 7, 10, 100]
results = []
for _ in range(5):
val = net.read("dbl", 0)
results.append(val)
for v in inputs:
net.write("py", 0, v) # Python -> py_triple input
results.append(net.read("py", 0)) # py_triple output -> Python
net.stop()
print("values read from C++ DoubleItNode output:", results)
assert all(v == 84 for v in results), f"expected all 84, got {results}"
print("all correct (42 * 2 = 84)")
print("inputs written from Python: ", inputs)
print("outputs read from py_triple:", results)
expected = [v * 3 for v in inputs]
assert results == expected, f"expected {expected}, got {results}"
print("all correct (x * 3 computed by a Python node inside the network)")
# Drop the network deterministically. The network holds the Python callable,
# which (via globals) forms a reference cycle; deleting the global breaks it so
# the network is reclaimed promptly instead of lingering to interpreter exit.
del net

View File

@ -0,0 +1,101 @@
"""
09_opencv_cellshade/example_hybrid.py
Hybrid cell-shading pipeline: C++ nodes handle capture, grayscale conversion,
and edge detection; a Python/numpy function replaces the C++ quantise node;
Python drives the display loop using cv2.
Pipeline:
[py_quantise]
[CaptureNode] [CompositeNode]result cv2.imshow
out0=colour [ToGrayNode][EdgesNode] edges cv2.imshow
out1=grey
For a pure-C++ version see main.cpp; for the C++ static-network version see
12_static_cellshade/main.cpp.
Press 'q' or Esc to stop.
"""
import sys
import os
# Adjust path to wherever CMake placed the .so
BUILD_DIR = os.environ.get("KPN_BUILD_DIR",
os.path.join(os.path.dirname(__file__),
"../../build/examples"))
sys.path.insert(0, BUILD_DIR)
import numpy as np
import cv2
import kpn_opencv as kpn
# ── Python node: replace the C++ quantise with numpy ─────────────────────────
# Receives and returns a BGR numpy array (H×W×3 uint8).
def py_quantise(bgr: np.ndarray) -> np.ndarray:
levels = 4
step = 256 // levels
q = (bgr.astype(np.int32) // step) * step + (step // 2)
return q.clip(0, 255).astype(np.uint8)
# ── Build network ─────────────────────────────────────────────────────────────
net = kpn.Network()
net.add("src", kpn.make_capture()) # out0=colour, out1=grey
net.add_node("quant", py_quantise, # Python node — numpy in/out
inputs=["mat"], outputs=["mat"])
net.add("gray", kpn.make_to_gray()) # in0=bgr → out0=gray
net.add("edges", kpn.make_edges()) # in0=gray → out0=edge_mask
net.add("comp", kpn.make_composite()) # in0=edge_mask, in1=colour
# out0=result, out1=edge_mask
# src.colour → py_quantise
net.connect("src", 0, "quant", 0)
# src.grey → to_gray
net.connect("src", 1, "gray", 0)
# gray → edges
net.connect("gray", 0, "edges", 0)
# quantised colour → composite.colour (input slot 1)
net.connect("quant", 0, "comp", 1)
# edge mask → composite.edges (input slot 0)
net.connect("edges", 0, "comp", 0)
net.build()
net.start()
# ── Display loop (drives GUI on this thread) ──────────────────────────────────
cv2.namedWindow("Cell Shade (Python quant)", cv2.WINDOW_NORMAL)
cv2.namedWindow("Edge Mask", cv2.WINDOW_NORMAL)
cv2.resizeWindow("Cell Shade (Python quant)", 1280, 720)
cv2.resizeWindow("Edge Mask", 640, 360)
try:
while True:
# Blocking reads — GIL released while waiting so C++ threads can run
result = net.read("comp", 0) # composite frame (BGR numpy array)
edges = net.read("comp", 1) # edge mask (grayscale numpy array)
cv2.imshow("Cell Shade (Python quant)", result)
cv2.imshow("Edge Mask", cv2.cvtColor(edges, cv2.COLOR_GRAY2BGR))
key = cv2.waitKey(1)
if key in (ord('q'), 27):
break
# Check windows still open
try:
if cv2.getWindowProperty("Cell Shade (Python quant)",
cv2.WND_PROP_VISIBLE) < 1:
break
except cv2.error:
break
finally:
net.stop()
cv2.destroyAllWindows()
del net # let C++ destructor run before nanobind tears down

View File

@ -0,0 +1,177 @@
#define KPN_BUILD_PYTHON
#include <kpn/python/auto_bind.hpp>
#include <nanobind/ndarray.h>
#include <opencv2/core.hpp>
#include <opencv2/imgproc.hpp>
#include <opencv2/videoio.hpp>
#include <chrono>
#include <cmath>
#include <iostream>
#include <thread>
#include <tuple>
namespace nb = nanobind;
using namespace kpn;
using namespace kpn::python;
// ── PythonConverter<cv::Mat> ──────────────────────────────────────────────────
// Converts cv::Mat ↔ numpy array (uint8, HxW or HxWxC shape).
//
// to_python: clones the mat onto the heap; the numpy array owns it via a
// capsule deleter — no shared cv::Mat refcount dangling after the Variant dies.
// from_python: calls numpy.ascontiguousarray, then clones into an owned cv::Mat.
namespace kpn {
template<> struct PythonConverter<cv::Mat> {
static constexpr const char* type_name = "mat";
static nb::object to_python(const cv::Mat& m) {
// Must be called with the GIL held (always true: called from read() or
// from within the gil_scoped_acquire block in PyNode::run_loop).
auto np = nb::module_::import_("numpy");
cv::Mat c = m.clone(); // ensure contiguous, independently owned
nb::bytes raw(reinterpret_cast<const char*>(c.data),
c.total() * c.elemSize());
nb::object arr = np.attr("frombuffer")(raw, "uint8");
int H = c.rows, W = c.cols, C = c.channels();
arr = arr.attr("reshape")(
C > 1 ? nb::make_tuple(H, W, C) : nb::make_tuple(H, W));
return arr.attr("copy")(); // writable, lifetime-independent copy
}
static cv::Mat from_python(nb::object o) {
auto np = nb::module_::import_("numpy");
// Ensure contiguous uint8 layout (in-place if already compatible)
nb::object arr = np.attr("ascontiguousarray")(o, "uint8");
auto shape = nb::cast<std::vector<int>>(arr.attr("shape"));
if (shape.size() < 2 || shape.size() > 3)
throw std::runtime_error(
"cv::Mat from_python: expected 2D (H×W) or 3D (H×W×C) uint8 array");
int H = shape[0], W = shape[1];
int C = (shape.size() == 3) ? shape[2] : 1;
int type = C > 1 ? CV_8UC(C) : CV_8UC1;
// Cast to ndarray to get the raw data pointer
auto binfo = nb::cast<nb::ndarray<nb::numpy, uint8_t>>(arr);
cv::Mat wrap(H, W, type, binfo.data());
return wrap.clone(); // own the pixel data
}
};
} // namespace kpn
// ── Pipeline functions ────────────────────────────────────────────────────────
static cv::Mat make_gradient(int W, int H) {
cv::Mat xr(H, W, CV_8UC1), yg(H, W, CV_8UC1), b(H, W, CV_8UC1, cv::Scalar(128));
for (int x = 0; x < W; ++x) xr.col(x).setTo(x * 255 / W);
for (int y = 0; y < H; ++y) yg.row(y).setTo(y * 255 / H);
cv::Mat channels[3] = {b, yg, xr};
cv::Mat grad;
cv::merge(channels, 3, grad);
return grad;
}
static std::tuple<cv::Mat, cv::Mat> capture() {
constexpr int W = 640, H = 480;
static cv::VideoCapture cap;
static bool opened = false;
if (!opened) {
opened = true;
cap.open(0, cv::CAP_V4L2);
if (cap.isOpened()) {
cap.set(cv::CAP_PROP_FRAME_WIDTH, W);
cap.set(cv::CAP_PROP_FRAME_HEIGHT, H);
} else {
std::cerr << "[capture] no webcam — using synthetic animated pattern\n";
}
}
cv::Mat frame;
if (cap.isOpened()) {
auto t0 = std::chrono::steady_clock::now();
cap >> frame;
auto elapsed = std::chrono::steady_clock::now() - t0;
if (elapsed < std::chrono::milliseconds(20))
std::this_thread::sleep_for(std::chrono::milliseconds(33) - elapsed);
if (frame.empty()) frame = cv::Mat::zeros(H, W, CV_8UC3);
} else {
static int tick = 0;
static cv::Mat grad = make_gradient(W, H);
++tick;
frame = grad.clone();
int r = 150 + (tick % 80) * 4;
cv::circle(frame, {W/2, H/2}, r, {255, 200, 0}, -1);
cv::circle(frame, {W/2, H/2}, r / 2, { 0, 128, 255}, -1);
cv::circle(frame, {W*2/5, H*2/5}, r / 3, {200, 0, 200}, -1);
std::this_thread::sleep_for(std::chrono::milliseconds(33));
}
return {frame.clone(), frame.clone()};
}
static cv::Mat to_gray(cv::Mat bgr) {
cv::Mat gray;
cv::cvtColor(bgr, gray, cv::COLOR_BGR2GRAY);
return gray;
}
static cv::Mat edges_fn(cv::Mat gray) {
cv::Mat blurred, mask;
cv::GaussianBlur(gray, blurred, {5, 5}, 0);
cv::Canny(blurred, mask, 50, 150);
return mask;
}
static cv::Mat quantise(cv::Mat bgr) {
constexpr int levels = 4;
constexpr double step = 256.0 / levels;
static const cv::Mat lut = []() {
cv::Mat l(1, 256, CV_8UC1);
for (int i = 0; i < 256; ++i)
l.at<uchar>(i) = cv::saturate_cast<uchar>(
std::floor(i / step) * step + step / 2.0);
return l;
}();
cv::Mat out;
cv::LUT(bgr, lut, out);
return out;
}
// Returns composite frame AND edge mask so the display node can show both
// without needing a fan-out on the edges channel.
static std::tuple<cv::Mat, cv::Mat> composite(cv::Mat edge_mask, cv::Mat colour) {
cv::Mat result = colour.clone();
result.setTo(cv::Scalar(0, 0, 0), edge_mask);
return {result, edge_mask};
}
// ── Registry ──────────────────────────────────────────────────────────────────
// Variant deduced as std::variant<cv::Mat> — every node uses only cv::Mat.
using CvNodes = NodeRegistry<
Entry<capture, "capture">,
Entry<to_gray, "to_gray">,
Entry<edges_fn, "edges">,
Entry<quantise, "quantise">,
Entry<composite, "composite">
>;
// ── Module ────────────────────────────────────────────────────────────────────
NB_MODULE(kpn_opencv, m) {
m.doc() = "KPN++ OpenCV bindings for the cell-shading pipeline";
// Registers: Network, INode, CaptureNode, ToGrayNode, EdgesNode,
// QuantiseNode, CompositeNode, and make_<name>() factories.
// Network.add_node(name, callable, inputs=["mat"], outputs=["mat"])
// accepts Python callables that receive/return numpy uint8 arrays.
bind_network<CvNodes>(m);
// Note: bind_debug is omitted here — cv::Mat functions cannot be called
// directly from Python without the variant/network machinery. Use
// net.write() + net.read() to inject/inspect individual nodes instead.
}

View File

@ -8,6 +8,12 @@
#include <thread>
#include <chrono>
// Teach KPN how many bytes a cv::Mat actually carries (header + pixel data).
template<>
struct kpn::ChannelDataSize<cv::Mat> {
static std::size_t bytes(const cv::Mat& m) { return m.total() * m.elemSize(); }
};
// ── Cell-shading pipeline ─────────────────────────────────────────────────────
//
// [capture] --"colour"--> [quantise] ──────────────────────────┐
@ -32,6 +38,7 @@ static cv::Mat make_gradient(int W, int H) {
// ── Pipeline functions ────────────────────────────────────────────────────────
// --8<-- [start:capture_fn]
static std::tuple<cv::Mat, cv::Mat> capture() {
constexpr int W = 640, H = 480;
static cv::VideoCapture cap;
@ -68,6 +75,7 @@ static std::tuple<cv::Mat, cv::Mat> capture() {
}
return {frame.clone(), frame.clone()};
}
// --8<-- [end:capture_fn]
static cv::Mat to_gray(cv::Mat bgr) {
cv::Mat gray;
@ -112,6 +120,7 @@ static std::tuple<cv::Mat, cv::Mat> composite(cv::Mat edge_mask, cv::Mat colour)
// The constructor opens both windows on the main thread (Wayland requirement).
// operator() is called by step() whenever both channels have a frame ready.
// --8<-- [start:display_node]
class DisplayNode : public kpn::MainThreadNode<DisplayNode,
kpn::in<"composite", "edges">,
cv::Mat, cv::Mat> {
@ -141,12 +150,14 @@ private:
catch (const cv::Exception&) { return false; }
}
};
// --8<-- [end:display_node]
// ─────────────────────────────────────────────────────────────────────────────
int main() {
using namespace kpn;
// --8<-- [start:opencv_network]
auto src = make_node<capture> (out<"colour","grey">{}, 8);
auto gray_node = make_node<to_gray> (in<"bgr">{}, out<"gray">{}, 8);
auto edge_node = make_node<edges_fn> (in<"gray">{}, out<"edges">{}, 8);
@ -171,13 +182,17 @@ int main() {
.connect("comp", comp.template output<"result">(), "display", disp.template input<"composite">())
.connect("comp", comp.template output<"edges">(), "display", disp.template input<"edges">())
.build();
// --8<-- [end:opencv_network]
net.set_watchdog_interval(std::chrono::milliseconds(5000));
#ifdef KPN_WEB_DEBUG
net.set_web_debug_port(9090);
#endif
std::cout << "Cell-shading pipeline running. Press 'q' to stop.\n";
std::cout << "Web debug UI: http://localhost:9090\n";
// --8<-- [start:main_thread_step]
net.start();
// Main thread drives display — imshow/waitKey stay on the GUI thread.
@ -186,5 +201,6 @@ int main() {
cv::waitKey(8); // yield event loop when no frame ready
net.stop();
// --8<-- [end:main_thread_step]
return 0;
}

View File

@ -0,0 +1,159 @@
// Example 14 — DebugHub with Shared Resource Token
//
// Two independent KPN networks compete for one shared inference resource
// (simulating a small GPU or single-session ONNX runtime). The DebugHub
// serves a single web UI at http://localhost:9090 with:
//
// [All Networks] — resource utilisation cards + cross-network node table
// [detect] — force-directed graph for the detection pipeline
// [classify] — force-directed graph for the classification pipeline
//
// Topology:
//
// detect pipeline:
// [source_detect] ──> [run_detect] ──> [sink_detect]
//
// classify pipeline:
// [source_classify] ──> [run_classify] ──> [sink_classify]
//
// Both [run_detect] and [run_classify] call gpu.acquire() before touching the
// simulated device. The priority-based token awards the next slot to the
// waiter that is more likely to make useful progress (higher priority score).
//
// Build: cmake -DKPN_WEB_DEBUG=ON .. && cmake --build .
// Run: ./14_debug_hub
// UI: http://localhost:9090
#ifdef KPN_WEB_DEBUG
#include <kpn/kpn.hpp>
#include <atomic>
#include <chrono>
#include <iostream>
#include <thread>
using namespace kpn;
using namespace std::chrono_literals;
// ── Simulated inference device ────────────────────────────────────────────────
//
// Represents any exclusive, serialised accelerator: GPU session, ONNX runtime,
// hardware encoder, etc. Only one caller can hold it at a time.
struct GPU {
// Detection model: fast, 8 ms per frame.
int detect(int frame_id) {
std::this_thread::sleep_for(8ms);
return frame_id * 2; // synthetic "score"
}
// Classification model: heavier, 14 ms per frame.
int classify(int frame_id) {
std::this_thread::sleep_for(14ms);
return frame_id % 10; // synthetic "label"
}
};
// Global pointer so free-function nodes can reach the resource.
// In production code, capture by reference inside an ObjectNode functor instead.
static SharedResource<GPU>* g_gpu = nullptr;
// ── Detection pipeline ────────────────────────────────────────────────────────
static int source_detect() {
static std::atomic<int> id{0};
std::this_thread::sleep_for(25ms); // ~40 fps source rate
return id.fetch_add(1, std::memory_order_relaxed);
}
static int run_detect(int frame_id) {
// Higher priority: detection is latency-critical.
auto guard = g_gpu->acquire([] { return 0.7f; });
return guard->detect(frame_id);
}
static std::atomic<uint64_t> detect_out{0};
static void sink_detect(int) {
detect_out.fetch_add(1, std::memory_order_relaxed);
}
// ── Classification pipeline ───────────────────────────────────────────────────
static int source_classify() {
static std::atomic<int> id{0};
std::this_thread::sleep_for(40ms); // ~25 fps source rate
return id.fetch_add(1, std::memory_order_relaxed);
}
static int run_classify(int frame_id) {
// Lower priority: classification is best-effort.
auto guard = g_gpu->acquire([] { return 0.3f; });
return guard->classify(frame_id);
}
static std::atomic<uint64_t> classify_out{0};
static void sink_classify(int) {
classify_out.fetch_add(1, std::memory_order_relaxed);
}
// ── main ──────────────────────────────────────────────────────────────────────
int main() {
SharedResource<GPU> gpu;
g_gpu = &gpu;
// ── Detection network ─────────────────────────────────────────────────────
auto src_det = make_node<source_detect, "source_detect">(4);
auto inf_det = make_node<run_detect, "run_detect" >(4);
auto snk_det = make_node<sink_detect, "sink_detect" >(4);
auto net_detect = make_network(
edge(src_det.output<0>(), inf_det.input<0>()),
edge(inf_det.output<0>(), snk_det.input<0>())
);
// ── Classification network ────────────────────────────────────────────────
auto src_cls = make_node<source_classify, "source_classify">(4);
auto inf_cls = make_node<run_classify, "run_classify" >(4);
auto snk_cls = make_node<sink_classify, "sink_classify" >(4);
auto net_classify = make_network(
edge(src_cls.output<0>(), inf_cls.input<0>()),
edge(inf_cls.output<0>(), snk_cls.input<0>())
);
// ── Hub — one debug server for both networks + the shared resource ─────────
DebugHub hub(9090);
hub.register_network("detect", net_detect);
hub.register_network("classify", net_classify);
hub.register_resource("gpu", &gpu);
net_detect.start();
net_classify.start();
hub.start();
std::cout << "Running — open http://localhost:9090\n"
<< "Tabs: [All Networks] [detect] [classify]\n"
<< "Press Enter to stop.\n";
std::cin.get();
net_detect.stop();
net_classify.stop();
std::cout << "\nResults:\n"
<< " detect: " << detect_out.load() << " frames\n"
<< " classify: " << classify_out.load() << " frames\n";
return 0;
}
#else // no KPN_WEB_DEBUG
#include <iostream>
int main() {
std::cerr << "This example requires KPN_WEB_DEBUG.\n"
<< "Rebuild with: cmake -DKPN_WEB_DEBUG=ON ..\n";
return 1;
}
#endif

View File

@ -0,0 +1,75 @@
// Example 15 — Per-node Error Handler
//
// Demonstrates set_error_handler() for deciding whether a network can
// continue when a node throws an exception.
//
// The "validator" node rejects even numbers by throwing std::runtime_error.
// Its error handler logs the failure and returns true (skip & continue),
// so odd numbers still flow through to the sink.
//
// Compare: a second handler (commented below) returns false instead,
// which stops the node and gracefully shuts the downstream side down.
//
// Pipeline: [source] --int--> [validator] --int--> [sink]
#include <kpn/kpn.hpp>
#include <chrono>
#include <iostream>
#include <thread>
static int counter = 0;
static int source() {
std::this_thread::sleep_for(std::chrono::milliseconds(20));
return ++counter;
}
static int validate(int x) {
if (x % 2 == 0)
throw std::runtime_error("even number rejected: " + std::to_string(x));
return x;
}
static int received = 0;
static void sink(int x) {
std::cout << " processed: " << x << '\n';
++received;
}
int main() {
using namespace kpn;
auto src = make_node<source> ();
auto proc = make_node<validate>();
auto snk = make_node<sink> ();
// --8<-- [start:error_handler]
// Return true → skip this invocation, keep the node running.
// Return false → stop the node (downstream drains then also stops).
proc.set_error_handler([](std::string_view name, std::exception_ptr ep) {
try { std::rethrow_exception(ep); }
catch (const std::exception& e) {
std::cerr << "[" << name << "] skipping item — " << e.what() << '\n';
}
return true;
});
// --8<-- [end:error_handler]
Network net;
net.add("source", src)
.add("validator", proc)
.add("sink", snk)
.connect("source", src.output<0>(), "validator", proc.input<0>())
.connect("validator", proc.output<0>(), "sink", snk.input<0>())
.build();
std::cout << "source emits 1..N; validator rejects even numbers.\n"
<< "Error messages on stderr, accepted items on stdout.\n\n";
net.start();
std::this_thread::sleep_for(std::chrono::milliseconds(300));
net.stop();
std::cout << "\nItems accepted by sink: " << received << '\n';
}

View File

@ -0,0 +1,83 @@
// Example 16 — Event Callbacks: overflow and node-stopped signals
//
// Two complementary observation mechanisms:
//
// 1. Per-node overflow callback set_overflow_callback()
// Fired (with a timestamp) when a node's output channel is full and an
// item is dropped. Useful for targeted monitoring of a specific node.
//
// 2. Network-level event handler net.set_event_handler()
// Aggregate callback covering every node: receives the node name, a
// NodeEvent (Overflow or Closed), and a timestamp. Register once and
// observe the whole network.
//
// Pipeline: [fast_source] --int--> [slow_sink]
//
// fast_source produces at ~500 items/s; slow_sink consumes at ~20 items/s.
// The channel capacity is 3, so overflows appear within milliseconds.
#include <kpn/kpn.hpp>
#include <atomic>
#include <chrono>
#include <iostream>
#include <thread>
using namespace kpn;
using namespace std::chrono;
// ── Node functions ────────────────────────────────────────────────────────────
// --8<-- [start:node_fns]
static std::atomic<int> g_seq{0};
static int fast_source() {
std::this_thread::sleep_for(milliseconds(2)); // ~500/s
return g_seq.fetch_add(1);
}
static void slow_sink(int x) {
std::this_thread::sleep_for(milliseconds(50)); // ~20/s
std::cout << " consumed: " << x << '\n';
}
// --8<-- [end:node_fns]
// ── main ──────────────────────────────────────────────────────────────────────
int main() {
auto src = make_node<fast_source>(/*capacity=*/3);
auto snk = make_node<slow_sink> (/*capacity=*/3);
// --8<-- [start:per_node_callback]
// Per-node overflow callback — no node name needed, known at registration.
std::atomic<int> overflow_count{0};
src.set_overflow_callback([&](steady_clock::time_point ts) {
auto ms = duration_cast<milliseconds>(ts.time_since_epoch()).count();
std::cerr << "[overflow] fast_source at t=" << ms << "ms\n";
overflow_count.fetch_add(1);
});
// --8<-- [end:per_node_callback]
Network net;
// --8<-- [start:network_event_handler]
// Network-level aggregate handler — covers every node, includes node name.
net.set_event_handler([](std::string_view name, NodeEvent ev,
steady_clock::time_point ts) {
auto ms = duration_cast<milliseconds>(ts.time_since_epoch()).count();
std::string_view kind = (ev == NodeEvent::Overflow) ? "overflow" : "closed";
std::cerr << "[net:" << kind << "] node=" << name << " t=" << ms << "ms\n";
});
// --8<-- [end:network_event_handler]
net.add("source", src)
.add("sink", snk)
.connect("source", src.output<0>(), "sink", snk.input<0>())
.build()
.start();
std::this_thread::sleep_for(milliseconds(300));
net.stop();
std::cout << "\nTotal overflows observed by per-node callback: "
<< overflow_count.load() << '\n';
}

View File

@ -1,8 +1,35 @@
cmake_minimum_required(VERSION 3.21)
# Build an example and register it as a CTest smoke test.
# Examples that are self-terminating (fixed sleep net.stop()) pass when
# they exit 0 within TIMEOUT seconds. OpenCV/UI examples are excluded.
function(kpn_example name)
add_executable(${name} ${name}/main.cpp)
target_link_libraries(${name} PRIVATE kpn)
add_test(NAME example_${name} COMMAND ${name})
set_tests_properties(example_${name} PROPERTIES
TIMEOUT 15
LABELS examples
)
endfunction()
# Register a Python example script as a CTest smoke test. Runs the script with
# PYTHONPATH pointing at the freshly-built kpn_python module, so it does not
# depend on the caller's working directory or a hard-coded "build/python" path.
function(kpn_python_example name)
if(NOT KPN_BUILD_PYTHON)
return()
endif()
add_test(
NAME example_${name}
COMMAND ${CMAKE_COMMAND} -E env
"PYTHONPATH=$<TARGET_FILE_DIR:kpn_python>"
${Python_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/${name}/example.py
)
set_tests_properties(example_${name} PROPERTIES
TIMEOUT 15
LABELS examples
)
endfunction()
kpn_example(01_hello_pipeline)
@ -11,19 +38,32 @@ kpn_example(03_multi_output)
kpn_example(04_storage_policy)
kpn_example(05_error_handling)
kpn_example(06_watchdog)
set_tests_properties(example_06_watchdog PROPERTIES TIMEOUT 40)
kpn_example(10_static_hello_pipeline)
kpn_example(11_static_fanout)
kpn_example(15_node_error_handler)
kpn_example(16_event_callbacks)
if(KPN_WEB_DEBUG)
kpn_target_enable_web_debug(06_watchdog)
add_executable(14_debug_hub 14_debug_hub/main.cpp)
target_link_libraries(14_debug_hub PRIVATE kpn)
kpn_target_enable_web_debug(14_debug_hub)
endif()
# 07 and 08 require the Python bindings only add if built
if(KPN_BUILD_PYTHON)
# These are Python scripts, not compiled targets installed alongside kpn_python
endif()
# 07 and 08 are Python scripts no compiled target, but run as smoke tests.
kpn_python_example(07_python_network)
kpn_python_example(08_python_subport)
# 09 requires OpenCV only build if found
find_package(OpenCV QUIET COMPONENTS core imgproc highgui videoio)
if(OpenCV_FOUND)
# Hybrid Python example: kpn_opencv module (requires both OpenCV and nanobind)
if(KPN_BUILD_PYTHON)
nanobind_add_module(kpn_opencv 09_opencv_cellshade/kpn_opencv.cpp)
target_link_libraries(kpn_opencv PRIVATE kpn ${OpenCV_LIBS})
target_compile_definitions(kpn_opencv PRIVATE KPN_BUILD_PYTHON)
message(STATUS "KPN++ kpn_opencv Python module: building (OpenCV ${OpenCV_VERSION})")
endif()
add_executable(09_opencv_cellshade 09_opencv_cellshade/main.cpp)
target_link_libraries(09_opencv_cellshade PRIVATE kpn ${OpenCV_LIBS})

301
include/kpn/branch.hpp Normal file
View File

@ -0,0 +1,301 @@
#pragma once
#include "channel.hpp"
#include "diagnostics.hpp"
#include "inode.hpp"
#include "port.hpp"
#include "traits.hpp"
#include <array>
#include <atomic>
#include <functional>
#include <memory>
#include <thread>
namespace kpn {
// ── RouterNode ────────────────────────────────────────────────────────────────
//
// Reads one item and pushes it to exactly one of N output channels, chosen by
// selector(item). If selector returns >= N the item is silently dropped.
//
// Usage:
// auto router = make_router<Image, 3>(
// [](const Image& img) -> std::size_t { return img.stream_id % 3; });
// net.connect("src", src.output<0>(), "router", router.input<0>())
// .connect("router", router.output<0>(), "nodeA", nodeA.input<0>())
// .connect("router", router.output<1>(), "nodeB", nodeB.input<0>())
// .connect("router", router.output<2>(), "nodeC", nodeC.input<0>());
template<typename T, std::size_t N, std::size_t Id = 0>
class RouterNode : public INode {
public:
using Selector = std::function<std::size_t(const T&)>;
using args_tuple = std::tuple<T>;
using return_tuple = repeat_tuple_t<T, N>;
using return_raw = return_tuple;
static constexpr std::size_t input_count = 1;
static constexpr std::size_t output_count = N;
static constexpr std::size_t unique_tag = Id;
static constexpr bool is_router_node = true;
explicit RouterNode(Selector sel, std::size_t fifo_capacity = 5)
: selector_(std::move(sel))
, fifo_capacity_(fifo_capacity)
{
input_ch_ = std::make_shared<Channel<T>>(fifo_capacity);
}
~RouterNode() override { stop(); }
// ── INode ─────────────────────────────────────────────────────────────────
void start() override {
input_ch_->enable();
stop_flag_.store(false, std::memory_order_relaxed);
thread_ = std::jthread([this](std::stop_token) { run_loop(); });
}
void stop() override {
stop_flag_.store(true, std::memory_order_relaxed);
input_ch_->disable();
if (thread_.joinable()) thread_.request_stop(), thread_.join();
}
bool running() const override {
return thread_.joinable() && !stop_flag_.load(std::memory_order_relaxed);
}
void set_name(std::string name) override { name_ = std::move(name); }
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};
}
// ── Port access ───────────────────────────────────────────────────────────
template<std::size_t I = 0>
InputPort<RouterNode, I> input() {
static_assert(I == 0, "RouterNode has exactly one input");
return {*this};
}
template<std::size_t I>
OutputPort<RouterNode, I> output() {
static_assert(I < N, "RouterNode output index out of range");
return {*this};
}
// ── Internal channel accessors (called by Network::connect) ───────────────
template<std::size_t I>
Channel<T>& input_channel() {
static_assert(I == 0);
return *input_ch_;
}
template<std::size_t I>
void set_input_channel(std::shared_ptr<Channel<T>> ch) {
static_assert(I == 0);
input_ch_ = std::move(ch);
}
template<std::size_t I>
void set_output_channel(Channel<T>* ch) {
static_assert(I < N);
out_channels_[I] = ch;
}
private:
void run_loop() {
while (!stop_flag_.load(std::memory_order_relaxed)) {
try {
auto t0 = clock_t::now();
T val = input_ch_->pop();
auto t1 = clock_t::now();
auto cpu0 = NodeStats::cpu_now();
std::size_t idx = selector_(val);
if (idx < N && out_channels_[idx]) {
try { out_channels_[idx]->push(val); }
catch (const ChannelOverflowError&) {}
}
auto cpu1 = NodeStats::cpu_now();
auto t2 = clock_t::now();
stats_.record_exec(duration_t(t2 - t1), duration_t(t1 - t0), cpu0, cpu1);
} catch (const ChannelClosedError&) {
break;
}
}
}
std::string name_;
std::size_t fifo_capacity_;
Selector selector_;
std::shared_ptr<Channel<T>> input_ch_;
std::array<Channel<T>*, N> out_channels_{};
std::atomic<bool> stop_flag_{false};
std::jthread thread_;
NodeStats stats_;
};
// ── FilterNode ────────────────────────────────────────────────────────────────
//
// Reads one item and pushes it downstream only when pred(item) is true.
// Dropped items are not counted as processed frames.
//
// Usage:
// auto filt = make_filter<Frame>([](const Frame& f) { return f.valid; });
// net.connect("src", src.output<0>(), "filt", filt.input<0>())
// .connect("filt", filt.output<0>(), "dst", dst.input<0>());
template<typename T, std::size_t Id = 0>
class FilterNode : public INode {
public:
using Predicate = std::function<bool(const T&)>;
using args_tuple = std::tuple<T>;
using return_tuple = std::tuple<T>;
using return_raw = return_tuple;
static constexpr std::size_t input_count = 1;
static constexpr std::size_t output_count = 1;
static constexpr std::size_t unique_tag = Id;
static constexpr bool is_filter_node = true;
explicit FilterNode(Predicate pred, std::size_t fifo_capacity = 5)
: pred_(std::move(pred))
, fifo_capacity_(fifo_capacity)
{
input_ch_ = std::make_shared<Channel<T>>(fifo_capacity);
}
~FilterNode() override { stop(); }
// ── INode ─────────────────────────────────────────────────────────────────
void start() override {
input_ch_->enable();
stop_flag_.store(false, std::memory_order_relaxed);
thread_ = std::jthread([this](std::stop_token) { run_loop(); });
}
void stop() override {
stop_flag_.store(true, std::memory_order_relaxed);
input_ch_->disable();
if (thread_.joinable()) thread_.request_stop(), thread_.join();
}
bool running() const override {
return thread_.joinable() && !stop_flag_.load(std::memory_order_relaxed);
}
void set_name(std::string name) override { name_ = std::move(name); }
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};
}
// ── Port access ───────────────────────────────────────────────────────────
template<std::size_t I = 0>
InputPort<FilterNode, I> input() {
static_assert(I == 0, "FilterNode has exactly one input");
return {*this};
}
template<std::size_t I = 0>
OutputPort<FilterNode, I> output() {
static_assert(I == 0, "FilterNode has exactly one output");
return {*this};
}
// ── Internal channel accessors (called by Network::connect) ───────────────
template<std::size_t I>
Channel<T>& input_channel() {
static_assert(I == 0);
return *input_ch_;
}
template<std::size_t I>
void set_input_channel(std::shared_ptr<Channel<T>> ch) {
static_assert(I == 0);
input_ch_ = std::move(ch);
}
template<std::size_t I>
void set_output_channel(Channel<T>* ch) {
static_assert(I == 0);
out_ch_ = ch;
}
private:
void run_loop() {
while (!stop_flag_.load(std::memory_order_relaxed)) {
try {
auto t0 = clock_t::now();
T val = input_ch_->pop();
auto t1 = clock_t::now();
auto cpu0 = NodeStats::cpu_now();
if (pred_(val) && out_ch_) {
try { out_ch_->push(val); }
catch (const ChannelOverflowError&) {}
auto cpu1 = NodeStats::cpu_now();
auto t2 = clock_t::now();
stats_.record_exec(duration_t(t2 - t1), duration_t(t1 - t0), cpu0, cpu1);
}
} catch (const ChannelClosedError&) {
break;
}
}
}
std::string name_;
std::size_t fifo_capacity_;
Predicate pred_;
std::shared_ptr<Channel<T>> input_ch_;
Channel<T>* out_ch_{nullptr};
std::atomic<bool> stop_flag_{false};
std::jthread thread_;
NodeStats stats_;
};
// ── Factories ─────────────────────────────────────────────────────────────────
template<typename T, std::size_t N>
RouterNode<T, N> make_router(std::function<std::size_t(const T&)> sel,
std::size_t capacity = 5) {
return RouterNode<T, N, 0>(std::move(sel), capacity);
}
template<typename T>
FilterNode<T> make_filter(std::function<bool(const T&)> pred,
std::size_t capacity = 5) {
return FilterNode<T, 0>(std::move(pred), capacity);
}
} // namespace kpn

View File

@ -2,16 +2,30 @@
#include "diagnostics.hpp"
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <cstdint>
#include <functional>
#include <memory>
#include <mutex>
#include <queue>
#include <stdexcept>
#include <string>
#include <thread>
#include <type_traits>
namespace kpn {
// ── Data size trait ───────────────────────────────────────────────────────────
// Returns the number of bytes of logical payload carried by a value.
// Defaults to sizeof(T), which is correct for PODs and fixed-size types.
// Specialize for heap-owning types (e.g. cv::Mat) to get accurate bandwidth:
//
// template<> struct kpn::ChannelDataSize<cv::Mat> {
// static std::size_t bytes(const cv::Mat& m) { return m.total() * m.elemSize(); }
// };
template<typename T>
struct ChannelDataSize {
static std::size_t bytes(const T&) { return sizeof(T); }
};
// ── Storage policy ────────────────────────────────────────────────────────────
template<typename T>
@ -44,67 +58,183 @@ public:
ChannelClosedError() : std::runtime_error("channel closed") {}
};
// ── CPU pause hint ────────────────────────────────────────────────────────────
// Signals the CPU that this is a spin-wait loop, improving HT sibling throughput
// and preventing branch-predictor thrash on x86. Falls back to a compiler barrier.
[[maybe_unused]] static void spin_hint() noexcept {
#if defined(__x86_64__) || defined(__i386__)
__asm__ volatile("pause" ::: "memory");
#elif defined(__aarch64__) || defined(__arm__)
__asm__ volatile("yield" ::: "memory");
#else
std::atomic_signal_fence(std::memory_order_seq_cst);
#endif
}
// ── Channel ───────────────────────────────────────────────────────────────────
// SPSC ring buffer with atomic wait/notify and configurable spin-before-sleep.
//
// `spin_count` (constructor arg, default 200): number of pause-hint iterations
// before falling back to atomic::wait (futex). At ~20 ns/pause on x86 this is
// ~4 µs. Set to 0 to disable spinning (useful for power-constrained or
// predominantly-idle pipelines).
//
// Memory ordering contract (SPSC):
// push(): tail_.store(release) pairs with pop()'s tail_.load(acquire)
// head_.load(acquire) pairs with pop()'s head_.store(release)
// pop(): head_.store(release) pairs with push()'s head_.load(acquire)
// tail_.load(acquire) pairs with push()'s tail_.store(release)
template<typename T>
class Channel {
public:
using storage_type = channel_storage_t<T>;
explicit Channel(std::size_t capacity = 5) : capacity_(capacity) {}
explicit Channel(std::size_t capacity = 5, std::size_t spin_count = 200)
: capacity_(capacity), spin_count_(spin_count)
{
std::size_t rs = 1;
while (rs <= capacity) rs <<= 1; // smallest power-of-2 > capacity
ring_mask_ = rs - 1;
buf_ = std::make_unique<storage_type[]>(rs);
}
Channel(const Channel&) = delete;
Channel& operator=(const Channel&) = delete;
// Push a value.
// - If channel is disabled (accepting_ == false): silently drop, return immediately.
// - If channel is full: throw ChannelOverflowError.
// - If channel is disabled (accepting_ == false): silently drop.
// - If channel is full (fill >= capacity_): throw ChannelOverflowError.
void push(T value) {
if (!accepting_.load(std::memory_order_relaxed)) {
stats_.record_drop();
return;
}
std::unique_lock lock(mutex_);
if (!accepting_.load(std::memory_order_relaxed)) {
const std::size_t data_bytes = ChannelDataSize<T>::bytes(value);
const std::size_t t = tail_.load(std::memory_order_relaxed);
const std::size_t h = head_.load(std::memory_order_acquire);
if (!accepting_.load(std::memory_order_acquire)) {
stats_.record_drop();
return;
}
if (queue_.size() >= capacity_) {
if (t - h >= capacity_) {
stats_.record_overflow();
throw ChannelOverflowError(capacity_);
}
queue_.push(make_storage(std::move(value)));
stats_.record_push(queue_.size());
lock.unlock();
cv_.notify_one();
const bool was_empty = (t == h);
buf_[t & ring_mask_] = make_storage(std::move(value));
tail_.store(t + 1, std::memory_order_release);
stats_.record_push(t - h + 1, data_bytes);
wake_.fetch_add(1, std::memory_order_release);
wake_.notify_one();
if (was_empty && push_callback_)
push_callback_();
}
// Blocking pop. Unblocks when an item is available or the channel is disabled.
// Throws ChannelClosedError if disabled and queue is empty.
// Lossless, non-blocking delivery for a must-deliver control token (EOF).
//
// A sentinel is stored out-of-band — in a dedicated slot that does NOT
// consume ring capacity — so this can never overflow and never blocks the
// caller. That distinction is essential: each KPN node has a single worker
// thread, so a *blocking* push would park that thread and stop it draining
// its own input, cascading into a hold-and-wait deadlock under backpressure.
// Setting a flag and returning keeps the worker free to keep popping.
//
// The consumer's pop() drains the ring first, then delivers this sentinel,
// preserving ordering (EOF arrives after all data pushed before it).
//
// Only the sole producer may call it (SPSC contract, same as push()).
// Returns false if the channel is already disabled (token discarded —
// teardown is in progress, so the sentinel is moot).
bool push_sentinel(T value) {
if (!accepting_.load(std::memory_order_acquire)) {
stats_.record_drop();
return false;
}
eof_value_ = make_storage(std::move(value));
has_eof_.store(true, std::memory_order_release);
// Wake a consumer blocked in pop(): the sentinel is now deliverable even
// though the ring may be empty.
wake_.fetch_add(1, std::memory_order_release);
wake_.notify_one();
if (push_callback_) push_callback_();
return true;
}
// Blocking pop. Returns when an item is available.
// Throws ChannelClosedError if the channel is disabled (regardless of fill).
T pop() {
std::unique_lock lock(mutex_);
cv_.wait(lock, [this] {
return !queue_.empty() || !accepting_.load(std::memory_order_relaxed);
});
if (queue_.empty())
for (;;) {
// Snapshot wake_ BEFORE reading tail_ to prevent lost wakeups.
const uint32_t w = wake_.load(std::memory_order_relaxed);
const std::size_t h = head_.load(std::memory_order_relaxed);
std::size_t t = tail_.load(std::memory_order_acquire);
// If empty, spin before sleeping: avoids the futex when the next item
// arrives within the spin window (~4 µs at default spin_count=200 on x86).
if (h == t) {
// Ring drained — deliver any pending out-of-band sentinel (EOF)
// now, so it always arrives after the data pushed before it.
{ T s; if (take_sentinel(s)) return s; }
if (!accepting_.load(std::memory_order_acquire))
throw ChannelClosedError{};
T value = extract(std::move(queue_.front()));
queue_.pop();
for (std::size_t si = 0; si < spin_count_; ++si) {
spin_hint();
t = tail_.load(std::memory_order_acquire);
if (t != h) break;
{ T s; if (take_sentinel(s)) return s; }
if (!accepting_.load(std::memory_order_relaxed))
throw ChannelClosedError{};
}
if (h == t) {
// Still empty after spin — sleep until push()/push_sentinel()
// or disable() fires. Re-check tail and the sentinel after
// loading w to guard against a lost wakeup.
if (tail_.load(std::memory_order_acquire) != h) continue;
if (has_eof_.load(std::memory_order_acquire)) continue;
wake_.wait(w, std::memory_order_relaxed);
continue;
}
}
// Item available (found immediately or during spin).
if (!accepting_.load(std::memory_order_acquire))
throw ChannelClosedError{};
T value = extract(std::move(buf_[h & ring_mask_]));
head_.store(h + 1, std::memory_order_release);
stats_.record_pop();
return value;
}
}
// Non-blocking pop with timeout. For watchdog/display use only — not used in run_loop.
// Non-blocking pop with timeout. For watchdog/display use only.
bool try_pop(T& out, std::chrono::milliseconds timeout) {
std::unique_lock lock(mutex_);
if (!cv_.wait_for(lock, timeout, [this] {
return !queue_.empty() || !accepting_.load(std::memory_order_relaxed);
}))
return false;
if (queue_.empty())
return false;
out = extract(std::move(queue_.front()));
queue_.pop();
const auto deadline = std::chrono::steady_clock::now() + timeout;
for (;;) {
if (try_pop_now(out)) return true;
if (!accepting_.load(std::memory_order_relaxed)) return false;
if (std::chrono::steady_clock::now() >= deadline) return false;
std::this_thread::sleep_for(std::chrono::microseconds(50));
}
}
// Immediate non-blocking pop. Returns false if the ring is empty.
// Once the ring is drained, delivers any pending out-of-band sentinel (EOF)
// so pool nodes — which pop only via this path — still receive the token.
bool try_pop_now(T& out) {
const std::size_t h = head_.load(std::memory_order_relaxed);
if (h == tail_.load(std::memory_order_acquire))
return take_sentinel(out);
out = extract(std::move(buf_[h & ring_mask_]));
head_.store(h + 1, std::memory_order_release);
stats_.record_pop();
return true;
}
@ -114,20 +244,33 @@ public:
accepting_.store(true, std::memory_order_relaxed);
}
// Disable the channel: drop all queued items, unblock any waiting pop().
// Called by consumer node on stop(). Producer push() will silently drop after this.
// Disable the channel: stop accepting new pushes, unblock any waiting pop().
// Items already in the ring are abandoned and freed when the Channel is destroyed.
void disable() {
accepting_.store(false, std::memory_order_relaxed);
{
std::lock_guard lock(mutex_);
while (!queue_.empty()) queue_.pop();
}
cv_.notify_all();
accepting_.store(false, std::memory_order_release);
wake_.fetch_add(1, std::memory_order_release);
wake_.notify_all();
}
// Register a callback fired when the queue transitions empty→non-empty.
void set_push_callback(std::function<void()> cb) {
push_callback_ = std::move(cb);
}
// Ring occupancy, derived lazily from indices — no separate counter on the
// hot path. Excludes any out-of-band sentinel (that lives outside the ring).
std::size_t size() const {
std::lock_guard lock(mutex_);
return queue_.size();
return tail_.load(std::memory_order_relaxed)
- head_.load(std::memory_order_relaxed);
}
// A pending out-of-band sentinel (EOF) counts as consumable work here even
// though it holds no ring slot. This is what node readiness checks call, so
// a channel carrying only a sentinel still schedules its consumer's next
// fire — without this the sentinel would never be popped and the pipeline
// would deadlock at teardown.
std::size_t approx_size() const {
return size() + (has_eof_.load(std::memory_order_acquire) ? 1u : 0u);
}
std::size_t capacity() const { return capacity_; }
@ -135,17 +278,19 @@ public:
const ChannelStats& stats() const { return stats_; }
ChannelSnapshot snapshot(const std::string& name) const {
std::lock_guard lock(mutex_);
const std::size_t t = tail_.load(std::memory_order_relaxed);
const std::size_t h = head_.load(std::memory_order_relaxed);
return {
name,
capacity_,
queue_.size(),
t - h,
stats_.peak_fill.load(std::memory_order_relaxed),
stats_.pushes.load(std::memory_order_relaxed),
stats_.bytes_pushed.load(std::memory_order_relaxed),
stats_.drops.load(std::memory_order_relaxed),
stats_.overflows.load(std::memory_order_relaxed),
stats_.pops.load(std::memory_order_relaxed),
sizeof(T), // payload bytes — sizeof(T) regardless of storage policy
sizeof(T),
};
}
@ -164,12 +309,38 @@ private:
return *s;
}
// Consume the out-of-band sentinel if one is pending. Consumer-only.
// Called only when the ring is observed empty, so the sentinel is always
// delivered after every value pushed before it.
bool take_sentinel(T& out) {
if (!has_eof_.load(std::memory_order_acquire)) return false;
out = extract(std::move(eof_value_));
has_eof_.store(false, std::memory_order_release);
stats_.record_pop();
return true;
}
const std::size_t capacity_;
std::queue<storage_type> queue_;
std::atomic<bool> accepting_{true};
mutable std::mutex mutex_;
std::condition_variable cv_;
const std::size_t spin_count_;
std::size_t ring_mask_;
std::unique_ptr<storage_type[]> buf_;
std::function<void()> push_callback_;
ChannelStats stats_;
// Out-of-band sentinel (EOF): stored outside the ring so its delivery never
// depends on ring capacity and never blocks the producer. Written by the
// producer (push_sentinel), read+cleared by the consumer (take_sentinel);
// has_eof_ is the publish/consume handshake.
storage_type eof_value_{};
std::atomic<bool> has_eof_{false};
// Separate cache lines: head_ is written only by the consumer;
// tail_ and wake_ are written only by the producer.
// wake_ wakes a blocked pop() on enqueue or on a pending sentinel.
alignas(64) std::atomic<std::size_t> head_{0};
alignas(64) std::atomic<std::size_t> tail_{0};
std::atomic<uint32_t> wake_{0};
std::atomic<bool> accepting_{true};
};
// ── Channel probe — type-erased snapshot accessor ─────────────────────────────

456
include/kpn/debug_hub.hpp Normal file
View File

@ -0,0 +1,456 @@
#pragma once
// Only active when KPN_WEB_DEBUG is defined.
#ifdef KPN_WEB_DEBUG
#include "diagnostics.hpp"
#include "web_debug.hpp"
#include <functional>
#include <memory>
#include <sstream>
#include <string>
#include <utility>
#include <vector>
namespace kpn {
// ── Hub HTML ──────────────────────────────────────────────────────────────────
// Multi-tab UI: one tab per registered network (force-directed graph) +
// an "All Networks" tab showing shared resource cards and a cross-network
// node table.
static const char* HUB_HTML = R"html(<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>KPN++ Debug Hub</title>
<style>
*{box-sizing:border-box}
body{margin:0;background:#1a1a2e;color:#eee;font-family:monospace}
#hdr{display:flex;align-items:center;padding:0 16px;background:#16213e;
border-bottom:1px solid #0f3460;height:44px;gap:8px;overflow-x:auto}
#hdr h1{margin:0;font-size:16px;color:#e94560;white-space:nowrap;margin-right:12px}
#tab-bar{display:flex;gap:2px;flex:1}
.tab{padding:4px 14px;border:none;background:#0f3460;color:#aaa;
cursor:pointer;font-family:monospace;font-size:11px;border-radius:2px;white-space:nowrap}
.tab.active{background:#e94560;color:#fff}
.tab:hover:not(.active){background:#1e3a6e;color:#eee}
#status{font-size:10px;color:#555;white-space:nowrap}
.panel{display:none}
.panel.active{display:block}
/* ── All Networks tab ─────────────────────────────────────── */
#panel-all{height:calc(100vh - 44px);overflow-y:auto;padding:16px;
display:none;gap:16px;grid-template-columns:300px 1fr}
#panel-all.active{display:grid;align-content:start}
#panel-all h2{font-size:11px;color:#e94560;margin:0 0 8px;
letter-spacing:1px;text-transform:uppercase}
#res-col{grid-column:1}
.res-card{background:#16213e;border:1px solid #0f3460;border-radius:4px;
padding:10px 12px;margin-bottom:8px}
.res-head{display:flex;justify-content:space-between;align-items:center}
.res-name{font-size:12px}
.badge{font-size:9px;padding:2px 6px;border-radius:2px}
.held{background:#e94560}.free{background:#4CAF50;color:#111}
.res-meta{font-size:9px;color:#666;margin-top:5px;display:flex;gap:12px;flex-wrap:wrap}
.bar-wrap{height:3px;background:#0f3460;border-radius:2px;margin-top:7px}
.bar{height:3px;border-radius:2px;transition:width 0.4s}
#nodes-col{grid-column:2;overflow-y:auto;max-height:calc(100vh - 76px)}
table{width:100%;border-collapse:collapse;font-size:10px}
th{padding:4px 8px;color:#555;border-bottom:1px solid #0f3460;text-align:left;
position:sticky;top:0;background:#1a1a2e;z-index:1}
td{padding:2px 8px;border-bottom:1px solid #16213e}
tr:hover td{background:#16213e}
.ntag{font-size:9px;background:#0f3460;padding:1px 4px;border-radius:2px;color:#4CAF50}
/* ── Per-network graph panels ─────────────────────────────── */
.graph-panel{width:100vw;height:calc(100vh - 44px)}
svg.net{width:100%;height:100%}
.node circle{stroke:#fff;stroke-width:1.5px}
.node text{font-size:11px;fill:#eee;pointer-events:none;text-anchor:middle}
.node .st{font-size:9px;fill:#aaa}
.link{fill:none;stroke-width:2px}
.lbl{font-size:9px;fill:#ccc}
#tip{position:absolute;background:#0f3460;border:1px solid #e94560;border-radius:4px;
padding:8px 12px;font-size:11px;pointer-events:none;display:none;
white-space:pre;line-height:1.6}
</style>
</head>
<body>
<div id="hdr">
<h1>KPN++ Debug Hub</h1>
<div id="tab-bar"></div>
<span id="status">connecting</span>
</div>
<div id="panels">
<div id="panel-all" class="panel"></div>
</div>
<div id="tip"></div>
<script src="https://d3js.org/d3.v7.min.js"></script>
<script>
const R = 28;
const tip = d3.select('#tip');
const nc = ema => ema>100?'#e94560':ema>50?'#e07040':ema>10?'#f0c040':'#4CAF50';
const ec = pct => pct>=80?'#e94560':pct>=50?'#f0c040':'#4CAF50';
const ea = pct => pct>=80?'url(#a2)':pct>=50?'url(#a1)':'url(#a0)';
// ── Tab management ────────────────────────────────────────────────────────────
let activeTab = null;
function ensureTab(id, label) {
if (document.getElementById('tab-' + id)) return;
const b = document.createElement('button');
b.className = 'tab'; b.id = 'tab-' + id; b.textContent = label;
b.onclick = () => showTab(id);
document.getElementById('tab-bar').appendChild(b);
}
function showTab(id) {
activeTab = id;
document.querySelectorAll('.tab').forEach(b =>
b.classList.toggle('active', b.id === 'tab-' + id));
document.querySelectorAll('.panel').forEach(p =>
p.classList.toggle('active', p.id === 'panel-' + id));
}
// ── All Networks tab ──────────────────────────────────────────────────────────
function renderAll(data) {
const panel = document.getElementById('panel-all');
// Resources column
let rhtml = '<div id="res-col"><h2>Shared Resources</h2>';
if (!data.resources || !data.resources.length)
rhtml += '<div style="color:#444;font-size:11px">None registered</div>';
for (const r of (data.resources || [])) {
const wpct = Math.min(100, r.avg_wait_ms).toFixed(1);
const bc = r.current_waiters > 0 ? '#e94560' : '#4CAF50';
rhtml += `<div class="res-card">
<div class="res-head">
<span class="res-name">${r.name}</span>
<span class="badge ${r.held ? 'held' : 'free'}">${r.held ? 'HELD' : 'free'}</span>
</div>
<div class="res-meta">
<span>avg wait ${r.avg_wait_ms.toFixed(1)} ms</span>
<span>waiters ${r.current_waiters} / peak ${r.peak_waiters}</span>
<span>${r.acquisitions} acq</span>
</div>
<div class="bar-wrap">
<div class="bar" style="width:${wpct}%;background:${bc}"></div>
</div>
</div>`;
}
rhtml += '</div>';
// Nodes column — all networks in one table
let rows = '';
for (const net of data.networks) {
for (const n of net.nodes) {
rows += `<tr>
<td><span class="ntag">${net.name}</span></td>
<td>${n.id}</td>
<td>${n.fps.toFixed(1)}</td>
<td>${n.ema_exec_ms.toFixed(2)}</td>
<td>${n.max_exec_ms.toFixed(2)}</td>
<td>${n.blocked_ms.toFixed(2)}</td>
<td>${n.cpu_util_pct.toFixed(1)}</td>
</tr>`;
}
}
const thtml = `<div id="nodes-col"><h2>All Nodes</h2>
<table>
<tr><th>Network</th><th>Node</th><th>fps</th>
<th>exec ema (ms)</th><th>exec max (ms)</th>
<th>blocked (ms)</th><th>cpu %</th></tr>
${rows}
</table></div>`;
panel.innerHTML = rhtml + thtml;
}
// ── Per-network graph ─────────────────────────────────────────────────────────
const nets = {};
function initNet(netData) {
const name = netData.name;
const div = document.createElement('div');
div.id = 'panel-' + name;
div.className = 'panel graph-panel';
document.getElementById('panels').appendChild(div);
const svg = d3.select(div).append('svg').attr('class', 'net');
const W = () => div.clientWidth || window.innerWidth;
const H = () => div.clientHeight || (window.innerHeight - 44);
const defs = svg.append('defs');
['#4CAF50','#f0c040','#e94560'].forEach((col, i) =>
defs.append('marker').attr('id','a'+i)
.attr('viewBox','0 -5 10 10').attr('refX',10).attr('refY',0)
.attr('markerWidth',6).attr('markerHeight',6).attr('orient','auto')
.append('path').attr('d','M0,-5L10,0L0,5').attr('fill',col));
const g = svg.append('g');
svg.call(d3.zoom().on('zoom', e => g.attr('transform', e.transform)));
const nodes = netData.nodes.map(n => ({...n, x: W()/2, y: H()/2}));
const byId = Object.fromEntries(nodes.map(n => [n.id, n]));
const links = netData.edges
.map(e => ({...e, source: byId[e.source], target: byId[e.target]}))
.filter(e => e.source && e.target);
const sim = d3.forceSimulation(nodes)
.force('link', d3.forceLink(links).distance(150).strength(0.5))
.force('charge', d3.forceManyBody().strength(-350))
.force('center', d3.forceCenter(W()/2, H()/2))
.force('collide', d3.forceCollide(R + 18))
.on('tick', tick);
const lsel = g.append('g').selectAll('line').data(links).join('line')
.attr('class','link')
.attr('stroke', d => ec(d.fill_pct))
.attr('marker-end', d => ea(d.fill_pct));
const llbl = g.append('g').selectAll('text').data(links).join('text')
.attr('class','lbl').text(d => `${d.fill_pct.toFixed(0)}%`);
const ng = g.append('g').selectAll('g').data(nodes).join('g').attr('class','node')
.call(d3.drag()
.on('start',(e,d)=>{ if(!e.active) sim.alphaTarget(0.3).restart(); d.fx=d.x; d.fy=d.y; })
.on('drag', (e,d)=>{ d.fx=e.x; d.fy=e.y; })
.on('end', (e,d)=>{ if(!e.active) sim.alphaTarget(0); d.fx=null; d.fy=null; }));
ng.append('circle').attr('r', R).attr('fill', d => nc(d.ema_exec_ms));
ng.append('text').attr('dy', 4).text(d => d.id);
ng.append('text').attr('class','st').attr('dy', 18)
.text(d => `${d.ema_exec_ms.toFixed(1)}ms ${d.fps.toFixed(1)}fps`);
ng.on('mousemove', (e,d) =>
tip.style('display','block')
.style('left',(e.pageX+12)+'px').style('top',(e.pageY+12)+'px')
.text(`${d.id}\nframes: ${d.frames} fps: ${d.fps.toFixed(2)}\n` +
`exec ema: ${d.ema_exec_ms.toFixed(2)}ms max: ${d.max_exec_ms.toFixed(2)}ms\n` +
`blocked: ${d.blocked_ms.toFixed(2)}ms cpu: ${d.cpu_util_pct.toFixed(1)}%`))
.on('mouseleave', () => tip.style('display','none'));
g.selectAll('.link')
.on('mousemove', (e,d) =>
tip.style('display','block')
.style('left',(e.pageX+12)+'px').style('top',(e.pageY+12)+'px')
.text(`${d.name}\nfill: ${d.fill_pct.toFixed(1)}% peak: ${d.peak_pct.toFixed(1)}%\n` +
`cap: ${d.capacity} pushes: ${d.pushes} drops: ${d.drops}\n` +
`bandwidth: ${(d.bw_mbs||0).toFixed(2)} MB/s`))
.on('mouseleave', () => tip.style('display','none'));
function tick() {
const w = W(), h = H();
nodes.forEach(d => {
d.x = Math.max(R, Math.min(w - R, d.x));
d.y = Math.max(R, Math.min(h - R, d.y));
});
lsel
.attr('x1', d => d.source.x).attr('y1', d => d.source.y)
.attr('x2', d => { const dx=d.target.x-d.source.x, dy=d.target.y-d.source.y,
dist=Math.sqrt(dx*dx+dy*dy)||1;
return d.target.x-(dx/dist)*(R+8); })
.attr('y2', d => { const dx=d.target.x-d.source.x, dy=d.target.y-d.source.y,
dist=Math.sqrt(dx*dx+dy*dy)||1;
return d.target.y-(dy/dist)*(R+8); });
llbl.attr('x', d => (d.source.x+d.target.x)/2)
.attr('y', d => (d.source.y+d.target.y)/2 - 6);
ng.attr('transform', d => `translate(${d.x},${d.y})`);
}
nets[name] = {nodes, links, sim, ng, lsel, llbl};
}
function updateNet(netData) {
const st = nets[netData.name];
if (!st) return;
const byId = Object.fromEntries(netData.nodes.map(n => [n.id, n]));
st.nodes.forEach(n => {
const f = byId[n.id];
if (f) Object.assign(n, {frames:f.frames, ema_exec_ms:f.ema_exec_ms,
max_exec_ms:f.max_exec_ms, blocked_ms:f.blocked_ms, fps:f.fps,
total_cpu_ms:f.total_cpu_ms, cpu_util_pct:f.cpu_util_pct});
});
netData.edges.forEach((e,i) => {
if (st.links[i]) Object.assign(st.links[i], {fill_pct:e.fill_pct,
peak_pct:e.peak_pct, pushes:e.pushes, drops:e.drops,
overflows:e.overflows, current:e.current, bw_mbs:e.bw_mbs});
});
st.ng.select('circle').attr('fill', d => nc(d.ema_exec_ms));
st.ng.select('.st').text(d => `${d.ema_exec_ms.toFixed(1)}ms ${d.fps.toFixed(1)}fps`);
st.lsel.attr('stroke', d => ec(d.fill_pct)).attr('marker-end', d => ea(d.fill_pct));
st.llbl.text(d => `${d.fill_pct.toFixed(0)}%`);
}
// ── Poll loop ─────────────────────────────────────────────────────────────────
let init = false;
async function poll() {
try {
const r = await fetch('/api/snapshot');
if (!r.ok) throw new Error(r.status);
const data = await r.json();
if (!init) {
ensureTab('all', 'All Networks');
data.networks.forEach(net => ensureTab(net.name, net.name));
showTab('all');
data.networks.forEach(initNet);
init = true;
}
renderAll(data);
data.networks.forEach(updateNet);
document.getElementById('status').textContent =
`${new Date().toLocaleTimeString()} · ${data.networks.length} nets · ${(data.resources||[]).length} resources`;
} catch(e) {
document.getElementById('status').textContent = 'error: ' + e;
}
}
poll();
setInterval(poll, 500);
window.addEventListener('resize', () =>
Object.values(nets).forEach(st =>
st.sim.force('center', d3.forceCenter(
(document.getElementById('panel-' + Object.keys(nets).find(k => nets[k] === st))?.clientWidth || window.innerWidth) / 2,
(document.getElementById('panel-' + Object.keys(nets).find(k => nets[k] === st))?.clientHeight || window.innerHeight - 44) / 2
)).alpha(0.1).restart()));
</script>
</body>
</html>
)html";
// ── DebugHub ──────────────────────────────────────────────────────────────────
class DebugHub {
public:
explicit DebugHub(uint16_t port = 9090) : port_(port) {}
~DebugHub() { stop(); }
DebugHub(const DebugHub&) = delete;
DebugHub& operator=(const DebugHub&) = delete;
// Register a network. Disables that network's own web server so the hub
// becomes the single debug endpoint. Call before network.start().
template<typename Net>
void register_network(const std::string& name, Net& net) {
net.disable_web_server();
networks_.push_back({name, [&net, name] {
auto s = net.network_snapshot();
s.name = name;
return s;
}});
}
// Register a shared resource — appears in the "All Networks" resource panel.
void register_resource(const std::string& name, IResourceProbe* probe) {
resources_.emplace_back(name, probe);
}
void start() {
server_ = std::make_unique<web_debug::WebDebugServer>(
port_,
[this] { return build_json(); },
HUB_HTML);
server_->start();
std::cerr << "[kpn] hub debug UI: http://localhost:" << port_ << "\n";
}
void stop() { if (server_) server_->stop(); }
private:
// Serialise nodes array for one network snapshot
static void write_nodes(std::ostream& o, const NetworkSnapshot& s) {
o << "[";
for (std::size_t i = 0; i < s.nodes.size(); ++i) {
const auto& n = s.nodes[i];
if (i) o << ',';
o << "{\"id\":\"" << web_debug::escape_json(n.name) << "\""
<< ",\"frames\":" << n.frames_processed
<< ",\"ema_exec_ms\":" << n.ema_exec_ms
<< ",\"max_exec_ms\":" << n.max_exec_ms
<< ",\"blocked_ms\":" << n.total_blocked_ms
<< ",\"fps\":" << n.throughput_fps
<< ",\"total_cpu_ms\":" << n.total_cpu_ms
<< ",\"cpu_util_pct\":" << n.cpu_util_pct
<< "}";
}
o << "]";
}
// Serialise edges array for one network snapshot
static void write_edges(std::ostream& o, const NetworkSnapshot& s) {
o << "[";
for (std::size_t i = 0; i < s.channels.size(); ++i) {
const auto& c = s.channels[i];
if (i) o << ',';
auto [src, dst] = web_debug::parse_edge_name(c.name);
o << "{\"name\":\"" << web_debug::escape_json(c.name) << "\""
<< ",\"source\":\"" << web_debug::escape_json(src) << "\""
<< ",\"target\":\"" << web_debug::escape_json(dst) << "\""
<< ",\"capacity\":" << c.capacity
<< ",\"current\":" << c.current_fill
<< ",\"fill_pct\":" << c.fill_pct()
<< ",\"peak_pct\":" << c.peak_pct()
<< ",\"pushes\":" << c.pushes
<< ",\"drops\":" << c.drops
<< ",\"overflows\":" << c.overflows
<< ",\"item_bytes\":" << c.item_bytes
<< ",\"bw_mbs\":" << c.bandwidth_mbs(s.elapsed_s)
<< "}";
}
o << "]";
}
std::string build_json() const {
std::ostringstream o;
o << std::fixed;
o.precision(2);
o << "{\"networks\":[";
for (std::size_t i = 0; i < networks_.size(); ++i) {
if (i) o << ',';
auto s = networks_[i].fn();
o << "{\"name\":\"" << web_debug::escape_json(networks_[i].name) << "\""
<< ",\"nodes\":"; write_nodes(o, s);
o << ",\"edges\":"; write_edges(o, s);
o << "}";
}
o << "],\"resources\":[";
for (std::size_t i = 0; i < resources_.size(); ++i) {
if (i) o << ',';
const auto r = resources_[i].second->snapshot(resources_[i].first);
o << "{\"name\":\"" << web_debug::escape_json(r.name) << "\""
<< ",\"acquisitions\":" << r.acquisitions
<< ",\"avg_wait_ms\":" << r.avg_wait_ms
<< ",\"peak_waiters\":" << r.peak_waiters
<< ",\"current_waiters\":" << r.current_waiters
<< ",\"held\":" << (r.held ? "true" : "false")
<< "}";
}
o << "]}";
return o.str();
}
struct Entry {
std::string name;
std::function<NetworkSnapshot()> fn;
};
uint16_t port_;
std::vector<Entry> networks_;
std::vector<std::pair<std::string,IResourceProbe*>> resources_;
std::unique_ptr<web_debug::WebDebugServer> server_;
};
} // namespace kpn
#endif // KPN_WEB_DEBUG

View File

@ -15,6 +15,7 @@ using duration_t = std::chrono::duration<double, std::milli>; // milliseconds
struct ChannelStats {
std::atomic<uint64_t> pushes{0};
std::atomic<uint64_t> bytes_pushed{0};
std::atomic<uint64_t> drops{0};
std::atomic<uint64_t> overflows{0};
std::atomic<uint64_t> pops{0};
@ -24,8 +25,9 @@ struct ChannelStats {
ChannelStats(const ChannelStats&) = delete;
ChannelStats& operator=(const ChannelStats&) = delete;
void record_push(std::size_t current_fill) {
void record_push(std::size_t current_fill, std::size_t data_bytes) {
pushes.fetch_add(1, std::memory_order_relaxed);
bytes_pushed.fetch_add(data_bytes, std::memory_order_relaxed);
std::size_t prev = peak_fill.load(std::memory_order_relaxed);
while (current_fill > prev &&
!peak_fill.compare_exchange_weak(prev, current_fill,
@ -54,6 +56,12 @@ struct NodeStats {
// blocked on mutexes/channels. Sampled once per frame.
std::atomic<int64_t> total_cpu_us{0}; // cumulative CPU µs consumed
// Pool scheduling stats — only meaningful for PoolNode / InterruptNode.
// exec_start_us: wall-clock µs when fire_once began; 0 when idle.
// Used by the watchdog to detect hung nodes (elapsed > max_exec_time).
std::atomic<int64_t> queue_wait_us{0}; // cumulative µs spent in pool queue
std::atomic<int64_t> exec_start_us{0}; // non-zero while fire_once is running
NodeStats() = default;
NodeStats(const NodeStats&) = delete;
NodeStats& operator=(const NodeStats&) = delete;
@ -71,6 +79,11 @@ struct NodeStats {
+ static_cast<int64_t>(ts.tv_nsec) / 1'000;
}
void record_queue_wait(duration_t wait) {
int64_t us = static_cast<int64_t>(wait.count() * 1000.0);
if (us > 0) queue_wait_us.fetch_add(us, std::memory_order_relaxed);
}
void record_exec(duration_t exec_time, duration_t blocked_time,
const struct timespec& cpu_before, const struct timespec& cpu_after) {
frames_processed.fetch_add(1, std::memory_order_relaxed);
@ -105,10 +118,11 @@ struct ChannelSnapshot {
std::size_t current_fill;
std::size_t peak_fill;
uint64_t pushes;
uint64_t bytes_pushed; // actual bytes accumulated via channel_data_size<T>
uint64_t drops;
uint64_t overflows;
uint64_t pops;
std::size_t item_bytes; // sizeof(T) for the stored type — set by Channel<T>
std::size_t item_bytes; // sizeof(T) — nominal struct size, not necessarily data size
double fill_pct() const {
return capacity ? 100.0 * current_fill / capacity : 0.0;
@ -116,10 +130,10 @@ struct ChannelSnapshot {
double peak_pct() const {
return capacity ? 100.0 * peak_fill / capacity : 0.0;
}
// Bandwidth in MB/s: bytes transferred / elapsed seconds
// Bandwidth in MB/s: actual bytes transferred / elapsed seconds
double bandwidth_mbs(double elapsed_s) const {
if (elapsed_s <= 0.0 || item_bytes == 0) return 0.0;
return static_cast<double>(pushes * item_bytes) / elapsed_s / 1e6;
if (elapsed_s <= 0.0) return 0.0;
return static_cast<double>(bytes_pushed) / elapsed_s / 1e6;
}
};
@ -128,10 +142,52 @@ struct NodeSnapshot {
uint64_t frames_processed;
double ema_exec_ms;
double max_exec_ms;
double total_blocked_ms;
double total_blocked_ms; // ThreadPerNode: time blocked in channel pop
double throughput_fps;
double total_cpu_ms; // cumulative CPU time consumed by this node's thread
double cpu_util_pct; // exec_ms / (exec_ms + blocked_ms) * 100
double queue_wait_ms{0}; // PoolNode: cumulative time spent in pool queue
};
// ── Pool statistics + snapshot ────────────────────────────────────────────────
struct PoolSnapshot {
std::string name;
std::size_t thread_count;
std::size_t queue_depth; // tasks waiting in the priority queue
std::size_t active_count; // tasks currently executing
uint64_t tasks_submitted;
uint64_t tasks_completed;
};
struct IPoolProbe {
virtual ~IPoolProbe() = default;
virtual PoolSnapshot snapshot(const std::string& name) const = 0;
};
// ── Cross-network snapshot (used by DebugHub) ─────────────────────────────────
struct NetworkSnapshot {
std::string name;
std::vector<NodeSnapshot> nodes;
std::vector<ChannelSnapshot> channels;
double elapsed_s;
};
// ── Resource statistics + snapshot ───────────────────────────────────────────
struct ResourceSnapshot {
std::string name;
uint64_t acquisitions;
double avg_wait_ms;
uint64_t peak_waiters;
uint64_t current_waiters;
bool held;
};
struct IResourceProbe {
virtual ~IResourceProbe() = default;
virtual ResourceSnapshot snapshot(const std::string& name) const = 0;
};
} // namespace kpn

View File

@ -1,7 +1,7 @@
#pragma once
#include "channel.hpp"
#include "diagnostics.hpp"
#include "node.hpp"
#include "inode.hpp"
#include "port.hpp"
#include "traits.hpp"
@ -15,24 +15,6 @@
namespace kpn {
namespace detail {
// Produces std::tuple<T, T, ..., T> with N repetitions — used so that
// Network::connect can do its normal return_tuple type-check against FanoutNode.
template<typename T, std::size_t N, typename Seq = std::make_index_sequence<N>>
struct repeat_tuple;
template<typename T, std::size_t N, std::size_t... Is>
struct repeat_tuple<T, N, std::index_sequence<Is...>> {
template<std::size_t> using always_T = T;
using type = std::tuple<always_T<Is>...>;
};
template<typename T, std::size_t N>
using repeat_tuple_t = typename repeat_tuple<T, N>::type;
} // namespace detail
// ── FanoutNode ────────────────────────────────────────────────────────────────
//
// Reads one item from its single input channel and pushes a copy to each of
@ -48,7 +30,7 @@ template<typename T, std::size_t N, std::size_t Id = 0>
class FanoutNode : public INode {
public:
using args_tuple = std::tuple<T>;
using return_tuple = detail::repeat_tuple_t<T, N>;
using return_tuple = repeat_tuple_t<T, N>;
using return_raw = return_tuple;
static constexpr std::size_t input_count = 1;

45
include/kpn/inode.hpp Normal file
View File

@ -0,0 +1,45 @@
#pragma once
#include "diagnostics.hpp"
#include <chrono>
#include <functional>
#include <string>
#include <string_view>
namespace kpn {
// Called when a node's function throws. Return true to skip the failed
// invocation and keep running, false to stop the node.
using NodeErrorHandler = std::function<bool(std::string_view node_name, std::exception_ptr)>;
// Lightweight timestamp-only callback fired on per-node events.
// The node name is known at registration time so it is not included here.
using NodeEventCallback = std::function<void(std::chrono::steady_clock::time_point)>;
// Event types reported to the network-level aggregate callback.
enum class NodeEvent { Overflow, Closed };
// ── INode — type-erased interface for Network / watchdog ─────────────────────
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 name) = 0;
// Network-injected callbacks (slot 1 of each node's callback array).
// Default no-ops; overridden by PoolNode, PoolObjectNode, InterruptNode.
virtual void set_network_overflow_callback(NodeEventCallback) {}
virtual void set_network_closed_callback(NodeEventCallback) {}
// halt(): alias for stop() — immediate, discards in-flight work.
virtual void halt() { stop(); }
// shutdown(): graceful drain before stopping. Base implementation falls
// back to stop(). Network and StaticNetwork override with topo-ordered drain.
virtual void shutdown() { stop(); }
};
} // namespace kpn

View File

@ -0,0 +1,279 @@
#pragma once
#include "channel.hpp"
#include "diagnostics.hpp"
#include "fixed_string.hpp"
#include "inode.hpp"
#include "port.hpp"
#include "scheduler.hpp"
#include "traits.hpp"
#include <array>
#include <atomic>
#include <chrono>
#include <cstddef>
#include <functional>
#include <iostream>
#include <memory>
#include <string>
#include <tuple>
#include <type_traits>
namespace kpn {
// ── InterruptNode ─────────────────────────────────────────────────────────────
//
// A source node (zero inputs) driven by an external event — camera frame ready,
// timer tick, socket data, etc. — rather than self-submission.
//
// Usage:
// auto node = make_interrupt_node<produce_frame>(pool, out<"frame">{});
// camera_sdk.on_frame_ready(node.get_trigger()); // register with external source
// network.add("camera", node).connect(...).build().start();
//
// The trigger callable is safe to call from any thread, including signal handlers,
// provided the underlying scheduler's submit() is signal-safe. After fire_once()
// completes the node is idle until the next trigger fires — it does NOT busy-loop.
template<auto Func,
typename OutputTag = out<>,
fixed_string Label = "",
std::size_t UniqueTag = 0>
class InterruptNode;
template<auto Func, fixed_string... OutNames, fixed_string Label, std::size_t UniqueTag>
class InterruptNode<Func, out<OutNames...>, Label, UniqueTag> : public INode {
public:
using F = decltype(Func);
using return_raw = return_t<F>;
using return_tuple = normalised_return_t<return_raw>;
static_assert(arity_v<F> == 0,
"InterruptNode function must take no arguments (it has no input channels)");
static constexpr std::string_view label() { return Label.view(); }
static constexpr std::size_t unique_tag = UniqueTag;
static constexpr std::size_t input_count = 0;
static constexpr std::size_t output_count = std::tuple_size_v<return_tuple>;
static_assert(
sizeof...(OutNames) == 0 || sizeof...(OutNames) == output_count,
"make_interrupt_node: number of output names must match return tuple size, or provide none"
);
explicit InterruptNode(std::shared_ptr<IScheduler> sched, std::size_t fifo_capacity = 5)
: scheduler_(std::move(sched)), fifo_capacity_(fifo_capacity)
{}
~InterruptNode() override { stop(); }
// ── INode ─────────────────────────────────────────────────────────────────
void start() override {
stop_flag_.store(false, std::memory_order_relaxed);
pending_.store(0, std::memory_order_relaxed);
// Does NOT self-submit — waits for first external trigger.
}
void stop() override {
stop_flag_.store(true, std::memory_order_seq_cst);
// In-flight fire_once() observes stop_flag_ on its next check.
}
bool running() const override { return !stop_flag_.load(std::memory_order_relaxed); }
void set_name(std::string name) override { name_ = std::move(name); }
void set_error_handler(NodeErrorHandler h) { error_handler_ = std::move(h); }
void set_max_exec_time(std::chrono::milliseconds t) { max_exec_time_ = t; }
void set_overflow_callback(NodeEventCallback cb) { event_callbacks_[0] = std::move(cb); }
void set_network_overflow_callback(NodeEventCallback cb) override { event_callbacks_[1] = std::move(cb); }
void set_closed_callback(NodeEventCallback cb) { closed_callbacks_[0] = std::move(cb); }
void set_network_closed_callback(NodeEventCallback cb) override { closed_callbacks_[1] = std::move(cb); }
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 qwait_ms = stats_.queue_wait_us.load(std::memory_order_relaxed) / 1000.0;
double total_ms = exec_ms; // no blocked time for interrupt nodes
return {
name, frames, exec_ms,
stats_.max_exec_us.load(std::memory_order_relaxed) / 1000.0,
0.0, // blocked_ms — not applicable
elapsed_s > 0 ? frames / elapsed_s : 0.0,
stats_.total_cpu_us.load(std::memory_order_relaxed) / 1000.0,
total_ms > 0 ? 100.0 : 0.0,
qwait_ms,
};
}
// ── Port access — by index ────────────────────────────────────────────────
template<std::size_t I>
OutputPort<InterruptNode, I> output() {
static_assert(I < output_count, "output index out of range");
return {*this};
}
template<fixed_string Name>
auto output() {
constexpr std::size_t idx = index_of<Name, OutNames...>();
static_assert(idx != npos, "unknown output port name");
return output<idx>();
}
template<std::size_t I>
void set_output_channel(Channel<std::tuple_element_t<I, return_tuple>>* ch) {
std::get<I>(output_channels_) = ch;
}
// ── Trigger ───────────────────────────────────────────────────────────────
// Returns a callable that fires this node when called.
// Pass it to a camera SDK, timer, or any external event source.
// Thread-safe; may be called from any thread.
std::function<void()> get_trigger() {
return [this] { trigger(); };
}
private:
// Each trigger() increments pending_. When going 0→1 a task is submitted.
// Each fire_once() handles one pending event and decrements; if more remain
// (old value > 1) it resubmits itself. This guarantees every trigger produces
// exactly one execution even if triggers arrive faster than fire_once completes.
void trigger() {
if (stop_flag_.load(std::memory_order_relaxed)) return;
if (pending_.fetch_add(1, std::memory_order_acq_rel) == 0)
scheduler_->submit([this] { fire_once(); });
}
void fire_once() {
if (stop_flag_.load(std::memory_order_relaxed)) {
pending_.store(0, std::memory_order_release);
return;
}
auto t0 = clock_t::now();
int64_t now_us = std::chrono::duration_cast<std::chrono::microseconds>(
t0.time_since_epoch()).count();
stats_.exec_start_us.store(now_us, std::memory_order_relaxed);
bool fatal = false;
try {
auto t1 = clock_t::now();
stats_.record_queue_wait(duration_t(t1 - t0));
auto cpu0 = NodeStats::cpu_now();
if constexpr (std::is_void_v<return_raw>) {
Func();
} else {
auto result = Func();
push_outputs(normalise(std::move(result)),
std::make_index_sequence<output_count>{});
}
auto cpu1 = NodeStats::cpu_now();
auto t2 = clock_t::now();
stats_.record_exec(duration_t(t2 - t1), duration_t::zero(), cpu0, cpu1);
} catch (const ChannelOverflowError&) {
fire_callbacks(event_callbacks_);
} catch (...) {
if (!error_handler_ || !error_handler_(name_, std::current_exception()))
fatal = true;
}
stats_.exec_start_us.store(0, std::memory_order_relaxed);
if (fatal) {
fire_callbacks(closed_callbacks_);
disable_outputs(std::make_index_sequence<output_count>{});
pending_.store(0, std::memory_order_release);
stop_flag_.store(true, std::memory_order_relaxed);
return;
}
// Decrement and resubmit only if more triggers are queued.
// fetch_sub returns old value; old > 1 means new > 0.
if (pending_.fetch_sub(1, std::memory_order_acq_rel) > 1)
scheduler_->submit([this] { fire_once(); });
}
template<typename R = return_raw>
static return_tuple normalise(R&& r) {
if constexpr (is_tuple_v<R>) return std::move(r);
else return std::make_tuple(std::move(r));
}
template<std::size_t... Is>
void push_outputs(return_tuple&& result, std::index_sequence<Is...>) {
(push_one<Is>(std::get<Is>(std::move(result))), ...);
}
template<std::size_t I>
void push_one(std::tuple_element_t<I, return_tuple>&& val) {
auto* ch = std::get<I>(output_channels_);
if (!ch) return;
try { ch->push(std::move(val)); }
catch (const ChannelOverflowError&) {
throw ChannelOverflowError(ch->capacity(),
"interrupt node '" + name_ + "' " + output_port_label<I>());
}
}
template<std::size_t I>
static std::string output_port_label() {
if constexpr (sizeof...(OutNames) > 0) {
constexpr std::array<std::string_view, sizeof...(OutNames)> names{OutNames.view()...};
return std::string("output['") + std::string(names[I]) + "']";
} else {
return "output[" + std::to_string(I) + "]";
}
}
template<typename Tup, std::size_t... Is>
static auto make_output_channel_tuple(std::index_sequence<Is...>)
-> std::tuple<Channel<std::tuple_element_t<Is, Tup>>*...>;
using output_channels_t = decltype(make_output_channel_tuple<return_tuple>(
std::make_index_sequence<output_count>{}));
template<std::size_t... Is>
void disable_outputs(std::index_sequence<Is...>) {
auto disable_one = [](auto* ch) { if (ch) ch->disable(); };
(disable_one(std::get<Is>(output_channels_)), ...);
}
static void fire_callbacks(const std::array<NodeEventCallback, 2>& cbs) {
const auto ts = std::chrono::steady_clock::now();
for (auto& cb : cbs) if (cb) cb(ts);
}
std::shared_ptr<IScheduler> scheduler_;
std::string name_;
std::size_t fifo_capacity_;
output_channels_t output_channels_{};
std::atomic<bool> stop_flag_{true};
std::atomic<int> pending_{0};
NodeStats stats_;
NodeErrorHandler error_handler_;
std::chrono::milliseconds max_exec_time_{0};
std::array<NodeEventCallback, 2> event_callbacks_{}; // [0]=user [1]=network
std::array<NodeEventCallback, 2> closed_callbacks_{};
};
// ── make_interrupt_node factory ───────────────────────────────────────────────
template<auto Func, fixed_string Label = "", std::size_t UniqueTag = 0>
auto make_interrupt_node(std::shared_ptr<IScheduler> sched, std::size_t fifo_capacity = 5) {
return InterruptNode<Func, out<>, Label, UniqueTag>(std::move(sched), fifo_capacity);
}
template<auto Func, fixed_string Label = "", std::size_t UniqueTag = 0,
fixed_string... OutNames>
auto make_interrupt_node(std::shared_ptr<IScheduler> sched, out<OutNames...>,
std::size_t fifo_capacity = 5) {
return InterruptNode<Func, out<OutNames...>, Label, UniqueTag>(
std::move(sched), fifo_capacity);
}
} // namespace kpn

View File

@ -5,8 +5,15 @@
#include "traits.hpp"
#include "channel.hpp"
#include "port.hpp"
#include "inode.hpp"
#include "scheduler.hpp"
#include "pool_node.hpp"
#include "interrupt_node.hpp"
#include "node.hpp"
#include "fanout.hpp"
#include "branch.hpp"
#include "shared_resource.hpp"
#include "static_network.hpp"
#include "debug_hub.hpp"
#include "main_thread_node.hpp"
#include "network.hpp"

View File

@ -2,7 +2,7 @@
#include "channel.hpp"
#include "diagnostics.hpp"
#include "fixed_string.hpp"
#include "node.hpp" // INode
#include "inode.hpp"
#include "port.hpp"
#include <atomic>

View File

@ -1,6 +1,6 @@
#pragma once
#include "diagnostics.hpp"
#include "node.hpp"
#include "inode.hpp"
#include "port.hpp"
#ifdef KPN_WEB_DEBUG
@ -44,6 +44,9 @@ public:
using DiagnosticsHandler =
std::function<void(const std::vector<NodeSnapshot>&,
const std::vector<ChannelSnapshot>&)>;
using EventHandler =
std::function<void(std::string_view node_name, NodeEvent,
std::chrono::steady_clock::time_point)>;
// ── Builder API ───────────────────────────────────────────────────────────
@ -111,6 +114,19 @@ public:
for (auto& [name, _] : nodes_)
if (color[name] == 0)
dfs(name, color);
if (event_handler_) {
for (auto& name : topo_) {
auto* node = nodes_.at(name);
node->set_network_overflow_callback(
[this, n = name](auto ts) {
event_handler_(n, NodeEvent::Overflow, ts);
});
node->set_network_closed_callback(
[this, n = name](auto ts) {
event_handler_(n, NodeEvent::Closed, ts);
});
}
}
return *this;
}
@ -126,14 +142,17 @@ public:
web_debug_port_,
[this]() {
auto s = collect_snapshots();
return web_debug::to_json(s.nodes, s.channels, s.elapsed_s);
return web_debug::to_json(s.nodes, s.channels, {}, s.elapsed_s, s.pools);
});
web_server_->start();
std::cerr << "[kpn] web debug UI: http://localhost:" << web_debug_port_ << "\n";
#endif
}
void stop() override {
void stop() override { halt(); }
// halt(): immediate stop — broadcasts disable to all channels and joins threads.
void halt() override {
#ifdef KPN_WEB_DEBUG
if (web_server_) web_server_->stop();
#endif
@ -142,6 +161,37 @@ public:
nodes_.at(*it)->stop();
}
// shutdown(): graceful drain in topological order.
// Stops source nodes first, polls until their output channels drain to zero,
// then stops the next layer, and so on.
void shutdown() override {
#ifdef KPN_WEB_DEBUG
if (web_server_) web_server_->stop();
#endif
stop_watchdog();
// Identify which nodes have no incoming edges (sources).
std::map<std::string, std::size_t> in_degree;
for (auto& [name, _] : nodes_) in_degree[name] = 0;
for (auto& [src, dsts] : adj_)
for (auto& dst : dsts) in_degree[dst]++;
// Walk topo order: stop each source layer, wait for its output channels
// to drain, then proceed to the next layer.
std::set<std::string> stopped;
for (auto& name : topo_) {
if (in_degree[name] == 0 || all_predecessors_stopped(name, stopped)) {
nodes_.at(name)->stop();
stopped.insert(name);
// Wait for output channels of this node to drain.
drain_output_channels(name);
}
}
// Stop any remaining nodes (sinks / nodes not yet stopped).
for (auto it = topo_.rbegin(); it != topo_.rend(); ++it)
if (!stopped.count(*it)) nodes_.at(*it)->stop();
}
bool running() const override { return watchdog_.joinable(); }
void set_name(std::string) override {}
@ -162,6 +212,11 @@ public:
void set_error_handler(ErrorHandler h) { error_handler_ = std::move(h); }
void set_diagnostics_handler(DiagnosticsHandler h) { diag_handler_ = std::move(h); }
void set_event_handler(EventHandler h) { event_handler_ = std::move(h); }
void register_pool(const std::string& name, IPoolProbe* probe) {
pool_probes_.emplace_back(name, probe);
}
#ifdef KPN_WEB_DEBUG
void set_web_debug_port(uint16_t port) { web_debug_port_ = port; }
@ -171,7 +226,7 @@ public:
// Can be called at any time; thread-safe (reads atomics with relaxed ordering).
void print_diagnostics(std::ostream& os = std::cerr) const {
auto s = collect_snapshots();
os << format_report(s.nodes, s.channels, s.elapsed_s);
os << format_report(s.nodes, s.channels, s.pools, s.elapsed_s);
}
private:
@ -180,6 +235,7 @@ private:
struct Snapshots {
std::vector<NodeSnapshot> nodes;
std::vector<ChannelSnapshot> channels;
std::vector<PoolSnapshot> pools;
double elapsed_s;
};
@ -195,11 +251,16 @@ private:
for (auto& probe : channel_probes_)
channels.push_back(probe->snapshot());
return {std::move(nodes), std::move(channels), elapsed_s};
std::vector<PoolSnapshot> pools;
for (auto& [name, probe] : pool_probes_)
pools.push_back(probe->snapshot(name));
return {std::move(nodes), std::move(channels), std::move(pools), elapsed_s};
}
static std::string format_report(const std::vector<NodeSnapshot>& nodes,
const std::vector<ChannelSnapshot>& channels,
const std::vector<PoolSnapshot>& pools = {},
double elapsed_s = 0.0) {
std::ostringstream os;
os << std::fixed << std::setprecision(1);
@ -258,6 +319,31 @@ private:
<< flag << "\n";
}
// Pool table
if (!pools.empty()) {
os << "\n│ Thread Pools:\n";
os << "" << std::left
<< std::setw(16) << "name"
<< std::setw(10) << "threads"
<< std::setw(12) << "queued"
<< std::setw(12) << "active"
<< std::setw(14) << "in/s"
<< std::setw(14) << "out/s"
<< "\n" << std::string(78, '-') << "\n";
for (auto& p : pools) {
double in_rate = elapsed_s > 0.0 ? p.tasks_submitted / elapsed_s : 0.0;
double out_rate = elapsed_s > 0.0 ? p.tasks_completed / elapsed_s : 0.0;
os << "" << std::left
<< std::setw(16) << p.name
<< std::setw(10) << p.thread_count
<< std::setw(12) << p.queue_depth
<< std::setw(12) << p.active_count
<< std::setw(14) << in_rate
<< std::setw(14) << out_rate
<< "\n";
}
}
// Bottleneck hint: node with highest ema_exec_ms
if (!nodes.empty()) {
auto it = std::max_element(nodes.begin(), nodes.end(),
@ -271,6 +357,31 @@ private:
return os.str();
}
// ── Shutdown helpers ──────────────────────────────────────────────────────
bool all_predecessors_stopped(const std::string& name,
const std::set<std::string>& stopped) const {
for (auto& [src, dsts] : adj_)
for (auto& dst : dsts)
if (dst == name && !stopped.count(src)) return false;
return true;
}
void drain_output_channels(const std::string& /*name*/) const {
// Poll all channel probes until none report non-zero fill.
// A short sleep prevents busy-spin; 1 ms is fine for drain purposes.
bool any_full = true;
while (any_full) {
any_full = false;
for (auto& probe : channel_probes_) {
auto snap = probe->snapshot();
if (snap.current_fill > 0) { any_full = true; break; }
}
if (any_full)
std::this_thread::sleep_for(std::chrono::milliseconds(1));
}
}
// ── Cycle detection / topological sort ───────────────────────────────────
void dfs(const std::string& name, std::map<std::string, int>& color) {
@ -292,16 +403,33 @@ private:
if (tok.stop_requested()) break;
auto s = collect_snapshots();
check_hung_nodes();
if (diag_handler_) {
diag_handler_(s.nodes, s.channels);
} else {
std::cerr << format_report(s.nodes, s.channels, s.elapsed_s);
std::cerr << format_report(s.nodes, s.channels, s.pools, s.elapsed_s);
}
}
});
}
void check_hung_nodes() const {
auto now_us = std::chrono::duration_cast<std::chrono::microseconds>(
clock_t::now().time_since_epoch()).count();
for (auto& [name, node] : nodes_) {
int64_t start = node->stats().exec_start_us.load(std::memory_order_relaxed);
if (start == 0) continue;
int64_t elapsed_ms = (now_us - start) / 1000;
// Warn if a node has been executing for > 5 s with no max_exec_time set,
// or if it exceeds its configured max. Threshold: 5000 ms default.
if (elapsed_ms > 5000) {
std::cerr << "[kpn] WARNING: node '" << name
<< "' has been executing for " << elapsed_ms << " ms\n";
}
}
}
void stop_watchdog() {
if (watchdog_.joinable())
watchdog_.request_stop(), watchdog_.join();
@ -316,8 +444,10 @@ private:
std::map<std::string, std::string> exposed_outputs_;
std::set<std::pair<std::string, std::size_t>> connected_outputs_;
std::vector<std::unique_ptr<IChannelProbe>> channel_probes_;
std::vector<std::pair<std::string, IPoolProbe*>> pool_probes_;
ErrorHandler error_handler_;
DiagnosticsHandler diag_handler_;
EventHandler event_handler_;
std::chrono::milliseconds watchdog_interval_{3000};
std::jthread watchdog_;
clock_t::time_point start_time_;

View File

@ -1,41 +1,29 @@
#pragma once
#include "channel.hpp"
#include "diagnostics.hpp"
#include "fixed_string.hpp"
#include "port.hpp"
#include "traits.hpp"
#include "inode.hpp"
#include "pool_node.hpp" // PoolNode, PoolObjectNode
#include <array>
#include <atomic>
#include <chrono>
#include <cstddef>
#include <iostream>
#include <memory>
#include <stdexcept>
#include <thread>
#include <tuple>
#include <type_traits>
// node.hpp — Node<> and ObjectNode<> as thin wrappers over PoolNode<>.
//
// Each Node owns a private single-thread ThreadPool so the API is unchanged:
// node.start() / node.stop() are self-contained with no external scheduler.
// Internally, all execution goes through PoolNode::fire_once() — the same code
// path as explicitly pool-scheduled nodes.
//
// To share a thread pool across multiple nodes, use make_pool_node() directly.
namespace kpn {
// ── INode — type-erased interface for Network / watchdog ─────────────────────
namespace detail {
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 name) = 0;
// Private base initialized before PoolNode so its pool can be passed to the
// PoolNode constructor (C++ initialises bases left-to-right).
struct NodePrivatePool {
std::shared_ptr<ThreadPool> pool{std::make_shared<ThreadPool>(1)};
};
} // namespace detail
// ── Node ─────────────────────────────────────────────────────────────────────
//
// Template parameters:
// Func — the wrapped function (auto NTTP, deduced as a function pointer)
// InputNames — optional kpn::in<"a","b"> tag type (at most one)
// OutputNames — optional kpn::out<"x","y"> tag type (at most one)
template<auto Func,
typename InputTag = in<>,
@ -44,259 +32,25 @@ template<auto Func,
std::size_t UniqueTag = 0>
class Node;
// Specialisation that unpacks the in<>/out<> tag packs
template<auto Func, fixed_string... InNames, fixed_string... OutNames,
fixed_string Label, std::size_t UniqueTag>
class Node<Func, in<InNames...>, out<OutNames...>, Label, UniqueTag> : public INode {
class Node<Func, in<InNames...>, out<OutNames...>, Label, UniqueTag>
: private detail::NodePrivatePool
, public PoolNode<Func, in<InNames...>, out<OutNames...>, Label, UniqueTag> {
using Base = PoolNode<Func, in<InNames...>, out<OutNames...>, Label, UniqueTag>;
public:
using F = decltype(Func);
using args_tuple = args_t<F>;
using return_raw = return_t<F>;
using return_tuple = normalised_return_t<return_raw>;
// Identity accessors — used by StaticNetwork for diagnostics and type-level uniqueness
static constexpr std::string_view label() { return Label.view(); }
static constexpr std::size_t unique_tag = UniqueTag;
static constexpr std::size_t input_count = arity_v<F>;
static constexpr std::size_t output_count = std::tuple_size_v<return_tuple>;
static_assert(
sizeof...(InNames) == 0 || sizeof...(InNames) == input_count,
"make_node: number of input names must match function arity, or provide none"
);
static_assert(
sizeof...(OutNames) == 0 || sizeof...(OutNames) == output_count,
"make_node: number of output names must match return tuple size, or provide none"
);
explicit Node(std::size_t fifo_capacity = 5)
: fifo_capacity_(fifo_capacity)
{
init_input_channels(std::make_index_sequence<input_count>{});
}
: detail::NodePrivatePool{}
, Base(pool, fifo_capacity)
{}
~Node() override { stop(); }
// ── INode ─────────────────────────────────────────────────────────────────
void start() override {
enable_inputs(std::make_index_sequence<input_count>{});
stop_flag_.store(false, std::memory_order_relaxed);
thread_ = std::jthread([this](std::stop_token) { run_loop(); });
}
void stop() override {
stop_flag_.store(true, std::memory_order_relaxed);
// Disable all input channels: drops queued items and unblocks waiting pop()
disable_inputs(std::make_index_sequence<input_count>{});
if (thread_.joinable()) thread_.request_stop(), thread_.join();
}
bool running() const override {
return thread_.joinable() && !stop_flag_.load(std::memory_order_relaxed);
}
void set_name(std::string name) override { name_ = std::move(name); }
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,
};
}
// ── Port access — by index ────────────────────────────────────────────────
template<std::size_t I>
InputPort<Node, I> input() {
static_assert(I < input_count, "input index out of range");
return {*this};
}
template<std::size_t I>
OutputPort<Node, I> output() {
static_assert(I < output_count, "output index out of range");
return {*this};
}
// ── Port access — by name ─────────────────────────────────────────────────
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>();
}
template<fixed_string Name>
auto output() {
constexpr std::size_t idx = index_of<Name, OutNames...>();
static_assert(idx != npos, "unknown output port name");
return output<idx>();
}
// ── Internal channel accessors (used by Network at connect time) ──────────
template<std::size_t I>
Channel<std::tuple_element_t<I, args_tuple>>& input_channel() {
return *std::get<I>(input_channels_);
}
// Replace the owned input channel with an externally provided one.
// Used by VariantNodeWrapper to share a Channel<T> with a VariantChannel adapter.
template<std::size_t I>
void set_input_channel(
std::shared_ptr<Channel<std::tuple_element_t<I, args_tuple>>> ch) {
std::get<I>(input_channels_) = std::move(ch);
}
template<std::size_t I>
void set_output_channel(
Channel<std::tuple_element_t<I, return_tuple>>* ch) {
std::get<I>(output_channels_) = ch;
}
private:
// ── Channel storage ───────────────────────────────────────────────────────
// Input channels — shared ownership so VariantChannel adapters can share them
template<std::size_t... Is>
void init_input_channels(std::index_sequence<Is...>) {
((std::get<Is>(input_channels_) =
std::make_shared<Channel<std::tuple_element_t<Is, args_tuple>>>(fifo_capacity_)),
...);
}
template<std::size_t... Is>
void enable_inputs(std::index_sequence<Is...>) {
(std::get<Is>(input_channels_)->enable(), ...);
}
template<std::size_t... Is>
void disable_inputs(std::index_sequence<Is...>) {
(std::get<Is>(input_channels_)->disable(), ...);
}
template<typename Tup, std::size_t... Is>
static auto make_input_channel_tuple(std::index_sequence<Is...>)
-> std::tuple<std::shared_ptr<Channel<std::tuple_element_t<Is, Tup>>>...>;
using input_channels_t = decltype(make_input_channel_tuple<args_tuple>(
std::make_index_sequence<input_count>{}));
// Output channels — non-owning pointers, set at connect time
template<typename Tup, std::size_t... Is>
static auto make_output_channel_tuple(std::index_sequence<Is...>)
-> std::tuple<Channel<std::tuple_element_t<Is, Tup>>*...>;
using output_channels_t = decltype(make_output_channel_tuple<return_tuple>(
std::make_index_sequence<output_count>{}));
// ── run_loop ──────────────────────────────────────────────────────────────
void run_loop() {
while (!stop_flag_.load(std::memory_order_relaxed)) {
try {
auto t0 = clock_t::now();
auto args = pop_inputs(std::make_index_sequence<input_count>{});
auto t1 = clock_t::now();
auto cpu0 = NodeStats::cpu_now();
if constexpr (std::is_void_v<return_raw>) {
std::apply(Func, args);
} else {
auto result = std::apply(Func, args);
push_outputs(normalise(std::move(result)),
std::make_index_sequence<output_count>{});
}
auto cpu1 = NodeStats::cpu_now();
auto t2 = clock_t::now();
stats_.record_exec(duration_t(t2 - t1), duration_t(t1 - t0), cpu0, cpu1);
} catch (const ChannelClosedError&) {
break;
} catch (const ChannelOverflowError& e) {
std::cerr << "[kpn] overflow: " << e.what() << "\n";
}
}
}
// Pop all inputs into a tuple of argument values
template<std::size_t... Is>
args_tuple pop_inputs(std::index_sequence<Is...>) {
return {std::get<Is>(input_channels_)->pop()...};
}
// Normalise return value to tuple (handles void and single-value returns)
template<typename R = return_raw>
static return_tuple normalise(R&& r) {
if constexpr (is_tuple_v<R>)
return std::move(r);
else
return std::make_tuple(std::move(r));
}
static return_tuple normalise_void() { return {}; }
// Push each output element to its connected channel (if connected)
template<std::size_t... Is>
void push_outputs(return_tuple&& result, std::index_sequence<Is...>) {
(push_one<Is>(std::get<Is>(std::move(result))), ...);
}
template<std::size_t I>
void push_one(std::tuple_element_t<I, return_tuple>&& val) {
auto* ch = std::get<I>(output_channels_);
if (!ch) return;
try {
ch->push(std::move(val));
} catch (const ChannelOverflowError&) {
throw ChannelOverflowError(ch->capacity(), "node '" + name_ + "' " + output_port_label<I>());
}
}
template<std::size_t I>
static std::string output_port_label() {
if constexpr (sizeof...(OutNames) > 0) {
constexpr std::array<std::string_view, sizeof...(OutNames)> names{OutNames.view()...};
return std::string("output['") + std::string(names[I]) + "']";
} else {
return "output[" + std::to_string(I) + "]";
}
}
// ── State ─────────────────────────────────────────────────────────────────
std::string name_;
std::size_t fifo_capacity_;
input_channels_t input_channels_;
output_channels_t output_channels_{};
std::atomic<bool> stop_flag_{false};
std::jthread thread_;
NodeStats stats_;
void start() override { pool->start(); Base::start(); }
void stop() override { Base::stop(); pool->stop(); }
};
// ── ObjectNode — wraps a callable object (functor / class with operator()) ────
//
// Use this when the node needs state initialised in a constructor.
// The object must outlive the ObjectNode (stored by reference).
//
// Usage:
// MyFunctor obj(...);
// auto node = make_node(obj, in<"x">{}, out<"y">{}, capacity);
// ── ObjectNode ────────────────────────────────────────────────────────────────
template<typename Obj,
typename InputTag = in<>,
@ -307,222 +61,20 @@ class ObjectNode;
template<typename Obj, fixed_string... InNames, fixed_string... OutNames,
fixed_string Label, std::size_t UniqueTag>
class ObjectNode<Obj, in<InNames...>, out<OutNames...>, Label, UniqueTag> : public INode {
class ObjectNode<Obj, in<InNames...>, out<OutNames...>, Label, UniqueTag>
: private detail::NodePrivatePool
, public PoolObjectNode<Obj, in<InNames...>, out<OutNames...>, Label, UniqueTag> {
using Base = PoolObjectNode<Obj, in<InNames...>, out<OutNames...>, Label, UniqueTag>;
public:
using F = decltype(&Obj::operator());
using args_tuple = args_t<F>;
using return_raw = return_t<F>;
using return_tuple = normalised_return_t<return_raw>;
static constexpr std::string_view label() { return Label.view(); }
static constexpr std::size_t unique_tag = UniqueTag;
static constexpr std::size_t input_count = arity_v<F>;
static constexpr std::size_t output_count = std::tuple_size_v<return_tuple>;
static_assert(
sizeof...(InNames) == 0 || sizeof...(InNames) == input_count,
"make_node: number of input names must match operator() arity, or provide none"
);
static_assert(
sizeof...(OutNames) == 0 || sizeof...(OutNames) == output_count,
"make_node: number of output names must match return tuple size, or provide none"
);
explicit ObjectNode(Obj& obj, std::size_t fifo_capacity = 5)
: obj_(obj), fifo_capacity_(fifo_capacity)
{
init_input_channels(std::make_index_sequence<input_count>{});
}
: detail::NodePrivatePool{}
, Base(obj, pool, fifo_capacity)
{}
~ObjectNode() override { stop(); }
// ── INode ─────────────────────────────────────────────────────────────────
void start() override {
enable_inputs(std::make_index_sequence<input_count>{});
stop_flag_.store(false, std::memory_order_relaxed);
thread_ = std::jthread([this](std::stop_token) { run_loop(); });
}
void stop() override {
stop_flag_.store(true, std::memory_order_relaxed);
disable_inputs(std::make_index_sequence<input_count>{});
if (thread_.joinable()) thread_.request_stop(), thread_.join();
}
bool running() const override {
return thread_.joinable() && !stop_flag_.load(std::memory_order_relaxed);
}
void set_name(std::string name) override { name_ = std::move(name); }
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,
};
}
// ── Port access ───────────────────────────────────────────────────────────
template<std::size_t I>
InputPort<ObjectNode, I> input() {
static_assert(I < input_count, "input index out of range");
return {*this};
}
template<std::size_t I>
OutputPort<ObjectNode, I> output() {
static_assert(I < output_count, "output index out of range");
return {*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>();
}
template<fixed_string Name>
auto output() {
constexpr std::size_t idx = index_of<Name, OutNames...>();
static_assert(idx != npos, "unknown output port name");
return output<idx>();
}
template<std::size_t I>
Channel<std::tuple_element_t<I, args_tuple>>& input_channel() {
return *std::get<I>(input_channels_);
}
template<std::size_t I>
void set_input_channel(
std::shared_ptr<Channel<std::tuple_element_t<I, args_tuple>>> ch) {
std::get<I>(input_channels_) = std::move(ch);
}
template<std::size_t I>
void set_output_channel(Channel<std::tuple_element_t<I, return_tuple>>* ch) {
std::get<I>(output_channels_) = ch;
}
private:
template<std::size_t... Is>
void init_input_channels(std::index_sequence<Is...>) {
((std::get<Is>(input_channels_) =
std::make_shared<Channel<std::tuple_element_t<Is, args_tuple>>>(fifo_capacity_)),
...);
}
template<std::size_t... Is>
void enable_inputs(std::index_sequence<Is...>) {
(std::get<Is>(input_channels_)->enable(), ...);
}
template<std::size_t... Is>
void disable_inputs(std::index_sequence<Is...>) {
(std::get<Is>(input_channels_)->disable(), ...);
}
template<typename Tup, std::size_t... Is>
static auto make_input_channel_tuple(std::index_sequence<Is...>)
-> std::tuple<std::shared_ptr<Channel<std::tuple_element_t<Is, Tup>>>...>;
using input_channels_t = decltype(make_input_channel_tuple<args_tuple>(
std::make_index_sequence<input_count>{}));
template<typename Tup, std::size_t... Is>
static auto make_output_channel_tuple(std::index_sequence<Is...>)
-> std::tuple<Channel<std::tuple_element_t<Is, Tup>>*...>;
using output_channels_t = decltype(make_output_channel_tuple<return_tuple>(
std::make_index_sequence<output_count>{}));
void run_loop() {
while (!stop_flag_.load(std::memory_order_relaxed)) {
try {
auto t0 = clock_t::now();
auto args = pop_inputs(std::make_index_sequence<input_count>{});
auto t1 = clock_t::now();
auto cpu0 = NodeStats::cpu_now();
if constexpr (std::is_void_v<return_raw>) {
std::apply([this](auto&&... a) { obj_(std::forward<decltype(a)>(a)...); }, args);
} else {
auto result = std::apply([this](auto&&... a) { return obj_(std::forward<decltype(a)>(a)...); }, args);
push_outputs(normalise(std::move(result)),
std::make_index_sequence<output_count>{});
}
auto cpu1 = NodeStats::cpu_now();
auto t2 = clock_t::now();
stats_.record_exec(duration_t(t2 - t1), duration_t(t1 - t0), cpu0, cpu1);
} catch (const ChannelClosedError&) {
break;
} catch (const ChannelOverflowError& e) {
std::cerr << "[kpn] overflow: " << e.what() << "\n";
}
}
}
template<std::size_t... Is>
args_tuple pop_inputs(std::index_sequence<Is...>) {
return {std::get<Is>(input_channels_)->pop()...};
}
template<typename R = return_raw>
static return_tuple normalise(R&& r) {
if constexpr (is_tuple_v<R>) return std::move(r);
else return std::make_tuple(std::move(r));
}
template<std::size_t... Is>
void push_outputs(return_tuple&& result, std::index_sequence<Is...>) {
(push_one<Is>(std::get<Is>(std::move(result))), ...);
}
template<std::size_t I>
void push_one(std::tuple_element_t<I, return_tuple>&& val) {
auto* ch = std::get<I>(output_channels_);
if (!ch) return;
try {
ch->push(std::move(val));
} catch (const ChannelOverflowError&) {
throw ChannelOverflowError(ch->capacity(), "node '" + name_ + "' " + output_port_label<I>());
}
}
template<std::size_t I>
static std::string output_port_label() {
if constexpr (sizeof...(OutNames) > 0) {
constexpr std::array<std::string_view, sizeof...(OutNames)> names{OutNames.view()...};
return std::string("output['") + std::string(names[I]) + "']";
} else {
return "output[" + std::to_string(I) + "]";
}
}
Obj& obj_;
std::string name_;
std::size_t fifo_capacity_;
input_channels_t input_channels_;
output_channels_t output_channels_{};
std::atomic<bool> stop_flag_{false};
std::jthread thread_;
NodeStats stats_;
void start() override { pool->start(); Base::start(); }
void stop() override { Base::stop(); pool->stop(); }
};
// ── make_node overloads for callable objects ──────────────────────────────────
@ -548,35 +100,24 @@ auto make_node(Obj& obj, in<InNames...>, out<OutNames...>, std::size_t fifo_capa
}
// ── make_node factory (NTTP) ──────────────────────────────────────────────────
//
// Usage:
// make_node<func>(capacity)
// make_node<func, "label">(capacity)
// make_node<func, "label", 1>(capacity) // UniqueTag=1
// make_node<func>(in<"a","b">{}, capacity)
// make_node<func, "label">(in<"a","b">{}, out<"x">{}, capacity)
// No port names
template<auto Func, fixed_string Label = "", std::size_t UniqueTag = 0>
auto make_node(std::size_t fifo_capacity = 5) {
return Node<Func, in<>, out<>, Label, UniqueTag>(fifo_capacity);
}
// in<> only
template<auto Func, fixed_string Label = "", std::size_t UniqueTag = 0,
fixed_string... InNames>
auto make_node(in<InNames...>, std::size_t fifo_capacity = 5) {
return Node<Func, in<InNames...>, out<>, Label, UniqueTag>(fifo_capacity);
}
// out<> only
template<auto Func, fixed_string Label = "", std::size_t UniqueTag = 0,
fixed_string... OutNames>
auto make_node(out<OutNames...>, std::size_t fifo_capacity = 5) {
return Node<Func, in<>, out<OutNames...>, Label, UniqueTag>(fifo_capacity);
}
// in<> and out<>
template<auto Func, fixed_string Label = "", std::size_t UniqueTag = 0,
fixed_string... InNames, fixed_string... OutNames>
auto make_node(in<InNames...>, out<OutNames...>, std::size_t fifo_capacity = 5) {

789
include/kpn/pool_node.hpp Normal file
View File

@ -0,0 +1,789 @@
#pragma once
#include "channel.hpp"
#include "diagnostics.hpp"
#include "fixed_string.hpp"
#include "inode.hpp"
#include "port.hpp"
#include "scheduler.hpp"
#include "traits.hpp"
#include <array>
#include <atomic>
#include <chrono>
#include <cstddef>
#include <functional>
#include <iostream>
#include <memory>
#include <optional>
#include <stdexcept>
#include <thread>
#include <tuple>
#include <type_traits>
namespace kpn {
// ── Sentinel detection ────────────────────────────────────────────────────────
// A value is a "sentinel" (must-deliver control token, e.g. EOF) if its type
// carries a bool-convertible eof flag — either directly (`v.eof`, as on a raw
// source Frame) or nested one level under a `.source` member (`v.source.eof`,
// as on the pipeline's SceneFrame/…/MatchedSceneFrame message types, which wrap
// the originating Frame). Sentinels are delivered losslessly and non-blockingly
// via Channel::push_sentinel() instead of the throwing push(), so backpressure
// can never drop the token that unblocks downstream teardown.
//
// Types with neither shape are never treated as sentinels — both traits are
// SFINAE-safe and the runtime check compiles away to `false` for them, so this
// stays a no-op for pipelines that don't use an eof convention.
template<typename T, typename = void>
struct has_eof_field : std::false_type {};
template<typename T>
struct has_eof_field<T, std::void_t<decltype(static_cast<bool>(std::declval<const T&>().eof))>>
: std::true_type {};
template<typename T, typename = void>
struct has_source_eof_field : std::false_type {};
template<typename T>
struct has_source_eof_field<T,
std::void_t<decltype(static_cast<bool>(std::declval<const T&>().source.eof))>>
: std::true_type {};
template<typename T>
constexpr bool is_sentinel_value(const T& v) {
if constexpr (has_eof_field<T>::value) return static_cast<bool>(v.eof);
else if constexpr (has_source_eof_field<T>::value) return static_cast<bool>(v.source.eof);
else return false;
}
// ── PoolNode ──────────────────────────────────────────────────────────────────
//
// Reactive alternative to Node<>. Instead of owning a blocked thread, the node
// is submitted to a shared IScheduler whenever all its input channels become
// non-empty. A single fire_once() call pops all inputs, executes the function,
// and pushes outputs. At most one fire_once() runs at a time (queued_ flag).
//
// Source nodes (input_count == 0) submit themselves immediately on start() and
// resubmit after each fire_once().
//
// Multiple PoolNodes can share one ThreadPool for resource-bounded execution,
// or each can have a dedicated single-thread pool for serialisation.
template<auto Func,
typename InputTag = in<>,
typename OutputTag = out<>,
fixed_string Label = "",
std::size_t UniqueTag = 0>
class PoolNode;
template<auto Func, fixed_string... InNames, fixed_string... OutNames,
fixed_string Label, std::size_t UniqueTag>
class PoolNode<Func, in<InNames...>, out<OutNames...>, Label, UniqueTag> : public INode {
public:
using F = decltype(Func);
using args_tuple = args_t<F>;
using return_raw = return_t<F>;
using return_tuple = normalised_return_t<return_raw>;
static constexpr std::string_view label() { return Label.view(); }
static constexpr std::size_t unique_tag = UniqueTag;
static constexpr std::size_t input_count = arity_v<F>;
static constexpr std::size_t output_count = std::tuple_size_v<return_tuple>;
static_assert(
sizeof...(InNames) == 0 || sizeof...(InNames) == input_count,
"make_pool_node: number of input names must match function arity, or provide none"
);
static_assert(
sizeof...(OutNames) == 0 || sizeof...(OutNames) == output_count,
"make_pool_node: number of output names must match return tuple size, or provide none"
);
explicit PoolNode(std::shared_ptr<IScheduler> sched, std::size_t fifo_capacity = 5)
: scheduler_(std::move(sched)), fifo_capacity_(fifo_capacity)
{
init_input_channels(std::make_index_sequence<input_count>{});
}
~PoolNode() override { stop(); }
// ── INode ─────────────────────────────────────────────────────────────────
void start() override {
enable_inputs(std::make_index_sequence<input_count>{});
stop_flag_.store(false, std::memory_order_relaxed);
queued_.store(false, std::memory_order_relaxed);
register_callbacks(std::make_index_sequence<input_count>{});
if constexpr (input_count == 0)
try_submit(0.5f);
}
void stop() override {
stop_flag_.store(true, std::memory_order_seq_cst);
disable_inputs(std::make_index_sequence<input_count>{});
// fire_once() observes stop_flag_ and will not resubmit.
// We do not wait for an in-flight fire_once() to complete here;
// callers that need that guarantee should call scheduler_->drain() first.
}
bool running() const override {
return !stop_flag_.load(std::memory_order_relaxed);
}
void set_name(std::string name) override { name_ = std::move(name); }
void set_error_handler(NodeErrorHandler h) { error_handler_ = std::move(h); }
void set_max_exec_time(std::chrono::milliseconds t) { max_exec_time_ = t; }
void set_overflow_callback(NodeEventCallback cb) { event_callbacks_[0] = std::move(cb); }
void set_network_overflow_callback(NodeEventCallback cb) override { event_callbacks_[1] = std::move(cb); }
void set_closed_callback(NodeEventCallback cb) { closed_callbacks_[0] = std::move(cb); }
void set_network_closed_callback(NodeEventCallback cb) override { closed_callbacks_[1] = std::move(cb); }
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 qwait_ms = stats_.queue_wait_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,
qwait_ms,
};
}
// ── Port access — by index ────────────────────────────────────────────────
template<std::size_t I>
InputPort<PoolNode, I> input() {
static_assert(I < input_count, "input index out of range");
return {*this};
}
template<std::size_t I>
OutputPort<PoolNode, I> output() {
static_assert(I < output_count, "output index out of range");
return {*this};
}
// ── Port access — by name ─────────────────────────────────────────────────
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>();
}
template<fixed_string Name>
auto output() {
constexpr std::size_t idx = index_of<Name, OutNames...>();
static_assert(idx != npos, "unknown output port name");
return output<idx>();
}
// ── Internal channel accessors ────────────────────────────────────────────
template<std::size_t I>
Channel<std::tuple_element_t<I, args_tuple>>& input_channel() {
return *std::get<I>(input_channels_);
}
template<std::size_t I>
void set_input_channel(
std::shared_ptr<Channel<std::tuple_element_t<I, args_tuple>>> ch) {
std::get<I>(input_channels_) = std::move(ch);
}
template<std::size_t I>
void set_output_channel(
Channel<std::tuple_element_t<I, return_tuple>>* ch) {
std::get<I>(output_channels_) = ch;
}
private:
// ── Channel storage ───────────────────────────────────────────────────────
template<std::size_t... Is>
void init_input_channels(std::index_sequence<Is...>) {
((std::get<Is>(input_channels_) =
std::make_shared<Channel<std::tuple_element_t<Is, args_tuple>>>(fifo_capacity_)),
...);
}
template<std::size_t... Is>
void enable_inputs(std::index_sequence<Is...>) {
(std::get<Is>(input_channels_)->enable(), ...);
}
template<std::size_t... Is>
void disable_inputs(std::index_sequence<Is...>) {
(std::get<Is>(input_channels_)->disable(), ...);
}
template<std::size_t... Is>
void disable_outputs(std::index_sequence<Is...>) {
auto disable_one = [](auto* ch) { if (ch) ch->disable(); };
(disable_one(std::get<Is>(output_channels_)), ...);
}
template<std::size_t... Is>
void register_callbacks(std::index_sequence<Is...>) {
(std::get<Is>(input_channels_)->set_push_callback(
[this] { on_input_ready(); }), ...);
}
static void fire_callbacks(const std::array<NodeEventCallback, 2>& cbs) {
const auto ts = std::chrono::steady_clock::now();
for (auto& cb : cbs) if (cb) cb(ts);
}
void self_stop() {
disable_inputs(std::make_index_sequence<input_count>{});
disable_outputs(std::make_index_sequence<output_count>{});
stats_.exec_start_us.store(0, std::memory_order_relaxed);
queued_.store(false, std::memory_order_release);
stop_flag_.store(true, std::memory_order_relaxed);
}
template<typename Tup, std::size_t... Is>
static auto make_input_channel_tuple(std::index_sequence<Is...>)
-> std::tuple<std::shared_ptr<Channel<std::tuple_element_t<Is, Tup>>>...>;
using input_channels_t = decltype(make_input_channel_tuple<args_tuple>(
std::make_index_sequence<input_count>{}));
template<typename Tup, std::size_t... Is>
static auto make_output_channel_tuple(std::index_sequence<Is...>)
-> std::tuple<Channel<std::tuple_element_t<Is, Tup>>*...>;
using output_channels_t = decltype(make_output_channel_tuple<return_tuple>(
std::make_index_sequence<output_count>{}));
// ── Scheduling ────────────────────────────────────────────────────────────
// Called by channel push_callbacks (on the producer's thread).
void on_input_ready() {
if (stop_flag_.load(std::memory_order_relaxed)) return;
std::size_t ready = count_ready(std::make_index_sequence<input_count>{});
if (ready == input_count)
try_submit(compute_priority());
}
template<std::size_t... Is>
std::size_t count_ready(std::index_sequence<Is...>) {
return ((std::get<Is>(input_channels_)->approx_size() > 0 ? 1u : 0u) + ...);
}
float compute_priority() {
if constexpr (input_count == 0) return 0.5f;
float sum = 0.0f;
sum_fill(sum, std::make_index_sequence<input_count>{});
return sum / static_cast<float>(input_count);
}
template<std::size_t... Is>
void sum_fill(float& sum, std::index_sequence<Is...>) {
((sum += std::get<Is>(input_channels_)->capacity() > 0
? float(std::get<Is>(input_channels_)->approx_size())
/ float(std::get<Is>(input_channels_)->capacity())
: 0.5f), ...);
}
void try_submit(float priority) {
bool expected = false;
if (queued_.compare_exchange_strong(expected, true, std::memory_order_acq_rel))
scheduler_->submit([this] { fire_once(); }, priority);
}
// ── Execution ─────────────────────────────────────────────────────────────
void fire_once() {
if (stop_flag_.load(std::memory_order_relaxed)) {
queued_.store(false, std::memory_order_release);
return;
}
// Record queue wait time (submission → now) and mark as executing
auto t0 = clock_t::now();
int64_t now_us = std::chrono::duration_cast<std::chrono::microseconds>(
t0.time_since_epoch()).count();
stats_.exec_start_us.store(now_us, std::memory_order_relaxed);
try {
auto args = pop_inputs(std::make_index_sequence<input_count>{});
auto t1 = clock_t::now();
stats_.record_queue_wait(duration_t(t1 - t0));
auto cpu0 = NodeStats::cpu_now();
if constexpr (std::is_void_v<return_raw>) {
std::apply(Func, args);
} else {
auto result = std::apply(Func, args);
push_outputs(normalise(std::move(result)),
std::make_index_sequence<output_count>{});
}
auto cpu1 = NodeStats::cpu_now();
auto t2 = clock_t::now();
// blocked_time = 0 for pool nodes (we don't block waiting for inputs)
stats_.record_exec(duration_t(t2 - t1), duration_t::zero(), cpu0, cpu1);
} catch (const ChannelClosedError&) {
fire_callbacks(closed_callbacks_);
self_stop();
return;
} catch (const ChannelOverflowError&) {
fire_callbacks(event_callbacks_);
} catch (...) {
if (error_handler_ && error_handler_(name_, std::current_exception())) {
// continue — fall through to resubmit check
} else {
fire_callbacks(closed_callbacks_);
self_stop();
return;
}
}
stats_.exec_start_us.store(0, std::memory_order_relaxed);
queued_.store(false, std::memory_order_release);
if (stop_flag_.load(std::memory_order_relaxed)) return;
// Source nodes always resubmit; others resubmit only if inputs are ready.
if constexpr (input_count == 0) {
try_submit(0.5f);
} else {
on_input_ready();
}
}
// Pop all inputs — safe because we're the sole consumer and fire_once
// is guarded by queued_ (only one fire_once runs at a time).
template<std::size_t... Is>
args_tuple pop_inputs(std::index_sequence<Is...>) {
return {pop_one<Is>()...};
}
template<std::size_t I>
std::tuple_element_t<I, args_tuple> pop_one() {
auto& ch = *std::get<I>(input_channels_);
std::tuple_element_t<I, args_tuple> val;
if (!ch.try_pop_now(val))
throw ChannelClosedError{};
return val;
}
template<typename R = return_raw>
static return_tuple normalise(R&& r) {
if constexpr (is_tuple_v<R>) return std::move(r);
else return std::make_tuple(std::move(r));
}
template<std::size_t... Is>
void push_outputs(return_tuple&& result, std::index_sequence<Is...>) {
(push_one_out<Is>(std::get<Is>(std::move(result))), ...);
}
template<std::size_t I>
void push_one_out(std::tuple_element_t<I, return_tuple>&& val) {
auto* ch = std::get<I>(output_channels_);
if (!ch) return;
// Sentinels (EOF) must never be dropped: a lost token wedges every
// downstream pop() forever. Deliver them out-of-band (push_sentinel),
// which never overflows and never blocks this node's worker thread.
if (is_sentinel_value(val)) {
ch->push_sentinel(std::move(val));
return;
}
try {
ch->push(std::move(val));
} catch (const ChannelOverflowError&) {
throw ChannelOverflowError(ch->capacity(),
"pool node '" + name_ + "' " + output_port_label<I>());
}
}
template<std::size_t I>
static std::string output_port_label() {
if constexpr (sizeof...(OutNames) > 0) {
constexpr std::array<std::string_view, sizeof...(OutNames)> names{OutNames.view()...};
return std::string("output['") + std::string(names[I]) + "']";
} else {
return "output[" + std::to_string(I) + "]";
}
}
// ── State ─────────────────────────────────────────────────────────────────
std::shared_ptr<IScheduler> scheduler_;
std::string name_;
std::size_t fifo_capacity_;
input_channels_t input_channels_;
output_channels_t output_channels_{};
std::atomic<bool> stop_flag_{true};
std::atomic<bool> queued_{false};
NodeStats stats_;
NodeErrorHandler error_handler_;
std::chrono::milliseconds max_exec_time_{0};
std::array<NodeEventCallback, 2> event_callbacks_{}; // [0]=user [1]=network
std::array<NodeEventCallback, 2> closed_callbacks_{};
};
// ── PoolObjectNode ────────────────────────────────────────────────────────────
//
// Same as PoolNode but wraps a stateful callable object (functor / class with
// operator()). The object must outlive the PoolObjectNode.
template<typename Obj,
typename InputTag = in<>,
typename OutputTag = out<>,
fixed_string Label = "",
std::size_t UniqueTag = 0>
class PoolObjectNode;
template<typename Obj, fixed_string... InNames, fixed_string... OutNames,
fixed_string Label, std::size_t UniqueTag>
class PoolObjectNode<Obj, in<InNames...>, out<OutNames...>, Label, UniqueTag> : public INode {
public:
using F = decltype(&Obj::operator());
using args_tuple = args_t<F>;
using return_raw = return_t<F>;
using return_tuple = normalised_return_t<return_raw>;
static constexpr std::string_view label() { return Label.view(); }
static constexpr std::size_t unique_tag = UniqueTag;
static constexpr std::size_t input_count = arity_v<F>;
static constexpr std::size_t output_count = std::tuple_size_v<return_tuple>;
static_assert(
sizeof...(InNames) == 0 || sizeof...(InNames) == input_count,
"make_pool_node: number of input names must match operator() arity, or provide none"
);
static_assert(
sizeof...(OutNames) == 0 || sizeof...(OutNames) == output_count,
"make_pool_node: number of output names must match return tuple size, or provide none"
);
explicit PoolObjectNode(Obj& obj, std::shared_ptr<IScheduler> sched,
std::size_t fifo_capacity = 5)
: obj_(obj), scheduler_(std::move(sched)), fifo_capacity_(fifo_capacity)
{
init_input_channels(std::make_index_sequence<input_count>{});
}
~PoolObjectNode() override { stop(); }
void start() override {
enable_inputs(std::make_index_sequence<input_count>{});
stop_flag_.store(false, std::memory_order_relaxed);
queued_.store(false, std::memory_order_relaxed);
register_callbacks(std::make_index_sequence<input_count>{});
if constexpr (input_count == 0)
try_submit(0.5f);
}
void stop() override {
stop_flag_.store(true, std::memory_order_seq_cst);
disable_inputs(std::make_index_sequence<input_count>{});
}
bool running() const override { return !stop_flag_.load(std::memory_order_relaxed); }
void set_name(std::string name) override { name_ = std::move(name); }
void set_error_handler(NodeErrorHandler h) { error_handler_ = std::move(h); }
void set_max_exec_time(std::chrono::milliseconds t) { max_exec_time_ = t; }
void set_overflow_callback(NodeEventCallback cb) { event_callbacks_[0] = std::move(cb); }
void set_network_overflow_callback(NodeEventCallback cb) override { event_callbacks_[1] = std::move(cb); }
void set_closed_callback(NodeEventCallback cb) { closed_callbacks_[0] = std::move(cb); }
void set_network_closed_callback(NodeEventCallback cb) override { closed_callbacks_[1] = std::move(cb); }
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 qwait_ms = stats_.queue_wait_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,
qwait_ms,
};
}
template<std::size_t I> InputPort<PoolObjectNode, I> input() { return {*this}; }
template<std::size_t I> OutputPort<PoolObjectNode, I> output() { return {*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>();
}
template<fixed_string Name>
auto output() {
constexpr std::size_t idx = index_of<Name, OutNames...>();
static_assert(idx != npos, "unknown output port name");
return output<idx>();
}
template<std::size_t I>
Channel<std::tuple_element_t<I, args_tuple>>& input_channel() {
return *std::get<I>(input_channels_);
}
template<std::size_t I>
void set_input_channel(std::shared_ptr<Channel<std::tuple_element_t<I, args_tuple>>> ch) {
std::get<I>(input_channels_) = std::move(ch);
}
template<std::size_t I>
void set_output_channel(Channel<std::tuple_element_t<I, return_tuple>>* ch) {
std::get<I>(output_channels_) = ch;
}
private:
template<std::size_t... Is>
void init_input_channels(std::index_sequence<Is...>) {
((std::get<Is>(input_channels_) =
std::make_shared<Channel<std::tuple_element_t<Is, args_tuple>>>(fifo_capacity_)),
...);
}
template<std::size_t... Is> void enable_inputs(std::index_sequence<Is...>) { (std::get<Is>(input_channels_)->enable(), ...); }
template<std::size_t... Is> void disable_inputs(std::index_sequence<Is...>) { (std::get<Is>(input_channels_)->disable(), ...); }
template<std::size_t... Is>
void disable_outputs(std::index_sequence<Is...>) {
auto disable_one = [](auto* ch) { if (ch) ch->disable(); };
(disable_one(std::get<Is>(output_channels_)), ...);
}
template<std::size_t... Is>
void register_callbacks(std::index_sequence<Is...>) {
(std::get<Is>(input_channels_)->set_push_callback([this] { on_input_ready(); }), ...);
}
static void fire_callbacks(const std::array<NodeEventCallback, 2>& cbs) {
const auto ts = std::chrono::steady_clock::now();
for (auto& cb : cbs) if (cb) cb(ts);
}
void self_stop() {
disable_inputs(std::make_index_sequence<input_count>{});
disable_outputs(std::make_index_sequence<output_count>{});
stats_.exec_start_us.store(0, std::memory_order_relaxed);
queued_.store(false, std::memory_order_release);
stop_flag_.store(true, std::memory_order_relaxed);
}
template<typename Tup, std::size_t... Is>
static auto make_input_channel_tuple(std::index_sequence<Is...>)
-> std::tuple<std::shared_ptr<Channel<std::tuple_element_t<Is, Tup>>>...>;
using input_channels_t = decltype(make_input_channel_tuple<args_tuple>(
std::make_index_sequence<input_count>{}));
template<typename Tup, std::size_t... Is>
static auto make_output_channel_tuple(std::index_sequence<Is...>)
-> std::tuple<Channel<std::tuple_element_t<Is, Tup>>*...>;
using output_channels_t = decltype(make_output_channel_tuple<return_tuple>(
std::make_index_sequence<output_count>{}));
void on_input_ready() {
if (stop_flag_.load(std::memory_order_relaxed)) return;
std::size_t ready = count_ready(std::make_index_sequence<input_count>{});
if (ready == input_count) try_submit(compute_priority());
}
template<std::size_t... Is>
std::size_t count_ready(std::index_sequence<Is...>) {
return ((std::get<Is>(input_channels_)->approx_size() > 0 ? 1u : 0u) + ...);
}
float compute_priority() {
if constexpr (input_count == 0) return 0.5f;
float sum = 0.0f;
sum_fill(sum, std::make_index_sequence<input_count>{});
return sum / static_cast<float>(input_count);
}
template<std::size_t... Is>
void sum_fill(float& sum, std::index_sequence<Is...>) {
((sum += std::get<Is>(input_channels_)->capacity() > 0
? float(std::get<Is>(input_channels_)->approx_size())
/ float(std::get<Is>(input_channels_)->capacity())
: 0.5f), ...);
}
void try_submit(float priority) {
bool expected = false;
if (queued_.compare_exchange_strong(expected, true, std::memory_order_acq_rel))
scheduler_->submit([this] { fire_once(); }, priority);
}
void fire_once() {
if (stop_flag_.load(std::memory_order_relaxed)) {
queued_.store(false, std::memory_order_release);
return;
}
auto t0 = clock_t::now();
int64_t now_us = std::chrono::duration_cast<std::chrono::microseconds>(
t0.time_since_epoch()).count();
stats_.exec_start_us.store(now_us, std::memory_order_relaxed);
try {
auto args = pop_inputs(std::make_index_sequence<input_count>{});
auto t1 = clock_t::now();
stats_.record_queue_wait(duration_t(t1 - t0));
auto cpu0 = NodeStats::cpu_now();
if constexpr (std::is_void_v<return_raw>) {
std::apply([this](auto&&... a) { obj_(std::forward<decltype(a)>(a)...); }, args);
} else {
auto result = std::apply([this](auto&&... a) { return obj_(std::forward<decltype(a)>(a)...); }, args);
push_outputs(normalise(std::move(result)), std::make_index_sequence<output_count>{});
}
auto cpu1 = NodeStats::cpu_now();
auto t2 = clock_t::now();
stats_.record_exec(duration_t(t2 - t1), duration_t::zero(), cpu0, cpu1);
} catch (const ChannelClosedError&) {
fire_callbacks(closed_callbacks_);
self_stop();
return;
} catch (const ChannelOverflowError&) {
fire_callbacks(event_callbacks_);
} catch (...) {
if (error_handler_ && error_handler_(name_, std::current_exception())) {
} else {
fire_callbacks(closed_callbacks_);
self_stop();
return;
}
}
stats_.exec_start_us.store(0, std::memory_order_relaxed);
queued_.store(false, std::memory_order_release);
if (stop_flag_.load(std::memory_order_relaxed)) return;
if constexpr (input_count == 0) try_submit(0.5f);
else on_input_ready();
}
template<std::size_t... Is>
args_tuple pop_inputs(std::index_sequence<Is...>) { return {pop_one<Is>()...}; }
template<std::size_t I>
std::tuple_element_t<I, args_tuple> pop_one() {
auto& ch = *std::get<I>(input_channels_);
std::tuple_element_t<I, args_tuple> val;
if (!ch.try_pop_now(val)) throw ChannelClosedError{};
return val;
}
template<typename R = return_raw>
static return_tuple normalise(R&& r) {
if constexpr (is_tuple_v<R>) return std::move(r);
else return std::make_tuple(std::move(r));
}
template<std::size_t... Is>
void push_outputs(return_tuple&& result, std::index_sequence<Is...>) {
(push_one_out<Is>(std::get<Is>(std::move(result))), ...);
}
template<std::size_t I>
void push_one_out(std::tuple_element_t<I, return_tuple>&& val) {
auto* ch = std::get<I>(output_channels_);
if (!ch) return;
// Sentinels (EOF) must never be dropped: a lost token wedges every
// downstream pop() forever. Deliver them out-of-band (push_sentinel),
// which never overflows and never blocks this node's worker thread.
if (is_sentinel_value(val)) {
ch->push_sentinel(std::move(val));
return;
}
try {
ch->push(std::move(val));
} catch (const ChannelOverflowError&) {
throw ChannelOverflowError(ch->capacity(),
"pool node '" + name_ + "'");
}
}
Obj& obj_;
std::shared_ptr<IScheduler> scheduler_;
std::string name_;
std::size_t fifo_capacity_;
input_channels_t input_channels_;
output_channels_t output_channels_{};
std::atomic<bool> stop_flag_{true};
std::atomic<bool> queued_{false};
NodeStats stats_;
NodeErrorHandler error_handler_;
std::chrono::milliseconds max_exec_time_{0};
std::array<NodeEventCallback, 2> event_callbacks_{}; // [0]=user [1]=network
std::array<NodeEventCallback, 2> closed_callbacks_{};
};
// ── make_pool_node factory (NTTP) ─────────────────────────────────────────────
template<auto Func, fixed_string Label = "", std::size_t UniqueTag = 0>
auto make_pool_node(std::shared_ptr<IScheduler> sched, std::size_t fifo_capacity = 5) {
return PoolNode<Func, in<>, out<>, Label, UniqueTag>(std::move(sched), fifo_capacity);
}
template<auto Func, fixed_string Label = "", std::size_t UniqueTag = 0,
fixed_string... InNames>
auto make_pool_node(std::shared_ptr<IScheduler> sched, in<InNames...>,
std::size_t fifo_capacity = 5) {
return PoolNode<Func, in<InNames...>, out<>, Label, UniqueTag>(std::move(sched), fifo_capacity);
}
template<auto Func, fixed_string Label = "", std::size_t UniqueTag = 0,
fixed_string... OutNames>
auto make_pool_node(std::shared_ptr<IScheduler> sched, out<OutNames...>,
std::size_t fifo_capacity = 5) {
return PoolNode<Func, in<>, out<OutNames...>, Label, UniqueTag>(std::move(sched), fifo_capacity);
}
template<auto Func, fixed_string Label = "", std::size_t UniqueTag = 0,
fixed_string... InNames, fixed_string... OutNames>
auto make_pool_node(std::shared_ptr<IScheduler> sched, in<InNames...>, out<OutNames...>,
std::size_t fifo_capacity = 5) {
return PoolNode<Func, in<InNames...>, out<OutNames...>, Label, UniqueTag>(
std::move(sched), fifo_capacity);
}
// ── make_pool_node factory (callable object) ──────────────────────────────────
template<typename Obj>
auto make_pool_node(Obj& obj, std::shared_ptr<IScheduler> sched,
std::size_t fifo_capacity = 5) {
return PoolObjectNode<Obj, in<>, out<>>(obj, std::move(sched), fifo_capacity);
}
template<typename Obj, fixed_string... InNames>
auto make_pool_node(Obj& obj, std::shared_ptr<IScheduler> sched, in<InNames...>,
std::size_t fifo_capacity = 5) {
return PoolObjectNode<Obj, in<InNames...>, out<>>(obj, std::move(sched), fifo_capacity);
}
template<typename Obj, fixed_string... OutNames>
auto make_pool_node(Obj& obj, std::shared_ptr<IScheduler> sched, out<OutNames...>,
std::size_t fifo_capacity = 5) {
return PoolObjectNode<Obj, in<>, out<OutNames...>>(obj, std::move(sched), fifo_capacity);
}
template<typename Obj, fixed_string... InNames, fixed_string... OutNames>
auto make_pool_node(Obj& obj, std::shared_ptr<IScheduler> sched,
in<InNames...>, out<OutNames...>,
std::size_t fifo_capacity = 5) {
return PoolObjectNode<Obj, in<InNames...>, out<OutNames...>>(
obj, std::move(sched), fifo_capacity);
}
} // namespace kpn

View File

@ -0,0 +1,311 @@
#pragma once
// Auto-binding helpers for KPN++ Python bindings.
//
// Usage in your binding .cpp:
//
// #define KPN_BUILD_PYTHON
// #include <kpn/python/auto_bind.hpp>
//
// int produce() { return 42; }
// int double_it(int x) { return x * 2; }
// void print_it(int x) { std::cout << x << '\n'; }
//
// using MyNodes = kpn::python::NodeRegistry<
// kpn::python::Entry<produce, "produce">,
// kpn::python::Entry<double_it, "double_it">,
// kpn::python::Entry<print_it, "print_it">
// >;
//
// NB_MODULE(my_kpn, m) {
// kpn::python::bind_network<MyNodes>(m); // KPN_BIND_PYTHON behaviour
// kpn::python::bind_debug<MyNodes>(m); // KPN_PYTHON_DEBUG behaviour
// }
//
// bind_network registers:
// - Network class (PyNetwork<auto-deduced-variant>) with auto-registered converters
// - make_<name>(capacity=5) factory for each entry
// - <Name>Node class for each entry
//
// bind_debug additionally registers each raw C++ function as a free Python
// callable (e.g. double_it(5) → 10) so node logic can be tested without a network.
//
// To support a custom type T, specialise kpn::PythonConverter<T> before calling
// bind_network:
//
// namespace kpn {
// template<> struct PythonConverter<MyVec3> {
// static constexpr const char* type_name = "vec3"; // optional friendly name
// static nb::object to_python(const MyVec3& v) { ... }
// static MyVec3 from_python(nb::object o) { ... }
// };
// } // namespace kpn
#include "../variant_node.hpp"
#include "../traits.hpp"
#include "bindings.hpp"
#ifdef KPN_BUILD_PYTHON
#include <nanobind/nanobind.h>
#include <nanobind/stl/shared_ptr.h>
#include <nanobind/stl/string.h>
#include <nanobind/stl/vector.h>
#include <cctype>
#include <string>
#include <tuple>
#include <type_traits>
// ── PythonConverter specialisations for built-in nanobind-castable types ─────
// These live in kpn:: to match the primary template in variant_node.hpp.
namespace kpn {
template<> struct PythonConverter<int> {
static constexpr const char* type_name = "int";
static nanobind::object to_python(const int& v) { return nanobind::cast(v); }
static int from_python(nanobind::object o) { return nanobind::cast<int>(std::move(o)); }
};
template<> struct PythonConverter<float> {
static constexpr const char* type_name = "float";
static nanobind::object to_python(const float& v) { return nanobind::cast(v); }
static float from_python(nanobind::object o) { return nanobind::cast<float>(std::move(o)); }
};
template<> struct PythonConverter<double> {
static constexpr const char* type_name = "double";
static nanobind::object to_python(const double& v) { return nanobind::cast(v); }
static double from_python(nanobind::object o) { return nanobind::cast<double>(std::move(o)); }
};
template<> struct PythonConverter<bool> {
static constexpr const char* type_name = "bool";
static nanobind::object to_python(const bool& v) { return nanobind::cast(v); }
static bool from_python(nanobind::object o) { return nanobind::cast<bool>(std::move(o)); }
};
template<> struct PythonConverter<std::string> {
static constexpr const char* type_name = "str";
static nanobind::object to_python(const std::string& v) { return nanobind::cast(v); }
static std::string from_python(nanobind::object o) {
return nanobind::cast<std::string>(std::move(o));
}
};
} // namespace kpn
namespace kpn::python {
namespace nb = nanobind;
// ── Entry<Func, Name> ─────────────────────────────────────────────────────────
// Compile-time descriptor for one bindable node function.
template<auto Func, fixed_string Name>
struct Entry {
static constexpr auto func = Func;
static constexpr auto name = Name;
};
// ── NodeRegistry<Es...> ───────────────────────────────────────────────────────
template<typename... Es>
struct NodeRegistry {
using entries_tuple = std::tuple<Es...>;
static constexpr std::size_t size = sizeof...(Es);
};
// ── Internal TMP ──────────────────────────────────────────────────────────────
namespace detail {
// All non-void port types for a single function (args + normalised returns).
template<auto Func>
struct entry_port_types {
using args = args_t<decltype(Func)>;
using ret = normalised_return_t<return_t<decltype(Func)>>;
using type = decltype(std::tuple_cat(std::declval<args>(), std::declval<ret>()));
};
// Flat tuple of all types across all entries (may contain duplicates).
template<typename... Es>
struct all_types_flat {
using type = decltype(std::tuple_cat(
std::declval<typename entry_port_types<Es::func>::type>()...));
};
template<typename Registry>
struct registry_flat_types;
template<typename... Es>
struct registry_flat_types<NodeRegistry<Es...>> {
using type = typename all_types_flat<Es...>::type;
};
// Unpack a tuple into unique_types_t (which takes a pack, not a tuple).
// unique_types_t<T> takes Ts... not std::tuple<Ts...>, so we need this bridge.
template<typename Tuple>
struct unpack_unique;
template<typename... Ts>
struct unpack_unique<std::tuple<Ts...>> {
using type = kpn::detail::unique_types_t<Ts...>;
};
// SFINAE: does PythonConverter<T> have a 'type_name' member?
template<typename Conv, typename = void>
struct has_type_name : std::false_type {};
template<typename Conv>
struct has_type_name<Conv, std::void_t<decltype(Conv::type_name)>> : std::true_type {};
inline std::string make_class_name(std::string_view snake) {
std::string result(snake);
if (!result.empty()) result[0] = static_cast<char>(std::toupper(result[0]));
result += "Node";
return result;
}
} // namespace detail
// ── registry_variant_t<Registry> ─────────────────────────────────────────────
// Deduces std::variant<UniqueTypes...> from all port types across the registry.
template<typename Registry>
using registry_variant_t = typename kpn::detail::tuple_to_variant<
typename detail::unpack_unique<
typename detail::registry_flat_types<Registry>::type>::type
>::type;
// ── Converter registration ────────────────────────────────────────────────────
template<typename T, typename Variant>
void register_one_type(PyNetwork<Variant>& net) {
const char* friendly = nullptr;
if constexpr (detail::has_type_name<PythonConverter<T>>::value)
friendly = PythonConverter<T>::type_name;
net.template register_full_type<T>(
[](const T& v) -> nb::object { return PythonConverter<T>::to_python(v); },
[](nb::object o) -> T { return PythonConverter<T>::from_python(std::move(o)); },
friendly);
}
template<typename Variant, typename... Ts>
void register_types_impl(PyNetwork<Variant>& net, std::tuple<Ts...>*) {
(register_one_type<Ts>(net), ...);
}
template<typename Registry, typename Variant>
void register_all_converters(PyNetwork<Variant>& net) {
using Flat = typename detail::registry_flat_types<Registry>::type;
using Unique = typename detail::unpack_unique<Flat>::type;
register_types_impl(net, static_cast<Unique*>(nullptr));
}
// ── Per-entry class + factory registration ───────────────────────────────────
namespace detail {
template<typename E, typename Variant>
void register_one_entry(nb::module_& m) {
using Wrapper = VariantNodeWrapper<E::func, Variant>;
auto class_name = make_class_name(E::name.view());
auto make_name = "make_" + std::string(E::name.view());
nb::class_<Wrapper, IVariantNode<Variant>>(m, class_name.c_str())
.def("__init__", [](Wrapper* self, std::size_t cap) {
new (self) Wrapper(cap);
}, nb::arg("capacity") = 5);
m.def(make_name.c_str(),
[](std::size_t cap) -> std::shared_ptr<IVariantNode<Variant>> {
return std::make_shared<Wrapper>(cap);
},
nb::arg("capacity") = 5);
}
template<typename Variant, typename... Es>
void register_entries_impl(nb::module_& m, std::tuple<Es...>*) {
(register_one_entry<Es, Variant>(m), ...);
}
} // namespace detail
// ── bind_network<Registry> ────────────────────────────────────────────────────
// Registers:
// - INode — base class (opaque Python handle)
// - Network — PyNetwork with auto-registered converters
// - <Name>Node — VariantNodeWrapper for each entry
// - make_<name>() — factory returning shared_ptr<INode>
template<typename Registry>
void bind_network(nb::module_& m) {
using Variant = registry_variant_t<Registry>;
using Net = PyNetwork<Variant>;
using Entries = typename Registry::entries_tuple;
nb::class_<IVariantNode<Variant>>(m, "INode");
nb::class_<Net>(m, "Network", nb::type_slots(network_type_slots<Variant>()))
.def("__init__", [](Net* self) {
new (self) Net();
register_all_converters<Registry>(*self);
})
// add(name, c++_node)
.def("add", [](Net& self, std::string name,
std::shared_ptr<IVariantNode<Variant>> node) {
self.add(std::move(name), std::move(node));
}, nb::arg("name"), nb::arg("node"))
// add_node(name, callable, inputs=[...], outputs=[...], capacity=5)
.def("add_node", &Net::add_node_python,
nb::arg("name"),
nb::arg("callable"),
nb::arg("inputs") = std::vector<std::string>{},
nb::arg("outputs") = std::vector<std::string>{},
nb::arg("capacity") = std::size_t(5))
.def("connect", &Net::connect,
nb::arg("src"), nb::arg("out_idx"),
nb::arg("dst"), nb::arg("in_idx"))
.def("build", &Net::build)
.def("start", &Net::start)
.def("stop", &Net::stop)
.def("read", &Net::read,
nb::arg("node"), nb::arg("out_idx") = std::size_t(0))
.def("write", &Net::write,
nb::arg("node"), nb::arg("in_idx"), nb::arg("value"))
;
detail::register_entries_impl<Variant>(m, static_cast<Entries*>(nullptr));
}
// ── bind_debug<Registry> ─────────────────────────────────────────────────────
// Exposes each node's raw C++ function as a free Python callable so node logic
// can be unit-tested without constructing a network.
//
// Example: assert kpn.double_it(5) == 10
namespace detail {
template<typename E>
void bind_one_debug(nb::module_& m) {
auto name_str = std::string(E::name.view());
m.def(name_str.c_str(), E::func);
}
template<typename... Es>
void bind_debug_impl(nb::module_& m, std::tuple<Es...>*) {
(bind_one_debug<Es>(m), ...);
}
} // namespace detail
template<typename Registry>
void bind_debug(nb::module_& m) {
using Entries = typename Registry::entries_tuple;
detail::bind_debug_impl(m, static_cast<Entries*>(nullptr));
}
} // namespace kpn::python
#endif // KPN_BUILD_PYTHON

View File

@ -26,12 +26,25 @@ namespace nb = nanobind;
// them via IVariantChannel adapters. The variant only lives at the boundary;
// each node's internal Channel<T> stores raw T values.
template<typename Variant>
class PyNode; // forward declaration
template<typename Variant>
class PyNetwork {
public:
using VNode = IVariantNode<Variant>;
using VChannel = IVariantChannel<Variant>;
// ── GC support ────────────────────────────────────────────────────────────
// Visit every Python object this network transitively holds (currently the
// callable of each PyNode). Used by the Network type's tp_traverse slot so
// Python's cyclic GC can discover instance → callable → globals() cycles.
// Defined out-of-line below, once PyNode is a complete type.
template<typename Fn>
void visit_python_objects(Fn&& visit) const;
// Drop all Python references held by nodes, breaking any cycle (tp_clear).
void clear_python_objects();
// ── Builder API ───────────────────────────────────────────────────────────
void add(std::string name, std::shared_ptr<VNode> node) {
@ -43,8 +56,6 @@ public:
}
// connect(src_name, out_idx, dst_name, in_idx)
// Wires src's output port out_idx to dst's input port in_idx.
// Type check: both sides must carry the same T.
void connect(const std::string& src_name, std::size_t out_idx,
const std::string& dst_name, std::size_t in_idx)
{
@ -65,7 +76,6 @@ public:
dst_name + ".input[" + std::to_string(in_idx) +
"] (" + dst.input_type(in_idx).name() + ")");
// The destination node owns the input channel — get it, then tell src to use it.
auto ch = dst.input_channel(in_idx);
src.set_output_channel(out_idx, std::move(ch));
adj_[src_name].push_back(dst_name);
@ -92,16 +102,12 @@ public:
// ── Python tap/inject ─────────────────────────────────────────────────────
// Read one value from node's output port. Releases GIL while blocking.
nb::object read(const std::string& node_name, std::size_t out_idx) {
// We need a channel that sits on the output of this node.
// read() installs a tap channel if not already present.
auto key = tap_key(node_name, out_idx);
if (!taps_.count(key)) {
auto& src = node_at(node_name);
if (out_idx >= src.output_count())
throw std::out_of_range(node_name + ": output index out of range");
// Create a tap channel matching the output type and wire it
auto tap = make_tap_channel(src.output_type(out_idx));
src.set_output_channel(out_idx, tap);
taps_[key] = std::move(tap);
@ -114,7 +120,6 @@ public:
return variant_to_python(std::move(v));
}
// Write a Python value into node's input port. Releases GIL while blocking.
void write(const std::string& node_name, std::size_t in_idx, nb::object value) {
auto& dst = node_at(node_name);
if (in_idx >= dst.input_count())
@ -128,8 +133,31 @@ public:
}
}
// ── Converter registration ────────────────────────────────────────────────
// Called once per type at module init time to register to/from Python converters.
// ── Python-callable node creation ─────────────────────────────────────────
// Creates a PyNode wrapping a Python callable and adds it to the graph.
// Type names must have been registered via register_full_type<T>().
void add_node_python(std::string name, nb::object callable,
std::vector<std::string> in_names,
std::vector<std::string> out_names,
std::size_t capacity = 5)
{
std::vector<std::type_index> in_types, out_types;
for (auto& s : in_names) in_types.push_back(resolve_type_name(s));
for (auto& s : out_names) out_types.push_back(resolve_type_name(s));
add(std::move(name),
std::make_shared<PyNode<Variant>>(
std::move(callable),
std::move(in_types),
std::move(out_types),
to_python_,
from_python_,
ch_factories_,
capacity));
}
// ── Type converter registration ───────────────────────────────────────────
template<typename T>
void register_type(
@ -143,6 +171,52 @@ public:
};
}
// register_channel_factory<T>: registers factory for creating input channels.
template<typename T>
void register_channel_factory() {
ch_factories_[std::type_index(typeid(T))] =
[](std::size_t cap) -> std::shared_ptr<VChannel> {
return std::make_shared<VariantChannel<T, Variant>>(
std::make_shared<Channel<T>>(cap));
};
}
// Backward-compatible alias.
template<typename T>
void register_tap_factory(std::size_t = 5) {
register_channel_factory<T>();
}
// register_full_type<T>: registers converters + channel factory + type name.
// This is what auto_bind.hpp calls; manual bindings can call register_type +
// register_tap_factory separately for backward compatibility.
template<typename T>
void register_full_type(
std::function<nb::object(const T&)> to_py,
std::function<T(nb::object)> from_py,
const char* friendly_name = nullptr)
{
register_type<T>(std::move(to_py), std::move(from_py));
register_channel_factory<T>();
auto idx = std::type_index(typeid(T));
type_names_.insert_or_assign(typeid(T).name(), idx);
if (friendly_name) type_names_.insert_or_assign(friendly_name, idx);
}
// ── Type name lookup ──────────────────────────────────────────────────────
void register_type_name(const std::string& name, std::type_index idx) {
type_names_.insert_or_assign(name, idx);
}
std::type_index resolve_type_name(const std::string& name) const {
auto it = type_names_.find(name);
if (it == type_names_.end())
throw std::runtime_error(
"type '" + name + "' not registered — call register_full_type<T>() first");
return it->second;
}
private:
VNode& node_at(const std::string& name) {
auto it = nodes_.find(name);
@ -166,15 +240,14 @@ private:
return node + ":" + std::to_string(idx);
}
std::shared_ptr<VChannel> make_tap_channel(std::type_index type) {
// Create the right VariantChannel<T> based on the registered type index.
// We need a factory registered per type — stored in tap_factories_.
auto it = tap_factories_.find(type);
if (it == tap_factories_.end())
std::shared_ptr<VChannel> make_tap_channel(std::type_index type,
std::size_t cap = 5) {
auto it = ch_factories_.find(type);
if (it == ch_factories_.end())
throw std::runtime_error(
"no tap factory for type: " + std::string(type.name()) +
"was register_type() called for this type?");
return it->second();
"no channel factory for type: " + std::string(type.name()) +
"call register_full_type<T>() or register_tap_factory<T>()");
return it->second(cap);
}
nb::object variant_to_python(Variant v) {
@ -194,18 +267,6 @@ private:
return it->second(std::move(obj));
}
public:
// Called by register_type to also register a tap channel factory.
template<typename T>
void register_tap_factory(std::size_t capacity = 5) {
auto idx = std::type_index(typeid(T));
tap_factories_[idx] = [capacity]() -> std::shared_ptr<VChannel> {
auto ch = std::make_shared<Channel<T>>(capacity);
return std::make_shared<VariantChannel<T, Variant>>(std::move(ch));
};
}
private:
std::map<std::string, std::shared_ptr<VNode>> nodes_;
std::map<std::string, std::vector<std::string>> adj_;
std::vector<std::string> topo_;
@ -213,19 +274,27 @@ private:
std::map<std::type_index, std::function<nb::object(const Variant&)>> to_python_;
std::map<std::type_index, std::function<Variant(nb::object)>> from_python_;
std::map<std::type_index, std::function<std::shared_ptr<VChannel>()>> tap_factories_;
// Channel factory: type → function(capacity) → VChannel.
// Used both for tap channels (read()) and PyNode input channel creation.
std::map<std::type_index,
std::function<std::shared_ptr<VChannel>(std::size_t)>> ch_factories_;
// Friendly name → type_index (e.g. "int" → typeid(int)).
std::map<std::string, std::type_index> type_names_;
};
// ── PyNode<Variant> ───────────────────────────────────────────────────────────
// A pure-Python processing node. Holds a nanobind callable.
// run_loop: pop inputs (release GIL), call Python (acquire GIL), push outputs (release GIL).
// run_loop: pop inputs (release GIL), call Python (acquire GIL), push outputs.
template<typename Variant>
class PyNode : public IVariantNode<Variant> {
public:
using VChannel = IVariantChannel<Variant>;
using ChannelFactory = std::function<std::shared_ptr<VChannel>(std::size_t capacity)>;
using ChannelFactory =
std::function<std::shared_ptr<VChannel>(std::size_t capacity)>;
PyNode(nb::object callable,
std::vector<std::type_index> in_types,
@ -264,7 +333,6 @@ public:
for (auto& ch : in_channels_) ch->disable();
if (thread_.joinable()) {
thread_.request_stop();
// Release GIL while joining — run_loop may be waiting to acquire it.
nb::gil_scoped_release release;
thread_.join();
}
@ -309,14 +377,20 @@ public:
out_channels_[i] = std::move(ch);
}
// ── GC support (tp_traverse / tp_clear on the owning Network) ──────────────
// The node holds a Python callable, which typically forms an
// instance → callable → globals() → instance cycle. Expose the callable so
// the Network's GC slots can traverse and clear it. See bindings.hpp's
// network_tp_traverse/network_tp_clear.
const nb::object& python_callable() const { return callable_; }
void clear_python_callable() { callable_ = nb::object(); }
private:
void run_loop() {
// This thread does not hold the GIL. It acquires it only for Python calls.
while (!stop_flag_.load(std::memory_order_relaxed)) {
try {
auto t0 = clock_t::now();
// Pop all inputs — no GIL needed, these are pure C++ channel ops
std::vector<Variant> inputs(in_channels_.size());
for (std::size_t i = 0; i < in_channels_.size(); ++i)
inputs[i] = in_channels_[i]->pop();
@ -324,7 +398,6 @@ private:
auto t1 = clock_t::now();
auto cpu0 = NodeStats::cpu_now();
// Acquire GIL only for the Python call and type conversion
std::vector<Variant> outputs;
{
nb::gil_scoped_acquire acquire;
@ -347,7 +420,6 @@ private:
auto t2 = clock_t::now();
stats_.record_exec(duration_t(t2 - t1), duration_t(t1 - t0), cpu0, cpu1);
// Push outputs — no GIL needed
for (std::size_t i = 0; i < out_channels_.size(); ++i) {
if (out_channels_[i])
out_channels_[i]->push(std::move(outputs[i]));
@ -385,15 +457,69 @@ private:
NodeStats stats_;
};
// ── register_py_network ───────────────────────────────────────────────────────
// Registers PyNetwork<Variant> and PyNode<Variant> with the given nanobind module.
// Call once per module, passing the Variant type derived from your registered node types.
// ── PyNetwork GC helpers (defined here: PyNode is now complete) ────────────────
template<typename Variant>
template<typename Fn>
void PyNetwork<Variant>::visit_python_objects(Fn&& visit) const {
for (const auto& [name, node] : nodes_)
if (auto* py = dynamic_cast<const PyNode<Variant>*>(node.get()))
visit(py->python_callable());
}
template<typename Variant>
void PyNetwork<Variant>::clear_python_objects() {
for (auto& [name, node] : nodes_)
if (auto* py = dynamic_cast<PyNode<Variant>*>(node.get()))
py->clear_python_callable();
}
// ── GC type slots for the Network binding ─────────────────────────────────────
// The Network holds Python callables (via PyNode), forming uncollectable
// instance → callable → globals() → instance cycles at interpreter shutdown.
// These slots let Python's cyclic collector traverse and break them, silencing
// nanobind's leak warnings. See the nanobind "Reference leaks" documentation.
template<typename Variant>
int network_tp_traverse(PyObject* self, visitproc visit, void* arg) {
Py_VISIT(Py_TYPE(self));
if (!nb::inst_ready(self))
return 0;
auto* net = nb::inst_ptr<PyNetwork<Variant>>(self);
int rv = 0;
net->visit_python_objects([&](const nb::object& obj) {
if (rv == 0 && obj.is_valid())
rv = visit(obj.ptr(), arg);
});
return rv;
}
template<typename Variant>
int network_tp_clear(PyObject* self) {
auto* net = nb::inst_ptr<PyNetwork<Variant>>(self);
net->clear_python_objects();
return 0;
}
template<typename Variant>
PyType_Slot* network_type_slots() {
static PyType_Slot slots[] = {
{ Py_tp_traverse, reinterpret_cast<void*>(&network_tp_traverse<Variant>) },
{ Py_tp_clear, reinterpret_cast<void*>(&network_tp_clear<Variant>) },
{ 0, nullptr }
};
return slots;
}
// ── register_py_network (legacy helper) ───────────────────────────────────────
// Registers PyNetwork<Variant> with the given nanobind module.
// Prefer bind_network<Registry> from auto_bind.hpp for new code.
template<typename Variant>
void register_py_network(nb::module_& m, const char* class_name = "Network") {
using Net = PyNetwork<Variant>;
nb::class_<Net>(m, class_name)
nb::class_<Net>(m, class_name, nb::type_slots(network_type_slots<Variant>()))
.def(nb::init<>())
.def("connect", &Net::connect,
nb::arg("src"), nb::arg("out_idx"),
@ -402,7 +528,7 @@ void register_py_network(nb::module_& m, const char* class_name = "Network") {
.def("start", &Net::start)
.def("stop", &Net::stop)
.def("read", &Net::read,
nb::arg("node"), nb::arg("out_idx") = 0)
nb::arg("node"), nb::arg("out_idx") = std::size_t(0))
.def("write", &Net::write,
nb::arg("node"), nb::arg("in_idx"), nb::arg("value"));
}

211
include/kpn/scheduler.hpp Normal file
View File

@ -0,0 +1,211 @@
#pragma once
#include "diagnostics.hpp"
#include <atomic>
#include <condition_variable>
#include <functional>
#include <memory>
#include <mutex>
#include <optional>
#include <queue>
#include <thread>
#include <vector>
namespace kpn {
// ── IScheduler ────────────────────────────────────────────────────────────────
struct IScheduler {
virtual ~IScheduler() = default;
// Submit a task with an optional priority in [0, 1]. Higher = run sooner.
virtual void submit(std::function<void()> task, float priority = 0.5f) = 0;
// Start worker threads. Must be called before submit().
virtual void start() = 0;
// Halt: signal workers to exit and join them. Pending tasks are discarded.
virtual void stop() = 0;
// Drain: block until all in-flight tasks complete. Workers keep running.
virtual void drain() = 0;
};
// ── ThreadPool ────────────────────────────────────────────────────────────────
//
// Work-stealing thread pool with per-thread priority queues.
//
// Each worker owns a priority_queue (max-heap by priority, FIFO within equal
// priority via sequence number). submit() distributes via round-robin. When a
// worker's queue is empty it tries to steal from the most-loaded peer using
// try_lock to avoid blocking; if no work is found it sleeps on a shared CV.
//
// total_ counts tasks submitted-but-not-completed (queued + executing).
// drain() waits until total_ == 0.
class ThreadPool : public IScheduler, public IPoolProbe {
public:
explicit ThreadPool(std::size_t thread_count) : thread_count_(thread_count) {}
~ThreadPool() {
if (!stopped_.load(std::memory_order_relaxed))
stop();
}
void start() override {
stopped_.store(false, std::memory_order_relaxed);
queues_.clear();
for (std::size_t i = 0; i < thread_count_; ++i)
queues_.push_back(std::make_unique<WorkerQueue>());
workers_.reserve(thread_count_);
for (std::size_t i = 0; i < thread_count_; ++i)
workers_.emplace_back([this, i] { worker_loop(i); });
}
void stop() override {
stopped_.store(true, std::memory_order_seq_cst);
for (auto& q : queues_) {
std::lock_guard lock(q->mx);
std::size_t discarded = q->pq.size();
while (!q->pq.empty()) q->pq.pop();
total_.fetch_sub(discarded, std::memory_order_relaxed);
}
// Lock cv_mx_ before notifying so the stop signal can't be lost in the
// gap between a worker's predicate check and its wait() (see submit()).
{ std::lock_guard<std::mutex> lk(cv_mx_); }
cv_.notify_all();
for (auto& t : workers_) if (t.joinable()) t.join();
workers_.clear();
queues_.clear();
}
void drain() override {
std::unique_lock lock(drain_mx_);
drain_cv_.wait(lock, [this] {
return total_.load(std::memory_order_acquire) == 0;
});
}
void submit(std::function<void()> task, float priority = 0.5f) override {
std::size_t target = next_.fetch_add(1, std::memory_order_relaxed) % thread_count_;
{
std::lock_guard lock(queues_[target]->mx);
queues_[target]->pq.push(
{std::move(task), priority, seq_.fetch_add(1, std::memory_order_relaxed)});
}
total_.fetch_add(1, std::memory_order_relaxed);
submitted_.fetch_add(1, std::memory_order_relaxed);
// Synchronize with worker_loop's predicate evaluation: taking cv_mx_
// here guarantees a worker is either before its predicate check (and
// will observe total_ > 0) or already blocked in wait() (and will be
// woken). Without this, notify_one() can slip into the gap between the
// worker's predicate check and its wait(), and be lost — a deadlock.
{ std::lock_guard<std::mutex> lk(cv_mx_); }
cv_.notify_one();
}
std::size_t thread_count() const { return thread_count_; }
// ── IPoolProbe ────────────────────────────────────────────────────────────
PoolSnapshot snapshot(const std::string& name) const override {
std::size_t a = active_.load(std::memory_order_relaxed);
std::size_t t = total_.load(std::memory_order_relaxed);
return {
name, thread_count_,
t > a ? t - a : 0, // queued (approximate)
a, // executing
submitted_.load(std::memory_order_relaxed),
completed_.load(std::memory_order_relaxed),
};
}
private:
struct Task {
std::function<void()> fn;
float priority;
uint64_t seq;
// max-heap: higher priority runs first; older task wins tie
bool operator<(const Task& o) const {
if (priority != o.priority) return priority < o.priority;
return seq > o.seq;
}
};
// Separate cache lines to prevent false sharing between adjacent queues.
struct alignas(64) WorkerQueue {
std::priority_queue<Task> pq;
std::mutex mx;
};
std::optional<std::function<void()>> try_pop(WorkerQueue& q) {
std::lock_guard lock(q.mx);
if (q.pq.empty()) return std::nullopt;
auto fn = std::move(const_cast<Task&>(q.pq.top()).fn);
q.pq.pop();
return fn;
}
std::optional<std::function<void()>> try_steal(std::size_t thief) {
// Find the most-loaded peer without blocking — racy peek is fine.
std::size_t victim = thief, best = 0;
for (std::size_t i = 0; i < queues_.size(); ++i) {
if (i == thief) continue;
std::unique_lock lk(queues_[i]->mx, std::try_to_lock);
if (!lk) continue;
std::size_t n = queues_[i]->pq.size();
if (n > best) { best = n; victim = i; }
}
if (victim == thief) return std::nullopt;
return try_pop(*queues_[victim]);
}
void execute(std::function<void()>& fn) {
active_.fetch_add(1, std::memory_order_relaxed);
fn();
completed_.fetch_add(1, std::memory_order_relaxed);
active_.fetch_sub(1, std::memory_order_relaxed);
// Notify drain() if this was the last in-flight task.
// acq_rel ensures the decrement is visible before any drain() load.
// Lock drain_mx_ before notifying to avoid a lost wakeup against
// drain()'s predicate check (same hazard as submit()/cv_mx_).
if (total_.fetch_sub(1, std::memory_order_acq_rel) == 1) {
{ std::lock_guard<std::mutex> lk(drain_mx_); }
drain_cv_.notify_all();
}
}
void worker_loop(std::size_t id) {
while (true) {
if (auto fn = try_pop(*queues_[id])) { execute(*fn); continue; }
if (auto fn = try_steal(id)) { execute(*fn); continue; }
std::unique_lock lock(cv_mx_);
cv_.wait(lock, [this] {
return stopped_.load(std::memory_order_seq_cst)
|| total_.load(std::memory_order_relaxed) > 0;
});
if (stopped_.load(std::memory_order_seq_cst)
&& total_.load(std::memory_order_relaxed) == 0)
return;
}
}
const std::size_t thread_count_;
std::vector<std::unique_ptr<WorkerQueue>> queues_;
std::vector<std::thread> workers_;
std::mutex cv_mx_;
std::condition_variable cv_;
std::mutex drain_mx_;
std::condition_variable drain_cv_;
std::atomic<bool> stopped_{true};
std::atomic<size_t> total_{0}; // queued + executing
std::atomic<size_t> active_{0}; // executing only (for snapshot)
std::atomic<size_t> next_{0}; // round-robin submit cursor
std::atomic<uint64_t> seq_{0}; // tie-break for equal-priority tasks
std::atomic<uint64_t> submitted_{0};
std::atomic<uint64_t> completed_{0};
};
} // namespace kpn

View File

@ -0,0 +1,190 @@
#pragma once
#include "diagnostics.hpp"
#include <algorithm>
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <functional>
#include <mutex>
#include <utility>
#include <vector>
namespace kpn {
template<typename T> class Channel; // forward declaration for acquire_balanced
// ── SharedResource ────────────────────────────────────────────────────────────
//
// Wraps an exclusive resource (e.g. an ONNX session, a CUDA stream) and
// arbitrates concurrent access using a priority-based waiter queue.
//
// When multiple nodes compete, the one with the highest priority score wins
// the next slot. Priority is re-evaluated at release time so it reflects the
// current queue state, not the state when the node first started waiting.
//
// Starvation prevention: each waiter's effective score grows with elapsed wait
// time (aging_per_second), ensuring a low-priority node eventually gets served.
//
// Usage:
// SharedResource<OrtSession> res(session_args...);
//
// // inside a node functor —
// auto guard = res.acquire_balanced(in_channel, out_channel);
// guard->Run(...); // guard releases automatically on scope exit
template<typename T>
class SharedResource : public IResourceProbe {
public:
// ── RAII guard ────────────────────────────────────────────────────────────
class Guard {
SharedResource* owner_;
explicit Guard(SharedResource* o) : owner_(o) {}
friend class SharedResource;
public:
Guard(Guard&& o) noexcept : owner_(std::exchange(o.owner_, nullptr)) {}
Guard& operator=(Guard&&) = delete;
Guard(const Guard&) = delete;
Guard& operator=(const Guard&) = delete;
~Guard() { if (owner_) owner_->release(); }
T& get() { return owner_->resource_; }
T* operator->() { return &owner_->resource_; }
T& operator*() { return owner_->resource_; }
};
// ── Construction ──────────────────────────────────────────────────────────
template<typename... Args>
explicit SharedResource(Args&&... args)
: resource_(std::forward<Args>(args)...) {}
SharedResource(const SharedResource&) = delete;
SharedResource& operator=(const SharedResource&) = delete;
SharedResource(SharedResource&&) = delete;
SharedResource& operator=(SharedResource&&) = delete;
// ── Acquire ───────────────────────────────────────────────────────────────
// Acquire with a callable that returns a priority in [0, 1].
// Higher = more urgent. Called at every release to pick the best waiter.
template<typename PriorityFn>
Guard acquire(PriorityFn&& fn) {
std::unique_lock lock(mutex_);
if (!held_) {
held_ = true;
acq_.fetch_add(1, std::memory_order_relaxed);
return Guard(this);
}
Waiter w{std::function<float()>(std::forward<PriorityFn>(fn)), clock_t::now()};
waiters_.push_back(&w);
update_peak(waiters_.size());
current_waiters_.store(waiters_.size(), std::memory_order_relaxed);
auto t0 = w.wait_start;
w.cv.wait(lock, [&w] { return w.ready; });
int64_t wait_us = std::chrono::duration_cast<std::chrono::microseconds>(
clock_t::now() - t0).count();
waiters_.erase(std::find(waiters_.begin(), waiters_.end(), &w));
current_waiters_.store(waiters_.size(), std::memory_order_relaxed);
acq_.fetch_add(1, std::memory_order_relaxed);
total_wait_us_.fetch_add(static_cast<uint64_t>(wait_us > 0 ? wait_us : 0),
std::memory_order_relaxed);
return Guard(this);
}
// Acquire with no priority (all waiters treated equally, order is fair-ish).
Guard acquire() {
return acquire([] { return 0.5f; });
}
// Acquire with priority derived from channel fill fractions:
// score = input_fill × output_headroom
// A node with a full input queue and empty output queue has the highest
// urgency — it has work to do and nowhere to stall downstream.
template<typename In, typename Out>
Guard acquire_balanced(const Channel<In>& in_ch, const Channel<Out>& out_ch) {
return acquire([&in_ch, &out_ch] {
float in_fill = in_ch.capacity() ? float(in_ch.size()) / in_ch.capacity() : 0.5f;
float out_head = out_ch.capacity() ? 1.0f - float(out_ch.size()) / out_ch.capacity() : 0.5f;
return in_fill * out_head;
});
}
// ── IResourceProbe ────────────────────────────────────────────────────────
ResourceSnapshot snapshot(const std::string& name) const override {
std::lock_guard lock(mutex_);
uint64_t a = acq_.load(std::memory_order_relaxed);
uint64_t w = total_wait_us_.load(std::memory_order_relaxed);
return {
name,
a,
a > 0 ? double(w) / a / 1000.0 : 0.0,
peak_waiters_.load(std::memory_order_relaxed),
current_waiters_.load(std::memory_order_relaxed),
held_,
};
}
private:
void release() {
std::unique_lock lock(mutex_);
if (waiters_.empty()) {
held_ = false;
return;
}
// Re-evaluate every waiter's current priority and apply aging bonus.
auto now = clock_t::now();
Waiter* best = nullptr;
float best_score = -1.0f;
for (Waiter* w : waiters_) {
float age_s = std::chrono::duration<float>(now - w->wait_start).count();
float score = w->priority_fn() + age_s * kAgingPerSecond;
if (score > best_score) { best_score = score; best = w; }
}
best->ready = true;
best->cv.notify_one();
// held_ stays true — ownership transfers to the woken waiter.
}
void update_peak(std::size_t n) {
uint64_t prev = peak_waiters_.load(std::memory_order_relaxed);
while (n > prev &&
!peak_waiters_.compare_exchange_weak(prev, n,
std::memory_order_relaxed, std::memory_order_relaxed))
;
}
struct Waiter {
std::function<float()> priority_fn;
clock_t::time_point wait_start;
std::condition_variable cv;
bool ready{false};
Waiter(std::function<float()> fn, clock_t::time_point t)
: priority_fn(std::move(fn)), wait_start(t) {}
};
static constexpr float kAgingPerSecond = 0.05f;
T resource_;
bool held_{false};
mutable std::mutex mutex_;
std::vector<Waiter*> waiters_;
std::atomic<uint64_t> acq_{0};
std::atomic<uint64_t> total_wait_us_{0};
std::atomic<uint64_t> peak_waiters_{0};
std::atomic<uint64_t> current_waiters_{0};
};
// ── Factory ───────────────────────────────────────────────────────────────────
template<typename T, typename... Args>
SharedResource<T> make_shared_resource(Args&&... args) {
return SharedResource<T>(std::forward<Args>(args)...);
}
} // namespace kpn

View File

@ -2,7 +2,7 @@
#include "channel.hpp"
#include "diagnostics.hpp"
#include "fanout.hpp"
#include "node.hpp"
#include "inode.hpp"
#include "port.hpp"
#include "tmp/fanout_groups.hpp"
#include "tmp/topo_sort.hpp"
@ -13,7 +13,9 @@
#endif
#include <iostream>
#include <map>
#include <string>
#include <thread>
#include <tuple>
#include <type_traits>
#include <utility>
@ -74,6 +76,10 @@ std::string node_display_name() {
return std::string(lbl);
} else if constexpr (requires { NodeT::is_fanout_node; NodeT::unique_tag; }) {
return "fanout[" + std::to_string(NodeT::unique_tag) + "]";
} else if constexpr (requires { NodeT::is_router_node; NodeT::unique_tag; }) {
return "router[" + std::to_string(NodeT::unique_tag) + "]";
} else if constexpr (requires { NodeT::is_filter_node; NodeT::unique_tag; }) {
return "filter[" + std::to_string(NodeT::unique_tag) + "]";
} else if constexpr (requires { NodeT::unique_tag; }) {
return "node[" + std::to_string(NodeT::unique_tag) + "]";
} else {
@ -106,21 +112,35 @@ public:
void start() override {
stop_flag_ = false;
start_time_ = clock_t::now();
if (event_handler_) {
for (std::size_t i = 0; i < user_nodes_topo_.size(); ++i) {
auto* node = user_nodes_topo_[i];
const auto& n = user_node_names_[i];
node->set_network_overflow_callback(
[this, n](auto ts) { event_handler_(n, NodeEvent::Overflow, ts); });
node->set_network_closed_callback(
[this, n](auto ts) { event_handler_(n, NodeEvent::Closed, ts); });
}
}
for (auto* n : user_nodes_topo_) n->start();
for (auto* n : fanout_nodes_ptr_) n->start();
#ifdef KPN_WEB_DEBUG
if (web_server_enabled_) {
web_server_ = std::make_unique<web_debug::WebDebugServer>(
web_debug_port_,
[this]() {
auto s = collect_snapshots();
return web_debug::to_json(s.nodes, s.channels, s.elapsed_s);
return web_debug::to_json(s.nodes, s.channels, s.resources, s.elapsed_s, s.pools);
});
web_server_->start();
std::cerr << "[kpn] web debug UI: http://localhost:" << web_debug_port_ << "\n";
}
#endif
}
void stop() override {
void stop() override { halt(); }
void halt() override {
stop_flag_ = true;
#ifdef KPN_WEB_DEBUG
if (web_server_) web_server_->stop();
@ -131,6 +151,22 @@ public:
(*it)->stop();
}
// shutdown(): graceful drain in topological order (sources first).
// Stops source nodes, polls channels until empty, then stops each downstream layer.
void shutdown() override {
stop_flag_ = true;
#ifdef KPN_WEB_DEBUG
if (web_server_) web_server_->stop();
#endif
// user_nodes_topo_ is already in sources-first order.
// Stop each node and drain its output channels before moving on.
for (auto* n : user_nodes_topo_) {
n->stop();
drain_all_channels();
}
for (auto* n : fanout_nodes_ptr_) n->stop();
}
bool running() const override { return !stop_flag_; }
void set_name(std::string name) override { name_ = std::move(name); }
@ -139,10 +175,36 @@ public:
return {n, 0, 0, 0, 0, 0, 0, 0};
}
using EventHandler =
std::function<void(std::string_view node_name, NodeEvent,
std::chrono::steady_clock::time_point)>;
void set_event_handler(EventHandler h) { event_handler_ = std::move(h); }
#ifdef KPN_WEB_DEBUG
void set_web_debug_port(uint16_t port) { web_debug_port_ = port; }
// Called by DebugHub::register_network() so the hub owns the debug server.
void disable_web_server() { web_server_enabled_ = false; }
#endif
// Returns a snapshot of this network's nodes and channels for the DebugHub.
NetworkSnapshot network_snapshot() const {
auto s = collect_snapshots();
return {"", std::move(s.nodes), std::move(s.channels), s.elapsed_s};
}
// Register a shared resource so it appears in diagnostics and the debug UI.
// The probe must outlive this network (typically the resource is on the same stack).
void register_resource(const std::string& name, IResourceProbe* probe) {
resource_probes_.emplace_back(name, probe);
}
// Register a thread pool so it appears in diagnostics and the debug UI.
// The probe must outlive this network (typically the pool is on the same stack/shared_ptr).
void register_pool(const std::string& name, IPoolProbe* probe) {
pool_probes_.emplace_back(name, probe);
}
// Print diagnostics using compile-time node labels
void print_diagnostics(std::ostream& os = std::cerr) const {
os << "\n┌─ KPN++ StaticNetwork diagnostics ─────────────────────────────\n";
@ -161,6 +223,8 @@ private:
struct Snapshots {
std::vector<NodeSnapshot> nodes;
std::vector<ChannelSnapshot> channels;
std::vector<ResourceSnapshot> resources;
std::vector<PoolSnapshot> pools;
double elapsed_s;
};
@ -178,7 +242,27 @@ private:
for (auto& probe : channel_probes_)
channels.push_back(probe->snapshot());
return {std::move(nodes), std::move(channels), elapsed_s};
std::vector<ResourceSnapshot> resources;
for (auto& [name, probe] : resource_probes_)
resources.push_back(probe->snapshot(name));
std::vector<PoolSnapshot> pools;
for (auto& [name, probe] : pool_probes_)
pools.push_back(probe->snapshot(name));
return {std::move(nodes), std::move(channels), std::move(resources), std::move(pools), elapsed_s};
}
void drain_all_channels() const {
bool any_full = true;
while (any_full) {
any_full = false;
for (auto& probe : channel_probes_) {
if (probe->snapshot().current_fill > 0) { any_full = true; break; }
}
if (any_full)
std::this_thread::sleep_for(std::chrono::milliseconds(1));
}
}
std::string name_;
@ -189,9 +273,13 @@ private:
std::vector<std::string> user_node_names_;
std::vector<std::string> fanout_node_names_;
std::vector<std::unique_ptr<IChannelProbe>> channel_probes_;
std::vector<std::pair<std::string, IResourceProbe*>> resource_probes_;
std::vector<std::pair<std::string, IPoolProbe*>> pool_probes_;
EventHandler event_handler_;
clock_t::time_point start_time_;
#ifdef KPN_WEB_DEBUG
uint16_t web_debug_port_{9090};
bool web_server_enabled_{true};
std::unique_ptr<web_debug::WebDebugServer> web_server_;
#endif
};
@ -229,12 +317,16 @@ auto make_network(Edges&&... edges) {
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(node_display_name<SrcT>());
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(node_display_name<DstT>());
user_node_names.push_back(dname);
d->set_name(dname);
}
};
(collect(edges), ...);
@ -317,8 +409,10 @@ auto make_network(Edges&&... edges) {
std::apply([&](auto&... fn) {
([&](auto& node) {
using NodeT = std::decay_t<decltype(node)>;
auto fname = node_name.template operator()<NodeT>();
static_cast<INode&>(node).set_name(fname);
fanout_ptrs.push_back(static_cast<INode*>(&node));
fanout_node_names.push_back(node_name.template operator()<NodeT>());
fanout_node_names.push_back(fname);
}(fn), ...);
}, *fanout_storage);

View File

@ -83,4 +83,18 @@ template<typename F>
inline constexpr std::size_t output_count_v =
std::tuple_size_v<normalised_return_t<return_t<F>>>;
// ── repeat_tuple: std::tuple<T, T, ..., T> with N repetitions ────────────────
template<typename T, std::size_t N, typename Seq = std::make_index_sequence<N>>
struct repeat_tuple;
template<typename T, std::size_t N, std::size_t... Is>
struct repeat_tuple<T, N, std::index_sequence<Is...>> {
template<std::size_t> using always_T = T;
using type = std::tuple<always_T<Is>...>;
};
template<typename T, std::size_t N>
using repeat_tuple_t = typename repeat_tuple<T, N>::type;
} // namespace kpn

View File

@ -239,11 +239,13 @@ private:
};
// ── PythonConverter ───────────────────────────────────────────────────────────
// Specialise for each type you want to use across a PyNetwork.
// Required members: to_python(const T&), from_python(nb::object).
// Optional member: static constexpr const char* type_name = "friendly_name";
// Built-in specialisations for int/float/double/bool/std::string are provided
// automatically when you include <kpn/python/auto_bind.hpp>.
template<typename T>
struct PythonConverter {
static_assert(sizeof(T) == 0,
"PythonConverter<T> must be specialised for each type used in a PyNetwork");
};
struct PythonConverter {};
} // namespace kpn

View File

@ -46,7 +46,9 @@ static std::pair<std::string,std::string> parse_edge_name(const std::string& nam
static std::string to_json(const std::vector<NodeSnapshot>& nodes,
const std::vector<ChannelSnapshot>& channels,
double elapsed_s = 0.0) {
const std::vector<ResourceSnapshot>& resources = {},
double elapsed_s = 0.0,
const std::vector<PoolSnapshot>& pools = {}) {
std::ostringstream o;
o << std::fixed;
o.precision(2);
@ -84,6 +86,32 @@ static std::string to_json(const std::vector<NodeSnapshot>& nodes,
<< ",\"bw_mbs\":" << c.bandwidth_mbs(elapsed_s)
<< "}";
}
o << "],\"resources\":[";
for (std::size_t i = 0; i < resources.size(); ++i) {
const auto& r = resources[i];
if (i) o << ',';
o << "{\"name\":\"" << escape_json(r.name) << "\""
<< ",\"acquisitions\":" << r.acquisitions
<< ",\"avg_wait_ms\":" << r.avg_wait_ms
<< ",\"peak_waiters\":" << r.peak_waiters
<< ",\"current_waiters\":" << r.current_waiters
<< ",\"held\":" << (r.held ? "true" : "false")
<< "}";
}
o << "],\"pools\":[";
for (std::size_t i = 0; i < pools.size(); ++i) {
const auto& p = pools[i];
if (i) o << ',';
double in_rate = elapsed_s > 0.0 ? p.tasks_submitted / elapsed_s : 0.0;
double out_rate = elapsed_s > 0.0 ? p.tasks_completed / elapsed_s : 0.0;
o << "{\"name\":\"" << escape_json(p.name) << "\""
<< ",\"thread_count\":" << p.thread_count
<< ",\"queue_depth\":" << p.queue_depth
<< ",\"active_count\":" << p.active_count
<< ",\"in_rate\":" << in_rate
<< ",\"out_rate\":" << out_rate
<< "}";
}
o << "]}";
return o.str();
}
@ -112,12 +140,24 @@ static const char* HTML = R"html(<!DOCTYPE html>
border-radius: 4px; padding: 8px 12px; font-size: 11px; pointer-events: none;
display: none; white-space: pre; line-height: 1.6;
}
#resources {
position: absolute; bottom: 12px; right: 12px;
background: #16213e; border: 1px solid #0f3460; border-radius: 4px;
padding: 8px 12px; font-size: 10px; min-width: 220px;
display: none;
}
#resources h2 { margin: 0 0 6px; font-size: 11px; color: #e94560; }
.res-row { display: flex; justify-content: space-between; gap: 12px; margin-top: 3px; }
.res-name { color: #eee; }
.res-held-y { color: #e94560; }
.res-held-n { color: #4CAF50; }
</style>
</head>
<body>
<div id="header"><h1>KPN++ Web Debug</h1><span id="status">connecting...</span></div>
<svg id="graph"></svg>
<div id="tooltip"></div>
<div id="resources"><h2>Shared Resources</h2><div id="res-list"></div></div>
<script src="https://d3js.org/d3.v7.min.js"></script>
<script>
const nodeRadius = 30;
@ -139,7 +179,7 @@ const defs = svg.append('defs');
const g = svg.append('g');
svg.call(d3.zoom().on('zoom', e => g.attr('transform', e.transform)));
let sim, linkSel, nodeSel, labelSel, edgeLabelSel;
let sim, linkSel, nodeSel, labelSel, edgeLabelSel, edgeLabelBwSel;
let nodes = [], links = [];
function edgeColor(fill_pct) {
@ -186,6 +226,10 @@ function init(data) {
.attr('class', 'link-label')
.text(d => `${d.fill_pct.toFixed(0)}%`);
edgeLabelBwSel = g.append('g').selectAll('text').data(links).join('text')
.attr('class', 'link-label')
.text(d => `${d.bw_mbs.toFixed(1)} MB/s`);
// Nodes
const nodeG = g.append('g').selectAll('g').data(nodes).join('g')
.attr('class', 'node')
@ -200,6 +244,7 @@ function init(data) {
.text(d => `${d.ema_exec_ms.toFixed(1)}ms ${d.fps.toFixed(1)}fps`);
nodeSel = nodeG;
renderResources(data.resources);
// Tooltips
const tip = d3.select('#tooltip');
@ -224,7 +269,25 @@ function init(data) {
}).on('mouseleave', () => tip.style('display','none'));
}
function renderResources(resources) {
const panel = document.getElementById('resources');
const list = document.getElementById('res-list');
if (!resources || resources.length === 0) { panel.style.display = 'none'; return; }
panel.style.display = 'block';
list.innerHTML = resources.map(r => {
const heldCls = r.held ? 'res-held-y' : 'res-held-n';
const heldTxt = r.held ? 'HELD' : 'free';
return `<div class="res-row">
<span class="res-name">${r.name}</span>
<span class="${heldCls}">${heldTxt}</span>
<span>wait ${r.avg_wait_ms.toFixed(1)}ms</span>
<span>waiters ${r.current_waiters}/${r.peak_waiters}</span>
</div>`;
}).join('');
}
function update(data) {
renderResources(data.resources);
// Update node stats in-place (preserve simulation x/y positions)
const byId = Object.fromEntries(data.nodes.map(n => [n.id, n]));
nodes.forEach(n => {
@ -252,6 +315,7 @@ function update(data) {
linkSel.attr('stroke', d => edgeColor(d.fill_pct))
.attr('marker-end', d => edgeArrow(d.fill_pct));
edgeLabelSel.text(d => `${d.fill_pct.toFixed(0)}%`);
edgeLabelBwSel.text(d => `${d.bw_mbs.toFixed(1)} MB/s`);
}
function ticked() {
@ -278,6 +342,10 @@ function ticked() {
.attr('x', d => (d.source.x + d.target.x)/2)
.attr('y', d => (d.source.y + d.target.y)/2 - 6);
edgeLabelBwSel
.attr('x', d => (d.source.x + d.target.x)/2)
.attr('y', d => (d.source.y + d.target.y)/2 + 8);
nodeSel.attr('transform', d => `translate(${d.x},${d.y})`);
}
@ -311,12 +379,14 @@ class WebDebugServer {
public:
using SnapshotFn = std::function<std::string()>;
explicit WebDebugServer(uint16_t port, SnapshotFn fn)
: port_(port), snapshot_fn_(std::move(fn)) {}
explicit WebDebugServer(uint16_t port, SnapshotFn fn,
const char* custom_html = nullptr)
: port_(port), snapshot_fn_(std::move(fn))
, html_(custom_html ? custom_html : HTML) {}
void start() {
svr_.Get("/", [](const httplib::Request&, httplib::Response& res) {
res.set_content(HTML, "text/html");
svr_.Get("/", [this](const httplib::Request&, httplib::Response& res) {
res.set_content(html_, "text/html");
});
svr_.Get("/api/snapshot", [this](const httplib::Request&, httplib::Response& res) {
res.set_content(snapshot_fn_(), "application/json");
@ -339,6 +409,7 @@ public:
private:
uint16_t port_;
SnapshotFn snapshot_fn_;
const char* html_;
httplib::Server svr_;
std::thread thread_;
};

50
mkdocs.yml Normal file
View File

@ -0,0 +1,50 @@
site_name: KPN++
site_description: A C++20 Kahn Process Network library
repo_url: https://gitea.tourolle.paris/dtourolle/KPN
repo_name: dtourolle/KPN
theme:
name: material
palette:
- scheme: slate
primary: indigo
accent: indigo
features:
- navigation.tabs
- navigation.sections
- navigation.top
- content.code.copy
- content.code.annotate
nav:
- Home: index.md
- Getting Started: getting-started.md
- Concepts:
- Nodes: nodes.md
- Networks: network.md
- Channels: channels.md
- Error Handling & Events: error-handling.md
- Advanced:
- Static Networks: static-network.md
- Shared Resources: shared-resource.md
- Fan-out & Routing: fanout.md
- Examples: examples.md
markdown_extensions:
- admonition
- toc:
permalink: true
- pymdownx.highlight:
anchor_linenums: true
line_spans: __span
pygments_lang_class: true
- pymdownx.inlinehilite
- pymdownx.superfences
- pymdownx.tabbed:
alternate_style: true
- pymdownx.snippets:
base_path: ['.']
check_paths: true
- pymdownx.details
- attr_list
- md_in_html

View File

@ -1,164 +1,39 @@
#define KPN_BUILD_PYTHON
#include <kpn/python/bindings.hpp>
#include <nanobind/nanobind.h>
#include <nanobind/stl/shared_ptr.h>
#include <nanobind/stl/string.h>
#include <nanobind/stl/vector.h>
#include <kpn/python/auto_bind.hpp>
#include <iostream>
#include <map>
namespace nb = nanobind;
using namespace kpn;
using namespace kpn::python;
// ── Node functions for the hello-pipeline examples ────────────────────────────
// ── Demo node functions (hello-pipeline) ──────────────────────────────────────
static int produce() { return 42; }
static int double_it(int x) { return x * 2; }
static void print_it(int x) { std::cout << "result: " << x << '\n'; }
// ── Variant type ──────────────────────────────────────────────────────────────
// Deduplicated port types across all registered node functions.
// produce: () → int | double_it: int → int | print_it: int → void
// Unique types: int
// ── Registry ──────────────────────────────────────────────────────────────────
// Variant type is auto-deduced as std::variant<int> from the port types above.
using KpnVariant = std::variant<int>;
using Net = PyNetwork<KpnVariant>;
using ProduceNode = VariantNodeWrapper<produce, KpnVariant>;
using DoubleItNode= VariantNodeWrapper<double_it, KpnVariant>;
using PrintItNode = VariantNodeWrapper<print_it, KpnVariant>;
using DemoNodes = NodeRegistry<
Entry<produce, "produce">,
Entry<double_it, "double_it">,
Entry<print_it, "print_it">
>;
// ── Converter helpers for int ─────────────────────────────────────────────────
static nb::object int_to_py(const KpnVariant& v) {
return nb::int_(std::get<int>(v));
}
static KpnVariant int_from_py(nb::object o) {
return KpnVariant{ nb::cast<int>(o) };
}
// ── Type name resolver (extensible) ──────────────────────────────────────────
static std::type_index resolve_type(const std::string& name) {
if (name == "int") return std::type_index(typeid(int));
throw std::runtime_error("unknown type name '" + name +
"' — only 'int' is registered in this module");
}
// ── Ensure converters are registered on a Net instance ───────────────────────
static void ensure_converters(Net& net) {
net.register_type<int>(
[](const int& v) -> nb::object { return nb::int_(v); },
[](nb::object o) -> int { return nb::cast<int>(o); }
);
net.register_tap_factory<int>();
}
// ── Module ────────────────────────────────────────────────────────────────────
NB_MODULE(kpn_python, m) {
m.doc() = "KPN++ Python bindings — Kahn Process Network library";
// ── IVariantNode base ─────────────────────────────────────────────────────
nb::class_<IVariantNode<KpnVariant>>(m, "INode");
// Registers: Network, INode, ProduceNode, DoubleItNode, PrintItNode,
// make_produce(), make_double_it(), make_print_it()
bind_network<DemoNodes>(m);
// ── Concrete C++ node wrappers ─────────────────────────────────────────────
// Factories return shared_ptr so Python can pass them to net.add().
nb::class_<ProduceNode, IVariantNode<KpnVariant>>(m, "ProduceNode")
.def("__init__", [](ProduceNode* self, std::size_t cap) {
new (self) ProduceNode(cap);
}, nb::arg("capacity") = 5);
nb::class_<DoubleItNode, IVariantNode<KpnVariant>>(m, "DoubleItNode")
.def("__init__", [](DoubleItNode* self, std::size_t cap) {
new (self) DoubleItNode(cap);
}, nb::arg("capacity") = 5);
nb::class_<PrintItNode, IVariantNode<KpnVariant>>(m, "PrintItNode")
.def("__init__", [](PrintItNode* self, std::size_t cap) {
new (self) PrintItNode(cap);
}, nb::arg("capacity") = 5);
// Expose factory functions that return shared_ptr — these are what net.add() accepts.
m.def("make_produce", [](std::size_t cap) -> std::shared_ptr<IVariantNode<KpnVariant>> {
return std::make_shared<ProduceNode>(cap);
}, nb::arg("capacity") = 5);
m.def("make_double_it", [](std::size_t cap) -> std::shared_ptr<IVariantNode<KpnVariant>> {
return std::make_shared<DoubleItNode>(cap);
}, nb::arg("capacity") = 5);
m.def("make_print_it", [](std::size_t cap) -> std::shared_ptr<IVariantNode<KpnVariant>> {
return std::make_shared<PrintItNode>(cap);
}, nb::arg("capacity") = 5);
// ── Network ───────────────────────────────────────────────────────────────
nb::class_<Net>(m, "Network")
.def(nb::init<>())
// add(name, c++_node) — for pre-constructed C++ nodes
.def("add", [](Net& self, std::string name,
std::shared_ptr<IVariantNode<KpnVariant>> node) {
self.add(std::move(name), std::move(node));
}, nb::arg("name"), nb::arg("node"))
// add_node(name, callable, inputs=[type_names], outputs=[type_names])
// Creates a pure Python processing node.
.def("add_node", [](Net& self,
std::string name,
nb::object callable,
std::vector<std::string> in_names,
std::vector<std::string> out_names,
std::size_t capacity)
{
std::vector<std::type_index> in_types, out_types;
for (auto& s : in_names) in_types.push_back(resolve_type(s));
for (auto& s : out_names) out_types.push_back(resolve_type(s));
std::map<std::type_index, std::function<nb::object(const KpnVariant&)>> to_py;
std::map<std::type_index, std::function<KpnVariant(nb::object)>> from_py;
std::map<std::type_index, PyNode<KpnVariant>::ChannelFactory> ch_factories;
auto int_idx = std::type_index(typeid(int));
to_py[int_idx] = int_to_py;
from_py[int_idx] = int_from_py;
ch_factories[int_idx] = [](std::size_t cap) -> std::shared_ptr<IVariantChannel<KpnVariant>> {
auto ch = std::make_shared<Channel<int>>(cap);
return std::make_shared<VariantChannel<int, KpnVariant>>(std::move(ch));
};
auto node = std::make_shared<PyNode<KpnVariant>>(
std::move(callable),
std::move(in_types),
std::move(out_types),
std::move(to_py),
std::move(from_py),
std::move(ch_factories),
capacity
);
self.add(std::move(name), std::move(node));
},
nb::arg("name"), nb::arg("callable"),
nb::arg("inputs") = std::vector<std::string>{},
nb::arg("outputs") = std::vector<std::string>{},
nb::arg("capacity") = 5)
.def("connect", &Net::connect,
nb::arg("src"), nb::arg("out_idx"),
nb::arg("dst"), nb::arg("in_idx"))
.def("build", &Net::build)
.def("start", &Net::start)
.def("stop", &Net::stop)
// read(node, out_idx=0) — blocking pop from a C++ node's output port into Python
.def("read", [](Net& self, const std::string& node, std::size_t out_idx) {
ensure_converters(self);
return self.read(node, out_idx);
}, nb::arg("node"), nb::arg("out_idx") = 0)
// write(node, in_idx, value) — push a Python value into a node's input port
.def("write", [](Net& self, const std::string& node,
std::size_t in_idx, nb::object value) {
ensure_converters(self);
self.write(node, in_idx, std::move(value));
}, nb::arg("node"), nb::arg("in_idx"), nb::arg("value"));
// Also expose raw functions for direct testing (no network needed):
// kpn.produce() → 42
// kpn.double_it(5) → 10
// kpn.print_it(84) → prints "result: 84", returns None
bind_debug<DemoNodes>(m);
}

121
scripts/render_readme.py Normal file
View File

@ -0,0 +1,121 @@
#!/usr/bin/env python3
"""
Render README.md from README.md.in by expanding @snippet directives.
Directive format (in README.md.in):
<!-- @snippet path/to/file.cpp snippet_name -->
Snippet tags (in C++ source files):
// --8<-- [start:snippet_name]
...content...
// --8<-- [end:snippet_name]
In Python files use # instead of //.
"""
import re
import sys
import textwrap
from pathlib import Path
ROOT = Path(__file__).parent.parent
TEMPLATE = ROOT / "README.md.in"
OUTPUT = ROOT / "README.md"
DIRECTIVE_RE = re.compile(r'<!--\s*@snippet\s+(\S+)\s+(\S+)\s*-->')
LANG_MAP = {'.cpp': 'cpp', '.hpp': 'cpp', '.h': 'cpp', '.py': 'python', '.c': 'c'}
def comment_prefix(path: Path) -> str:
return '#' if path.suffix == '.py' else '//'
def extract_snippet(file_path: Path, name: str) -> str:
prefix = comment_prefix(file_path)
open_tag = f'{prefix} --8<-- [start:{name}]'
close_tag = f'{prefix} --8<-- [end:{name}]'
text = file_path.read_text()
lines = text.splitlines()
in_snippet = False
found = False
result = []
for line in lines:
stripped = line.strip()
if stripped == open_tag:
if in_snippet:
raise ValueError(f"Nested open tag for '{name}' in {file_path}")
in_snippet = True
found = True
continue
if stripped == close_tag:
if not in_snippet:
raise ValueError(f"Unexpected close tag for '{name}' in {file_path}")
in_snippet = False
continue
if in_snippet:
result.append(line)
if not found:
raise ValueError(f"Snippet '{name}' not found in {file_path}")
if in_snippet:
raise ValueError(f"Unclosed snippet '{name}' in {file_path}")
# Strip leading/trailing blank lines
while result and not result[0].strip():
result.pop(0)
while result and not result[-1].strip():
result.pop()
# Dedent common leading whitespace
content = textwrap.dedent('\n'.join(result))
return content
def lang_for(file_path: Path) -> str:
return LANG_MAP.get(file_path.suffix, '')
def render(template: str) -> str:
errors = []
def replace(m: re.Match) -> str:
rel_path = m.group(1)
name = m.group(2)
file_path = ROOT / rel_path
if not file_path.exists():
errors.append(f"File not found: {file_path}")
return m.group(0)
try:
content = extract_snippet(file_path, name)
except ValueError as e:
errors.append(str(e))
return m.group(0)
lang = lang_for(file_path)
return f'```{lang}\n{content}\n```'
result = DIRECTIVE_RE.sub(replace, template)
if errors:
for err in errors:
print(f"ERROR: {err}", file=sys.stderr)
sys.exit(1)
return result
def main() -> None:
if not TEMPLATE.exists():
print(f"Error: {TEMPLATE} not found", file=sys.stderr)
sys.exit(1)
rendered = render(TEMPLATE.read_text())
OUTPUT.write_text(rendered)
print(f"Rendered {TEMPLATE.name}{OUTPUT.name}")
if __name__ == '__main__':
main()

View File

@ -1,4 +1,4 @@
// network.cpp — orchestrator/watchdog implementation details.
// network.cpp — orchestrator/watchdog implementation details. (CI: pipeline trigger)
// Most of the Network class is header-only (template-heavy).
// Non-template implementation lives here once the watchdog grows
// beyond the stub in network.hpp.

View File

@ -32,6 +32,9 @@ add_executable(kpn_tests
test_node.cpp
test_network.cpp
test_static_network.cpp
test_shared_resource.cpp
test_pool_node.cpp
test_scheduler.cpp
)
target_link_libraries(kpn_tests PRIVATE

View File

@ -1,6 +1,8 @@
#include <catch2/catch_test_macros.hpp>
#include <catch2/catch_approx.hpp>
#include <kpn/channel.hpp>
#include <thread>
#include <vector>
using namespace kpn;
@ -70,13 +72,21 @@ TEST_CASE("push to disabled channel is silently dropped", "[channel]") {
REQUIRE(ch.size() == 0);
}
TEST_CASE("disable clears existing queue contents", "[channel]") {
TEST_CASE("disable stops accepting and unblocks pop", "[channel]") {
// With the SPSC lock-free ring, disable() does not drain the ring immediately;
// items are freed when the Channel is destroyed. What it must do is:
// 1. reject further pushes (drops silently)
// 2. unblock any waiting pop() (throws ChannelClosedError)
Channel<int> ch(5);
ch.push(1);
ch.push(2);
REQUIRE(ch.size() == 2);
ch.disable();
REQUIRE(ch.size() == 0);
// Further pushes are dropped
ch.push(3);
REQUIRE(ch.size() <= 2);
// pop() must throw even though items remain in the ring
REQUIRE_THROWS_AS(ch.pop(), ChannelClosedError);
}
TEST_CASE("enable re-accepts pushes after disable", "[channel]") {
@ -97,3 +107,134 @@ TEST_CASE("large type stored as shared_ptr — no copy on pop", "[channel]") {
auto out = ch.pop();
REQUIRE(out.tag == 123);
}
// ── ChannelDataSize / bytes_pushed tests ─────────────────────────────────────
TEST_CASE("bytes_pushed uses sizeof(T) by default for POD type", "[channel][bandwidth]") {
Channel<int> ch(10);
ch.push(1);
ch.push(2);
ch.push(3);
ch.pop(); ch.pop(); ch.pop();
auto snap = ch.snapshot("test");
REQUIRE(snap.pushes == 3);
REQUIRE(snap.bytes_pushed == 3 * sizeof(int));
}
TEST_CASE("bytes_pushed uses sizeof(T) by default for large struct", "[channel][bandwidth]") {
struct Blob { char data[256]; };
Channel<Blob> ch(10);
ch.push(Blob{});
ch.push(Blob{});
auto snap = ch.snapshot("test");
REQUIRE(snap.bytes_pushed == 2 * sizeof(Blob));
}
// A fake heap-owning type whose logical payload size differs from sizeof.
struct FakeFrame {
std::vector<uint8_t> pixels;
};
// Specialise ChannelDataSize so the channel counts actual pixel bytes.
template<>
struct kpn::ChannelDataSize<FakeFrame> {
static std::size_t bytes(const FakeFrame& f) { return f.pixels.size(); }
};
TEST_CASE("bytes_pushed uses ChannelDataSize specialisation for heap-owning type", "[channel][bandwidth]") {
Channel<FakeFrame> ch(10);
ch.push(FakeFrame{std::vector<uint8_t>(1000)});
ch.push(FakeFrame{std::vector<uint8_t>(2000)});
auto snap = ch.snapshot("test");
REQUIRE(snap.pushes == 2);
REQUIRE(snap.bytes_pushed == 3000);
// item_bytes is still sizeof(FakeFrame) — the struct header
REQUIRE(snap.item_bytes == sizeof(FakeFrame));
}
TEST_CASE("bandwidth_mbs is non-zero and correct for heap-owning type", "[channel][bandwidth]") {
Channel<FakeFrame> ch(10);
// Push 10 frames of 1 MB each
for (int i = 0; i < 10; ++i)
ch.push(FakeFrame{std::vector<uint8_t>(1'000'000)});
auto snap = ch.snapshot("test");
// 10 MB over 1 second => 10.0 MB/s
REQUIRE(snap.bandwidth_mbs(1.0) == Catch::Approx(10.0).epsilon(1e-6));
// Without the fix (using sizeof), this would have been ~40 bytes/s ≈ 0.00004 MB/s.
REQUIRE(snap.bandwidth_mbs(1.0) > 1.0);
}
TEST_CASE("bandwidth_mbs returns 0 when elapsed_s is zero or negative", "[channel][bandwidth]") {
Channel<int> ch(5);
ch.push(42);
auto snap = ch.snapshot("test");
REQUIRE(snap.bandwidth_mbs(0.0) == 0.0);
REQUIRE(snap.bandwidth_mbs(-1.0) == 0.0);
}
TEST_CASE("push_sentinel never overflows even on a full channel", "[channel][sentinel]") {
Channel<int> ch(2);
ch.push(1);
ch.push(2); // channel full — a plain push(3) would throw ChannelOverflowError
// The sentinel is stored out-of-band, so it neither throws nor blocks the
// caller — the exact property an EOF token needs under backpressure. This
// returns immediately with the ring still full.
REQUIRE(ch.push_sentinel(99));
REQUIRE(ch.size() == 2); // sentinel did not consume ring capacity
}
TEST_CASE("push_sentinel is delivered after all ring data, in order", "[channel][sentinel]") {
Channel<int> ch(4);
ch.push(1);
ch.push(2);
ch.push_sentinel(99); // enqueue EOF while data is still buffered
// Data drains first; the sentinel arrives only once the ring is empty.
REQUIRE(ch.pop() == 1);
REQUIRE(ch.pop() == 2);
REQUIRE(ch.pop() == 99);
}
TEST_CASE("push_sentinel wakes a blocked pop", "[channel][sentinel]") {
Channel<int> ch(2); // empty
std::thread producer([&] {
std::this_thread::sleep_for(std::chrono::milliseconds(20));
ch.push_sentinel(99); // must wake a consumer parked on an empty ring
});
REQUIRE(ch.pop() == 99);
producer.join();
}
TEST_CASE("approx_size counts a pending sentinel so consumers stay schedulable",
"[channel][sentinel]") {
Channel<int> ch(4);
REQUIRE(ch.approx_size() == 0);
ch.push_sentinel(99);
// Node readiness checks call approx_size(); it must report the out-of-band
// sentinel as consumable work even though it holds no ring slot.
REQUIRE(ch.approx_size() == 1);
REQUIRE(ch.size() == 0); // ...but the ring itself is still empty
int out = 0;
REQUIRE(ch.try_pop_now(out));
REQUIRE(out == 99);
REQUIRE(ch.approx_size() == 0);
}
TEST_CASE("try_pop_now delivers a pending sentinel once the ring is empty",
"[channel][sentinel]") {
Channel<int> ch(2);
ch.push(1);
ch.push_sentinel(99);
int out = 0;
REQUIRE(ch.try_pop_now(out)); // ring data first
REQUIRE(out == 1);
REQUIRE(ch.try_pop_now(out)); // then the sentinel
REQUIRE(out == 99);
REQUIRE_FALSE(ch.try_pop_now(out)); // nothing left
}

View File

@ -2,6 +2,7 @@
#include <kpn/node.hpp>
#include <chrono>
#include <thread>
#include <utility>
using namespace kpn;
@ -47,3 +48,104 @@ TEST_CASE("node stop unblocks cleanly", "[node]") {
node.stop();
REQUIRE_FALSE(node.running());
}
// ── Error handler tests ───────────────────────────────────────────────────────
namespace {
struct SometimesThrower {
bool throw_next = true;
int operator()(int x) {
if (std::exchange(throw_next, false))
throw std::runtime_error("deliberate skip");
return x * 2;
}
};
struct AlwaysThrows {
int operator()(int) { throw std::runtime_error("always throws"); return 0; }
};
static int always_throws_fn(int) {
throw std::runtime_error("nttp always throws");
return 0;
}
} // namespace
TEST_CASE("node stops cleanly on exception with no error handler", "[node][error_handler]") {
AlwaysThrows obj;
auto node = make_node(obj);
node.start();
node.input_channel<0>().push(1);
std::this_thread::sleep_for(std::chrono::milliseconds(50));
REQUIRE_FALSE(node.running());
}
TEST_CASE("node continues when error handler returns true", "[node][error_handler]") {
SometimesThrower obj;
auto node = make_node(obj);
Channel<int> out_ch(5);
node.set_output_channel<0>(&out_ch);
bool handler_called = false;
node.set_error_handler([&](std::string_view, std::exception_ptr) {
handler_called = true;
return true;
});
node.start();
node.input_channel<0>().push(0); // throws → skipped, no output
node.input_channel<0>().push(21); // succeeds → 42
int result = out_ch.pop(); // blocking — waits for the second item
node.stop();
REQUIRE(handler_called);
REQUIRE(result == 42);
}
TEST_CASE("node stops when error handler returns false", "[node][error_handler]") {
AlwaysThrows obj;
auto node = make_node(obj);
node.set_error_handler([](std::string_view, std::exception_ptr) { return false; });
node.start();
node.input_channel<0>().push(1);
std::this_thread::sleep_for(std::chrono::milliseconds(50));
REQUIRE_FALSE(node.running());
}
TEST_CASE("error handler receives node name and exception", "[node][error_handler]") {
AlwaysThrows obj;
auto node = make_node(obj);
node.set_name("test_node");
std::string captured_name;
std::string captured_msg;
node.set_error_handler([&](std::string_view name, std::exception_ptr ep) {
captured_name = name;
try { std::rethrow_exception(ep); }
catch (const std::exception& e) { captured_msg = e.what(); }
return false;
});
node.start();
node.input_channel<0>().push(1);
std::this_thread::sleep_for(std::chrono::milliseconds(50));
node.stop();
REQUIRE(captured_name == "test_node");
REQUIRE(captured_msg == "always throws");
}
TEST_CASE("Node<NTTP> stops cleanly on exception with no error handler", "[node][error_handler]") {
auto node = make_node<always_throws_fn>();
node.start();
node.input_channel<0>().push(1);
std::this_thread::sleep_for(std::chrono::milliseconds(50));
REQUIRE_FALSE(node.running());
}

464
tests/test_pool_node.cpp Normal file
View File

@ -0,0 +1,464 @@
#include <catch2/catch_test_macros.hpp>
#include <kpn/scheduler.hpp>
#include <kpn/pool_node.hpp>
#include <kpn/interrupt_node.hpp>
#include <atomic>
#include <chrono>
#include <mutex>
#include <thread>
using namespace kpn;
static int double_it(int x) { return x * 2; }
static std::tuple<int, float> split_it(int x) { return {x, float(x) * 0.5f}; }
static void consume_it(int x) { (void)x; }
// ── ThreadPool ────────────────────────────────────────────────────────────────
TEST_CASE("thread pool starts and stops cleanly", "[scheduler]") {
ThreadPool pool(2);
pool.start();
pool.stop();
}
TEST_CASE("thread pool executes submitted tasks", "[scheduler]") {
ThreadPool pool(2);
pool.start();
std::atomic<int> counter{0};
for (int i = 0; i < 10; ++i)
pool.submit([&] { counter.fetch_add(1); });
pool.drain();
REQUIRE(counter.load() == 10);
pool.stop();
}
TEST_CASE("thread pool drain waits for all tasks", "[scheduler]") {
ThreadPool pool(1);
pool.start();
std::atomic<bool> done{false};
pool.submit([&] {
std::this_thread::sleep_for(std::chrono::milliseconds(20));
done.store(true);
});
pool.drain();
REQUIRE(done.load());
pool.stop();
}
TEST_CASE("thread pool priority: higher priority tasks run first", "[scheduler]") {
ThreadPool pool(1); // single thread so order is deterministic
pool.start();
// Submit a task that blocks the worker, then queue two tasks with
// different priorities. When the blocker finishes, the high-priority
// task should run before the low-priority one.
std::vector<int> order;
std::mutex order_mutex;
std::atomic<bool> blocker_done{false};
pool.submit([&] {
std::this_thread::sleep_for(std::chrono::milliseconds(30));
blocker_done.store(true);
}, 0.5f);
// Wait until blocker is running, then enqueue the two ordered tasks.
while (!blocker_done.load()) std::this_thread::sleep_for(std::chrono::milliseconds(1));
pool.submit([&] { std::lock_guard g(order_mutex); order.push_back(1); }, 0.1f);
pool.submit([&] { std::lock_guard g(order_mutex); order.push_back(2); }, 0.9f);
pool.drain();
REQUIRE(order == std::vector<int>{2, 1});
pool.stop();
}
// ── PoolNode ──────────────────────────────────────────────────────────────────
TEST_CASE("pool node input/output counts", "[pool_node]") {
STATIC_REQUIRE(PoolNode<double_it>::input_count == 1);
STATIC_REQUIRE(PoolNode<double_it>::output_count == 1);
STATIC_REQUIRE(PoolNode<split_it>::output_count == 2);
STATIC_REQUIRE(PoolNode<consume_it>::output_count == 0);
}
TEST_CASE("pool node processes items end-to-end", "[pool_node]") {
auto pool = std::make_shared<ThreadPool>(2);
pool->start();
auto node = make_pool_node<double_it>(pool);
Channel<int> out_ch(10);
node.set_output_channel<0>(&out_ch);
node.start();
node.input_channel<0>().push(21);
int result = out_ch.pop();
node.stop();
pool->stop();
REQUIRE(result == 42);
}
TEST_CASE("pool node processes multiple items in order", "[pool_node]") {
auto pool = std::make_shared<ThreadPool>(2);
pool->start();
auto node = make_pool_node<double_it>(pool, 20); // capacity 20
Channel<int> out_ch(20);
node.set_output_channel<0>(&out_ch);
node.start();
constexpr int N = 10;
for (int i = 0; i < N; ++i)
node.input_channel<0>().push(i);
std::vector<int> results;
for (int i = 0; i < N; ++i)
results.push_back(out_ch.pop());
node.stop();
pool->stop();
REQUIRE(results.size() == N);
for (int i = 0; i < N; ++i)
REQUIRE(results[i] == i * 2);
}
TEST_CASE("pool node stop is clean with no deadlock", "[pool_node]") {
auto pool = std::make_shared<ThreadPool>(2);
pool->start();
auto node = make_pool_node<double_it>(pool);
node.start();
// Node is idle (no input pushed) — stop must return without deadlock.
node.stop();
REQUIRE_FALSE(node.running());
pool->stop();
}
TEST_CASE("pool node two-stage pipeline produces correct count", "[pool_node]") {
auto pool = std::make_shared<ThreadPool>(4);
pool->start();
auto src = make_pool_node<double_it>(pool);
auto transform = make_pool_node<double_it>(pool);
Channel<int> out_ch(20);
// Wire src output → transform input channel, transform output → out_ch.
src.set_output_channel<0>(&transform.input_channel<0>());
transform.set_output_channel<0>(&out_ch);
src.start();
transform.start();
constexpr int N = 5;
for (int i = 1; i <= N; ++i)
src.input_channel<0>().push(i);
std::vector<int> results;
for (int i = 0; i < N; ++i)
results.push_back(out_ch.pop());
// Stop nodes before they (and their channels) go out of scope.
src.stop();
transform.stop();
pool->stop();
REQUIRE(results.size() == static_cast<std::size_t>(N));
for (int i = 0; i < N; ++i)
REQUIRE(results[i] == (i + 1) * 4); // double_it twice
}
// ── InterruptNode ─────────────────────────────────────────────────────────────
namespace {
static std::atomic<int> g_interrupt_counter{0};
static int interrupt_produce() { return g_interrupt_counter.fetch_add(1); }
} // namespace
TEST_CASE("interrupt node fires on each trigger", "[interrupt_node]") {
auto pool = std::make_shared<ThreadPool>(2);
pool->start();
g_interrupt_counter.store(0);
auto node = make_interrupt_node<interrupt_produce>(pool, out<>{});
Channel<int> out_ch(20);
node.set_output_channel<0>(&out_ch);
node.start();
auto trigger = node.get_trigger();
constexpr int N = 5;
for (int i = 0; i < N; ++i) trigger();
std::vector<int> results;
for (int i = 0; i < N; ++i)
results.push_back(out_ch.pop());
node.stop();
pool->stop();
REQUIRE(results.size() == static_cast<std::size_t>(N));
}
TEST_CASE("interrupt node does not fire without trigger", "[interrupt_node]") {
auto pool = std::make_shared<ThreadPool>(2);
pool->start();
g_interrupt_counter.store(0);
auto node = make_interrupt_node<interrupt_produce>(pool, out<>{});
Channel<int> out_ch(5);
node.set_output_channel<0>(&out_ch);
node.start();
std::this_thread::sleep_for(std::chrono::milliseconds(30));
// No trigger fired — output channel should be empty.
REQUIRE(out_ch.approx_size() == 0);
node.stop();
pool->stop();
}
TEST_CASE("interrupt node: trigger after stop is ignored", "[interrupt_node]") {
auto pool = std::make_shared<ThreadPool>(2);
pool->start();
g_interrupt_counter.store(0);
auto node = make_interrupt_node<interrupt_produce>(pool, out<>{});
Channel<int> out_ch(5);
node.set_output_channel<0>(&out_ch);
node.start();
auto trigger = node.get_trigger();
node.stop();
trigger(); // should be a no-op
std::this_thread::sleep_for(std::chrono::milliseconds(10));
REQUIRE(out_ch.approx_size() == 0);
pool->stop();
}
// ── Overflow callback ─────────────────────────────────────────────────────────
TEST_CASE("pool node overflow callback fires on full output channel", "[pool_node][overflow]") {
auto pool = std::make_shared<ThreadPool>(2);
pool->start();
auto node = make_pool_node<double_it>(pool);
// Pre-fill a tiny channel so every node push overflows.
Channel<int> full_ch(1);
full_ch.push(99);
node.set_output_channel<0>(&full_ch);
std::atomic<int> overflow_count{0};
node.set_overflow_callback([&](auto) { overflow_count.fetch_add(1); });
node.start();
node.input_channel<0>().push(1);
node.input_channel<0>().push(2);
std::this_thread::sleep_for(std::chrono::milliseconds(50));
node.stop();
pool->stop();
REQUIRE(overflow_count.load() > 0);
}
TEST_CASE("pool node overflow callback is independent per instance", "[pool_node][overflow]") {
auto pool = std::make_shared<ThreadPool>(2);
pool->start();
auto nodeA = make_pool_node<double_it>(pool);
auto nodeB = make_pool_node<double_it>(pool);
std::atomic<int> a_overflows{0}, b_overflows{0};
nodeA.set_overflow_callback([&](auto) { a_overflows.fetch_add(1); });
Channel<int> full_ch(1);
full_ch.push(0);
nodeA.set_output_channel<0>(&full_ch);
Channel<int> ok_ch(20);
nodeB.set_output_channel<0>(&ok_ch);
nodeA.start();
nodeB.start();
nodeA.input_channel<0>().push(1);
nodeA.input_channel<0>().push(2);
nodeB.input_channel<0>().push(10);
std::this_thread::sleep_for(std::chrono::milliseconds(50));
nodeA.stop();
nodeB.stop();
pool->stop();
REQUIRE(a_overflows.load() > 0);
REQUIRE(b_overflows.load() == 0);
}
TEST_CASE("interrupt node overflow callback fires on full output", "[interrupt_node][overflow]") {
auto pool = std::make_shared<ThreadPool>(2);
pool->start();
g_interrupt_counter.store(0);
auto node = make_interrupt_node<interrupt_produce>(pool, out<>{});
Channel<int> full_ch(1);
full_ch.push(99);
node.set_output_channel<0>(&full_ch);
std::atomic<int> overflow_count{0};
node.set_overflow_callback([&](auto) { overflow_count.fetch_add(1); });
node.start();
auto trigger = node.get_trigger();
trigger(); trigger(); trigger();
std::this_thread::sleep_for(std::chrono::milliseconds(50));
node.stop();
pool->stop();
REQUIRE(overflow_count.load() > 0);
}
// ── self_stop: disable inputs + outputs on crash ──────────────────────────────
static int always_throw(int) { throw std::runtime_error("node crashed"); return 0; }
TEST_CASE("pool node self_stop disables output on crash so downstream sees closed", "[pool_node][self_stop]") {
auto pool = std::make_shared<ThreadPool>(2);
pool->start();
auto node = make_pool_node<always_throw>(pool, 5);
Channel<int> out_ch(10);
node.set_output_channel<0>(&out_ch);
node.start();
node.input_channel<0>().push(1);
std::this_thread::sleep_for(std::chrono::milliseconds(100));
REQUIRE_FALSE(out_ch.is_accepting());
node.stop();
pool->stop();
}
TEST_CASE("pool node self_stop disables input on crash", "[pool_node][self_stop]") {
auto pool = std::make_shared<ThreadPool>(2);
pool->start();
auto node = make_pool_node<always_throw>(pool, 5);
Channel<int> out_ch(5);
node.set_output_channel<0>(&out_ch);
node.start();
node.input_channel<0>().push(1);
std::this_thread::sleep_for(std::chrono::milliseconds(100));
REQUIRE_FALSE(node.input_channel<0>().is_accepting());
node.stop();
pool->stop();
}
TEST_CASE("pool node closed callback fires on self_stop from crash", "[pool_node][self_stop]") {
auto pool = std::make_shared<ThreadPool>(2);
pool->start();
auto node = make_pool_node<always_throw>(pool, 5);
Channel<int> out_ch(5);
node.set_output_channel<0>(&out_ch);
std::atomic<bool> closed_fired{false};
node.set_closed_callback([&](auto) { closed_fired.store(true); });
node.start();
node.input_channel<0>().push(1);
std::this_thread::sleep_for(std::chrono::milliseconds(100));
REQUIRE(closed_fired.load());
node.stop();
pool->stop();
}
// ── Network-level event callbacks ─────────────────────────────────────────────
TEST_CASE("network_overflow_callback fires on overflow", "[pool_node][network]") {
auto pool = std::make_shared<ThreadPool>(2);
pool->start();
auto node = make_pool_node<double_it>(pool);
Channel<int> full_ch(1);
full_ch.push(0);
node.set_output_channel<0>(&full_ch);
std::atomic<int> net_overflows{0};
node.set_network_overflow_callback([&](auto) { net_overflows.fetch_add(1); });
node.start();
node.input_channel<0>().push(1);
node.input_channel<0>().push(2);
std::this_thread::sleep_for(std::chrono::milliseconds(50));
node.stop();
pool->stop();
REQUIRE(net_overflows.load() > 0);
}
TEST_CASE("network_closed_callback fires on crash", "[pool_node][network]") {
auto pool = std::make_shared<ThreadPool>(2);
pool->start();
auto node = make_pool_node<always_throw>(pool);
Channel<int> out_ch(5);
node.set_output_channel<0>(&out_ch);
std::atomic<bool> net_closed{false};
node.set_network_closed_callback([&](auto) { net_closed.store(true); });
node.start();
node.input_channel<0>().push(1);
std::this_thread::sleep_for(std::chrono::milliseconds(100));
REQUIRE(net_closed.load());
node.stop();
pool->stop();
}
TEST_CASE("per-node and network overflow callbacks both fire independently", "[pool_node][network]") {
auto pool = std::make_shared<ThreadPool>(2);
pool->start();
auto node = make_pool_node<double_it>(pool);
Channel<int> full_ch(1);
full_ch.push(0);
node.set_output_channel<0>(&full_ch);
std::atomic<int> per_node{0}, network{0};
node.set_overflow_callback([&](auto) { per_node.fetch_add(1); });
node.set_network_overflow_callback([&](auto) { network.fetch_add(1); });
node.start();
node.input_channel<0>().push(1);
node.input_channel<0>().push(2);
std::this_thread::sleep_for(std::chrono::milliseconds(50));
node.stop();
pool->stop();
REQUIRE(per_node.load() > 0);
REQUIRE(network.load() > 0);
}

229
tests/test_scheduler.cpp Normal file
View File

@ -0,0 +1,229 @@
#include <catch2/catch_test_macros.hpp>
#include <kpn/scheduler.hpp>
#include <atomic>
#include <chrono>
#include <thread>
#include <vector>
#include <mutex>
using namespace kpn;
using namespace std::chrono_literals;
// ── basic execution ───────────────────────────────────────────────────────────
TEST_CASE("scheduler runs submitted tasks", "[scheduler]") {
ThreadPool pool(2);
pool.start();
std::atomic<int> counter{0};
for (int i = 0; i < 100; ++i)
pool.submit([&counter]{ counter.fetch_add(1, std::memory_order_relaxed); });
pool.drain();
REQUIRE(counter.load() == 100);
pool.stop();
}
TEST_CASE("scheduler single thread executes all tasks", "[scheduler]") {
ThreadPool pool(1);
pool.start();
std::atomic<int> counter{0};
for (int i = 0; i < 50; ++i)
pool.submit([&counter]{ counter.fetch_add(1, std::memory_order_relaxed); });
pool.drain();
REQUIRE(counter.load() == 50);
pool.stop();
}
// ── drain ─────────────────────────────────────────────────────────────────────
TEST_CASE("drain returns immediately when pool is idle", "[scheduler]") {
ThreadPool pool(2);
pool.start();
pool.drain(); // nothing submitted — should return immediately
pool.stop();
}
TEST_CASE("drain waits for all tasks to complete", "[scheduler]") {
ThreadPool pool(4);
pool.start();
std::atomic<int> counter{0};
constexpr int N = 200;
for (int i = 0; i < N; ++i) {
pool.submit([&counter]{
std::this_thread::sleep_for(1ms);
counter.fetch_add(1, std::memory_order_relaxed);
});
}
pool.drain();
REQUIRE(counter.load() == N);
pool.stop();
}
TEST_CASE("drain is safe to call multiple times", "[scheduler]") {
ThreadPool pool(2);
pool.start();
std::atomic<int> counter{0};
pool.submit([&counter]{ counter.fetch_add(1, std::memory_order_relaxed); });
pool.drain();
REQUIRE(counter.load() == 1);
pool.submit([&counter]{ counter.fetch_add(1, std::memory_order_relaxed); });
pool.drain();
REQUIRE(counter.load() == 2);
pool.stop();
}
// ── priority ordering ─────────────────────────────────────────────────────────
TEST_CASE("higher priority tasks run before lower priority on single thread", "[scheduler]") {
// Single thread guarantees serial execution — we can observe order.
ThreadPool pool(1);
pool.start();
// Pause the worker so we can fill the queue before it drains.
std::mutex gate;
gate.lock();
pool.submit([&gate]{ std::lock_guard lg(gate); }); // blocks worker
std::vector<float> order;
std::mutex order_mx;
for (float p : {0.1f, 0.9f, 0.5f, 0.8f, 0.2f}) {
pool.submit([p, &order, &order_mx]{
std::lock_guard lg(order_mx);
order.push_back(p);
}, p);
}
gate.unlock(); // release the blocking task
pool.drain();
pool.stop();
// order should be descending by priority
REQUIRE(order.size() == 5);
for (std::size_t i = 1; i < order.size(); ++i)
REQUIRE(order[i - 1] >= order[i]);
}
TEST_CASE("equal priority tasks execute in FIFO order on single thread", "[scheduler]") {
ThreadPool pool(1);
pool.start();
std::mutex gate;
gate.lock();
pool.submit([&gate]{ std::lock_guard lg(gate); });
std::vector<int> order;
std::mutex order_mx;
for (int i = 0; i < 5; ++i) {
pool.submit([i, &order, &order_mx]{
std::lock_guard lg(order_mx);
order.push_back(i);
}, 0.5f); // all same priority
}
gate.unlock();
pool.drain();
pool.stop();
REQUIRE(order == std::vector<int>{0, 1, 2, 3, 4});
}
// ── total_ / active_ accounting ───────────────────────────────────────────────
TEST_CASE("snapshot queue depth and active counts are consistent", "[scheduler]") {
ThreadPool pool(2);
pool.start();
// While tasks are running, active should be > 0 and total >= active.
std::atomic<bool> running{false};
std::mutex gate;
gate.lock();
for (int i = 0; i < 4; ++i) {
pool.submit([&gate, &running]{
running.store(true, std::memory_order_relaxed);
std::lock_guard lg(gate);
});
}
// Spin until at least one task has started
while (!running.load(std::memory_order_relaxed))
std::this_thread::yield();
auto snap = pool.snapshot("test");
REQUIRE(snap.active_count > 0);
REQUIRE(snap.queue_depth + snap.active_count > 0);
gate.unlock();
pool.drain();
auto snap2 = pool.snapshot("test");
REQUIRE(snap2.active_count == 0);
REQUIRE(snap2.queue_depth == 0);
pool.stop();
}
TEST_CASE("submitted and completed counters are accurate", "[scheduler]") {
ThreadPool pool(3);
pool.start();
constexpr int N = 60;
for (int i = 0; i < N; ++i)
pool.submit([]{ std::this_thread::yield(); });
pool.drain();
auto snap = pool.snapshot("test");
REQUIRE(snap.tasks_submitted == static_cast<uint64_t>(N));
REQUIRE(snap.tasks_completed == static_cast<uint64_t>(N));
pool.stop();
}
// ── work stealing ─────────────────────────────────────────────────────────────
TEST_CASE("work stealing: all tasks complete with uneven initial distribution", "[scheduler]") {
// 4-thread pool. Submit a burst to ensure some threads start empty and must steal.
ThreadPool pool(4);
pool.start();
std::atomic<int> counter{0};
constexpr int N = 400;
for (int i = 0; i < N; ++i)
pool.submit([&counter]{
std::this_thread::sleep_for(100us);
counter.fetch_add(1, std::memory_order_relaxed);
});
pool.drain();
REQUIRE(counter.load() == N);
pool.stop();
}
TEST_CASE("work stealing: tasks complete with more threads than initial queue targets", "[scheduler]") {
// With round-robin, some threads may get no tasks initially and must steal.
constexpr std::size_t THREADS = 8;
ThreadPool pool(THREADS);
pool.start();
std::atomic<int> counter{0};
// Submit fewer tasks than threads so most threads must steal
for (int i = 0; i < 4; ++i)
pool.submit([&counter]{ counter.fetch_add(1, std::memory_order_relaxed); });
pool.drain();
REQUIRE(counter.load() == 4);
pool.stop();
}

View File

@ -0,0 +1,239 @@
#include <catch2/catch_test_macros.hpp>
#include <catch2/catch_approx.hpp>
#include <kpn/shared_resource.hpp>
#include <kpn/channel.hpp>
#include <atomic>
#include <chrono>
#include <thread>
#include <vector>
using namespace kpn;
using namespace std::chrono_literals;
// ── Basic acquire / release ───────────────────────────────────────────────────
TEST_CASE("acquire returns guard that accesses the resource", "[shared_resource]") {
SharedResource<int> res(42);
{
auto g = res.acquire();
REQUIRE(*g == 42);
*g = 99;
} // g released here — second acquire must not overlap in the same thread
{
auto g2 = res.acquire();
REQUIRE(*g2 == 99);
}
}
TEST_CASE("guard operator-> reaches resource members", "[shared_resource]") {
struct Pair { int x{1}; int y{2}; };
SharedResource<Pair> res;
auto g = res.acquire();
REQUIRE(g->x == 1);
REQUIRE(g->y == 2);
g->x = 10;
REQUIRE(g.get().x == 10);
}
TEST_CASE("guard releases on scope exit", "[shared_resource]") {
SharedResource<int> res(0);
{
auto g = res.acquire();
REQUIRE(res.snapshot("r").held);
}
// After guard destroyed, resource is free
REQUIRE(!res.snapshot("r").held);
}
// ── Mutual exclusion ──────────────────────────────────────────────────────────
TEST_CASE("only one thread holds the resource at a time", "[shared_resource]") {
SharedResource<int> res(0);
std::atomic<int> concurrent_holders{0};
std::atomic<int> violations{0};
std::atomic<bool> go{false};
auto worker = [&] {
while (!go.load()) std::this_thread::yield();
for (int i = 0; i < 20; ++i) {
auto g = res.acquire();
int h = concurrent_holders.fetch_add(1) + 1;
if (h > 1) violations.fetch_add(1);
std::this_thread::sleep_for(100us);
concurrent_holders.fetch_sub(1);
}
};
std::vector<std::thread> threads;
for (int i = 0; i < 4; ++i) threads.emplace_back(worker);
go.store(true);
for (auto& t : threads) t.join();
REQUIRE(violations.load() == 0);
}
// ── Priority ordering ─────────────────────────────────────────────────────────
TEST_CASE("higher priority waiter is served before lower priority waiter", "[shared_resource]") {
SharedResource<int> res(0);
// Hold the resource so threads have to queue.
auto holder = res.acquire();
std::vector<int> order;
std::mutex order_mtx;
// Launch two waiters: low priority first, then high priority.
std::thread low([&] {
auto g = res.acquire([] { return 0.1f; });
std::lock_guard lk(order_mtx);
order.push_back(1);
});
std::this_thread::sleep_for(5ms); // ensure low is queued first
std::thread high([&] {
auto g = res.acquire([] { return 0.9f; });
std::lock_guard lk(order_mtx);
order.push_back(2);
});
std::this_thread::sleep_for(5ms); // ensure high is also queued
// Release — high priority should win even though low arrived first.
{ auto drop = std::move(holder); }
low.join();
high.join();
REQUIRE(order.size() == 2);
REQUIRE(order[0] == 2); // high priority served first
REQUIRE(order[1] == 1);
}
// ── acquire_balanced uses channel fills ───────────────────────────────────────
TEST_CASE("acquire_balanced: full input + empty output gives score ~1.0", "[shared_resource]") {
// We test the priority function indirectly via ordering.
// Node A: in=full, out=empty → score ≈ 1.0 (high)
// Node B: in=empty, out=full → score ≈ 0.0 (low)
Channel<int> in_a(4); // fill it
Channel<int> out_a(4); // leave empty
Channel<int> in_b(4); // leave empty
Channel<int> out_b(4); // fill it
in_a.enable(); out_a.enable();
in_b.enable(); out_b.enable();
for (int i = 0; i < 4; ++i) { in_a.push(i); out_b.push(i); }
SharedResource<int> res(0);
auto holder = res.acquire(); // block others
std::vector<int> order;
std::mutex mtx;
// Node B (low priority) waits first
std::thread tb([&] {
auto g = res.acquire_balanced(in_b, out_b);
std::lock_guard lk(mtx);
order.push_back(2);
});
std::this_thread::sleep_for(5ms);
// Node A (high priority) waits second
std::thread ta([&] {
auto g = res.acquire_balanced(in_a, out_a);
std::lock_guard lk(mtx);
order.push_back(1);
});
std::this_thread::sleep_for(5ms);
{ auto drop = std::move(holder); } // release
ta.join();
tb.join();
REQUIRE(order.size() == 2);
REQUIRE(order[0] == 1); // node A served first despite arriving second
}
// ── Statistics ────────────────────────────────────────────────────────────────
TEST_CASE("stats: acquisitions counted correctly", "[shared_resource]") {
SharedResource<int> res(0);
{
auto g1 = res.acquire();
}
{
auto g2 = res.acquire();
}
REQUIRE(res.snapshot("r").acquisitions == 2);
}
TEST_CASE("stats: peak_waiters reflects maximum concurrent queue depth", "[shared_resource]") {
SharedResource<int> res(0);
auto holder = res.acquire();
std::atomic<int> ready{0};
auto waiter = [&] {
ready.fetch_add(1);
auto g = res.acquire();
};
std::thread t1(waiter), t2(waiter), t3(waiter);
// Wait until all three are queued
while (ready.load() < 3) std::this_thread::sleep_for(1ms);
std::this_thread::sleep_for(5ms); // give them time to block on acquire
{ auto drop = std::move(holder); } // release
t1.join(); t2.join(); t3.join();
REQUIRE(res.snapshot("r").peak_waiters >= 2); // at least 2 queued simultaneously
}
TEST_CASE("stats: current_waiters returns to 0 after all served", "[shared_resource]") {
SharedResource<int> res(0);
auto holder = res.acquire();
std::thread t1([&] { auto g = res.acquire(); });
std::thread t2([&] { auto g = res.acquire(); });
std::this_thread::sleep_for(10ms);
{ auto drop = std::move(holder); }
t1.join(); t2.join();
REQUIRE(res.snapshot("r").current_waiters == 0);
}
TEST_CASE("stats: avg_wait_ms is positive when contention occurred", "[shared_resource]") {
SharedResource<int> res(0);
{
auto holder = res.acquire();
std::thread t([&] { auto g = res.acquire(); });
std::this_thread::sleep_for(10ms);
{ auto drop = std::move(holder); }
t.join();
}
REQUIRE(res.snapshot("r").avg_wait_ms > 0.0);
}
// ── No-arg acquire ────────────────────────────────────────────────────────────
TEST_CASE("no-arg acquire works and releases correctly", "[shared_resource]") {
SharedResource<int> res(7);
auto g = res.acquire();
REQUIRE(*g == 7);
REQUIRE(res.snapshot("r").held);
}
// ── make_shared_resource factory ──────────────────────────────────────────────
TEST_CASE("make_shared_resource constructs with forwarded args", "[shared_resource]") {
auto res = make_shared_resource<std::string>("hello");
auto g = res.acquire();
REQUIRE(*g == "hello");
}