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:
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user