[Fix] (MG_Remote, Wire): close R-2 arm 4 over MGHostSpan, stop the producer writing SEG_STAGEs consumer cursors, bound the stage-mark queue, and answer a reply slot only for rows the catalogue flags kReplySlot

This commit is contained in:
2026-09-11 15:20:15 -04:00
parent 31de23142f
commit 79b7866511
3 changed files with 692 additions and 134 deletions
+360 -113
View File
@@ -61,27 +61,94 @@ namespace MobileGL::MG_Remote::Wire {
static_assert(static_cast<Uint32>(MG_Pipe::kMGHostSpanSegNone) == kSegNone,
"kMGHostSpanSegNone and SegmentId::kSegNone must be the same value");
// The collision named in the file header, asserted rather than described. If a later edit
// moves either bit these fire and the mapping below is revisited; if someone deletes the
// mapping and stamps MGPipeCallFlags straight into the header, the RingTest wrap cases
// stay green and nine opcodes vanish, which is why this is a static_assert and not a
// comment.
static_assert(static_cast<Uint16>(MG_Pipe::kVarTail) ==
static_cast<Uint16>(Transport::kRecPad),
"MGPipeCallFlags::kVarTail and RingRecordFlags::kRecPad share a bit AND a "
"field; the encoder must translate, never stamp. If this ever stops being "
"true, keep translating anyway - two flag spaces in one field is the hazard, "
"not this particular overlap");
static_assert(static_cast<Uint16>(MG_Pipe::kHostSpan) ==
static_cast<Uint16>(Transport::kRecBorrowSlot),
"MGPipeCallFlags::kHostSpan and RingRecordFlags::kRecBorrowSlot share a bit");
// ---- the collision, asserted EXHAUSTIVELY rather than described -------------------
//
// Naming the two overlaps somebody happened to notice is not enough: the review found a
// THIRD (kReplySlot == kRecVarTail) that four hand-written asserts had missed, and the
// fourth would have been found by a user. So the assertions below cover the whole of both
// enums - every bit, the exact overlap mask, and the completeness of the translation - and
// a new enumerator on either side breaks the build rather than being dropped in silence.
namespace FlagSpace {
constexpr Uint16 kCallAll = static_cast<Uint16>(MG_Pipe::kNeedsAck) |
static_cast<Uint16>(MG_Pipe::kHasBlob) |
static_cast<Uint16>(MG_Pipe::kVarTail) |
static_cast<Uint16>(MG_Pipe::kHostSpan) |
static_cast<Uint16>(MG_Pipe::kReplySlot) |
static_cast<Uint16>(MG_Pipe::kOptional);
constexpr Uint16 kRingAll = static_cast<Uint16>(Transport::kRecNeedsAck) |
static_cast<Uint16>(Transport::kRecHasBlob) |
static_cast<Uint16>(Transport::kRecPad) |
static_cast<Uint16>(Transport::kRecBorrowSlot) |
static_cast<Uint16>(Transport::kRecVarTail);
// The three call flags the encoder TRANSLATES, and the three it deliberately drops
// because MGPipeCallFlagsFor(op) recovers them from the opcode.
constexpr Uint16 kTranslated = static_cast<Uint16>(MG_Pipe::kNeedsAck) |
static_cast<Uint16>(MG_Pipe::kHasBlob) |
static_cast<Uint16>(MG_Pipe::kVarTail);
constexpr Uint16 kDropped = static_cast<Uint16>(MG_Pipe::kHostSpan) |
static_cast<Uint16>(MG_Pipe::kReplySlot) |
static_cast<Uint16>(MG_Pipe::kOptional);
} // namespace FlagSpace
static_assert(FlagSpace::kCallAll == 0x3Fu,
"MGPipeCallFlags gained or lost an enumerator; re-derive the translation in "
"EncodeRecord and extend the overlap assertions below before assuming the "
"new bit is safe to drop");
static_assert(FlagSpace::kRingAll == 0x1Fu,
"RingRecordFlags gained or lost an enumerator; the two spaces share one "
"16-bit field, so a new ring bit may now alias a call flag");
static_assert((FlagSpace::kTranslated | FlagSpace::kDropped) == FlagSpace::kCallAll &&
(FlagSpace::kTranslated & FlagSpace::kDropped) == 0,
"every MGPipeCallFlags bit must be either translated or deliberately "
"dropped; a seventh enumerator that needs a ring bit would otherwise be "
"dropped in silence");
// FIVE OF THE SIX CALL-FLAG BITS ALIAS A RING BIT. Only kOptional (1<<5) is free, and it
// is free by luck rather than by design - RingRecordFlags simply has not reached 1<<5 yet.
static_assert((FlagSpace::kCallAll & FlagSpace::kRingAll) == FlagSpace::kRingAll,
"the overlap between the two flag spaces moved");
// Named individually so a failure says WHICH pair, and so the three that actually bite are
// impossible to overlook while reading.
static_assert(static_cast<Uint16>(MG_Pipe::kNeedsAck) ==
static_cast<Uint16>(Transport::kRecNeedsAck),
"the two kNeedsAck bits agree; the mapping below relies on it only for "
"readability, not for correctness");
"kNeedsAck == kRecNeedsAck (harmless: the meanings agree)");
static_assert(static_cast<Uint16>(MG_Pipe::kHasBlob) ==
static_cast<Uint16>(Transport::kRecHasBlob),
"the two blob bits agree");
"kHasBlob == kRecHasBlob (harmless: the meanings agree)");
static_assert(static_cast<Uint16>(MG_Pipe::kVarTail) ==
static_cast<Uint16>(Transport::kRecPad),
"kVarTail == kRecPad - THE DANGEROUS ONE. Stamping MGPipeCallFlags into the "
"header would make RingConsumer::Pop discard all nine kVarTail opcodes as "
"wrap fillers. The encoder must translate, never stamp; if this ever stops "
"being true, keep translating anyway - two flag spaces in one field is the "
"hazard, not this particular overlap");
static_assert(static_cast<Uint16>(MG_Pipe::kHostSpan) ==
static_cast<Uint16>(Transport::kRecBorrowSlot),
"kHostSpan == kRecBorrowSlot - a stamped host-span record would look like a "
"slot borrowed into the GPU timeline and retire on completedFrameSerial");
static_assert(static_cast<Uint16>(MG_Pipe::kReplySlot) ==
static_cast<Uint16>(Transport::kRecVarTail),
"kReplySlot == kRecVarTail - live in the REVERSE direction: the encoder "
"stamps kRecVarTail on nine opcodes, and a reader who believes "
"PipeWire.inc's 'MGPipeCallFlags of the call' comment reads those nine as "
"kReplySlot");
static_assert((static_cast<Uint16>(MG_Pipe::kOptional) & FlagSpace::kRingAll) == 0,
"kOptional is the one call flag with no ring alias, and only because "
"RingRecordFlags has not reached 1<<5");
// ---- and the two headers really are one layout -----------------------------------
//
// The decoder walks backwards from RingRecordView::payload to the MGPWireRecHeader in
// front of it, so the two structs being separately asserted to be eight bytes is not
// enough: if RingRecordHeader ever reorders its fields, every flag assert above still
// passes and every payload read shifts by the difference.
static_assert(sizeof(MGPWireRecHeader) == sizeof(Transport::RingRecordHeader));
static_assert(offsetof(MGPWireRecHeader, Op) == offsetof(Transport::RingRecordHeader, kind),
"MGPWireRecHeader::Op and RingRecordHeader::kind are the same two bytes");
static_assert(offsetof(MGPWireRecHeader, Flags) ==
offsetof(Transport::RingRecordHeader, flags),
"MGPWireRecHeader::Flags and RingRecordHeader::flags are the same two bytes");
static_assert(offsetof(MGPWireRecHeader, Size) == offsetof(Transport::RingRecordHeader, size),
"MGPWireRecHeader::Size and RingRecordHeader::size are the same four bytes");
// ---------------------------------------------------------------------------------
// The catalogue, once. Name and payload type per opcode.
@@ -427,6 +494,29 @@ namespace MobileGL::MG_Remote::Wire {
}
}
void CheckHostSpanIsHonest(const MGHostSpan& span, const SegmentTable& segments) {
CheckHostSpanIsHonest(span);
if (span.Size == 0) {
// Fully absent, and the arms above already proved Seg agrees with that.
return;
}
// ARM 4, WHICH THE OTHER OVERLOAD CANNOT DO. A span naming a real segment and a run
// past the end of it used to pass every check and reach WireVerbSink::OnDrawVbo, which
// this file's header promises is "a DECODED, VALIDATED argument list". It resolves to
// nullptr through MGPipeHostBytes - a draw from a null index pointer - or, for any
// consumer that adds Offset to its own SEG_STAGE base instead of going through the
// resolver, reads outside the segment. P5 emits no spans, so this was latent; P8 arms
// it, which is exactly when nobody will be reading this code.
if (segments.Resolve(span.Seg, span.Offset, span.Size) == nullptr) {
MGLOG_F("MGPipe: Fatal{ProtocolCorruption, \"host-span\"} seg=%u offset=%llu "
"size=%llu does not lie inside that segment (R-2.3 arm 4)",
static_cast<unsigned>(span.Seg),
static_cast<unsigned long long>(span.Offset),
static_cast<unsigned long long>(span.Size));
std::abort();
}
}
// ---------------------------------------------------------------------------------
// The record layout: the tail arithmetic both sides run
// ---------------------------------------------------------------------------------
@@ -527,13 +617,27 @@ namespace MobileGL::MG_Remote::Wire {
tails = 1;
break;
}
case MGPWireOp::ResourceSubData:
case MGPWireOp::BufferSubDataResident: {
case MGPWireOp::ResourceSubData: {
const auto& p = *static_cast<const MGPSubData*>(payload);
tail0 = TailBytesFor(p.RegionCount, sizeof(MGPSubRegion));
tails = 1;
break;
}
case MGPWireOp::BufferSubDataResident: {
// NO TAIL, because its catalogue row has no kVarTail (PipeCalls.def gives it
// kHasBlob|kOptional) and MGPipeApplyBufferSubDataResident takes no regions. The
// layout used to share ResourceSubData's arm, which meant a record with
// RegionCount = 2 was REQUIRED to carry 80 bytes the arm then dropped on the
// floor - and MGPipeBuildSubDataRecord is the shared builder that fills
// RegionCount for both halves, so that was one routing change away from being
// live. The resident path is the BUFFER half only and a buffer record declares no
// regions, so a non-zero count is a fault rather than a tail.
const auto& p = *static_cast<const MGPSubData*>(payload);
if (p.RegionCount != 0) {
WireProtocolFatalAt("BufferSubDataResident.RegionCount", p.RegionCount, 0);
}
break;
}
case MGPWireOp::DrawVbo: {
// MGPDrawRange[NumDraws], then a CONDITIONAL MGHostSpan. The span's start is
// realigned to 8 because MGPDrawRange is twelve bytes: see WireRecordLayout's
@@ -580,10 +684,72 @@ namespace MobileGL::MG_Remote::Wire {
Bool PipeWireEncoder::Valid() const { return m_control != nullptr && m_cmd != nullptr; }
Uint8* PipeWireEncoder::StageAllocate(Uint64 size) {
if (m_stageBase == nullptr) {
const SegmentView view = m_segments != nullptr ? m_segments->Get(kSegStage)
: SegmentView{};
if (view.Base == nullptr || view.Size == 0) {
WireProtocolFatal("PipeWireEncoder::StageBytes", "SEG_STAGE has no segment view");
}
// The RingProducer c0's constructor takes is the authority on how many of the
// segment's bytes are really the staging area - the rest is whatever the session
// put in front of it. It is read, never written: SEG_STAGE's cursor triple belongs
// to nobody in P5 (see ReclaimStagedBytes' header).
Uint64 capacity = view.Size;
if (m_stage != nullptr && m_stage->Valid()) {
if (m_stage->Capacity() > view.Size) {
WireProtocolFatalAt("SEG_STAGE.capacity", m_stage->Capacity(), view.Size);
}
capacity = m_stage->Capacity();
}
m_stageBase = static_cast<Uint8*>(view.Base);
m_stageCapacity = capacity;
}
const Uint64 need = Align8(size);
if (need > m_stageCapacity) {
MGLOG_F("MGPipe: Fatal{RingOverrun, \"SEG_STAGE\"} a %llu byte blob cannot fit a "
"%llu byte staging segment at any occupancy; P5 does not chunk (R-10) - "
"raise MOBILEGL_IPC_STAGE_MB or report the record to the integrator",
static_cast<unsigned long long>(size),
static_cast<unsigned long long>(m_stageCapacity));
std::abort();
}
for (int attempt = 0; attempt < 2; ++attempt) {
const Uint64 offset = m_stageHead % m_stageCapacity;
// A run is always contiguous: one that would straddle the end skips the remainder,
// exactly as the ring's wrap pad does, and the skipped bytes are reclaimed with
// everything else behind them.
const Uint64 skip = offset + need > m_stageCapacity ? m_stageCapacity - offset : 0;
if ((m_stageHead + skip + need) - m_stageTail <= m_stageCapacity) {
const Uint64 at = (m_stageHead + skip) % m_stageCapacity;
m_stageHead += skip + need;
return m_stageBase + at;
}
if (attempt == 0) {
// One try at reclaiming what the server has already retired. A second failure
// means the bytes genuinely do not fit, which R-10 says P5 does not chunk and
// must instead prove it never needs to.
ReclaimStagedBytes();
}
}
MGLOG_F("MGPipe: Fatal{RingOverrun, \"SEG_STAGE\"} a %llu byte blob does not fit a %llu "
"byte staging segment with %llu bytes still in flight (retiredSeq=%llu); P5 "
"does not chunk (R-10) - raise MOBILEGL_IPC_STAGE_MB or report the record to "
"the integrator",
static_cast<unsigned long long>(size),
static_cast<unsigned long long>(m_stageCapacity),
static_cast<unsigned long long>(m_stageHead - m_stageTail),
static_cast<unsigned long long>(
m_control != nullptr ? m_control->retiredSeq.load(std::memory_order_acquire) : 0));
std::abort();
}
MGPBlobRef PipeWireEncoder::StageBytes(const void* bytes, Uint64 size) {
if (!Valid() || m_stage == nullptr || m_segments == nullptr) {
if (!Valid() || m_segments == nullptr) {
WireProtocolFatal("PipeWireEncoder::StageBytes",
"no SEG_STAGE producer or segment table installed");
"no command producer or segment table installed");
}
if (size == 0) {
// "The record declared no blob" and "the record declared an empty blob" must not
@@ -596,33 +762,9 @@ namespace MobileGL::MG_Remote::Wire {
WireProtocolFatal("PipeWireEncoder::StageBytes", "non-zero size with a null source");
}
void* slot = m_stage->Reserve(static_cast<Uint16>(MGPWireOp::kInvalid),
Transport::kRecHasBlob, size);
if (slot == nullptr) {
// One try at reclaiming what the server has already retired, then give up: a
// second failure means the run genuinely does not fit SEG_STAGE, which R-10 says
// P5 does not chunk and must instead prove it never needs to.
ReclaimStagedBytes();
slot = m_stage->Reserve(static_cast<Uint16>(MGPWireOp::kInvalid),
Transport::kRecHasBlob, size);
}
if (slot == nullptr) {
MGLOG_F("MGPipe: Fatal{RingOverrun, \"SEG_STAGE\"} a %llu byte blob does not fit a "
"%llu byte staging ring with %llu bytes free; P5 does not chunk (R-10) - "
"raise MOBILEGL_IPC_STAGE_MB or report the record to the integrator",
static_cast<unsigned long long>(size),
static_cast<unsigned long long>(m_stage->Capacity()),
static_cast<unsigned long long>(m_stage->FreeBytes()));
std::abort();
}
Uint8* slot = StageAllocate(size);
std::memcpy(slot, bytes, static_cast<SizeT>(size));
const SegmentView stageView = m_segments->Get(kSegStage);
if (stageView.Base == nullptr) {
WireProtocolFatal("PipeWireEncoder::StageBytes", "SEG_STAGE has no segment view");
}
const Uint64 offset =
static_cast<Uint64>(static_cast<const Uint8*>(slot) - static_cast<const Uint8*>(stageView.Base));
const Uint64 offset = static_cast<Uint64>(slot - m_stageBase);
MGPBlobRef ref{};
ref.Seg = kSegStage;
@@ -630,18 +772,14 @@ namespace MobileGL::MG_Remote::Wire {
ref.Size = size;
ref.Pad0 = 0;
// The self-check that keeps the two halves of "SEG_STAGE" one thing: the segment view
// the decoder resolves through must cover the ring this producer just wrote into. A
// view installed over the CONTROL page, or over the ring plus its header, resolves to
// the decoder resolves through must cover the bytes this allocator just wrote into. A
// view installed over the CONTROL page, or over the segment plus a header, resolves to
// a plausible pointer that is not these bytes.
if (m_segments->Resolve(ref.Seg, ref.Offset, ref.Size) != slot) {
WireProtocolFatal("PipeWireEncoder::StageBytes",
"the SEG_STAGE segment view does not cover the staging ring's "
"byte area; the two would resolve to different addresses");
"the SEG_STAGE segment view does not cover the staging area; the "
"two would resolve to different addresses");
}
// The stage ring's own Publish: the decoder reads these bytes by OFFSET, never by
// popping the stage ring, so the head has to be visible before the command record
// that names them is.
m_stage->Publish();
return ref;
}
@@ -759,16 +897,32 @@ namespace MobileGL::MG_Remote::Wire {
MGPBlobRef blob{};
std::memcpy(&blob, bytes + slots.Offset + i * sizeof(MGPBlobRef), sizeof(MGPBlobRef));
CheckBlobIsHonest(op, blob, *m_segments);
// THE ENCODER MUST NOT ACCEPT A RECORD THE DECODER FATALS ON. w1's ruling that
// CreateShaderState's modules travel inside the Reflection archive lived only
// in the decoder, so an emitter that declared a per-stage run got a valid seq
// here and a Fatal on a peer - exactly the asymmetry EncodeRecord's own
// comment says it exists to prevent.
if (op == MGPWireOp::CreateShaderState && i < 6 && blob.Size != 0) {
MGLOG_F("MGPipe: Fatal{ProtocolCorruption, \"CreateShaderState.Spirv[%u]\"} "
"declares %llu bytes at the ENCODER; under split the modules travel "
"inside the Reflection archive and the six per-stage runs stay "
"undeclared",
static_cast<unsigned>(i),
static_cast<unsigned long long>(blob.Size));
std::abort();
}
}
}
if ((callFlags & static_cast<Uint32>(kHostSpan)) != 0 && layout.TailCount == 2 &&
layout.TailBytes[1] != 0) {
layout.TailBytes[1] != 0 && m_segments != nullptr) {
const Uint64 at = layout.TailOffset[1] - sizeof(MGPWireRecHeader);
const Uint64 spans = layout.TailBytes[1] / sizeof(MGHostSpan);
for (Uint64 i = 0; i < spans; ++i) {
MGHostSpan span{};
std::memcpy(&span, bytes + at + i * sizeof(MGHostSpan), sizeof(MGHostSpan));
CheckHostSpanIsHonest(span);
// All four arms, including the one that needs the table: a producer that
// computed an offset wrongly is caught here rather than on a peer.
CheckHostSpanIsHonest(span, *m_segments);
}
}
@@ -778,49 +932,56 @@ namespace MobileGL::MG_Remote::Wire {
++m_emitSeq;
// The stage mark: where SEG_STAGE stood once everything this record names had been
// staged. ReclaimStagedBytes releases up to the newest mark the server has retired.
if (m_stage != nullptr) {
m_stageMarks.push_back(StageMark{m_emitSeq, m_stage->LocalHead()});
}
m_stageMarks.push_back(StageMark{m_emitSeq, m_stageHead});
return m_emitSeq;
}
void PipeWireEncoder::ReclaimStagedBytes() {
if (m_stage == nullptr || m_control == nullptr) {
if (m_control == nullptr) {
return;
}
const Uint64 retired = m_control->retiredSeq.load(std::memory_order_acquire);
Uint64 upTo = 0;
Bool found = false;
Uint64 upTo = m_stageTail;
while (m_stageMarkFront < m_stageMarks.size() &&
m_stageMarks[m_stageMarkFront].Seq <= retired) {
upTo = m_stageMarks[m_stageMarkFront].StageCursor;
found = true;
++m_stageMarkFront;
}
// Compact rather than erase-from-front on every call: the list is at most as long as
// the number of records in flight, which the verb barrier keeps at one or two.
if (m_stageMarkFront != 0 && m_stageMarkFront == m_stageMarks.size()) {
// COMPACT ON A THRESHOLD, NOT ONLY ON A FULL DRAIN. The queue used to be cleared only
// when front reached size(), so any pipelining deeper than "fully drained at every
// reclaim" - which is precisely the regime this design exists to survive when R-1's
// barrier retires family by family - left front < size() for ever and grew the vector
// 16 bytes per encoded record for the life of the context. Erasing the consumed prefix
// once it is half the queue is amortised O(1) and bounds the storage at twice the
// records actually in flight.
if (m_stageMarkFront == m_stageMarks.size()) {
m_stageMarks.clear();
m_stageMarkFront = 0;
} else if (m_stageMarkFront != 0 && m_stageMarkFront * 2 >= m_stageMarks.size()) {
m_stageMarks.erase(m_stageMarks.begin(),
m_stageMarks.begin() + static_cast<std::ptrdiff_t>(m_stageMarkFront));
m_stageMarkFront = 0;
}
if (!found || upTo <= m_stageReclaimed) {
return;
// SEG_STAGE is CLIENT-OWNED memory (contract table 1: "client stages, server copies")
// and the server only reads it, so the client is both the allocator and the thing that
// frees. What it may not do is free ahead of retiredSeq - the whole content of R-11 on
// this side - and what it may ALSO not do is write RingControl's stage tails, which
// Ring.h makes consumer-owned. So the reclaim watermark is this local counter and the
// shared triple is untouched.
if (upTo > m_stageTail) {
m_stageTail = upTo;
}
m_stageReclaimed = upTo;
// SEG_STAGE is CLIENT-OWNED memory (contract table 1: "client stages, server copies"),
// and the server only ever reads it, so the client is both the producer and the thing
// that frees. What it may not do is free ahead of retiredSeq, which is the whole
// content of R-11 on this side.
m_control->stageAppliedTail.store(upTo, std::memory_order_release);
m_control->stageRetiredTail.store(upTo, std::memory_order_release);
}
Uint64 PipeWireEncoder::StagedBytesInFlight() const {
if (m_stage == nullptr) {
return 0;
}
return m_stage->LocalHead() - m_stageReclaimed;
}
Uint64 PipeWireEncoder::StagedBytesInFlight() const { return m_stageHead - m_stageTail; }
// THE STORAGE, NOT THE LIVE COUNT. `size() - front` is the number of marks still in
// flight, and it stays at one or two even while the vector behind it grows for ever - so a
// control written against it would have gone green through exactly the leak it was meant
// to catch. What leaks is the container, so that is what this reports.
SizeT PipeWireEncoder::StageMarksHeld() const { return m_stageMarks.size(); }
void PipeWireEncoder::Publish() {
if (m_cmd == nullptr) {
@@ -830,13 +991,21 @@ namespace MobileGL::MG_Remote::Wire {
// notify-then-publish loses the wakeup. The doorbell itself belongs to the SESSION
// (s1) - the codec does not own a Doorbell and must not, or a unit case could not
// drive encoder -> ring -> decoder without one.
if (m_stage != nullptr) {
m_stage->Publish();
}
//
// SEG_STAGE needs no publish: the decoder reads those bytes by OFFSET, never by
// popping a ring, and the release store on SEG_CMD's head below is what orders the
// staged writes before the record that names them.
m_cmd->Publish();
if (m_control != nullptr) {
// submittedSeq is the one watermark the PRODUCER owns (Ring.h's five-watermark
// block). Nobody waits on it; it answers "how far ahead of the server is the
// client right now".
m_control->submittedSeq.store(m_emitSeq, std::memory_order_release);
}
// The reclaim has a trigger in this package, rather than depending on a c1 barrier
// that does not exist yet: every publish is a chance to notice what the server has
// already retired, and it costs one acquire load.
ReclaimStagedBytes();
}
Uint64 PipeWireEncoder::EmitSeq() const { return m_emitSeq; }
@@ -851,8 +1020,18 @@ namespace MobileGL::MG_Remote::Wire {
ReplySink* replies)
: m_control(control), m_segments(segments), m_replies(replies) {
m_auditPoison = MG_Config::Ipc.Audit;
// Once, at construction, beside the resolver it is modelled on - not per record from
// the apply thread. The thunk is inert without a decoder on the calling thread, so an
// early install changes nothing for a monolith caller of MGPipeApplyWireRecord.
InstallApplyHook();
}
void PipeWireDecoder::InstallApplyHook() {
MG_Pipe::gMGPipeWireRecordApply = &MGPipeWireRecordApplyThunk;
}
void PipeWireDecoder::UninstallApplyHook() { MG_Pipe::gMGPipeWireRecordApply = nullptr; }
Bool PipeWireDecoder::Valid() const { return m_control != nullptr && m_segments != nullptr; }
Uint64 PipeWireDecoder::AppliedSeq() const { return m_applySeq; }
@@ -867,6 +1046,34 @@ namespace MobileGL::MG_Remote::Wire {
Uint64 PipeWireDecoder::PoisonedStageBytes() const { return m_poisonedBytes; }
Bool PipeWireDecoder::LastAcceptanceKnown() const { return m_lastAcceptanceKnown; }
Bool PipeWireDecoder::LastAcceptance() const { return m_lastAcceptance; }
Uint64 PipeWireDecoder::AcceptedRecords() const { return m_accepted; }
Uint64 PipeWireDecoder::DeclinedRecords() const { return m_declined; }
void PipeWireDecoder::PostReply(MGPWireOp op, Uint64 seq, Int32 status, const void* bytes,
Uint64 size) {
// THE ONE GATE ON SEG_REPLY. s1 sizes ReplyPool from MGPipeCallFlagsFor - table 0 says
// that table is what "every package" reads - so writing SEG_REPLY[seq % slots] for a
// record the pool reserved no slot for silently overwrites a waiter's answer. And
// because the slot header stamps the WRITER's seq for self-check, the waiter's check
// then fails for ever: the barrier HANGS rather than returning something wrong, which
// is the harder failure to diagnose of the two.
if ((MGPipeCallFlagsFor(op) & static_cast<Uint32>(kReplySlot)) == 0) {
MGLOG_F("MGPipe: Fatal{ProtocolCorruption, \"%s\"} the decoder tried to answer into "
"a reply slot for a call the catalogue gives no kReplySlot; s1 sizes "
"ReplyPool from MGPipeCallFlagsFor and reserved none",
WireOpName(op));
std::abort();
}
if (m_replies != nullptr) {
m_replies->PostReply(seq, status, bytes, size);
}
}
Bool MGPipeWireRecordApplyThunk(MGPWireOp op, const void* record, Uint64 size, Uint64 remaining) {
(void)remaining;
if (t_activeDecoder == nullptr) {
@@ -875,13 +1082,21 @@ namespace MobileGL::MG_Remote::Wire {
return t_activeDecoder->ApplyChecked(op, record, size);
}
void PipeWireDecoder::NoteResolvedRun(const MGPBlobRef& blob) {
void PipeWireDecoder::NoteResolvedRun(MGPWireOp op, const MGPBlobRef& blob) {
if (blob.Size == 0 || blob.Seg != kSegStage) {
return;
}
if (m_resolvedCount < sizeof(m_resolved) / sizeof(m_resolved[0])) {
m_resolved[m_resolvedCount++] = blob;
if (m_resolvedCount >= sizeof(m_resolved) / sizeof(m_resolved[0])) {
// LOUD, NOT A SILENT DROP. This array is what the 0xDD fill covers, and a poison
// that quietly stopped covering a run is the same failure as no poison at all -
// rule C's only mechanical control going dark without a line in the log.
MGLOG_F("MGPipe: Fatal{ProtocolCorruption, \"%s\"} more than %llu SEG_STAGE runs in "
"one record; the audit fill would stop covering them",
WireOpName(op),
static_cast<unsigned long long>(sizeof(m_resolved) / sizeof(m_resolved[0])));
std::abort();
}
m_resolved[m_resolvedCount++] = blob;
}
const void* PipeWireDecoder::ResolveOrFatal(MGPWireOp op, const MGPBlobRef& blob) {
@@ -893,7 +1108,7 @@ namespace MobileGL::MG_Remote::Wire {
// way: there is no recovery from a segment that stopped covering its own runs.
WireProtocolFatalAt("segment-resolve", blob.Offset, blob.Size);
}
NoteResolvedRun(blob);
NoteResolvedRun(op, blob);
return bytes;
}
@@ -905,7 +1120,12 @@ namespace MobileGL::MG_Remote::Wire {
for (Uint32 i = 0; i < m_resolvedCount; ++i) {
const MGPBlobRef& blob = m_resolved[i];
const SegmentView view = m_segments->Get(kSegStage);
if (view.Base == nullptr || blob.Offset + blob.Size > view.Size) {
// The SUBTRACTION form, the same one Resolve uses, so the addition cannot wrap.
// Every run here has already been through Resolve, so this is unreachable today -
// but it is the one place in the file where a wrap would be an arbitrary 0xDD
// memset, and "unreachable" is not a reason to write the weaker test.
if (view.Base == nullptr || blob.Offset > view.Size ||
blob.Size > view.Size - blob.Offset) {
continue;
}
// R-2.5. The record has been applied and its bytes are retired, so an applier that
@@ -937,13 +1157,14 @@ namespace MobileGL::MG_Remote::Wire {
const MGPWireOp op = static_cast<MGPWireOp>(record.kind);
m_resolvedCount = 0;
m_lastAcceptanceKnown = false;
PipeWireDecoder* previous = t_activeDecoder;
t_activeDecoder = this;
// The generated gate first, ALWAYS: MGPipeApplyWireRecord owns the per-opcode
// `size >= sizeof(MGPWireRec_X)` check, because that is the half that follows from the
// opcode alone and therefore belongs to the generator. It then calls back into
// ApplyChecked through the hook, which owns the half that needs the payload.
MG_Pipe::gMGPipeWireRecordApply = &MGPipeWireRecordApplyThunk;
// ApplyChecked through the hook, which owns the half that needs the payload. The hook
// was installed once, at construction.
const Bool applied = MG_Pipe::MGPipeApplyWireRecord(op, base, size, size);
t_activeDecoder = previous;
@@ -1008,12 +1229,20 @@ namespace MobileGL::MG_Remote::Wire {
};
// The four Bool-returning appliers answer ACCEPTANCE, not "applied" (R-5: the client
// may not re-derive it, because an if-constexpr discard, a stale handle and a refused
// record are all invisible from the call site). The answer rides the reply slot the
// record's seq already names, so no payload of theirs needs an MGPReplySlot member.
const auto postAcceptance = [&](Bool accepted) {
if (m_replies != nullptr) {
m_replies->PostReply(seq, accepted ? ReplySink::kStatusOk : ReplySink::kStatusDeclined,
nullptr, 0);
// record are all invisible from the call site).
//
// IT DOES NOT GO IN A REPLY SLOT. None of the four carries kReplySlot, and s1 sizes
// ReplyPool from that table - see PostReply. The answer is recorded here and read
// through LastAcceptance() / Accepted+DeclinedRecords() until the integrator rules on
// which half of the contract moves (table 0 says these four use DECLINED; the
// catalogue gives them no slot).
const auto noteAcceptance = [&](Bool accepted) {
m_lastAcceptanceKnown = true;
m_lastAcceptance = accepted;
if (accepted) {
++m_accepted;
} else {
++m_declined;
}
};
@@ -1037,7 +1266,7 @@ namespace MobileGL::MG_Remote::Wire {
// ---- resources -------------------------------------------------------------------
case MGPWireOp::ResourceCreate:
postAcceptance(MGPipeApplyResourceCreate(*static_cast<const MGPResourceDesc*>(payload)));
noteAcceptance(MGPipeApplyResourceCreate(*static_cast<const MGPResourceDesc*>(payload)));
return true;
case MGPWireOp::ResourceRespecify: {
@@ -1056,7 +1285,7 @@ namespace MobileGL::MG_Remote::Wire {
level.Level = MGPipeRespecifiedLevelOf(desc);
scope = &level;
}
postAcceptance(MGPipeApplyResourceRespecify(desc, nullptr, scope));
noteAcceptance(MGPipeApplyResourceRespecify(desc, nullptr, scope));
return true;
}
@@ -1074,9 +1303,7 @@ namespace MobileGL::MG_Remote::Wire {
//
// DECLINED is a real answer, not a failure: the three frontend sites already
// tolerate it (BufferObject.cpp:238, :603-606, :657-660).
if (m_replies != nullptr) {
m_replies->PostReply(seq, ReplySink::kStatusDeclined, nullptr, 0);
}
PostReply(op, seq, ReplySink::kStatusDeclined, nullptr, 0);
return true;
case MGPWireOp::UnmapPersistent:
@@ -1297,7 +1524,10 @@ namespace MobileGL::MG_Remote::Wire {
MGHostSpan span{};
std::memcpy(&span, reinterpret_cast<const Uint8*>(spans) + i * sizeof(MGHostSpan),
sizeof(MGHostSpan));
CheckHostSpanIsHonest(span);
// ALL FOUR ARMS, the segment-range one included: this row and DrawVbo are
// the only two host-span carriers in the catalogue, and an out-of-segment
// span used to pass every check here.
CheckHostSpanIsHonest(span, *m_segments);
}
}
return false;
@@ -1366,7 +1596,7 @@ namespace MobileGL::MG_Remote::Wire {
}
case MGPWireOp::SetTextureParams:
postAcceptance(MGPipeApplySetTextureParams(*static_cast<const MGPTextureParams*>(payload)));
noteAcceptance(MGPipeApplySetTextureParams(*static_cast<const MGPTextureParams*>(payload)));
return true;
// ---- transfer ---------------------------------------------------------------------
@@ -1378,16 +1608,31 @@ namespace MobileGL::MG_Remote::Wire {
// to compute") and under split it must declare too - a length the reader computes
// from the record it is checking is not a bounds check.
const Bool namesABuffer = rec.Target == kMGPipeResourceTargetBuffer;
const Bool carriesContent =
namesABuffer ? MGPipeSubDataBufferSize(rec) != 0
: (rec.UnionBox.W != 0 && rec.UnionBox.H != 0 && rec.UnionBox.D != 0);
// THE TEXTURE PREDICATE, TIGHTENED. It used to be "all three extents non-zero",
// which read a 4x4x0 box as carrying nothing and let rule A's arm 2 sit out - so a
// record could describe a real destination and declare no bytes. A box is either
// EMPTY (every extent zero, which is how a pull that needs nothing is spelled) or
// WHOLE; a partially-zero extent is neither, and no emitter produces one.
const Bool boxIsEmpty =
rec.UnionBox.W == 0 && rec.UnionBox.H == 0 && rec.UnionBox.D == 0;
const Bool boxIsWhole =
rec.UnionBox.W != 0 && rec.UnionBox.H != 0 && rec.UnionBox.D != 0;
if (!namesABuffer && !boxIsEmpty && !boxIsWhole) {
MGLOG_F("MGPipe: Fatal{ProtocolCorruption, \"ResourceSubData\"} the union box "
"%ux%ux%u has a zero extent on some axes and not others; a box is "
"either empty or whole",
rec.UnionBox.W, rec.UnionBox.H, rec.UnionBox.D);
std::abort();
}
const Bool carriesContent = namesABuffer ? MGPipeSubDataBufferSize(rec) != 0
: (boxIsWhole || rec.RegionCount != 0);
const void* bytes = nullptr;
if (carriesContent) {
bytes = ResolveOrFatal(op, rec.Blob);
} else {
CheckBlobIsHonest(op, rec.Blob, *m_segments);
}
postAcceptance(MGPipeApplyResourceSubData(
noteAcceptance(MGPipeApplyResourceSubData(
rec, bytes, reinterpret_cast<const MGPSubRegion*>(tailAt(0))));
return true;
}
@@ -1425,9 +1670,7 @@ namespace MobileGL::MG_Remote::Wire {
// 22) - the destination is the client's shadow and the size is the resource's, not
// a fixed slot's - so the reply slot carries COMPLETION only.
MGPipeApplyResourceReadback(*static_cast<const MGPReadback*>(payload));
if (m_replies != nullptr) {
m_replies->PostReply(seq, ReplySink::kStatusOk, nullptr, 0);
}
PostReply(op, seq, ReplySink::kStatusOk, nullptr, 0);
return true;
case MGPWireOp::ResourceCopyRegion:
@@ -1466,7 +1709,11 @@ namespace MobileGL::MG_Remote::Wire {
sizeof(MGHostSpan));
}
std::memcpy(&span, tailAt(1), sizeof(span));
CheckHostSpanIsHonest(span);
// ALL FOUR ARMS. WireVerbSink's header promises OnDrawVbo "a DECODED,
// VALIDATED argument list"; without the segment-range arm a span whose run
// left SEG_STAGE reached the sink and that promise was false. P8 is what arms
// this path, which is exactly when nobody will be reading this code.
CheckHostSpanIsHonest(span, *m_segments);
userIndices = &span;
}
return m_verbs != nullptr &&
+92 -9
View File
@@ -131,10 +131,21 @@ namespace MobileGL::MG_Remote::Wire {
// declared. Fatal on an absent blob, then CheckBlobIsHonest on a present one.
void RequireDeclaredBlob(MG_Pipe::MGPWireOp op, const MG_Pipe::MGPBlobRef& blob,
const SegmentTable& segments);
// R-2.3 arm for MGHostSpan. P5's reduced path should produce ZERO host spans
// R-2.3 arms 1 and 3 for MGHostSpan. P5's reduced path should produce ZERO host spans
// (kCapNeedsHostIndexBytes / kCapNeedsHostUboBytes are both 0 in P5, table 0), so this
// firing at all is a finding, not just a corruption check.
//
// IT CANNOT DO ARM 4 - it has no segment table - so a span that names a real segment and a
// run PAST THE END OF IT passes this function. Use the overload below on any path that has
// a table; this one exists because c0 shipped the signature and other packages compile
// against it.
void CheckHostSpanIsHonest(const MG_Pipe::MGHostSpan& span);
// All four arms. Arm 4 is the one the signature above cannot express: a span is only
// honest if its Offset+Size actually lies inside the segment it names, and the promise
// WireVerbSink's header makes - that OnDrawVbo is handed a VALIDATED argument list - is
// false without it. Latent in P5 (nothing emits a span) and armed at P8, which is exactly
// when nobody will be reading this file.
void CheckHostSpanIsHonest(const MG_Pipe::MGHostSpan& span, const SegmentTable& segments);
// ---- one record's shape, computed ONCE and read by both sides ----------------------
//
@@ -222,18 +233,37 @@ namespace MobileGL::MG_Remote::Wire {
const WireTail* tails, Uint32 tailCount);
// Releases every SEG_STAGE run named by a record the apply side has RETIRED
// (RingControl::retiredSeq, R-9). Called by the client at its verb barrier and
// whenever StageBytes runs short; the allocator reclaims behind retiredSeq and
// nothing else may (table 1's "retires" column, R-11).
// (RingControl::retiredSeq, R-9, which this class only ever READS). Called from
// Publish() and whenever StageBytes runs short, so nothing outside this package has to
// remember to; the allocator reclaims behind retiredSeq and nothing else may
// (table 1's "retires" column, R-11).
//
// THE MARK IS HELD ON THIS SIDE, NOT ON THE WIRE. A record does not carry where its
// staged bytes end, so the encoder remembers {seq, stage cursor} per record and
// reclaims to the newest mark whose seq the server has retired. That is exact, needs
// no wire field, and does not depend on the verb barrier - so it keeps working when
// R-1's barrier retires family by family.
//
// SEG_STAGE'S CURSOR TRIPLE IN RingControl IS NOT USED BY EITHER SIDE IN P5, and this
// is the reason. Ring.h makes stageAppliedTail / stageRetiredTail CONSUMER-owned, the
// server never Pops the stage ring (the decoder resolves by offset), and a producer
// that wrote those cursors would be the very shape R-9 forbids one segment over. So
// the allocator below is entirely encoder-local and the triple is left at its
// InitRingControl values. **s1 must not attach a RingConsumer to
// RingCursorSet::Stage**: its m_localTail would never move, and PublishApplied /
// PublishRetired would then walk both cursors BACKWARDS under this allocator.
void ReclaimStagedBytes();
// Staged bytes not yet reclaimed. The number MOBILEGL_IPC_STAGE_MB has to cover.
Uint64 StagedBytesInFlight() const;
// The {seq, cursor} marks the queue is STILL STORING, consumed ones included - not the
// number in flight. Bounded by twice the records in flight, and exposed so a case can
// assert that bound rather than trust the comment, because an unbounded queue here is
// a steady-state leak no SSIM comparison and no two-scenario lane would ever see.
//
// It reports the storage deliberately: the in-flight count stays at one or two while
// the container behind it grows for ever, so a control written against that number
// goes green through exactly the leak it exists to catch.
SizeT StageMarksHeld() const;
// Release-stores the head cursor, then rings the consumer doorbell IF PARKED. The
// order is pinned by RingTest.cpp:446 and must not be swapped: notify-then-publish
@@ -254,6 +284,12 @@ namespace MobileGL::MG_Remote::Wire {
Uint64 StageCursor = 0;
};
// The SEG_STAGE linear allocator (BRIEF §5 w1's own wording). Monotonic byte counters
// over the segment; the in-segment offset is `cursor % capacity` and a run that would
// straddle the end skips to the boundary, exactly as a ring does, but WITHOUT touching
// RingControl - see ReclaimStagedBytes above.
Uint8* StageAllocate(Uint64 size);
Transport::RingControl* m_control = nullptr;
Transport::RingProducer* m_cmd = nullptr;
Transport::RingProducer* m_stage = nullptr;
@@ -262,7 +298,10 @@ namespace MobileGL::MG_Remote::Wire {
Uint64 m_maxRecordBytes = 0;
Vector<StageMark> m_stageMarks;
SizeT m_stageMarkFront = 0;
Uint64 m_stageReclaimed = 0;
Uint8* m_stageBase = nullptr;
Uint64 m_stageCapacity = 0;
Uint64 m_stageHead = 0; // monotonic bytes allocated
Uint64 m_stageTail = 0; // monotonic bytes reclaimed
};
// ---- decoder -----------------------------------------------------------------------
@@ -394,13 +433,52 @@ namespace MobileGL::MG_Remote::Wire {
// How many staged bytes this decoder has poisoned. Zero with the audit off, and the
// number a t1 lane asserts is non-zero with it on: an instrumentation that cannot be
// observed to have run is decoration.
//
// WHAT IT ACTUALLY COVERS is "the blob runs the arm resolved", which today is AT MOST
// ONE per record: tails live in SEG_CMD and are never noted, and CreateShaderState's
// six per-stage runs are Fatal rather than resolved, so the one archive is the only
// multi-kilobyte run in the catalogue that reaches it. The array is eight deep so a
// later phase that declares more can fill it without a code change - and overflowing
// it is Fatal rather than a silent drop, because a poison that quietly stopped
// covering a run is the same failure as no poison at all.
Uint64 PoisonedStageBytes() const;
// R-5's acceptance answer for the four Bool-returning appliers - ResourceCreate,
// ResourceRespecify, ResourceSubData, SetTextureParams.
//
// IT DOES NOT RIDE A REPLY SLOT, and that is a contract conflict this package could
// not settle on its own. CONTRACT-P5.md table 0's reply-slot-header row says DECLINED
// "is how the four Bool acceptance entry points say false (R-5)", but PipeCalls.def
// gives none of those four `kReplySlot` - and table 0 ALSO says kMGPipeCallFlags is
// what "every package" reads, so s1 will size ReplyPool from it. Writing
// SEG_REPLY[seq % slots] for a record the pool never reserved a slot for overwrites a
// waiter's answer, and because the slot header stamps the writer's seq for self-check,
// the waiter's check then fails FOR EVER and the barrier hangs rather than returning
// something wrong. So the decoder posts a reply only for rows whose flags say
// kReplySlot (PostReply itself Fatals otherwise) and exposes the acceptance here
// instead. The integrator rules on which half of the contract moves.
Bool LastAcceptanceKnown() const;
Bool LastAcceptance() const;
Uint64 AcceptedRecords() const;
Uint64 DeclinedRecords() const;
// Points MG_Pipe::gMGPipeWireRecordApply at this layer's thunk. Called once from the
// constructor and modelled on SegmentTable::InstallProcessResolver, which is the same
// shape for the same reason; Uninstall belongs beside that one at teardown. The thunk
// is inert without a decoder on the calling thread, so installing it early changes
// nothing for a monolith caller of MGPipeApplyWireRecord.
static void InstallApplyHook();
static void UninstallApplyHook();
private:
Bool ApplyChecked(MG_Pipe::MGPWireOp op, const void* record, Uint64 size);
const void* ResolveOrFatal(MG_Pipe::MGPWireOp op, const MG_Pipe::MGPBlobRef& blob);
void NoteResolvedRun(const MG_Pipe::MGPBlobRef& blob);
void NoteResolvedRun(MG_Pipe::MGPWireOp op, const MG_Pipe::MGPBlobRef& blob);
void PoisonResolvedRuns();
// The ONLY way this class answers a record. Fatals if `op` carries no kReplySlot -
// see LastAcceptanceKnown() for why that is a Fatal and not a log line.
void PostReply(MG_Pipe::MGPWireOp op, Uint64 seq, Int32 status, const void* bytes,
Uint64 size);
friend Bool MGPipeWireRecordApplyThunk(MG_Pipe::MGPWireOp, const void*, Uint64, Uint64);
@@ -411,14 +489,19 @@ namespace MobileGL::MG_Remote::Wire {
Uint64 m_applySeq = kInvalidSeq;
Bool m_auditPoison = false;
Uint64 m_poisonedBytes = 0;
// The SEG_STAGE runs the record being applied resolved, for the 0xDD fill. At most
// seven (CreateShaderState's blob members) plus one tail.
Bool m_lastAcceptanceKnown = false;
Bool m_lastAcceptance = false;
Uint64 m_accepted = 0;
Uint64 m_declined = 0;
// The SEG_STAGE runs the record being applied resolved, for the 0xDD fill. Eight deep
// so CreateShaderState's seven blob members plus a tail would fit if a later phase
// declares them; today at most one run is ever noted (see PoisonedStageBytes).
MG_Pipe::MGPBlobRef m_resolved[8];
Uint32 m_resolvedCount = 0;
};
// The hook MGPipeApplyWireRecord dispatches to once its generated per-opcode bounds gate
// has passed. Installed by DecodeAndApply on the thread that decodes.
// has passed. Inert unless a decoder is active on the calling thread.
Bool MGPipeWireRecordApplyThunk(MG_Pipe::MGPWireOp op, const void* record, Uint64 size,
Uint64 remaining);
+240 -12
View File
@@ -371,6 +371,19 @@ TEST_F(PipeWireCodecTest, AVarTailRecordIsNotSkippedAsAWrapFiller) {
// anywhere on this ring. This case is the trip wire for that.
static_assert(static_cast<Uint16>(kVarTail) == static_cast<Uint16>(Transport::kRecPad),
"the collision this case exists for is gone; keep the case anyway");
// And the third one, which the first round missed and a reviewer found: it bites in the
// REVERSE direction, because the encoder stamps kRecVarTail on nine opcodes and anyone
// trusting PipeWire.inc's "MGPipeCallFlags of the call" reads those nine as kReplySlot.
static_assert(static_cast<Uint16>(kReplySlot) == static_cast<Uint16>(Transport::kRecVarTail),
"kReplySlot and kRecVarTail alias");
// Five of the six call-flag bits alias a ring bit; only kOptional is free, and only
// because RingRecordFlags has not reached 1<<5.
static_assert((static_cast<Uint16>(kNeedsAck | kHasBlob | kVarTail | kHostSpan | kReplySlot |
kOptional) &
static_cast<Uint16>(Transport::kRecNeedsAck | Transport::kRecHasBlob |
Transport::kRecPad | Transport::kRecBorrowSlot |
Transport::kRecVarTail)) == 0x1Fu,
"the overlap between the two flag spaces moved");
Wire2 wire;
MGPVertexBuffers header{};
@@ -530,10 +543,12 @@ TEST_F(PipeWireCodecTest, KNeedsAckRespecifyCarriesItsRedefinitionScope) {
kInvalidSeq);
ASSERT_TRUE(wire.PumpOne(&applied));
EXPECT_TRUE(applied);
// Two records, two acceptance answers, on their own seqs (R-3: the seq IS the id).
ASSERT_EQ(wire.Answers().All.size(), 2u);
EXPECT_EQ(wire.Answers().All[0].Seq, 1u);
EXPECT_EQ(wire.Answers().All[1].Seq, 2u);
// Two records, two ACCEPTANCE answers - and NOT in a reply slot. Neither ResourceCreate
// nor ResourceRespecify carries kReplySlot, and s1 sizes ReplyPool from that table, so a
// reply written here would overwrite some waiter's slot.
EXPECT_TRUE(wire.Answers().All.empty());
EXPECT_EQ(wire.Decoder().AcceptedRecords() + wire.Decoder().DeclinedRecords(), 2u);
EXPECT_TRUE(wire.Decoder().LastAcceptanceKnown());
}
TEST_F(PipeWireCodecTest, KOptionalUnmapPersistentRoundTrips) {
@@ -726,7 +741,7 @@ TEST_F(PipeWireCodecTest, ResourceSubDataCarriesABlobAndARegionTailTogether) {
kInvalidSeq);
ASSERT_TRUE(wire.PumpOne(&applied));
EXPECT_TRUE(applied);
ASSERT_EQ(wire.Answers().All.size(), 2u);
EXPECT_TRUE(wire.Answers().All.empty());
// ACCEPTANCE IS NOT "APPLIED", and this case is where the difference shows. The record
// crossed and reached MGPipeApplyResourceSubData, which is the codec's whole job; the
// applier then DECLINED it, because no backend registered a P4a texture consumer in this
@@ -734,8 +749,10 @@ TEST_F(PipeWireCodecTest, ResourceSubDataCarriesABlobAndARegionTailTogether) {
// into lost texels on Magma). That is exactly the answer R-5 says must travel rather than
// be re-derived on the client: a client that cleared its dirty flags on the strength of
// having EMITTED would drop these texels for good.
EXPECT_EQ(wire.Answers().All[1].Seq, 2u);
EXPECT_EQ(wire.Answers().All[1].Status, ReplySink::kStatusDeclined);
EXPECT_TRUE(wire.Decoder().LastAcceptanceKnown());
EXPECT_FALSE(wire.Decoder().LastAcceptance());
// Both records answered - the texture create is declined by the same NoP4aConsumer belt.
EXPECT_EQ(wire.Decoder().AcceptedRecords() + wire.Decoder().DeclinedRecords(), 2u);
// And the texels themselves crossed intact: the decline is the applier's, not the wire's.
const void* staged =
wire.Segments().Resolve(upload.Blob.Seg, upload.Blob.Offset, upload.Blob.Size);
@@ -1051,11 +1068,12 @@ TEST_F(PipeWireCodecTest, TheDecoderWritesNoRingControlFieldOfItsOwn) {
TEST_F(PipeWireCodecTest, MaxRecordBytesSeenStaysFarBelowHalfTheRing) {
// R-10's proof obligation. P5 does no chunking and must instead show it never needed any.
//
// THE CAP IS ASKED FOR AT RUNTIME AND NEVER DERIVED FROM MOBILEGL_IPC_RING_MB. s1 found
// that SEG_CMD's 8 MiB holds a 4096-byte control page plus a POWER-OF-TWO ring, so the
// ring is 4 MiB and MaxRecordBytes() is 2 MiB - half of what both CONTRACT-P5.md §5 and
// Config.h's comment say. Every comparison in the codec goes through
// RingProducer::MaxRecordBytes() for exactly this reason.
// THE CAP IS ASKED FOR AT RUNTIME AND NEVER DERIVED FROM MOBILEGL_IPC_RING_MB, and this
// phase is why: the number moved twice in one afternoon. s1 first found that the control
// page came out of the segment (making the cap 2 MiB at the default), then fixed it the
// other way round - MOBILEGL_IPC_RING_MB now names the RING and the segment adds a page on
// top - so the cap is 4 MiB again and the contract is true as written. Nothing in the
// codec changed either time, because every comparison goes through MaxRecordBytes().
Wire2 wire;
MGPDrawInfo info{};
info.NumDraws = 64;
@@ -1119,6 +1137,102 @@ TEST_F(PipeWireCodecTest, StagedBytesAreReclaimedOnlyBehindRetiredSeq) {
EXPECT_EQ(wire.Encoder().StagedBytesInFlight(), 0u);
}
// ---- M2 / M3: SEG_STAGE's cursors and the mark queue --------------------------------------
TEST_F(PipeWireCodecTest, TheEncoderNeverWritesSegStagesConsumerCursors) {
// Ring.h makes stageAppliedTail / stageRetiredTail CONSUMER-owned and says the staging
// allocator reclaims behind retiredSeq "and nothing else may". A producer writing them is
// the same shape w1's own §8.1 argues against for appliedSeq, one segment over: if s1 ever
// attaches a RingConsumer to RingCursorSet::Stage - the obvious thing to do for a segment
// with a cursor triple - its m_localTail never moves (nothing Pops the stage ring) and the
// two cursors get walked forward by the client and back by the server.
Wire2 wire;
const std::uint8_t payload[128] = {};
(void)wire.Encoder().StageBytes(payload, sizeof(payload));
MGPBindRenderState bind{};
ASSERT_NE(wire.Encoder().EncodeRecord(MGPWireOp::BindRenderState, &bind, sizeof(bind)),
kInvalidSeq);
bool applied = false;
ASSERT_TRUE(wire.PumpOne(&applied));
wire.Encoder().ReclaimStagedBytes();
EXPECT_EQ(wire.Encoder().StagedBytesInFlight(), 0u) << "the reclaim did not run at all";
EXPECT_EQ(wire.Control().stageHead.load(), 0u);
EXPECT_EQ(wire.Control().stageAppliedTail.load(), 0u);
EXPECT_EQ(wire.Control().stageRetiredTail.load(), 0u);
}
TEST_F(PipeWireCodecTest, TheStageMarkQueueStaysBoundedWithOneRecordAlwaysInFlight) {
// The steady-state leak: the queue used to be cleared ONLY when it drained completely, so
// one unretired record at every reclaim meant front < size() for ever and 16 bytes per
// encoded record for the life of the context. That is exactly the regime W-3 says the
// design exists to survive once R-1's barrier retires family by family - and no SSIM
// comparison and no two-scenario lane would ever see it.
Wire2 wire;
MGPBindRenderState bind{};
const std::uint8_t blob[32] = {};
// PRIMING IS THE WHOLE POINT. Encoding and applying one record per iteration drains the
// queue at every reclaim, which is the one regime the old "clear only when front reaches
// size()" code handled - a case written that way stays green against the bug. One record
// encoded ahead of the one being applied is what makes `front < size()` permanent.
ASSERT_NE(wire.Encoder().EncodeRecord(MGPWireOp::BindRenderState, &bind, sizeof(bind)),
kInvalidSeq);
for (int i = 0; i < 4000; ++i) {
(void)wire.Encoder().StageBytes(blob, sizeof(blob));
ASSERT_NE(wire.Encoder().EncodeRecord(MGPWireOp::BindRenderState, &bind, sizeof(bind)),
kInvalidSeq);
bool applied = false;
ASSERT_TRUE(wire.PumpOne(&applied)) << "record " << i;
}
EXPECT_EQ(wire.Encoder().EmitSeq(), 4001u);
EXPECT_EQ(wire.Decoder().AppliedSeq(), 4000u) << "one record must still be in flight";
// Bounded by twice the records actually in flight, not by the records ever encoded.
EXPECT_LE(wire.Encoder().StageMarksHeld(), 4u)
<< "the mark queue is tracking history rather than flight";
}
// ---- M4: the reply slot is for kReplySlot rows only ----------------------------------------
TEST_F(PipeWireCodecTest, NoReplyIsWrittenForARowTheCatalogueGivesNoReplySlot) {
// s1 sizes ReplyPool from MGPipeCallFlagsFor, so a reply written for a record the pool
// reserved no slot for overwrites a waiter's answer - and because the slot header stamps
// the WRITER's seq for self-check, the waiter's check then fails for ever and the barrier
// HANGS rather than returning something wrong.
Wire2 wire;
ASSERT_EQ(MGPipeCallFlagsFor(MGPWireOp::ResourceCreate) & static_cast<Uint32>(kReplySlot), 0u);
ASSERT_EQ(MGPipeCallFlagsFor(MGPWireOp::SetTextureParams) & static_cast<Uint32>(kReplySlot), 0u);
MGPResourceDesc create{};
create.Resource = MakeHandle(131);
create.Target = static_cast<Uint8>(MGPipeResourceTarget::Buffer);
create.Width = 64;
create.Height = 1;
create.Depth = 1;
create.ArrayLayers = 1;
create.Levels = 1;
create.Samples = 1;
ASSERT_NE(wire.Encoder().EncodeRecord(MGPWireOp::ResourceCreate, &create, sizeof(create)),
kInvalidSeq);
bool applied = false;
ASSERT_TRUE(wire.PumpOne(&applied));
EXPECT_TRUE(applied);
EXPECT_TRUE(wire.Answers().All.empty()) << "a reply slot was written for a kNone row";
// The acceptance answer R-5 requires is still produced - it just does not ride SEG_REPLY.
EXPECT_TRUE(wire.Decoder().LastAcceptanceKnown());
EXPECT_EQ(wire.Decoder().AcceptedRecords() + wire.Decoder().DeclinedRecords(), 1u);
}
TEST_F(PipeWireCodecTest, EveryRowThatDoesWriteAReplyCarriesKReplySlot) {
// The other half: the three rows in P5 that answer into a slot all carry the flag, so
// PostReply's gate cannot be firing on any of them.
for (const MGPWireOp op :
{MGPWireOp::MapPersistent, MGPWireOp::ResourceReadback, MGPWireOp::ReadPixels}) {
EXPECT_NE(MGPipeCallFlagsFor(op) & static_cast<Uint32>(kReplySlot), 0u) << WireOpName(op);
}
}
TEST_F(PipeWireCodecTest, TheAuditFillOverwritesExactlyTheRunsTheRecordResolved) {
// R-2.5, the only mechanical control on rule C ("no applier entry point retains a pointer
// past its return"). An instrumentation that cannot be observed to have run is decoration,
@@ -1475,6 +1589,120 @@ TEST_F(PipeWireCodecTest, AHostSpanCountThatIsNeitherZeroNorCountIsFatal) {
EXPECT_NE(r.Log.find("SetShaderBuffers.HostSpanCount"), std::string::npos) << r.Log;
}
// ---- M1: R-2 arm 4 over MGHostSpan, on both host-span rows ------------------------------
TEST_F(PipeWireCodecTest, AHostSpanRunPastItsSegmentIsFatalOnSetShaderBuffers) {
// Before the fix this child exited 0: CheckHostSpanIsHonest took no SegmentTable and no
// caller resolved, so arm 4 was implemented for blobrefs only. The span names a REAL
// segment and a run that leaves it.
const ChildResult r = RunInChild([] {
Wire2 wire;
MGPShaderBuffers header{};
header.Count = 1;
header.HostSpanCount = 1;
MGPBufferRange range{};
MGHostSpan span{};
span.Ptr = nullptr; // rule B satisfied, so only arm 4 can catch this
span.Seg = kSegStage;
span.Offset = Wire2::kStageBytes - 8;
span.Size = 1024;
std::vector<std::uint8_t> tail(sizeof(range) + sizeof(span));
std::memcpy(tail.data(), &range, sizeof(range));
std::memcpy(tail.data() + sizeof(range), &span, sizeof(span));
ForgeAndDecode(wire, MGPWireOp::SetShaderBuffers, &header, sizeof(header), tail.data(),
tail.size());
});
ASSERT_TRUE(DiedOfAbort(r)) << DescribeStatus(r) << "\n" << r.Log;
EXPECT_NE(r.Log.find("Fatal{ProtocolCorruption, \"host-span\"}"), std::string::npos) << r.Log;
EXPECT_NE(r.Log.find("does not lie inside that segment (R-2.3 arm 4)"), std::string::npos)
<< r.Log;
}
TEST_F(PipeWireCodecTest, AHostSpanRunPastItsSegmentIsFatalOnDrawVbo) {
// The same span on the other host-span row - the one whose sink WireVerbSink's header
// promises is handed "a DECODED, VALIDATED argument list". Before the fix it reached
// OnDrawVbo.
const ChildResult r = RunInChild([] {
Wire2 wire;
MGPDrawInfo info{};
info.Mode = 4;
info.IndexSize = 2;
info.Flags = kDrawHasUserIndices;
info.NumDraws = 1;
MGPDrawRange ranges[1] = {{0, 4, 0}};
MGHostSpan span{};
span.Ptr = nullptr;
span.Seg = kSegStage;
span.Offset = 0xFFFFFFFFull;
span.Size = 0xFFFFu;
// The layout realigns the span to 8 after a 12-byte MGPDrawRange, so the forged tail
// has to carry the same four pad bytes the encoder would.
std::vector<std::uint8_t> tail(16 + sizeof(span), 0);
std::memcpy(tail.data(), ranges, sizeof(ranges));
std::memcpy(tail.data() + 16, &span, sizeof(span));
ForgeAndDecode(wire, MGPWireOp::DrawVbo, &info, sizeof(info), tail.data(), tail.size());
});
ASSERT_TRUE(DiedOfAbort(r)) << DescribeStatus(r) << "\n" << r.Log;
EXPECT_NE(r.Log.find("does not lie inside that segment (R-2.3 arm 4)"), std::string::npos)
<< r.Log;
}
// ---- M5 / m8 / q2 ------------------------------------------------------------------------
TEST_F(PipeWireCodecTest, BufferSubDataResidentMayNotDeclareRegions) {
// Its catalogue row has no kVarTail and its applier takes no regions, so a non-zero
// RegionCount is a fault rather than a tail. The layout used to share ResourceSubData's
// arm, which REQUIRED 80 bytes of tail the arm then dropped.
const ChildResult r = RunInChild([] {
Wire2 wire;
MGPSubData rec{};
rec.Res = MakeHandle(7);
rec.RegionCount = 2;
MGPipeSetSubDataBufferRange(rec, 0, 64);
rec.RegionCount = 2; // after the helper, which zeroes it
ForgeAndDecode(wire, MGPWireOp::BufferSubDataResident, &rec, sizeof(rec), nullptr, 0);
});
ASSERT_TRUE(DiedOfAbort(r)) << DescribeStatus(r) << "\n" << r.Log;
EXPECT_NE(r.Log.find("BufferSubDataResident.RegionCount"), std::string::npos) << r.Log;
}
TEST_F(PipeWireCodecTest, TheEncoderRefusesThePerStageSpirvRunTheDecoderCallsFatal) {
// The rule lived only in the decoder, so an emitter that declared a per-stage run got a
// valid seq here and a Fatal on a peer that could only report "corrupt stream".
const ChildResult r = RunInChild([] {
Wire2 wire;
MG_State::GLState::LinkArtifacts link;
MG_State::GLState::SpirvArtifacts spirv;
Vector<Uint8> archive;
MG_State::GLState::EncodeProgramArtifacts(link, spirv, archive);
MGPProgramDesc desc{};
desc.Cso = MakeHandle(3);
desc.Reflection = wire.Encoder().StageBytes(archive.data(), archive.size());
const std::uint32_t words[4] = {1, 2, 3, 4};
desc.Spirv[0] = wire.Encoder().StageBytes(words, sizeof(words));
(void)wire.Encoder().EncodeRecord(MGPWireOp::CreateShaderState, &desc, sizeof(desc));
});
ASSERT_TRUE(DiedOfAbort(r)) << DescribeStatus(r) << "\n" << r.Log;
EXPECT_NE(r.Log.find("declares 16 bytes at the ENCODER"), std::string::npos) << r.Log;
}
TEST_F(PipeWireCodecTest, APartiallyZeroUploadBoxIsFatalRatherThanReadAsEmpty) {
// The content predicate used to be "all three extents non-zero", so a 4x4x0 box was read
// as carrying nothing and rule A's arm 2 sat out for a record that named a real
// destination. A box is either empty or whole.
const ChildResult r = RunInChild([] {
Wire2 wire;
MGPSubData rec{};
rec.Res = MakeHandle(9);
rec.Target = MGPipePackSubDataTarget(static_cast<Uint32>(MGPipeResourceTarget::Tex2D), 0u);
rec.UnionBox = MGPBox{0, 0, 0, 4, 4, 0};
ForgeAndDecode(wire, MGPWireOp::ResourceSubData, &rec, sizeof(rec), nullptr, 0);
});
ASSERT_TRUE(DiedOfAbort(r)) << DescribeStatus(r) << "\n" << r.Log;
EXPECT_NE(r.Log.find("has a zero extent on some axes and not others"), std::string::npos)
<< r.Log;
}
TEST_F(PipeWireCodecTest, ASecondProcessResolverIsFatalRatherThanASilentRace) {
// Table 3: one gMGPipeSegmentResolver per process, installed by the SERVER role only.
const ChildResult r = RunInChild([] {