Files
KPN/tests/test_pool_node.cpp
dtourolle 97670d8ba3 fix: stop() must not return while a firing is still running
stop() set the flag, disabled the inputs and returned, leaving an executing
fire_once touching input_channels_, stats_ and pending_ while the caller went
on to destroy them. The comment was explicit about it: callers wanting the
guarantee should call scheduler_->drain() first. But ~PoolNode calls stop(),
and a destructor cannot ask its caller to have done that.

A node with a private pool survived by accident, because Node::stop() calls
pool->stop() and that joins the worker. A node sharing a pool — which
make_pool_node exists to create — had nothing joining it, so its own
destructor raced the firing.

stop() now waits on the submit gate, which is claimed for the whole of a
firing and released as its last act. A queued but unstarted firing also holds
it and will run, observe stop_flag_ and release, so the pool must still be
running when stop() is called; that is already the documented order and what
Node/ObjectNode do.

Two ways it declines to wait. It is bounded at five seconds, because a node
function that never returns must not convert teardown into a hang — it warns
and continues. And it returns immediately when called from the firing thread
itself, since an error handler that stops its own node would otherwise wait
for a firing that is waiting for it.

Verified in both directions: with the wait removed, stop() returns while the
node function is still sleeping and the flag it sets on the way out is still
false. 143/143.
2026-08-05 15:46:27 +02:00

723 lines
25 KiB
C++

