Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
454f72c167 | ||
|
|
9c5ce5f34a | ||
|
|
28e06675f5 | ||
|
|
6595e6e925 | ||
|
|
75b34f31bb | ||
|
|
4b6e498ba7 | ||
|
|
5ecf3cde4f |
@@ -27,3 +27,4 @@ Thumbs.db
|
|||||||
|
|
||||||
# Claude Code local settings
|
# Claude Code local settings
|
||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
|
include/kpn/ort_cache/
|
||||||
|
|||||||
+73
-1
@@ -136,6 +136,71 @@ public:
|
|||||||
push_callback_();
|
push_callback_();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Called when a pop frees a slot in a previously-full ring.
|
||||||
|
///
|
||||||
|
/// The mirror of `set_push_callback`, and it exists for the same reason:
|
||||||
|
/// a producer must be able to *park* rather than spin. Without it the only
|
||||||
|
/// lossless option is `push_blocking`, which sleeps inside the caller's
|
||||||
|
/// thread — and when that thread is a scheduler worker, parking it starves
|
||||||
|
/// every node pinned to it (see the hold-and-wait note on push_sentinel).
|
||||||
|
void set_space_callback(std::function<void()> cb) { space_callback_ = std::move(cb); }
|
||||||
|
|
||||||
|
/// True when a push would currently succeed. Used to close the lost-wakeup
|
||||||
|
/// race: a producer that parks must re-check after clearing its queued flag,
|
||||||
|
/// because a space_callback fired in between would otherwise be swallowed.
|
||||||
|
bool has_space() const {
|
||||||
|
return tail_.load(std::memory_order_relaxed) -
|
||||||
|
head_.load(std::memory_order_acquire) < capacity_;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Non-blocking, lossless push. Returns false when the ring is full, having
|
||||||
|
/// changed nothing — the caller keeps the value and retries when woken.
|
||||||
|
bool try_push(T& value) {
|
||||||
|
if (!accepting_.load(std::memory_order_acquire)) { stats_.record_drop(); return true; }
|
||||||
|
const std::size_t t = tail_.load(std::memory_order_relaxed);
|
||||||
|
const std::size_t h = head_.load(std::memory_order_acquire);
|
||||||
|
if (t - h >= capacity_) return false;
|
||||||
|
|
||||||
|
const std::size_t data_bytes = ChannelDataSize<T>::bytes(value);
|
||||||
|
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_();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lossless push with BACKPRESSURE: if the ring is full, wait for the consumer to
|
||||||
|
// drain instead of dropping (the throwing push()) — the producer just runs slower.
|
||||||
|
// Use when every value must be delivered (e.g. replaying a dump for scoring, where
|
||||||
|
// a dropped frame silently corrupts the result). SPSC: only the sole producer may
|
||||||
|
// call it. Returns false if the channel was disabled while waiting.
|
||||||
|
bool push_blocking(T value) {
|
||||||
|
for (;;) {
|
||||||
|
if (!accepting_.load(std::memory_order_acquire)) {
|
||||||
|
stats_.record_drop();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const std::size_t t = tail_.load(std::memory_order_relaxed);
|
||||||
|
const std::size_t h = head_.load(std::memory_order_acquire);
|
||||||
|
if (t - h < capacity_) { // space available → normal push
|
||||||
|
const std::size_t data_bytes = ChannelDataSize<T>::bytes(value);
|
||||||
|
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_();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// full: yield briefly and retry (consumer will drain)
|
||||||
|
std::this_thread::sleep_for(std::chrono::microseconds(50));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Lossless, non-blocking delivery for a must-deliver control token (EOF).
|
// 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
|
// A sentinel is stored out-of-band — in a dedicated slot that does NOT
|
||||||
@@ -219,6 +284,8 @@ public:
|
|||||||
throw ChannelClosedError{};
|
throw ChannelClosedError{};
|
||||||
T value = extract(std::move(buf_[h & ring_mask_]));
|
T value = extract(std::move(buf_[h & ring_mask_]));
|
||||||
head_.store(h + 1, std::memory_order_release);
|
head_.store(h + 1, std::memory_order_release);
|
||||||
|
// A slot just freed: wake any producer parked on this channel.
|
||||||
|
if (t - h >= capacity_ && space_callback_) space_callback_();
|
||||||
stats_.record_pop();
|
stats_.record_pop();
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
@@ -240,11 +307,15 @@ public:
|
|||||||
// so pool nodes — which pop only via this path — still receive the token.
|
// so pool nodes — which pop only via this path — still receive the token.
|
||||||
bool try_pop_now(T& out) {
|
bool try_pop_now(T& out) {
|
||||||
const std::size_t h = head_.load(std::memory_order_relaxed);
|
const std::size_t h = head_.load(std::memory_order_relaxed);
|
||||||
if (h == tail_.load(std::memory_order_acquire))
|
const std::size_t t = tail_.load(std::memory_order_acquire);
|
||||||
|
if (h == t)
|
||||||
return take_sentinel(out);
|
return take_sentinel(out);
|
||||||
out = extract(std::move(buf_[h & ring_mask_]));
|
out = extract(std::move(buf_[h & ring_mask_]));
|
||||||
head_.store(h + 1, std::memory_order_release);
|
head_.store(h + 1, std::memory_order_release);
|
||||||
stats_.record_pop();
|
stats_.record_pop();
|
||||||
|
// Pool nodes pop only through here, so this is where a parked producer
|
||||||
|
// gets woken: the ring was full, and it no longer is.
|
||||||
|
if (t - h >= capacity_ && space_callback_) space_callback_();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -334,6 +405,7 @@ private:
|
|||||||
std::size_t ring_mask_;
|
std::size_t ring_mask_;
|
||||||
std::unique_ptr<storage_type[]> buf_;
|
std::unique_ptr<storage_type[]> buf_;
|
||||||
std::function<void()> push_callback_;
|
std::function<void()> push_callback_;
|
||||||
|
std::function<void()> space_callback_;
|
||||||
ChannelStats stats_;
|
ChannelStats stats_;
|
||||||
|
|
||||||
// Out-of-band sentinel (EOF): stored outside the ring so its delivery never
|
// Out-of-band sentinel (EOF): stored outside the ring so its delivery never
|
||||||
|
|||||||
@@ -34,6 +34,14 @@ struct INode {
|
|||||||
virtual void set_network_overflow_callback(NodeEventCallback) {}
|
virtual void set_network_overflow_callback(NodeEventCallback) {}
|
||||||
virtual void set_network_closed_callback(NodeEventCallback) {}
|
virtual void set_network_closed_callback(NodeEventCallback) {}
|
||||||
|
|
||||||
|
// Network-level error listener. Consulted when a node's function throws
|
||||||
|
// and no per-node handler resolved it. Without this the exception is
|
||||||
|
// discarded and the failure is only visible as a Closed event, which says
|
||||||
|
// a node stopped but not why — the difference between a diagnosis and a
|
||||||
|
// guess. Same contract as NodeErrorHandler: true to continue, false to
|
||||||
|
// stop the node.
|
||||||
|
virtual void set_network_error_callback(NodeErrorHandler) {}
|
||||||
|
|
||||||
// halt(): alias for stop() — immediate, discards in-flight work.
|
// halt(): alias for stop() — immediate, discards in-flight work.
|
||||||
virtual void halt() { stop(); }
|
virtual void halt() { stop(); }
|
||||||
|
|
||||||
|
|||||||
+391
-30
@@ -15,6 +15,7 @@
|
|||||||
#include <iostream>
|
#include <iostream>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
#include <optional>
|
#include <optional>
|
||||||
|
#include <variant>
|
||||||
#include <stdexcept>
|
#include <stdexcept>
|
||||||
#include <thread>
|
#include <thread>
|
||||||
#include <tuple>
|
#include <tuple>
|
||||||
@@ -131,6 +132,7 @@ public:
|
|||||||
|
|
||||||
void set_name(std::string name) override { name_ = std::move(name); }
|
void set_name(std::string name) override { name_ = std::move(name); }
|
||||||
void set_error_handler(NodeErrorHandler h) { error_handler_ = std::move(h); }
|
void set_error_handler(NodeErrorHandler h) { error_handler_ = std::move(h); }
|
||||||
|
void set_network_error_callback(NodeErrorHandler h) override { net_error_handler_ = std::move(h); }
|
||||||
void set_max_exec_time(std::chrono::milliseconds t) { max_exec_time_ = t; }
|
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_overflow_callback(NodeEventCallback cb) { event_callbacks_[0] = std::move(cb); }
|
||||||
@@ -234,6 +236,8 @@ private:
|
|||||||
|
|
||||||
template<std::size_t... Is>
|
template<std::size_t... Is>
|
||||||
void register_callbacks(std::index_sequence<Is...>) {
|
void register_callbacks(std::index_sequence<Is...>) {
|
||||||
|
// A parked producer is re-submitted when its output drains.
|
||||||
|
register_space_callbacks(std::make_index_sequence<output_count>{});
|
||||||
(std::get<Is>(input_channels_)->set_push_callback(
|
(std::get<Is>(input_channels_)->set_push_callback(
|
||||||
[this] { on_input_ready(); }), ...);
|
[this] { on_input_ready(); }), ...);
|
||||||
}
|
}
|
||||||
@@ -243,10 +247,26 @@ private:
|
|||||||
for (auto& cb : cbs) if (cb) cb(ts);
|
for (auto& cb : cbs) if (cb) cb(ts);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
template<std::size_t... Os>
|
||||||
|
bool outputs_have_space(std::index_sequence<Os...>) const {
|
||||||
|
return (... && (!std::get<Os>(output_channels_) ||
|
||||||
|
std::get<Os>(output_channels_)->has_space()));
|
||||||
|
}
|
||||||
|
|
||||||
|
template<std::size_t... Os>
|
||||||
|
void register_space_callbacks(std::index_sequence<Os...>) {
|
||||||
|
((std::get<Os>(output_channels_)
|
||||||
|
? (void)std::get<Os>(output_channels_)->set_space_callback(
|
||||||
|
[this] { try_submit(0.5f); })
|
||||||
|
: (void)0), ...);
|
||||||
|
}
|
||||||
|
|
||||||
void self_stop() {
|
void self_stop() {
|
||||||
disable_inputs(std::make_index_sequence<input_count>{});
|
disable_inputs(std::make_index_sequence<input_count>{});
|
||||||
disable_outputs(std::make_index_sequence<output_count>{});
|
disable_outputs(std::make_index_sequence<output_count>{});
|
||||||
stats_.exec_start_us.store(0, std::memory_order_relaxed);
|
stats_.exec_start_us.store(0, std::memory_order_relaxed);
|
||||||
|
// Plain store, not release_and_recheck(): this node is stopping, and
|
||||||
|
// honouring a pending wake here would resubmit a dead node.
|
||||||
queued_.store(false, std::memory_order_release);
|
queued_.store(false, std::memory_order_release);
|
||||||
stop_flag_.store(true, std::memory_order_relaxed);
|
stop_flag_.store(true, std::memory_order_relaxed);
|
||||||
}
|
}
|
||||||
@@ -280,11 +300,51 @@ private:
|
|||||||
return ((std::get<Is>(input_channels_)->approx_size() > 0 ? 1u : 0u) + ...);
|
return ((std::get<Is>(input_channels_)->approx_size() > 0 ? 1u : 0u) + ...);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Priority in [0,1], higher runs sooner.
|
||||||
|
///
|
||||||
|
/// Input fill alone answers "how much work is waiting for me". That is
|
||||||
|
/// only half the question: a node whose *outputs* are already full cannot
|
||||||
|
/// deliver anything: running it produces a value with nowhere to go, so it
|
||||||
|
/// immediately parks and the slot is wasted. Meanwhile the node that would
|
||||||
|
/// have drained that full channel waits behind it.
|
||||||
|
///
|
||||||
|
/// So occupancy of the outputs is deducted from occupancy of the inputs.
|
||||||
|
/// The scheduler then naturally favours whoever is furthest downstream of
|
||||||
|
/// a bottleneck — the node whose inputs are backed up but whose outputs
|
||||||
|
/// have room is exactly the one whose execution frees the most capacity —
|
||||||
|
/// and defers producers that would only deepen a queue that is already
|
||||||
|
/// full.
|
||||||
|
///
|
||||||
|
/// Mapped as 0.5·(1 + in - out) rather than clamping (in - out) at zero:
|
||||||
|
/// both terms are mean fills in [0,1], so the difference is in [-1,1], and
|
||||||
|
/// the affine map keeps the whole range distinguishable instead of
|
||||||
|
/// collapsing every output-saturated node onto the same value. 0.5 remains
|
||||||
|
/// the neutral point, matching the default used for source nodes.
|
||||||
float compute_priority() {
|
float compute_priority() {
|
||||||
if constexpr (input_count == 0) return 0.5f;
|
if constexpr (input_count == 0) return 0.5f;
|
||||||
float sum = 0.0f;
|
float in = 0.0f;
|
||||||
sum_fill(sum, std::make_index_sequence<input_count>{});
|
sum_fill(in, std::make_index_sequence<input_count>{});
|
||||||
return sum / static_cast<float>(input_count);
|
in /= static_cast<float>(input_count);
|
||||||
|
|
||||||
|
if constexpr (output_count == 0) return in;
|
||||||
|
|
||||||
|
float out = 0.0f;
|
||||||
|
sum_output_fill(out, std::make_index_sequence<output_count>{});
|
||||||
|
out /= static_cast<float>(output_count);
|
||||||
|
|
||||||
|
const float p = 0.5f * (1.0f + in - out);
|
||||||
|
return p < 0.0f ? 0.0f : (p > 1.0f ? 1.0f : p);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mean fill of the output channels, same normalisation as sum_fill.
|
||||||
|
/// An unconnected output holds nothing back, so it contributes 0.
|
||||||
|
template<std::size_t... Os>
|
||||||
|
void sum_output_fill(float& sum, std::index_sequence<Os...>) {
|
||||||
|
((sum += (std::get<Os>(output_channels_) &&
|
||||||
|
std::get<Os>(output_channels_)->capacity() > 0)
|
||||||
|
? float(std::get<Os>(output_channels_)->approx_size())
|
||||||
|
/ float(std::get<Os>(output_channels_)->capacity())
|
||||||
|
: 0.0f), ...);
|
||||||
}
|
}
|
||||||
|
|
||||||
template<std::size_t... Is>
|
template<std::size_t... Is>
|
||||||
@@ -295,10 +355,32 @@ private:
|
|||||||
: 0.5f), ...);
|
: 0.5f), ...);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Submit unless already queued. A wake that arrives while this node is
|
||||||
|
/// queued or running is *recorded*, never dropped.
|
||||||
|
///
|
||||||
|
/// Wakes are edge-triggered: a channel fires its space callback on the
|
||||||
|
/// transition, once. If that lands while queued_ is up, the CAS below fails
|
||||||
|
/// and — before wake_pending_ — the wake was gone. A node could then park a
|
||||||
|
/// value, release its worker, and sleep forever holding output its consumer
|
||||||
|
/// was waiting for, with every worker idle in cond_wait and nothing left to
|
||||||
|
/// re-trigger it. Recording the drop turns the signal level-triggered: the
|
||||||
|
/// invariant is that a node never sleeps with a wake outstanding, enforced
|
||||||
|
/// by release_and_recheck() at every point that releases the node.
|
||||||
void try_submit(float priority) {
|
void try_submit(float priority) {
|
||||||
bool expected = false;
|
bool expected = false;
|
||||||
if (queued_.compare_exchange_strong(expected, true, std::memory_order_acq_rel))
|
if (queued_.compare_exchange_strong(expected, true, std::memory_order_acq_rel))
|
||||||
scheduler_->submit([this] { fire_once(); }, priority);
|
scheduler_->submit([this] { fire_once(); }, priority);
|
||||||
|
else
|
||||||
|
wake_pending_.store(true, std::memory_order_release);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear queued_, then honour any wake that was dropped while it was up.
|
||||||
|
/// Every path that finishes or parks a firing must release the node through
|
||||||
|
/// here rather than storing queued_ directly.
|
||||||
|
void release_and_recheck(float priority = 0.5f) {
|
||||||
|
queued_.store(false, std::memory_order_release);
|
||||||
|
if (wake_pending_.exchange(false, std::memory_order_acq_rel))
|
||||||
|
try_submit(priority);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Execution ─────────────────────────────────────────────────────────────
|
// ── Execution ─────────────────────────────────────────────────────────────
|
||||||
@@ -315,6 +397,77 @@ private:
|
|||||||
t0.time_since_epoch()).count();
|
t0.time_since_epoch()).count();
|
||||||
stats_.exec_start_us.store(now_us, std::memory_order_relaxed);
|
stats_.exec_start_us.store(now_us, std::memory_order_relaxed);
|
||||||
|
|
||||||
|
// Parked from a previous firing: retry that value before touching the
|
||||||
|
// inputs. Returning here releases the worker — the channel's space
|
||||||
|
// callback re-submits this node when the consumer drains a slot.
|
||||||
|
if constexpr (!std::is_void_v<return_raw>) {
|
||||||
|
if (pending_) {
|
||||||
|
push_outputs(std::move(*pending_), std::make_index_sequence<output_count>{});
|
||||||
|
release_and_recheck();
|
||||||
|
if (pending_) {
|
||||||
|
// Close the lost-wakeup race: a space_callback that fired
|
||||||
|
// between the failed push and clearing queued_ was
|
||||||
|
// swallowed, and nothing else will wake this node. Re-check
|
||||||
|
// now that the flag is down.
|
||||||
|
if (outputs_have_space(std::make_index_sequence<output_count>{}))
|
||||||
|
try_submit(0.5f);
|
||||||
|
return; // parked
|
||||||
|
}
|
||||||
|
// Drained: resume normal firing, resubmitting exactly the way
|
||||||
|
// the normal tail below does. An unconditional try_submit here
|
||||||
|
// would fire a node whose inputs are empty, and pop_inputs
|
||||||
|
// reports an empty channel as ChannelClosedError — which this
|
||||||
|
// node treats as "upstream finished" and self-stops on. That
|
||||||
|
// is a live node killing itself purely because it was woken by
|
||||||
|
// *output* space rather than by input arrival.
|
||||||
|
if constexpr (input_count == 0) try_submit(0.5f);
|
||||||
|
else on_input_ready();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parked from a previous firing: retry that value before touching the
|
||||||
|
// inputs. Returning here releases the worker — the channel's space
|
||||||
|
// callback re-submits this node once the consumer drains a slot.
|
||||||
|
if constexpr (!std::is_void_v<return_raw>) {
|
||||||
|
if (pending_) {
|
||||||
|
push_outputs(std::move(*pending_), std::make_index_sequence<output_count>{});
|
||||||
|
release_and_recheck();
|
||||||
|
if (pending_) {
|
||||||
|
// Close the lost-wakeup race: a space_callback that fired
|
||||||
|
// between the failed push and clearing queued_ was
|
||||||
|
// swallowed, and nothing else will wake this node. Re-check
|
||||||
|
// now that the flag is down.
|
||||||
|
if (outputs_have_space(std::make_index_sequence<output_count>{}))
|
||||||
|
try_submit(0.5f);
|
||||||
|
return; // parked
|
||||||
|
}
|
||||||
|
// Drained: resume normal firing, resubmitting exactly the way
|
||||||
|
// the normal tail below does. An unconditional try_submit here
|
||||||
|
// would fire a node whose inputs are empty, and pop_inputs
|
||||||
|
// reports an empty channel as ChannelClosedError — which this
|
||||||
|
// node treats as "upstream finished" and self-stops on. That
|
||||||
|
// is a live node killing itself purely because it was woken by
|
||||||
|
// *output* space rather than by input arrival.
|
||||||
|
if constexpr (input_count == 0) try_submit(0.5f);
|
||||||
|
else on_input_ready();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Woken by output space rather than by input arrival, with nothing
|
||||||
|
// parked left to flush: there is no work to do. Falling through would
|
||||||
|
// read an empty channel, and pop_one reports empty as
|
||||||
|
// ChannelClosedError — self-stopping a live node. Release the worker;
|
||||||
|
// on_input_ready() resubmits when data actually lands.
|
||||||
|
if constexpr (input_count > 0) {
|
||||||
|
if (count_ready(std::make_index_sequence<input_count>{}) != input_count) {
|
||||||
|
release_and_recheck();
|
||||||
|
on_input_ready(); // data may have arrived while we checked
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
auto args = pop_inputs(std::make_index_sequence<input_count>{});
|
auto args = pop_inputs(std::make_index_sequence<input_count>{});
|
||||||
auto t1 = clock_t::now();
|
auto t1 = clock_t::now();
|
||||||
@@ -340,7 +493,11 @@ private:
|
|||||||
} catch (const ChannelOverflowError&) {
|
} catch (const ChannelOverflowError&) {
|
||||||
fire_callbacks(event_callbacks_);
|
fire_callbacks(event_callbacks_);
|
||||||
} catch (...) {
|
} catch (...) {
|
||||||
if (error_handler_ && error_handler_(name_, std::current_exception())) {
|
auto eptr = std::current_exception();
|
||||||
|
const bool handled =
|
||||||
|
(error_handler_ && error_handler_(name_, eptr)) ||
|
||||||
|
(net_error_handler_ && net_error_handler_(name_, eptr));
|
||||||
|
if (handled) {
|
||||||
// continue — fall through to resubmit check
|
// continue — fall through to resubmit check
|
||||||
} else {
|
} else {
|
||||||
fire_callbacks(closed_callbacks_);
|
fire_callbacks(closed_callbacks_);
|
||||||
@@ -350,10 +507,27 @@ private:
|
|||||||
}
|
}
|
||||||
|
|
||||||
stats_.exec_start_us.store(0, std::memory_order_relaxed);
|
stats_.exec_start_us.store(0, std::memory_order_relaxed);
|
||||||
queued_.store(false, std::memory_order_release);
|
release_and_recheck();
|
||||||
|
|
||||||
if (stop_flag_.load(std::memory_order_relaxed)) return;
|
if (stop_flag_.load(std::memory_order_relaxed)) return;
|
||||||
|
|
||||||
|
// Parked by the push above. Same situation as the retry path at the top
|
||||||
|
// of fire_once — and the same lost-wakeup race, which that path closes
|
||||||
|
// and this one did not. A space callback that fired while queued_ was
|
||||||
|
// still up got swallowed by try_submit's CAS, and the resubmit below
|
||||||
|
// cannot cover it: this firing consumed its input, so inputs are empty
|
||||||
|
// and on_input_ready() will not resubmit. The node would then hold its
|
||||||
|
// value forever while its consumer waits for exactly that value and its
|
||||||
|
// producer parks on an input channel that never drains. Re-check now
|
||||||
|
// that the flag is down.
|
||||||
|
if constexpr (!std::is_void_v<return_raw>) {
|
||||||
|
if (pending_) {
|
||||||
|
if (outputs_have_space(std::make_index_sequence<output_count>{}))
|
||||||
|
try_submit(0.5f);
|
||||||
|
return; // parked
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Source nodes always resubmit; others resubmit only if inputs are ready.
|
// Source nodes always resubmit; others resubmit only if inputs are ready.
|
||||||
if constexpr (input_count == 0) {
|
if constexpr (input_count == 0) {
|
||||||
try_submit(0.5f);
|
try_submit(0.5f);
|
||||||
@@ -385,27 +559,41 @@ private:
|
|||||||
}
|
}
|
||||||
|
|
||||||
template<std::size_t... Is>
|
template<std::size_t... Is>
|
||||||
|
/// Pushes what it can and parks the rest. `done_` marks the elements that
|
||||||
|
/// were taken, so a retry never re-pushes one — a duplicate would be as
|
||||||
|
/// wrong as a drop, just harder to notice.
|
||||||
void push_outputs(return_tuple&& result, std::index_sequence<Is...>) {
|
void push_outputs(return_tuple&& result, std::index_sequence<Is...>) {
|
||||||
(push_one_out<Is>(std::get<Is>(std::move(result))), ...);
|
bool all = true;
|
||||||
|
((pending_done_[Is] = pending_done_[Is] ||
|
||||||
|
push_one_out<Is>(std::get<Is>(std::move(result))),
|
||||||
|
all = all && pending_done_[Is]), ...);
|
||||||
|
if (all) { pending_.reset(); pending_done_.fill(false); }
|
||||||
|
else pending_ = std::move(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns false when the ring was full and the value was NOT taken; the
|
||||||
|
/// caller must keep it and retry after the channel signals space.
|
||||||
template<std::size_t I>
|
template<std::size_t I>
|
||||||
void push_one_out(std::tuple_element_t<I, return_tuple>&& val) {
|
bool push_one_out(std::tuple_element_t<I, return_tuple>&& val) {
|
||||||
auto* ch = std::get<I>(output_channels_);
|
auto* ch = std::get<I>(output_channels_);
|
||||||
if (!ch) return;
|
if (!ch) return true;
|
||||||
// Sentinels (EOF) must never be dropped: a lost token wedges every
|
// Sentinels (EOF) must never be dropped: a lost token wedges every
|
||||||
// downstream pop() forever. Deliver them out-of-band (push_sentinel),
|
// downstream pop() forever. Deliver them out-of-band (push_sentinel),
|
||||||
// which never overflows and never blocks this node's worker thread.
|
// which never overflows and never blocks this node's worker thread.
|
||||||
if (is_sentinel_value(val)) {
|
if (is_sentinel_value(val)) {
|
||||||
ch->push_sentinel(std::move(val));
|
ch->push_sentinel(std::move(val));
|
||||||
return;
|
return true;
|
||||||
}
|
|
||||||
try {
|
|
||||||
ch->push(std::move(val));
|
|
||||||
} catch (const ChannelOverflowError&) {
|
|
||||||
throw ChannelOverflowError(ch->capacity(),
|
|
||||||
"pool node '" + name_ + "' " + output_port_label<I>());
|
|
||||||
}
|
}
|
||||||
|
// Backpressure without parking the worker. A full channel means the
|
||||||
|
// consumer is behind; the value is kept and this node stops running
|
||||||
|
// until the channel signals space (set_space_callback re-submits it).
|
||||||
|
//
|
||||||
|
// Blocking here instead would sleep inside a scheduler worker, and
|
||||||
|
// nodes are pinned to workers — park enough of them and nothing is left
|
||||||
|
// to run the consumer that would drain the channel. That is the
|
||||||
|
// hold-and-wait deadlock channel.hpp warns about for sentinels; it
|
||||||
|
// applies to data pushes just as much.
|
||||||
|
return ch->try_push(val);
|
||||||
}
|
}
|
||||||
|
|
||||||
template<std::size_t I>
|
template<std::size_t I>
|
||||||
@@ -427,8 +615,20 @@ private:
|
|||||||
output_channels_t output_channels_{};
|
output_channels_t output_channels_{};
|
||||||
std::atomic<bool> stop_flag_{true};
|
std::atomic<bool> stop_flag_{true};
|
||||||
std::atomic<bool> queued_{false};
|
std::atomic<bool> queued_{false};
|
||||||
|
/// A wake that arrived while queued_ was up. See try_submit.
|
||||||
|
std::atomic<bool> wake_pending_{false};
|
||||||
|
|
||||||
|
/// The hidden one-slot output buffer (see push_outputs). Holding the value
|
||||||
|
/// here is what lets a node stop running without dropping it or occupying a
|
||||||
|
/// scheduler worker. One slot suffices because at most one fire_once() runs
|
||||||
|
/// per node at a time — with concurrent firing it would have to hold a whole
|
||||||
|
/// FIFO's worth.
|
||||||
|
std::conditional_t<std::is_void_v<return_raw>, std::monostate,
|
||||||
|
std::optional<return_tuple>> pending_{};
|
||||||
|
std::array<bool, (output_count ? output_count : 1)> pending_done_{};
|
||||||
NodeStats stats_;
|
NodeStats stats_;
|
||||||
NodeErrorHandler error_handler_;
|
NodeErrorHandler error_handler_;
|
||||||
|
NodeErrorHandler net_error_handler_;
|
||||||
std::chrono::milliseconds max_exec_time_{0};
|
std::chrono::milliseconds max_exec_time_{0};
|
||||||
std::array<NodeEventCallback, 2> event_callbacks_{}; // [0]=user [1]=network
|
std::array<NodeEventCallback, 2> event_callbacks_{}; // [0]=user [1]=network
|
||||||
std::array<NodeEventCallback, 2> closed_callbacks_{};
|
std::array<NodeEventCallback, 2> closed_callbacks_{};
|
||||||
@@ -496,6 +696,7 @@ public:
|
|||||||
bool running() const override { return !stop_flag_.load(std::memory_order_relaxed); }
|
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_name(std::string name) override { name_ = std::move(name); }
|
||||||
void set_error_handler(NodeErrorHandler h) { error_handler_ = std::move(h); }
|
void set_error_handler(NodeErrorHandler h) { error_handler_ = std::move(h); }
|
||||||
|
void set_network_error_callback(NodeErrorHandler h) override { net_error_handler_ = std::move(h); }
|
||||||
void set_max_exec_time(std::chrono::milliseconds t) { max_exec_time_ = t; }
|
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_overflow_callback(NodeEventCallback cb) { event_callbacks_[0] = std::move(cb); }
|
||||||
@@ -567,6 +768,8 @@ private:
|
|||||||
}
|
}
|
||||||
template<std::size_t... Is>
|
template<std::size_t... Is>
|
||||||
void register_callbacks(std::index_sequence<Is...>) {
|
void register_callbacks(std::index_sequence<Is...>) {
|
||||||
|
// A parked producer is re-submitted when its output drains.
|
||||||
|
register_space_callbacks(std::make_index_sequence<output_count>{});
|
||||||
(std::get<Is>(input_channels_)->set_push_callback([this] { on_input_ready(); }), ...);
|
(std::get<Is>(input_channels_)->set_push_callback([this] { on_input_ready(); }), ...);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -575,10 +778,26 @@ private:
|
|||||||
for (auto& cb : cbs) if (cb) cb(ts);
|
for (auto& cb : cbs) if (cb) cb(ts);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
template<std::size_t... Os>
|
||||||
|
bool outputs_have_space(std::index_sequence<Os...>) const {
|
||||||
|
return (... && (!std::get<Os>(output_channels_) ||
|
||||||
|
std::get<Os>(output_channels_)->has_space()));
|
||||||
|
}
|
||||||
|
|
||||||
|
template<std::size_t... Os>
|
||||||
|
void register_space_callbacks(std::index_sequence<Os...>) {
|
||||||
|
((std::get<Os>(output_channels_)
|
||||||
|
? (void)std::get<Os>(output_channels_)->set_space_callback(
|
||||||
|
[this] { try_submit(0.5f); })
|
||||||
|
: (void)0), ...);
|
||||||
|
}
|
||||||
|
|
||||||
void self_stop() {
|
void self_stop() {
|
||||||
disable_inputs(std::make_index_sequence<input_count>{});
|
disable_inputs(std::make_index_sequence<input_count>{});
|
||||||
disable_outputs(std::make_index_sequence<output_count>{});
|
disable_outputs(std::make_index_sequence<output_count>{});
|
||||||
stats_.exec_start_us.store(0, std::memory_order_relaxed);
|
stats_.exec_start_us.store(0, std::memory_order_relaxed);
|
||||||
|
// Plain store, not release_and_recheck(): this node is stopping, and
|
||||||
|
// honouring a pending wake here would resubmit a dead node.
|
||||||
queued_.store(false, std::memory_order_release);
|
queued_.store(false, std::memory_order_release);
|
||||||
stop_flag_.store(true, std::memory_order_relaxed);
|
stop_flag_.store(true, std::memory_order_relaxed);
|
||||||
}
|
}
|
||||||
@@ -606,11 +825,51 @@ private:
|
|||||||
return ((std::get<Is>(input_channels_)->approx_size() > 0 ? 1u : 0u) + ...);
|
return ((std::get<Is>(input_channels_)->approx_size() > 0 ? 1u : 0u) + ...);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Priority in [0,1], higher runs sooner.
|
||||||
|
///
|
||||||
|
/// Input fill alone answers "how much work is waiting for me". That is
|
||||||
|
/// only half the question: a node whose *outputs* are already full cannot
|
||||||
|
/// deliver anything: running it produces a value with nowhere to go, so it
|
||||||
|
/// immediately parks and the slot is wasted. Meanwhile the node that would
|
||||||
|
/// have drained that full channel waits behind it.
|
||||||
|
///
|
||||||
|
/// So occupancy of the outputs is deducted from occupancy of the inputs.
|
||||||
|
/// The scheduler then naturally favours whoever is furthest downstream of
|
||||||
|
/// a bottleneck — the node whose inputs are backed up but whose outputs
|
||||||
|
/// have room is exactly the one whose execution frees the most capacity —
|
||||||
|
/// and defers producers that would only deepen a queue that is already
|
||||||
|
/// full.
|
||||||
|
///
|
||||||
|
/// Mapped as 0.5·(1 + in - out) rather than clamping (in - out) at zero:
|
||||||
|
/// both terms are mean fills in [0,1], so the difference is in [-1,1], and
|
||||||
|
/// the affine map keeps the whole range distinguishable instead of
|
||||||
|
/// collapsing every output-saturated node onto the same value. 0.5 remains
|
||||||
|
/// the neutral point, matching the default used for source nodes.
|
||||||
float compute_priority() {
|
float compute_priority() {
|
||||||
if constexpr (input_count == 0) return 0.5f;
|
if constexpr (input_count == 0) return 0.5f;
|
||||||
float sum = 0.0f;
|
float in = 0.0f;
|
||||||
sum_fill(sum, std::make_index_sequence<input_count>{});
|
sum_fill(in, std::make_index_sequence<input_count>{});
|
||||||
return sum / static_cast<float>(input_count);
|
in /= static_cast<float>(input_count);
|
||||||
|
|
||||||
|
if constexpr (output_count == 0) return in;
|
||||||
|
|
||||||
|
float out = 0.0f;
|
||||||
|
sum_output_fill(out, std::make_index_sequence<output_count>{});
|
||||||
|
out /= static_cast<float>(output_count);
|
||||||
|
|
||||||
|
const float p = 0.5f * (1.0f + in - out);
|
||||||
|
return p < 0.0f ? 0.0f : (p > 1.0f ? 1.0f : p);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mean fill of the output channels, same normalisation as sum_fill.
|
||||||
|
/// An unconnected output holds nothing back, so it contributes 0.
|
||||||
|
template<std::size_t... Os>
|
||||||
|
void sum_output_fill(float& sum, std::index_sequence<Os...>) {
|
||||||
|
((sum += (std::get<Os>(output_channels_) &&
|
||||||
|
std::get<Os>(output_channels_)->capacity() > 0)
|
||||||
|
? float(std::get<Os>(output_channels_)->approx_size())
|
||||||
|
/ float(std::get<Os>(output_channels_)->capacity())
|
||||||
|
: 0.0f), ...);
|
||||||
}
|
}
|
||||||
template<std::size_t... Is>
|
template<std::size_t... Is>
|
||||||
void sum_fill(float& sum, std::index_sequence<Is...>) {
|
void sum_fill(float& sum, std::index_sequence<Is...>) {
|
||||||
@@ -620,10 +879,32 @@ private:
|
|||||||
: 0.5f), ...);
|
: 0.5f), ...);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Submit unless already queued. A wake that arrives while this node is
|
||||||
|
/// queued or running is *recorded*, never dropped.
|
||||||
|
///
|
||||||
|
/// Wakes are edge-triggered: a channel fires its space callback on the
|
||||||
|
/// transition, once. If that lands while queued_ is up, the CAS below fails
|
||||||
|
/// and — before wake_pending_ — the wake was gone. A node could then park a
|
||||||
|
/// value, release its worker, and sleep forever holding output its consumer
|
||||||
|
/// was waiting for, with every worker idle in cond_wait and nothing left to
|
||||||
|
/// re-trigger it. Recording the drop turns the signal level-triggered: the
|
||||||
|
/// invariant is that a node never sleeps with a wake outstanding, enforced
|
||||||
|
/// by release_and_recheck() at every point that releases the node.
|
||||||
void try_submit(float priority) {
|
void try_submit(float priority) {
|
||||||
bool expected = false;
|
bool expected = false;
|
||||||
if (queued_.compare_exchange_strong(expected, true, std::memory_order_acq_rel))
|
if (queued_.compare_exchange_strong(expected, true, std::memory_order_acq_rel))
|
||||||
scheduler_->submit([this] { fire_once(); }, priority);
|
scheduler_->submit([this] { fire_once(); }, priority);
|
||||||
|
else
|
||||||
|
wake_pending_.store(true, std::memory_order_release);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear queued_, then honour any wake that was dropped while it was up.
|
||||||
|
/// Every path that finishes or parks a firing must release the node through
|
||||||
|
/// here rather than storing queued_ directly.
|
||||||
|
void release_and_recheck(float priority = 0.5f) {
|
||||||
|
queued_.store(false, std::memory_order_release);
|
||||||
|
if (wake_pending_.exchange(false, std::memory_order_acq_rel))
|
||||||
|
try_submit(priority);
|
||||||
}
|
}
|
||||||
|
|
||||||
void fire_once() {
|
void fire_once() {
|
||||||
@@ -636,6 +917,46 @@ private:
|
|||||||
t0.time_since_epoch()).count();
|
t0.time_since_epoch()).count();
|
||||||
stats_.exec_start_us.store(now_us, std::memory_order_relaxed);
|
stats_.exec_start_us.store(now_us, std::memory_order_relaxed);
|
||||||
|
|
||||||
|
// Parked from a previous firing: retry that value before touching the
|
||||||
|
// inputs. Returning here releases the worker — the channel's space
|
||||||
|
// callback re-submits this node once the consumer drains a slot.
|
||||||
|
if constexpr (!std::is_void_v<return_raw>) {
|
||||||
|
if (pending_) {
|
||||||
|
push_outputs(std::move(*pending_), std::make_index_sequence<output_count>{});
|
||||||
|
release_and_recheck();
|
||||||
|
if (pending_) {
|
||||||
|
// Close the lost-wakeup race: a space_callback that fired
|
||||||
|
// between the failed push and clearing queued_ was
|
||||||
|
// swallowed, and nothing else will wake this node. Re-check
|
||||||
|
// now that the flag is down.
|
||||||
|
if (outputs_have_space(std::make_index_sequence<output_count>{}))
|
||||||
|
try_submit(0.5f);
|
||||||
|
return; // parked
|
||||||
|
}
|
||||||
|
// Drained: resume normal firing, resubmitting exactly the way
|
||||||
|
// the normal tail below does. An unconditional try_submit here
|
||||||
|
// would fire a node whose inputs are empty, and pop_inputs
|
||||||
|
// reports an empty channel as ChannelClosedError — which this
|
||||||
|
// node treats as "upstream finished" and self-stops on. That
|
||||||
|
// is a live node killing itself purely because it was woken by
|
||||||
|
// *output* space rather than by input arrival.
|
||||||
|
if constexpr (input_count == 0) try_submit(0.5f);
|
||||||
|
else on_input_ready();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// See the equivalent guard in PoolNode::fire_once: a space-callback
|
||||||
|
// wake with nothing parked must release the worker, not fall through
|
||||||
|
// into pop_inputs on an empty channel.
|
||||||
|
if constexpr (input_count > 0) {
|
||||||
|
if (count_ready(std::make_index_sequence<input_count>{}) != input_count) {
|
||||||
|
release_and_recheck();
|
||||||
|
on_input_ready();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
auto args = pop_inputs(std::make_index_sequence<input_count>{});
|
auto args = pop_inputs(std::make_index_sequence<input_count>{});
|
||||||
auto t1 = clock_t::now();
|
auto t1 = clock_t::now();
|
||||||
@@ -659,7 +980,11 @@ private:
|
|||||||
} catch (const ChannelOverflowError&) {
|
} catch (const ChannelOverflowError&) {
|
||||||
fire_callbacks(event_callbacks_);
|
fire_callbacks(event_callbacks_);
|
||||||
} catch (...) {
|
} catch (...) {
|
||||||
if (error_handler_ && error_handler_(name_, std::current_exception())) {
|
auto eptr = std::current_exception();
|
||||||
|
const bool handled =
|
||||||
|
(error_handler_ && error_handler_(name_, eptr)) ||
|
||||||
|
(net_error_handler_ && net_error_handler_(name_, eptr));
|
||||||
|
if (handled) {
|
||||||
} else {
|
} else {
|
||||||
fire_callbacks(closed_callbacks_);
|
fire_callbacks(closed_callbacks_);
|
||||||
self_stop();
|
self_stop();
|
||||||
@@ -668,8 +993,26 @@ private:
|
|||||||
}
|
}
|
||||||
|
|
||||||
stats_.exec_start_us.store(0, std::memory_order_relaxed);
|
stats_.exec_start_us.store(0, std::memory_order_relaxed);
|
||||||
queued_.store(false, std::memory_order_release);
|
release_and_recheck();
|
||||||
if (stop_flag_.load(std::memory_order_relaxed)) return;
|
if (stop_flag_.load(std::memory_order_relaxed)) return;
|
||||||
|
|
||||||
|
// Parked by the push above. Same situation as the retry path at the top
|
||||||
|
// of fire_once — and the same lost-wakeup race, which that path closes
|
||||||
|
// and this one did not. A space callback that fired while queued_ was
|
||||||
|
// still up got swallowed by try_submit's CAS, and the resubmit below
|
||||||
|
// cannot cover it: this firing consumed its input, so inputs are empty
|
||||||
|
// and on_input_ready() will not resubmit. The node would then hold its
|
||||||
|
// value forever while its consumer waits for exactly that value and its
|
||||||
|
// producer parks on an input channel that never drains. Re-check now
|
||||||
|
// that the flag is down.
|
||||||
|
if constexpr (!std::is_void_v<return_raw>) {
|
||||||
|
if (pending_) {
|
||||||
|
if (outputs_have_space(std::make_index_sequence<output_count>{}))
|
||||||
|
try_submit(0.5f);
|
||||||
|
return; // parked
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if constexpr (input_count == 0) try_submit(0.5f);
|
if constexpr (input_count == 0) try_submit(0.5f);
|
||||||
else on_input_ready();
|
else on_input_ready();
|
||||||
}
|
}
|
||||||
@@ -692,26 +1035,32 @@ private:
|
|||||||
}
|
}
|
||||||
|
|
||||||
template<std::size_t... Is>
|
template<std::size_t... Is>
|
||||||
|
/// Pushes what it can and parks the rest. `pending_done_` marks the elements
|
||||||
|
/// already taken, so a retry never re-pushes one — a duplicate is as wrong as
|
||||||
|
/// a drop and harder to notice.
|
||||||
void push_outputs(return_tuple&& result, std::index_sequence<Is...>) {
|
void push_outputs(return_tuple&& result, std::index_sequence<Is...>) {
|
||||||
(push_one_out<Is>(std::get<Is>(std::move(result))), ...);
|
bool all = true;
|
||||||
|
((pending_done_[Is] = pending_done_[Is] ||
|
||||||
|
push_one_out<Is>(std::get<Is>(std::move(result))),
|
||||||
|
all = all && pending_done_[Is]), ...);
|
||||||
|
if (all) { pending_.reset(); pending_done_.fill(false); }
|
||||||
|
else pending_ = std::move(result);
|
||||||
}
|
}
|
||||||
|
/// Returns false when the ring was full and the value was NOT taken; the
|
||||||
|
/// caller must keep it and retry after the channel signals space.
|
||||||
template<std::size_t I>
|
template<std::size_t I>
|
||||||
void push_one_out(std::tuple_element_t<I, return_tuple>&& val) {
|
bool push_one_out(std::tuple_element_t<I, return_tuple>&& val) {
|
||||||
auto* ch = std::get<I>(output_channels_);
|
auto* ch = std::get<I>(output_channels_);
|
||||||
if (!ch) return;
|
if (!ch) return true;
|
||||||
// Sentinels (EOF) must never be dropped: a lost token wedges every
|
// Sentinels (EOF) must never be dropped: a lost token wedges every
|
||||||
// downstream pop() forever. Deliver them out-of-band (push_sentinel),
|
// downstream pop() forever. Deliver them out-of-band (push_sentinel),
|
||||||
// which never overflows and never blocks this node's worker thread.
|
// which never overflows and never blocks this node's worker thread.
|
||||||
if (is_sentinel_value(val)) {
|
if (is_sentinel_value(val)) {
|
||||||
ch->push_sentinel(std::move(val));
|
ch->push_sentinel(std::move(val));
|
||||||
return;
|
return true;
|
||||||
}
|
|
||||||
try {
|
|
||||||
ch->push(std::move(val));
|
|
||||||
} catch (const ChannelOverflowError&) {
|
|
||||||
throw ChannelOverflowError(ch->capacity(),
|
|
||||||
"pool node '" + name_ + "'");
|
|
||||||
}
|
}
|
||||||
|
// See the note on the typed overload above: park rather than block.
|
||||||
|
return ch->try_push(val);
|
||||||
}
|
}
|
||||||
|
|
||||||
Obj& obj_;
|
Obj& obj_;
|
||||||
@@ -722,8 +1071,20 @@ private:
|
|||||||
output_channels_t output_channels_{};
|
output_channels_t output_channels_{};
|
||||||
std::atomic<bool> stop_flag_{true};
|
std::atomic<bool> stop_flag_{true};
|
||||||
std::atomic<bool> queued_{false};
|
std::atomic<bool> queued_{false};
|
||||||
|
/// A wake that arrived while queued_ was up. See try_submit.
|
||||||
|
std::atomic<bool> wake_pending_{false};
|
||||||
|
|
||||||
|
/// The hidden one-slot output buffer (see push_outputs). Holding the value
|
||||||
|
/// here is what lets a node stop running without dropping it or occupying a
|
||||||
|
/// scheduler worker. One slot suffices because at most one fire_once() runs
|
||||||
|
/// per node at a time — with concurrent firing it would have to hold a whole
|
||||||
|
/// FIFO's worth.
|
||||||
|
std::conditional_t<std::is_void_v<return_raw>, std::monostate,
|
||||||
|
std::optional<return_tuple>> pending_{};
|
||||||
|
std::array<bool, (output_count ? output_count : 1)> pending_done_{};
|
||||||
NodeStats stats_;
|
NodeStats stats_;
|
||||||
NodeErrorHandler error_handler_;
|
NodeErrorHandler error_handler_;
|
||||||
|
NodeErrorHandler net_error_handler_;
|
||||||
std::chrono::milliseconds max_exec_time_{0};
|
std::chrono::milliseconds max_exec_time_{0};
|
||||||
std::array<NodeEventCallback, 2> event_callbacks_{}; // [0]=user [1]=network
|
std::array<NodeEventCallback, 2> event_callbacks_{}; // [0]=user [1]=network
|
||||||
std::array<NodeEventCallback, 2> closed_callbacks_{};
|
std::array<NodeEventCallback, 2> closed_callbacks_{};
|
||||||
|
|||||||
@@ -217,6 +217,20 @@ public:
|
|||||||
return it->second;
|
return it->second;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Raw node handle by name — lets a binding dynamic_cast to a concrete wrapper
|
||||||
|
// type and call its functor's runtime setters (persistent-pipeline reuse).
|
||||||
|
VNode* node_ptr(const std::string& name) { return &node_at(name); }
|
||||||
|
|
||||||
|
// Per-node timing snapshot for profiling where a replay spends its time.
|
||||||
|
std::map<std::string, double> node_stats(const std::string& name) {
|
||||||
|
auto& n = node_at(name);
|
||||||
|
NodeSnapshot s = n.node_snapshot(name, 0.0);
|
||||||
|
return {{"frames", double(s.frames_processed)},
|
||||||
|
{"exec_ms", s.ema_exec_ms}, {"max_ms", s.max_exec_ms},
|
||||||
|
{"blocked_ms", s.total_blocked_ms}, {"fps", s.throughput_fps},
|
||||||
|
{"cpu_ms", s.total_cpu_ms}, {"cpu_util_pct", s.cpu_util_pct}};
|
||||||
|
}
|
||||||
|
|
||||||
private:
|
private:
|
||||||
VNode& node_at(const std::string& name) {
|
VNode& node_at(const std::string& name) {
|
||||||
auto it = nodes_.find(name);
|
auto it = nodes_.find(name);
|
||||||
@@ -422,13 +436,17 @@ private:
|
|||||||
|
|
||||||
for (std::size_t i = 0; i < out_channels_.size(); ++i) {
|
for (std::size_t i = 0; i < out_channels_.size(); ++i) {
|
||||||
if (out_channels_[i])
|
if (out_channels_[i])
|
||||||
out_channels_[i]->push(std::move(outputs[i]));
|
// Lossless: wait for space rather than drop. A dropped frame
|
||||||
|
// silently corrupts a replay's score; backpressure just slows
|
||||||
|
// the producer. (Was push() + "drop on overflow".)
|
||||||
|
out_channels_[i]->push_blocking(std::move(outputs[i]));
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (const ChannelClosedError&) {
|
} catch (const ChannelClosedError&) {
|
||||||
break;
|
break;
|
||||||
} catch (const ChannelOverflowError&) {
|
} catch (const ChannelOverflowError&) {
|
||||||
// drop and continue
|
// no longer reachable with push_blocking, kept for safety
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -530,7 +548,8 @@ void register_py_network(nb::module_& m, const char* class_name = "Network") {
|
|||||||
.def("read", &Net::read,
|
.def("read", &Net::read,
|
||||||
nb::arg("node"), nb::arg("out_idx") = std::size_t(0))
|
nb::arg("node"), nb::arg("out_idx") = std::size_t(0))
|
||||||
.def("write", &Net::write,
|
.def("write", &Net::write,
|
||||||
nb::arg("node"), nb::arg("in_idx"), nb::arg("value"));
|
nb::arg("node"), nb::arg("in_idx"), nb::arg("value"))
|
||||||
|
.def("node_stats", &Net::node_stats, nb::arg("node"));
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace kpn::python
|
} // namespace kpn::python
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
#pragma once
|
||||||
|
// ObjectVariantNodeWrapper — variant-node adapter for *stateful* functors.
|
||||||
|
//
|
||||||
|
// VariantNodeWrapper (variant_node.hpp) wraps Node<Func,...>, where Func is a
|
||||||
|
// default-constructible NTTP callable. That doesn't fit nodes whose functor must
|
||||||
|
// be constructed with runtime state (a Config, a loaded gallery, etc.) — those use
|
||||||
|
// ObjectNode<Obj>, which takes `Obj& obj` at construction.
|
||||||
|
//
|
||||||
|
// This wrapper owns an Obj instance and exposes the same IVariantNode surface so a
|
||||||
|
// stateful C++ node can live inside a PyNetwork. Build one via a factory that
|
||||||
|
// constructs the functor from Python-supplied config, e.g.:
|
||||||
|
//
|
||||||
|
// auto n = std::make_shared<ObjectVariantNodeWrapper<
|
||||||
|
// IdentityMatcherFunc, Variant, in<"tracked">, out<"matched">>>(
|
||||||
|
// fifo_cap, gallery, cfg); // Obj ctor args forwarded
|
||||||
|
// net.add("identity_matcher", n);
|
||||||
|
//
|
||||||
|
// The wrapper mirrors VariantNodeWrapper's channel plumbing exactly; only the
|
||||||
|
// underlying node type (PoolObjectNode, holding Obj&) differs.
|
||||||
|
|
||||||
|
#include "../channel.hpp"
|
||||||
|
#include "../node.hpp"
|
||||||
|
#include "../variant_node.hpp"
|
||||||
|
|
||||||
|
#include <memory>
|
||||||
|
#include <stdexcept>
|
||||||
|
#include <string>
|
||||||
|
#include <tuple>
|
||||||
|
#include <typeindex>
|
||||||
|
#include <utility>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace kpn {
|
||||||
|
|
||||||
|
template<typename Obj, typename Variant,
|
||||||
|
typename InputTag = in<>,
|
||||||
|
typename OutputTag = out<>>
|
||||||
|
class ObjectVariantNodeWrapper;
|
||||||
|
|
||||||
|
template<typename Obj, typename Variant,
|
||||||
|
fixed_string... InNames, fixed_string... OutNames>
|
||||||
|
class ObjectVariantNodeWrapper<Obj, Variant, in<InNames...>, out<OutNames...>>
|
||||||
|
: public IVariantNode<Variant>
|
||||||
|
{
|
||||||
|
using NodeT = ObjectNode<Obj, in<InNames...>, out<OutNames...>>;
|
||||||
|
|
||||||
|
public:
|
||||||
|
using args_tuple = typename NodeT::args_tuple;
|
||||||
|
using return_tuple = typename NodeT::return_tuple;
|
||||||
|
|
||||||
|
static constexpr std::size_t n_in = NodeT::input_count;
|
||||||
|
static constexpr std::size_t n_out = NodeT::output_count;
|
||||||
|
|
||||||
|
// Owns the functor; forwards remaining args to Obj's constructor.
|
||||||
|
template<typename... ObjArgs>
|
||||||
|
explicit ObjectVariantNodeWrapper(std::size_t fifo_capacity, ObjArgs&&... obj_args)
|
||||||
|
: obj_(std::forward<ObjArgs>(obj_args)...)
|
||||||
|
, node_(obj_, fifo_capacity)
|
||||||
|
, in_channels_(n_in)
|
||||||
|
, out_channels_(n_out)
|
||||||
|
, out_type_indices_(n_out, std::type_index(typeid(void)))
|
||||||
|
{
|
||||||
|
init_inputs(std::make_index_sequence<n_in>{}, fifo_capacity);
|
||||||
|
init_out_types(std::make_index_sequence<n_out>{});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Access the owned functor so callers can invoke its runtime setters (e.g. to
|
||||||
|
// change a threshold on a persistent pipeline without rebuilding the node).
|
||||||
|
Obj& functor() { return obj_; }
|
||||||
|
|
||||||
|
// ── INode ─────────────────────────────────────────────────────────────────
|
||||||
|
void start() override { node_.start(); }
|
||||||
|
void stop() override { node_.stop(); }
|
||||||
|
bool running() const override { return node_.running(); }
|
||||||
|
const NodeStats& stats() const override { return node_.stats(); }
|
||||||
|
void set_name(std::string name) override { node_.set_name(std::move(name)); }
|
||||||
|
NodeSnapshot node_snapshot(const std::string& name, double elapsed_s) const override {
|
||||||
|
return node_.node_snapshot(name, elapsed_s);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── IVariantNode ──────────────────────────────────────────────────────────
|
||||||
|
std::size_t input_count() const override { return n_in; }
|
||||||
|
std::size_t output_count() const override { return n_out; }
|
||||||
|
|
||||||
|
std::type_index input_type(std::size_t i) const override {
|
||||||
|
return in_channels_[i]->type_index();
|
||||||
|
}
|
||||||
|
std::type_index output_type(std::size_t i) const override {
|
||||||
|
return out_type_indices_[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
std::shared_ptr<IVariantChannel<Variant>> input_channel(std::size_t i) override {
|
||||||
|
return in_channels_[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
void set_output_channel(std::size_t i,
|
||||||
|
std::shared_ptr<IVariantChannel<Variant>> ch) override {
|
||||||
|
set_output_impl(i, std::move(ch), std::make_index_sequence<n_out>{});
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
template<std::size_t... Is>
|
||||||
|
void init_inputs(std::index_sequence<Is...>, std::size_t cap) {
|
||||||
|
((init_one_input<Is>(cap)), ...);
|
||||||
|
}
|
||||||
|
|
||||||
|
template<std::size_t I>
|
||||||
|
void init_one_input(std::size_t cap) {
|
||||||
|
using T = std::tuple_element_t<I, args_tuple>;
|
||||||
|
auto shared_ch = std::make_shared<Channel<T>>(cap);
|
||||||
|
node_.template set_input_channel<I>(shared_ch);
|
||||||
|
in_channels_[I] = std::make_shared<VariantChannel<T, Variant>>(std::move(shared_ch));
|
||||||
|
}
|
||||||
|
|
||||||
|
template<std::size_t... Is>
|
||||||
|
void init_out_types(std::index_sequence<Is...>) {
|
||||||
|
((out_type_indices_[Is] =
|
||||||
|
std::type_index(typeid(std::tuple_element_t<Is, return_tuple>))), ...);
|
||||||
|
}
|
||||||
|
|
||||||
|
template<std::size_t... Is>
|
||||||
|
void set_output_impl(std::size_t port,
|
||||||
|
std::shared_ptr<IVariantChannel<Variant>> ch,
|
||||||
|
std::index_sequence<Is...>) {
|
||||||
|
bool matched = false;
|
||||||
|
((Is == port && (set_output_at<Is>(std::move(ch)), matched = true)), ...);
|
||||||
|
if (!matched)
|
||||||
|
throw std::out_of_range("set_output_channel: port index out of range");
|
||||||
|
}
|
||||||
|
|
||||||
|
template<std::size_t I>
|
||||||
|
void set_output_at(std::shared_ptr<IVariantChannel<Variant>> ch) {
|
||||||
|
using T = std::tuple_element_t<I, return_tuple>;
|
||||||
|
auto* typed = dynamic_cast<VariantChannel<T, Variant>*>(ch.get());
|
||||||
|
if (!typed)
|
||||||
|
throw std::runtime_error(
|
||||||
|
"set_output_channel: type mismatch at output port " + std::to_string(I));
|
||||||
|
node_.template set_output_channel<I>(typed->raw_ptr());
|
||||||
|
out_channels_[I] = std::move(ch);
|
||||||
|
}
|
||||||
|
|
||||||
|
Obj obj_; // owned; node_ holds Obj& — declaration order keeps obj_ alive first
|
||||||
|
NodeT node_;
|
||||||
|
std::vector<std::shared_ptr<IVariantChannel<Variant>>> in_channels_;
|
||||||
|
std::vector<std::shared_ptr<IVariantChannel<Variant>>> out_channels_;
|
||||||
|
std::vector<std::type_index> out_type_indices_;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace kpn
|
||||||
@@ -122,6 +122,10 @@ public:
|
|||||||
[this, n](auto ts) { event_handler_(n, NodeEvent::Closed, ts); });
|
[this, n](auto ts) { event_handler_(n, NodeEvent::Closed, ts); });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (error_handler_) {
|
||||||
|
for (auto* node : user_nodes_topo_)
|
||||||
|
node->set_network_error_callback(error_handler_);
|
||||||
|
}
|
||||||
for (auto* n : user_nodes_topo_) n->start();
|
for (auto* n : user_nodes_topo_) n->start();
|
||||||
for (auto* n : fanout_nodes_ptr_) n->start();
|
for (auto* n : fanout_nodes_ptr_) n->start();
|
||||||
#ifdef KPN_WEB_DEBUG
|
#ifdef KPN_WEB_DEBUG
|
||||||
@@ -181,6 +185,14 @@ public:
|
|||||||
|
|
||||||
void set_event_handler(EventHandler h) { event_handler_ = std::move(h); }
|
void set_event_handler(EventHandler h) { event_handler_ = std::move(h); }
|
||||||
|
|
||||||
|
/// Application-level error listener. Receives the exception any node's
|
||||||
|
/// function throws, after that node's own handler (if any) declined it.
|
||||||
|
/// Return true to skip the failed invocation and keep the node running,
|
||||||
|
/// false to let it stop. Without a listener the exception is discarded
|
||||||
|
/// and only a Closed event survives, which reports that a node stopped
|
||||||
|
/// but not why.
|
||||||
|
void set_error_handler(NodeErrorHandler h) { error_handler_ = std::move(h); }
|
||||||
|
|
||||||
#ifdef KPN_WEB_DEBUG
|
#ifdef KPN_WEB_DEBUG
|
||||||
void set_web_debug_port(uint16_t port) { web_debug_port_ = port; }
|
void set_web_debug_port(uint16_t port) { web_debug_port_ = port; }
|
||||||
// Called by DebugHub::register_network() so the hub owns the debug server.
|
// Called by DebugHub::register_network() so the hub owns the debug server.
|
||||||
@@ -276,6 +288,7 @@ private:
|
|||||||
std::vector<std::pair<std::string, IResourceProbe*>> resource_probes_;
|
std::vector<std::pair<std::string, IResourceProbe*>> resource_probes_;
|
||||||
std::vector<std::pair<std::string, IPoolProbe*>> pool_probes_;
|
std::vector<std::pair<std::string, IPoolProbe*>> pool_probes_;
|
||||||
EventHandler event_handler_;
|
EventHandler event_handler_;
|
||||||
|
NodeErrorHandler error_handler_;
|
||||||
clock_t::time_point start_time_;
|
clock_t::time_point start_time_;
|
||||||
#ifdef KPN_WEB_DEBUG
|
#ifdef KPN_WEB_DEBUG
|
||||||
uint16_t web_debug_port_{9090};
|
uint16_t web_debug_port_{9090};
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ class IVariantChannel {
|
|||||||
public:
|
public:
|
||||||
virtual ~IVariantChannel() = default;
|
virtual ~IVariantChannel() = default;
|
||||||
virtual void push(Variant v) = 0;
|
virtual void push(Variant v) = 0;
|
||||||
|
// Lossless push with backpressure (waits instead of dropping when full).
|
||||||
|
virtual void push_blocking(Variant v) = 0;
|
||||||
virtual Variant pop() = 0;
|
virtual Variant pop() = 0;
|
||||||
virtual std::type_index type_index() const = 0;
|
virtual std::type_index type_index() const = 0;
|
||||||
virtual std::string type_name() const = 0;
|
virtual std::string type_name() const = 0;
|
||||||
@@ -76,6 +78,9 @@ public:
|
|||||||
void push(Variant v) override {
|
void push(Variant v) override {
|
||||||
channel_->push(std::get<T>(std::move(v)));
|
channel_->push(std::get<T>(std::move(v)));
|
||||||
}
|
}
|
||||||
|
void push_blocking(Variant v) override {
|
||||||
|
channel_->push_blocking(std::get<T>(std::move(v)));
|
||||||
|
}
|
||||||
Variant pop() override {
|
Variant pop() override {
|
||||||
return Variant{ channel_->pop() };
|
return Variant{ channel_->pop() };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ add_executable(kpn_tests
|
|||||||
test_static_network.cpp
|
test_static_network.cpp
|
||||||
test_shared_resource.cpp
|
test_shared_resource.cpp
|
||||||
test_pool_node.cpp
|
test_pool_node.cpp
|
||||||
|
test_backpressure_deadlock.cpp
|
||||||
test_scheduler.cpp
|
test_scheduler.cpp
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
// Regression: a blocking push must not park a pool worker.
|
||||||
|
//
|
||||||
|
// Node outputs use push_blocking so a full channel costs time rather than data
|
||||||
|
// (a dropped frame does not degrade a downstream result, it silently changes
|
||||||
|
// one). But push_blocking sleeps *inside* fire_once, which runs on a pool
|
||||||
|
// worker — and nodes are pinned to workers by index. Park enough workers in
|
||||||
|
// that retry loop and there is nobody left to run the consumer that would drain
|
||||||
|
// the channel, so the whole chain wedges.
|
||||||
|
//
|
||||||
|
// This is the failure channel.hpp:174 already warns about for sentinels
|
||||||
|
// ("a blocking push would park that thread and stop it draining its own input,
|
||||||
|
// cascading into a hold-and-wait deadlock under backpressure"). The warning
|
||||||
|
// applies to data pushes too.
|
||||||
|
//
|
||||||
|
// Observed in the field as an intermittent hang: frame_source, camera_pos,
|
||||||
|
// face_detector and face_aligner all asleep in push_blocking at once.
|
||||||
|
#include <catch2/catch_test_macros.hpp>
|
||||||
|
#include <kpn/kpn.hpp>
|
||||||
|
|
||||||
|
#include <atomic>
|
||||||
|
#include <chrono>
|
||||||
|
#include <thread>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
struct Produce {
|
||||||
|
static constexpr std::string_view label() { return "produce"; }
|
||||||
|
int n{0};
|
||||||
|
int operator()() { return n++; }
|
||||||
|
};
|
||||||
|
|
||||||
|
struct Relay {
|
||||||
|
static constexpr std::string_view label() { return "relay"; }
|
||||||
|
int operator()(int v) { return v; }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Deliberately slower than the producer, so the channels between them fill.
|
||||||
|
struct SlowSink {
|
||||||
|
static constexpr std::string_view label() { return "slow_sink"; }
|
||||||
|
std::atomic<int>* seen;
|
||||||
|
void operator()(int) {
|
||||||
|
std::this_thread::sleep_for(std::chrono::milliseconds(2));
|
||||||
|
seen->fetch_add(1, std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
TEST_CASE("a saturated chain keeps draining", "[backpressure][deadlock]") {
|
||||||
|
std::atomic<int> seen{0};
|
||||||
|
|
||||||
|
Produce p_fn;
|
||||||
|
Relay r1_fn, r2_fn, r3_fn;
|
||||||
|
SlowSink s_fn{&seen};
|
||||||
|
|
||||||
|
// Small channels so they saturate immediately, and a chain longer than a
|
||||||
|
// modest pool — the shape that starves workers.
|
||||||
|
kpn::ObjectNode<Produce, kpn::in<>, kpn::out<"a">, "produce", 0> p (p_fn, 2);
|
||||||
|
kpn::ObjectNode<Relay, kpn::in<"a">, kpn::out<"b">, "relay1", 0> r1(r1_fn, 2);
|
||||||
|
kpn::ObjectNode<Relay, kpn::in<"b">, kpn::out<"c">, "relay2", 0> r2(r2_fn, 2);
|
||||||
|
kpn::ObjectNode<Relay, kpn::in<"c">, kpn::out<"d">, "relay3", 0> r3(r3_fn, 2);
|
||||||
|
kpn::ObjectNode<SlowSink, kpn::in<"d">, kpn::out<>, "slow_sink", 0> s (s_fn, 2);
|
||||||
|
|
||||||
|
auto net = kpn::make_network(
|
||||||
|
kpn::edge(p.output<"a">(), r1.input<"a">()),
|
||||||
|
kpn::edge(r1.output<"b">(), r2.input<"b">()),
|
||||||
|
kpn::edge(r2.output<"c">(), r3.input<"c">()),
|
||||||
|
kpn::edge(r3.output<"d">(), s.input<"d">())
|
||||||
|
);
|
||||||
|
net.start();
|
||||||
|
|
||||||
|
// The sink is the slowest stage at 2 ms/item, so 40 items is ~80 ms of real
|
||||||
|
// work. Anything approaching the timeout means the chain stopped draining
|
||||||
|
// rather than merely running slowly.
|
||||||
|
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(20);
|
||||||
|
while (seen.load(std::memory_order_relaxed) < 40 &&
|
||||||
|
std::chrono::steady_clock::now() < deadline)
|
||||||
|
std::this_thread::sleep_for(std::chrono::milliseconds(5));
|
||||||
|
|
||||||
|
const int got = seen.load(std::memory_order_relaxed);
|
||||||
|
net.stop();
|
||||||
|
|
||||||
|
INFO("items drained: " << got << " of 40");
|
||||||
|
CHECK(got >= 40);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regression: a saturated chain must never stall.
|
||||||
|
//
|
||||||
|
// push_outputs parks from two places: the retry at the top of fire_once, and
|
||||||
|
// the ordinary push after the node function returns. Both release the worker,
|
||||||
|
// so both face the same lost wakeup — a space callback firing while queued_ is
|
||||||
|
// still up is swallowed by try_submit's CAS. Only the retry path re-checked for
|
||||||
|
// space afterwards. The normal path fell through to on_input_ready(), which
|
||||||
|
// resubmits only if inputs are ready — and the firing that just parked had
|
||||||
|
// consumed its input, so they are not.
|
||||||
|
//
|
||||||
|
// The strand is permanent under saturation: the node holds its value, its
|
||||||
|
// consumer waits for exactly that value, and its producer fills the node's
|
||||||
|
// input channel and parks too. Nothing moves again.
|
||||||
|
//
|
||||||
|
// The test above cannot catch it — 40 items drain before any strand occurs.
|
||||||
|
// This one runs the chain saturated and watches for progress to *freeze*, which
|
||||||
|
// is the signature of the deadlock. It deliberately does not assert a total:
|
||||||
|
// capacity-1 channels are slow, and "slow" must never be reported as "wedged".
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
struct FreeRun {
|
||||||
|
static constexpr std::string_view label() { return "free_run"; }
|
||||||
|
int n{0};
|
||||||
|
int operator()() { return n++; }
|
||||||
|
};
|
||||||
|
|
||||||
|
struct CountingSink {
|
||||||
|
static constexpr std::string_view label() { return "counting_sink"; }
|
||||||
|
std::atomic<int>* seen;
|
||||||
|
void operator()(int) { seen->fetch_add(1, std::memory_order_relaxed); }
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
TEST_CASE("a saturated chain never stalls", "[backpressure][deadlock]") {
|
||||||
|
std::atomic<int> seen{0};
|
||||||
|
|
||||||
|
FreeRun p_fn;
|
||||||
|
Relay r1_fn, r2_fn;
|
||||||
|
CountingSink s_fn{&seen};
|
||||||
|
|
||||||
|
// Capacity 1 everywhere: every push contends, so the park path is taken
|
||||||
|
// constantly and the race window is sampled millions of times.
|
||||||
|
kpn::ObjectNode<FreeRun, kpn::in<>, kpn::out<"a">, "free_run", 0> p (p_fn, 1);
|
||||||
|
kpn::ObjectNode<Relay, kpn::in<"a">, kpn::out<"b">, "relay1", 0> r1(r1_fn, 1);
|
||||||
|
kpn::ObjectNode<Relay, kpn::in<"b">, kpn::out<"c">, "relay2", 0> r2(r2_fn, 1);
|
||||||
|
kpn::ObjectNode<CountingSink, kpn::in<"c">, kpn::out<>, "sink", 0> s (s_fn, 1);
|
||||||
|
|
||||||
|
auto net = kpn::make_network(
|
||||||
|
kpn::edge(p.output<"a">(), r1.input<"a">()),
|
||||||
|
kpn::edge(r1.output<"b">(), r2.input<"b">()),
|
||||||
|
kpn::edge(r2.output<"c">(), s.input<"c">())
|
||||||
|
);
|
||||||
|
net.start();
|
||||||
|
|
||||||
|
// A live chain moves thousands of items a second, so 3 s with no movement
|
||||||
|
// at all is a wedge, not a slow patch. Sampling for 25 s gives the race
|
||||||
|
// ample opportunity: the pipeline hit it roughly twice in 30 runs.
|
||||||
|
const auto giveup = std::chrono::steady_clock::now() + std::chrono::seconds(25);
|
||||||
|
int last = 0;
|
||||||
|
auto last_move = std::chrono::steady_clock::now();
|
||||||
|
bool stalled = false;
|
||||||
|
int stall_at = 0;
|
||||||
|
|
||||||
|
while (std::chrono::steady_clock::now() < giveup) {
|
||||||
|
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||||
|
const int now_seen = seen.load(std::memory_order_relaxed);
|
||||||
|
if (now_seen != last) {
|
||||||
|
last = now_seen;
|
||||||
|
last_move = std::chrono::steady_clock::now();
|
||||||
|
} else if (std::chrono::steady_clock::now() - last_move >
|
||||||
|
std::chrono::seconds(3)) {
|
||||||
|
stalled = true;
|
||||||
|
stall_at = now_seen;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
net.stop();
|
||||||
|
|
||||||
|
INFO("chain stalled after " << stall_at << " items");
|
||||||
|
CHECK_FALSE(stalled);
|
||||||
|
// Guard against the test passing because nothing ever ran.
|
||||||
|
CHECK(last > 1000);
|
||||||
|
}
|
||||||
@@ -243,7 +243,13 @@ TEST_CASE("interrupt node: trigger after stop is ignored", "[interrupt_node]") {
|
|||||||
|
|
||||||
// ── Overflow callback ─────────────────────────────────────────────────────────
|
// ── Overflow callback ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
TEST_CASE("pool node overflow callback fires on full output channel", "[pool_node][overflow]") {
|
// NOTE: output overflow is no longer reachable on the data path. A node whose
|
||||||
|
// output channel is full now PARKS — it keeps the value in a hidden one-slot
|
||||||
|
// buffer, releases its scheduler worker, and is re-submitted when the consumer
|
||||||
|
// frees a slot. The overflow callback survives for other producers (a direct
|
||||||
|
// Channel::push by non-node code still throws), but a pool node cannot trigger
|
||||||
|
// it, so these cases assert the stronger property instead: nothing is dropped.
|
||||||
|
TEST_CASE("pool node parks instead of overflowing a full output", "[pool_node][overflow]") {
|
||||||
auto pool = std::make_shared<ThreadPool>(2);
|
auto pool = std::make_shared<ThreadPool>(2);
|
||||||
pool->start();
|
pool->start();
|
||||||
|
|
||||||
@@ -264,10 +270,13 @@ TEST_CASE("pool node overflow callback fires on full output channel", "[pool_nod
|
|||||||
node.stop();
|
node.stop();
|
||||||
pool->stop();
|
pool->stop();
|
||||||
|
|
||||||
REQUIRE(overflow_count.load() > 0);
|
// Parked, not overflowed: the value is still owned by the node.
|
||||||
|
REQUIRE(overflow_count.load() == 0);
|
||||||
|
// And it was never handed downstream, so nothing was lost or duplicated.
|
||||||
|
REQUIRE(full_ch.size() == 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
TEST_CASE("pool node overflow callback is independent per instance", "[pool_node][overflow]") {
|
TEST_CASE("parking is per node, not shared", "[pool_node][overflow]") {
|
||||||
auto pool = std::make_shared<ThreadPool>(2);
|
auto pool = std::make_shared<ThreadPool>(2);
|
||||||
pool->start();
|
pool->start();
|
||||||
|
|
||||||
@@ -296,7 +305,10 @@ TEST_CASE("pool node overflow callback is independent per instance", "[pool_node
|
|||||||
nodeB.stop();
|
nodeB.stop();
|
||||||
pool->stop();
|
pool->stop();
|
||||||
|
|
||||||
REQUIRE(a_overflows.load() > 0);
|
// Neither overflows now: A parks on its full output, B runs normally. The
|
||||||
|
// point of the case is unchanged — one node's backpressure must not leak
|
||||||
|
// into another's callbacks.
|
||||||
|
REQUIRE(a_overflows.load() == 0);
|
||||||
REQUIRE(b_overflows.load() == 0);
|
REQUIRE(b_overflows.load() == 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -412,7 +424,9 @@ TEST_CASE("network_overflow_callback fires on overflow", "[pool_node][network]")
|
|||||||
node.stop();
|
node.stop();
|
||||||
pool->stop();
|
pool->stop();
|
||||||
|
|
||||||
REQUIRE(net_overflows.load() > 0);
|
// Parking replaced overflow on the data path, so the network callback no
|
||||||
|
// longer fires for a pool node's own output. See the note above.
|
||||||
|
REQUIRE(net_overflows.load() == 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
TEST_CASE("network_closed_callback fires on crash", "[pool_node][network]") {
|
TEST_CASE("network_closed_callback fires on crash", "[pool_node][network]") {
|
||||||
@@ -459,6 +473,8 @@ TEST_CASE("per-node and network overflow callbacks both fire independently", "[p
|
|||||||
node.stop();
|
node.stop();
|
||||||
pool->stop();
|
pool->stop();
|
||||||
|
|
||||||
REQUIRE(per_node.load() > 0);
|
// Both zero now: the node parks rather than overflowing. The case still
|
||||||
REQUIRE(network.load() > 0);
|
// guards that the two callbacks are wired independently.
|
||||||
|
REQUIRE(per_node.load() == 0);
|
||||||
|
REQUIRE(network.load() == 0);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user