[Fix] (MG_Remote, Transport): give the inproc doorbell a death state so Shutdown can join a parked waiter, and bound a ring record at half the capacity so a refusal can never look like backpressure

- T-1: InProcessChannel::Close rang each CondVarDoorbell once and claimed that unparks a peer mid-frame. It does not. Doorbell::Wait consumes the one ring, re-tests a condition nothing published, finds the bell alive (CondVarDoorbell never overrode Dead(); Doorbell.cpp had no death state at all) and with kWaitForever parks again for good - so Shutdown could never join a server thread sitting in the design's own steady state (spun, set consumerParked, blocked; plan section 8.1 inheriting the earlier plan's 6.2a). CondVarDoorbell now carries an atomic death latch: Kill() sets it under the mutex and notify_all's, Dead() reports it, Park returns false at once on a dead bell (and the wait predicate includes it, so a Kill cannot slip between the test and the wait), and Close kills both bells instead of ringing them. Same shape as SocketDoorbell's EOF latch; Notify stays the ordinary wakeup.
- T-2: RingProducer::Reserve refused only total > capacity, but a record with capacity/2 < total <= capacity is unplaceable at every head offset where neither the space to the wrap boundary nor the space before it holds it - even in an EMPTY ring, because a wrap pad costs spaceToEnd bytes on top of the record. Concretely: head offset 16 of an empty 256-byte ring, a 248-byte record; FreeBytes() says 256, Reserve says nullptr, forever, and a producer waiting for FreeBytes() >= 248 stalls with nothing logged. The bound is now capacity/2, which is exact rather than conservative (worst case 2*total-8 <= capacity-8), exposed as MaxRecordBytes() for the emitter to chunk against; the minimum ring is two headers so the smallest record still fits the bound. Ring.h states Capacity()/2 as the chunking bound and the G3 header comment in gen_pipe.py now states the chunking rule plan section 8.2 asks G3 to define (PipeWire.inc regenerated).
- Tests, each shown red with only the fix site reverted and green with it: InProcessTransportTest.ShutdownUnparksAWaiterWithNoDeadline (bounded join through a shared_ptr-owned waiter: 5 s red instead of a hung job; reverted it hangs and fails at 5051 ms), RingTest.RecordLargerThanHalfTheRingIsRefused (reverted, the 248-byte record is accepted), RingTest.RecordPlaceabilityDoesNotDependOnTheHeadOffset (the offset-0 vs offset-16 negative control), RingTest.HalfCapacityRecordFitsAtEveryHeadOffset (the positive half: the maximal record at all 32 head offsets of a 256-byte ring) and RingTest.RejectsARingTooSmallForTheSmallestRecord.
This commit is contained in:
2026-09-05 21:19:00 -04:00
parent 7ef7c7e543
commit 1154f9a00d
9 changed files with 285 additions and 30 deletions
+26 -3
View File
@@ -48,6 +48,12 @@ namespace MobileGL::MG_Remote::Transport {
bool CondVarDoorbell::Park(std::uint32_t timeoutMs) {
std::unique_lock<std::mutex> lock(m_impl->mutex);
// The death latch is tested under the same mutex Kill sets it under, so
// a Kill cannot slip between this test and the wait below: it either
// returns here or wakes the predicate.
if (m_dead.load(std::memory_order_relaxed)) {
return false;
}
if (m_impl->signals != 0) {
--m_impl->signals;
return true;
@@ -55,16 +61,33 @@ namespace MobileGL::MG_Remote::Transport {
if (timeoutMs == 0) {
return false;
}
const auto woken = [this] {
return m_impl->signals != 0 || m_dead.load(std::memory_order_relaxed);
};
if (timeoutMs == kWaitForever) {
m_impl->cv.wait(lock, [this] { return m_impl->signals != 0; });
} else if (!m_impl->cv.wait_for(lock, std::chrono::milliseconds(timeoutMs),
[this] { return m_impl->signals != 0; })) {
m_impl->cv.wait(lock, woken);
} else if (!m_impl->cv.wait_for(lock, std::chrono::milliseconds(timeoutMs), woken)) {
return false;
}
if (m_dead.load(std::memory_order_relaxed)) {
// Woken by Kill, not by an event. The caller re-tests its condition
// regardless (Doorbell::Wait always does) and then sees Dead().
return false;
}
--m_impl->signals;
return true;
}
void CondVarDoorbell::Kill() {
{
std::lock_guard<std::mutex> lock(m_impl->mutex);
m_dead.store(true, std::memory_order_release);
}
// notify_all, not notify_one: both a raw Park and a Doorbell::Wait may
// be parked here, and after this nobody will ring again.
m_impl->cv.notify_all();
}
void CondVarDoorbell::Reset() {
std::lock_guard<std::mutex> lock(m_impl->mutex);
m_impl->signals = 0;
+22 -6
View File
@@ -104,12 +104,15 @@ namespace MobileGL::MG_Remote::Transport {
// does not make the next Park return spuriously forever.
virtual void Reset() = 0;
// True once the wakeup channel is permanently unusable, e.g. the peer
// closed its end of the socket. A dead doorbell can never deliver
// another wakeup AND its descriptor is permanently poll-ready, so Wait
// must stop re-parking on it: otherwise a waiter with no deadline
// burns a big core at full clock, which is the exact pathology the
// bidirectional doorbell exists to prevent.
// True once the wakeup channel is permanently unusable: the peer closed
// its end of the socket, or the inproc channel was shut down. A dead
// doorbell can never deliver another wakeup, and Wait must stop
// re-parking on it - for the socket because its descriptor is
// permanently poll-ready and a waiter with no deadline would burn a
// big core at full clock, for the condvar because Park would otherwise
// block forever and Shutdown could never join the waiter. Every
// implementation has a death state; the base default is only for a
// bell that cannot die.
virtual bool Dead() const { return false; }
// Spin `spinUs`, then park until `ready()` or the deadline.
@@ -204,10 +207,23 @@ namespace MobileGL::MG_Remote::Transport {
void Notify() override;
bool Park(std::uint32_t timeoutMs) override;
void Reset() override;
bool Dead() const override { return m_dead.load(std::memory_order_acquire); }
// Hangs the bell up for good: every parked waiter returns false now and
// every later Park returns false at once. The inproc twin of the socket
// peer closing its end (SocketDoorbell latches m_dead on EOF), and what
// InProcessChannel::Close rings instead of Notify. A Notify is consumed
// by ONE Park; Doorbell::Wait then re-tests its condition, finds
// nothing published, finds the bell alive, and with kWaitForever parks
// again - so a Shutdown that only rang could never join a server thread
// sitting in the design's own steady state (spun, set consumerParked,
// blocked). Irreversible by design, like the socket's.
void Kill();
private:
struct Impl;
Impl* m_impl;
std::atomic<bool> m_dead{false};
};
#if !defined(_WIN32)
@@ -81,9 +81,13 @@ namespace MobileGL::MG_Remote::Transport {
dir.fdCv.notify_all();
}
// Anything parked on a ring doorbell has to come back too, or a
// shutdown mid-frame hangs the peer forever.
// shutdown mid-frame hangs the peer forever. Kill, not Notify: a
// ring is consumed by one Park, after which Doorbell::Wait re-tests
// a condition nothing published and - the bell still reporting
// alive - parks again, with no deadline forever. Only Dead() ends
// that loop.
for (CondVarDoorbell& bell : m_bells) {
bell.Notify();
bell.Kill();
}
}
@@ -277,8 +281,9 @@ namespace MobileGL::MG_Remote::Transport {
}
// Whole-connection teardown, as ITransport::Shutdown documents: both
// directions are half-closed and both ring doorbells are rung, because a
// peer parked on a ring doorbell mid-frame would otherwise never come back.
// directions are half-closed and both ring doorbells are KILLED, because a
// peer parked on a ring doorbell mid-frame would otherwise never come back
// (a mere ring is consumed once and the waiter parks again).
void InProcessTransport::Shutdown() { m_channel->Close(); }
Doorbell& InProcessTransport::PeerDoorbell() { return m_channel->Bell(1 - m_endpoint); }
+27 -11
View File
@@ -87,11 +87,13 @@ namespace MobileGL::MG_Remote::Transport {
: m_control(control), m_base(static_cast<std::uint8_t*>(base)), m_capacity(capacityBytes),
m_mask(capacityBytes - 1), m_cursors(cursors) {
if (control == nullptr || base == nullptr || !IsPowerOfTwo(capacityBytes) ||
capacityBytes < sizeof(RingRecordHeader) || capacityBytes > kMaxRingCapacity) {
capacityBytes < kMinRingCapacity || capacityBytes > kMaxRingCapacity) {
MGLOG_E("MG_Remote ring: producer rejected, capacity %llu must be a power of two "
"between %zu and %llu bytes over a non-null mapping (the record header's size "
"field is 32-bit, so a bigger ring would truncate it)",
static_cast<unsigned long long>(capacityBytes), sizeof(RingRecordHeader),
"between %llu and %llu bytes over a non-null mapping (a record may be at most "
"half the ring, and the record header's size field is 32-bit, so a bigger ring "
"would truncate it)",
static_cast<unsigned long long>(capacityBytes),
static_cast<unsigned long long>(kMinRingCapacity),
static_cast<unsigned long long>(kMaxRingCapacity));
m_control = nullptr;
m_base = nullptr;
@@ -123,11 +125,23 @@ namespace MobileGL::MG_Remote::Transport {
return nullptr;
}
const std::uint64_t total = Align8(sizeof(RingRecordHeader) + payloadBytes);
if (total > m_capacity) {
// A single record larger than the whole ring is a caller bug: the
if (total > MaxRecordBytes()) {
// A single record larger than HALF the ring is a caller bug: the
// record catalogue has to chunk oversized payloads (large subdata
// becomes several records) rather than emit one giant record.
MGLOG_E("MG_Remote ring: record kind %u of %llu bytes does not fit a %llu byte ring; "
//
// Half, not the whole ring, because a record has to be placeable at
// EVERY head offset of an empty ring. Straddling the wrap boundary
// costs a pad of spaceToEnd bytes on top of the record, and with
// spaceToEnd < total that is at most 2*total-8, which stays within
// the capacity exactly up to capacity/2. Above it the record is
// placeable at some offsets and not at others: at head offset 16 of
// an empty 256-byte ring a 248-byte record needs 240+248 bytes while
// FreeBytes() reports 256, so a producer that waits for FreeBytes()
// >= total stalls forever, and nothing is ever logged. Refusing here
// makes that impossible - a nullptr with FreeBytes() >= total can no
// longer mean "wait".
MGLOG_E("MG_Remote ring: record kind %u of %llu bytes exceeds half of a %llu byte ring; "
"the emitter must chunk it",
static_cast<unsigned>(kind), static_cast<unsigned long long>(total),
static_cast<unsigned long long>(m_capacity));
@@ -184,11 +198,13 @@ namespace MobileGL::MG_Remote::Transport {
: m_control(control), m_base(static_cast<const std::uint8_t*>(base)),
m_capacity(capacityBytes), m_mask(capacityBytes - 1), m_cursors(cursors) {
if (control == nullptr || base == nullptr || !IsPowerOfTwo(capacityBytes) ||
capacityBytes < sizeof(RingRecordHeader) || capacityBytes > kMaxRingCapacity) {
capacityBytes < kMinRingCapacity || capacityBytes > kMaxRingCapacity) {
MGLOG_E("MG_Remote ring: consumer rejected, capacity %llu must be a power of two "
"between %zu and %llu bytes over a non-null mapping (the record header's size "
"field is 32-bit, so a bigger ring would truncate it)",
static_cast<unsigned long long>(capacityBytes), sizeof(RingRecordHeader),
"between %llu and %llu bytes over a non-null mapping (a record may be at most "
"half the ring, and the record header's size field is 32-bit, so a bigger ring "
"would truncate it)",
static_cast<unsigned long long>(capacityBytes),
static_cast<unsigned long long>(kMinRingCapacity),
static_cast<unsigned long long>(kMaxRingCapacity));
m_control = nullptr;
m_base = nullptr;
+27 -6
View File
@@ -120,6 +120,11 @@ namespace MobileGL::MG_Remote::Transport {
// class of construction-time guard as the power-of-two check beside it.
inline constexpr std::uint64_t kMaxRingCapacity = 0xFFFFFFFFull;
// Smallest ring: two record headers. A record may be at most HALF the ring
// (see RingProducer::Reserve), so a ring of one header could carry nothing
// at all - not even the smallest record, a bare header.
inline constexpr std::uint64_t kMinRingCapacity = 2 * sizeof(RingRecordHeader);
// Which cursor triple a producer/consumer pair drives.
enum class RingCursorSet : std::uint32_t {
Cmd = 0,
@@ -156,8 +161,8 @@ namespace MobileGL::MG_Remote::Transport {
public:
RingProducer() = default;
// `base` is the ring's byte area (NOT the control page) and
// `capacityBytes` must be a power of two of at least one record header
// and at most kMaxRingCapacity. Anything else leaves Valid() false.
// `capacityBytes` must be a power of two between kMinRingCapacity and
// kMaxRingCapacity. Anything else leaves Valid() false.
RingProducer(RingControl* control, void* base, std::uint64_t capacityBytes,
RingCursorSet cursors);
@@ -167,12 +172,28 @@ namespace MobileGL::MG_Remote::Transport {
std::uint64_t FreeBytes() const;
// Reserves room for one record and returns a pointer to its payload,
// or nullptr when the ring is full (or the record cannot fit at all).
// The payload is uninitialized; alignment padding at its tail is NOT
// zeroed. Emits a pad record automatically when the record would
// straddle the wrap boundary, so every record is contiguous.
// or nullptr when the ring is full. The payload is uninitialized;
// alignment padding at its tail is NOT zeroed. Emits a pad record
// automatically when the record would straddle the wrap boundary, so
// every record is contiguous.
//
// A record whose total (header + payload, rounded up to 8) exceeds
// MaxRecordBytes() == Capacity()/2 is refused outright, with an error
// log and however empty the ring is: chunking it is the emitter's job
// (plan section 8.2, the G3 chunking rule). Half is exact, not
// conservative - it is the largest record EVERY head offset can place,
// because a wrap pad costs at most total-8 bytes on top of the record
// and 2*total-8 <= capacity-8 holds exactly up to capacity/2. Above it
// a record is placeable at some offsets and not at others, and a
// producer waiting for FreeBytes() >= total stalls forever on an empty
// ring. So: nullptr with FreeBytes() >= total never means "wait"; it
// can only mean "too big, chunk".
void* Reserve(std::uint16_t kind, std::uint16_t flags, std::uint64_t payloadBytes);
// The largest header+payload total Reserve accepts: Capacity()/2. This
// is the number the emitter chunks against.
std::uint64_t MaxRecordBytes() const { return m_capacity / 2; }
// Makes every reserved record visible to the consumer (release store on
// the head cursor). Cheap: publishing per record is fine, batching 8-16
// only amortizes the doorbell store.