fix: an empty channel is not a closed one

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 a benign empty read does not merely skip a frame — it kills
the node and, through the disabled channels, whatever depended on it.

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 already guards against it by checking readiness before popping.
That guard is the live protection and it works; this commit makes the thing
it is guarding non-lethal.

So the pop_one path changed here is unreachable today, and I would rather say
that than imply a fixed hang. Its value is that the readiness check is now a
performance detail rather than the only thing standing between a routine wake
and a dead pipeline. Three separate comment blocks in fire_once exist to warn
about exactly this hazard; they were added because it had already been hit
during development, and the conflation they warn about is what this removes.

Verified in three directions. With the guard and the distinction: passes.
With the guard removed but the distinction present: still passes, which is
the point — the new ChannelEmptyError path catches what the guard used to.
With both removed, reproducing the original code: the node self-stops on the
firing that has nothing to read, the next value throws "channel closed" out
of its own output channel, and the relay handles one item instead of two.
This commit is contained in:
2026-08-05 13:55:57 +02:00
parent 15e993f6ca
commit 8d319eeb88
3 changed files with 113 additions and 2 deletions
+66
View File
@@ -602,3 +602,69 @@ TEST_CASE("a twice-parked value keeps its payload", "[pool_node][backpressure]")
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();
}