fix: the sentinel slot holds one token and refuses a second
push_sentinel wrote eof_value_ unconditionally. Offering a second token
before the first was taken did two wrong things at once.
It lost the first silently, and a lost EOF is not a lost frame — it is the
token every downstream node is waiting for in order to shut down, so losing
it wedges the pipeline.
And it wrote the storage while the consumer could be moving the previous
value out of it. I expected that to be a stale read; ThreadSanitizer shows
it is worse. On the shared_ptr storage that non-trivial types use, the
racing write tears the refcount, and the stress case added here reports
heap-use-after-free in extract() alongside the data race.
try_push_sentinel now refuses when the slot is occupied, which turns the
slot into a correct SPSC handshake: the producer is the only writer of
eof_value_ and the only one that sets has_eof_, the consumer is the only one
that clears it, so observing it false is what licenses the write. Refusal is
recorded as a drop, and PoolNode reports it through the overflow event
callback, because a refused control token going unnoticed is the failure
this commit exists to stop.
Refusing rather than queueing is deliberate. Two control tokens on one
channel means the stream ended twice, which is a caller protocol error and
not backpressure; parking and retrying would spin against a slot only the
consumer can free, and there is no sensible second value to deliver after
the end of a stream. The non-consuming try_push_sentinel exists so a refused
token is still the caller's to report — the consuming push_sentinel cannot
offer that, since the value has already been moved into its parameter.
Single-shot EOF is what every current caller does, so this is latent for
them today. It stops being latent the moment a pipeline is reused for a
second input, which is what the persistent-pipeline work in 4b6e498 sets up.
Verified in both directions under -DKPN_SANITIZER=thread: the new contended
case reports three data races and a heap-use-after-free against the old
overwrite, and is clean with the handshake. Full suite 137/137, TSan clean
across unit and stress suites.
This commit is contained in:
@@ -570,7 +570,14 @@ private:
|
||||
// downstream pop() forever. Deliver them out-of-band (push_sentinel),
|
||||
// which never overflows and never blocks this node's worker thread.
|
||||
if (is_sentinel_value(val)) {
|
||||
ch->push_sentinel(std::move(val));
|
||||
// A refused sentinel is a protocol error, not backpressure, so it
|
||||
// is reported rather than parked and retried — retrying would spin
|
||||
// forever against a slot only the consumer can free, and there is
|
||||
// no correct value to deliver second anyway. Closed is normal
|
||||
// during teardown and stays quiet.
|
||||
if (ch->try_push_sentinel(val) == Channel<std::tuple_element_t<I, return_tuple>>
|
||||
::SentinelResult::SlotBusy)
|
||||
fire_callbacks(event_callbacks_);
|
||||
return true;
|
||||
}
|
||||
// Backpressure without parking the worker. A full channel means the
|
||||
@@ -1087,7 +1094,14 @@ private:
|
||||
// downstream pop() forever. Deliver them out-of-band (push_sentinel),
|
||||
// which never overflows and never blocks this node's worker thread.
|
||||
if (is_sentinel_value(val)) {
|
||||
ch->push_sentinel(std::move(val));
|
||||
// A refused sentinel is a protocol error, not backpressure, so it
|
||||
// is reported rather than parked and retried — retrying would spin
|
||||
// forever against a slot only the consumer can free, and there is
|
||||
// no correct value to deliver second anyway. Closed is normal
|
||||
// during teardown and stays quiet.
|
||||
if (ch->try_push_sentinel(val) == Channel<std::tuple_element_t<I, return_tuple>>
|
||||
::SentinelResult::SlotBusy)
|
||||
fire_callbacks(event_callbacks_);
|
||||
return true;
|
||||
}
|
||||
// See the note on the typed overload above: park rather than block.
|
||||
|
||||
Reference in New Issue
Block a user