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:
@@ -473,6 +473,14 @@ private:
|
||||
auto t2 = clock_t::now();
|
||||
// blocked_time = 0 for pool nodes (we don't block waiting for inputs)
|
||||
stats_.record_exec(duration_t(t2 - t1), duration_t::zero(), cpu0, cpu1);
|
||||
} catch (const ChannelEmptyError&) {
|
||||
// Not an error: there was simply nothing to take. Release and wait
|
||||
// to be woken again. fire_once checks readiness before it gets
|
||||
// here, and this node is the sole consumer of its inputs, so this
|
||||
// is unreachable today — it exists so that if the check is ever
|
||||
// weakened the cost is a wasted firing rather than a dead node.
|
||||
finish_firing();
|
||||
return;
|
||||
} catch (const ChannelClosedError&) {
|
||||
fire_callbacks(closed_callbacks_);
|
||||
self_stop();
|
||||
@@ -512,8 +520,16 @@ private:
|
||||
std::tuple_element_t<I, args_tuple> pop_one() {
|
||||
auto& ch = *std::get<I>(input_channels_);
|
||||
std::tuple_element_t<I, args_tuple> val;
|
||||
if (!ch.try_pop_now(val))
|
||||
if (!ch.try_pop_now(val)) {
|
||||
// try_pop_now returns false for "nothing available", which covers
|
||||
// two very different situations. A closed channel means upstream is
|
||||
// finished and this node should stop. An open one means only that
|
||||
// nothing is here at this instant — and treating that as closed
|
||||
// kills a live node, which then disables its own inputs and outputs
|
||||
// and takes the rest of the pipeline with it.
|
||||
if (ch.is_accepting()) throw ChannelEmptyError{};
|
||||
throw ChannelClosedError{};
|
||||
}
|
||||
return val;
|
||||
}
|
||||
|
||||
@@ -980,6 +996,14 @@ private:
|
||||
auto cpu1 = NodeStats::cpu_now();
|
||||
auto t2 = clock_t::now();
|
||||
stats_.record_exec(duration_t(t2 - t1), duration_t::zero(), cpu0, cpu1);
|
||||
} catch (const ChannelEmptyError&) {
|
||||
// Not an error: there was simply nothing to take. Release and wait
|
||||
// to be woken again. fire_once checks readiness before it gets
|
||||
// here, and this node is the sole consumer of its inputs, so this
|
||||
// is unreachable today — it exists so that if the check is ever
|
||||
// weakened the cost is a wasted firing rather than a dead node.
|
||||
finish_firing();
|
||||
return;
|
||||
} catch (const ChannelClosedError&) {
|
||||
fire_callbacks(closed_callbacks_);
|
||||
self_stop();
|
||||
@@ -1014,7 +1038,16 @@ private:
|
||||
std::tuple_element_t<I, args_tuple> pop_one() {
|
||||
auto& ch = *std::get<I>(input_channels_);
|
||||
std::tuple_element_t<I, args_tuple> val;
|
||||
if (!ch.try_pop_now(val)) throw ChannelClosedError{};
|
||||
if (!ch.try_pop_now(val)) {
|
||||
// try_pop_now returns false for "nothing available", which covers
|
||||
// two very different situations. A closed channel means upstream is
|
||||
// finished and this node should stop. An open one means only that
|
||||
// nothing is here at this instant — and treating that as closed
|
||||
// kills a live node, which then disables its own inputs and outputs
|
||||
// and takes the rest of the pipeline with it.
|
||||
if (ch.is_accepting()) throw ChannelEmptyError{};
|
||||
throw ChannelClosedError{};
|
||||
}
|
||||
return val;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user