#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 ─────────────────────────────────────────────────────────
// 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);
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();
// 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("parking is per node, not shared", "[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();
// 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);
}
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();
// 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]") {
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();
// Both zero now: the node parks rather than overflowing. The case still
// guards that the two callbacks are wired independently.
REQUIRE(per_node.load() == 0);
REQUIRE(network.load() == 0);
}
// Regression: NodeSnapshot's fields must line up with what nodes initialise.
//
// The snapshot is an aggregate that every node type fills positionally, and
// a8cfe73 appended queued/wake_pending/total_exec_ms to it in an order no call
// site used: each node supplies total_exec_ms as the element straight after
// queue_wait_ms, but the struct declared the two bools there. So the exec total
// landed in `queued`, `queued` landed in `wake_pending`, and `wake_pending`
// landed in total_exec_ms. The compiler said so (-Wnarrowing, bool to double,
// once per node instantiation) and the build carried on.
//
// It matters more than a cosmetic mix-up: these three fields exist to diagnose a
// wedge, and a wedged pipeline reported total_exec_ms as 0 or 1 and `queued` as
// "did this node ever run". Reading them would have pointed at the wrong node.
//
// Asserted against ema_exec_ms because that field is independently computed and
// was already correct: a true sum over several frames cannot be below the
// exponentially-weighted average of the same samples.
TEST_CASE("node snapshot fields line up with the values nodes supply",
"[pool_node][diagnostics]") {
auto pool = std::make_shared<ThreadPool>(1);
pool->start();
auto node = make_pool_node<double_it>(pool, 64);
Channel<int> out(64);
node.set_output_channel<0>(&out);
node.start();
for (int i = 0; i < 8; ++i) node.input_channel<0>().push(i);
std::this_thread::sleep_for(std::chrono::milliseconds(100));
auto snap = node.node_snapshot("n", 1.0);
node.stop();
pool->stop();
INFO("frames=" << snap.frames_processed
<< " ema=" << snap.ema_exec_ms
<< " total=" << snap.total_exec_ms);
REQUIRE(snap.frames_processed == 8);
// The mis-ordered aggregate put wake_pending here, so this was 0.0 or 1.0.
CHECK(snap.total_exec_ms >= snap.ema_exec_ms);
// ...and the exec total here, which is non-zero, so `queued` read true for
// any node that had ever run — including one asleep with nothing to do.
CHECK_FALSE(snap.queued);
CHECK_FALSE(snap.wake_pending);
}
// Regression: a value parked twice must keep its payload.
//
// push_outputs ends with
//
// else pending_ = std::move(result);
//
// and the retry path calls it as push_outputs(std::move(*pending_), …), so on
// that path `result` is the parked tuple itself. The assignment was therefore a
// self-move-assignment. std::tuple's is elementwise, and libstdc++'s
// std::vector does not guard against self-move: it swaps its data into a
// temporary and leaves the vector empty. So the first park was clean (the
// argument is a local temporary) and the second erased the payload.
//
// The value was still delivered, still in order, still counted — just empty.
// Downstream cannot distinguish that from a frame on which the node genuinely
// found nothing, which is why it never surfaced as an error: in
// scene-actor-extraction it reads as "no faces in this frame" and the run
// completes with a quietly wrong answer.
//
// Reaching it needs *two* outputs. With one, the only thing that resubmits a
// parked node is that output's own space callback, which by definition fires
// when there is room — so the retry always succeeds and never reassigns. With
// two, output A draining resubmits the node while output B is still full: the
// retry skips A (already delivered, tracked in pending_done_) and fails on B,
// and that is the reassignment that eats B's payload.
//
// Driven through raw channels rather than consumer nodes so each step is
// forced rather than raced: B is pre-filled and stays full for exactly as long
// as the test wants it to.
namespace {
struct TwoPayloads {
static constexpr std::string_view label() { return "two_payloads"; }
std::tuple<std::vector<int>, std::vector<int>> operator()() {
return {std::vector<int>(4, 1), std::vector<int>(4, 2)};
}
};
} // namespace
TEST_CASE("a twice-parked value keeps its payload", "[pool_node][backpressure]") {
auto pool = std::make_shared<ThreadPool>(2);
pool->start();
TwoPayloads fn;
auto node = make_pool_node(fn, pool);
// Both capacity 1. A must be *full* for its pop to signal space at all —
// Channel fires the space callback only on the full->not-full edge, so a
// roomy A would never resubmit the node and the retry would never happen.
Channel<std::vector<int>> out_a(1), out_b(1);
node.set_output_channel<0>(&out_a);
node.set_output_channel<1>(&out_b);
// B is full before the node ever runs, so the very first firing parks.
out_b.push(std::vector<int>(4, 99));
node.start();
std::this_thread::sleep_for(std::chrono::milliseconds(50));
// Draining A resubmits the node while B is still full: this is the retry
// that reassigned the tuple to itself.
(void)out_a.pop();
std::this_thread::sleep_for(std::chrono::milliseconds(50));
// Now let B through and collect what the node had been holding for it.
(void)out_b.pop(); // the pre-fill
std::this_thread::sleep_for(std::chrono::milliseconds(50));
std::vector<int> parked = out_b.pop(); // the value parked across two tries
node.stop();
pool->stop();
INFO("parked payload size " << parked.size());
CHECK(parked.size() == 4);
if (parked.size() == 4) CHECK(parked[0] == 2);
}
// Regression: a node woken with nothing to read must not stop itself.
//
// pop_one reported an empty channel the same way it reported a closed one, by
// throwing ChannelClosedError, and fire_once treats that as "upstream is
// finished" and calls self_stop(). self_stop disables the node's own inputs
// *and* outputs, so one benign empty read does not merely skip a frame — it
// kills the node and, through the disabled channels, the rest of the pipeline.
//
// A node genuinely does get woken with empty inputs: a space callback fires
// when its output drains, which has nothing to do with input arrival. fire_once
// guards against it by checking readiness before popping, and that guard is
// what this test pins. pop_one now also distinguishes the two cases, so if the
// guard is ever weakened the cost is a wasted firing rather than a dead node.
//
// The sequence below reaches the guard deliberately. The output is capacity 1
// so that draining it signals space at all — Channel fires the space callback
// only on the full->not-full edge — and by the final pop the input is long
// since consumed, so the resulting firing has nothing to read.
namespace {
struct CountingRelay {
static constexpr std::string_view label() { return "counting_relay"; }
std::atomic<int>* calls;
int operator()(int v) { calls->fetch_add(1, std::memory_order_relaxed); return v; }
};
} // namespace
TEST_CASE("a node woken with empty inputs does not stop itself", "[pool_node]") {
std::atomic<int> calls{0};
std::atomic<int> closed{0};
auto pool = std::make_shared<ThreadPool>(2);
pool->start();
CountingRelay fn{&calls};
auto node = make_pool_node(fn, pool, 8);
Channel<int> out(1);
node.set_output_channel<0>(&out);
node.set_closed_callback([&](auto) { closed.fetch_add(1, std::memory_order_relaxed); });
out.push(99); // output full before the node runs
node.start();
node.input_channel<0>().push(1); // fires, cannot deliver, parks
std::this_thread::sleep_for(std::chrono::milliseconds(50));
REQUIRE(out.pop() == 99); // space -> retry delivers the parked value
std::this_thread::sleep_for(std::chrono::milliseconds(50));
REQUIRE(out.pop() == 1); // space again -> fires with empty inputs
std::this_thread::sleep_for(std::chrono::milliseconds(50));
// That firing had nothing to read. The node must still be alive.
CHECK(closed.load(std::memory_order_relaxed) == 0);
CHECK(node.running());
// And must still do its job when real input arrives.
node.input_channel<0>().push(2);
std::this_thread::sleep_for(std::chrono::milliseconds(50));
CHECK(out.pop() == 2);
CHECK(calls.load(std::memory_order_relaxed) == 2);
node.stop();
pool->stop();
}
// Regression: stop() must not return while a firing is still running.
//
// stop() set the flag, disabled the inputs and returned, leaving an executing
// fire_once touching input_channels_, stats_ and pending_ while the caller went
// on to destroy them. The old comment was explicit that callers wanting the
// guarantee should call scheduler_->drain() first — but ~PoolNode calls stop(),
// and a destructor cannot ask its caller to have done that.
//
// A node with a private pool survived by accident: Node::stop() calls
// pool->stop(), which joins the worker. A node sharing a pool, which
// make_pool_node exists to create, had nothing joining it at all, so its own
// destructor raced the firing.
//
// Asserted through an observable side effect rather than by trying to catch the
// use-after-free: if stop() returns before the node function has finished, the
// flag it sets on the way out is still false.
namespace {
struct SlowFiring {
static constexpr std::string_view label() { return "slow_firing"; }
std::atomic<bool>* entered;
std::atomic<bool>* finished;
void operator()(int) {
entered->store(true, std::memory_order_release);
std::this_thread::sleep_for(std::chrono::milliseconds(200));
finished->store(true, std::memory_order_release);
}
};
} // namespace
TEST_CASE("stop waits for a firing already in flight", "[pool_node]") {
std::atomic<bool> entered{false}, finished{false};
auto pool = std::make_shared<ThreadPool>(2);
pool->start();
SlowFiring fn{&entered, &finished};
auto node = make_pool_node(fn, pool, 4);
node.start();
node.input_channel<0>().push(1);
// Stop only once the node is demonstrably inside its function.
while (!entered.load(std::memory_order_acquire))
std::this_thread::sleep_for(std::chrono::milliseconds(1));
node.stop();
CHECK(finished.load(std::memory_order_acquire));
pool->stop();
}