From 817e1c40cf88f95797774fa58c63d9a23cb7b787 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Fri, 11 Sep 2026 14:13:07 -0400 Subject: [PATCH] [Feat] (MG_Remote, Transport): the ring-owning session pair over ShmSegment - four segments, the five watermarks with their first real writers, the SEG_REPLY slot pool and the SEG_EVENT reverse channel, with the doorbell pair taken from the transport and the ring capacity derived as the largest power of two left after the control page --- MobileGL/MG_Remote/Transport/EventRing.h | 205 +++++++++++ MobileGL/MG_Remote/Transport/ReplySlot.h | 251 +++++++++++++ MobileGL/MG_Remote/Transport/Ring.cpp | 255 ++++++++++++++ MobileGL/MG_Remote/Transport/RoleMemory.h | 85 +++++ MobileGL/MG_Remote/Transport/SessionRings.h | 369 ++++++++++++++++++++ MobileGL/MG_Remote/Transport/ShmSegment.cpp | 309 ++++++++++++++++ 6 files changed, 1474 insertions(+) create mode 100644 MobileGL/MG_Remote/Transport/EventRing.h create mode 100644 MobileGL/MG_Remote/Transport/ReplySlot.h create mode 100644 MobileGL/MG_Remote/Transport/RoleMemory.h create mode 100644 MobileGL/MG_Remote/Transport/SessionRings.h diff --git a/MobileGL/MG_Remote/Transport/EventRing.h b/MobileGL/MG_Remote/Transport/EventRing.h new file mode 100644 index 00000000..bca91fda --- /dev/null +++ b/MobileGL/MG_Remote/Transport/EventRing.h @@ -0,0 +1,205 @@ +// MobileGL - MobileGL/MG_Remote/Transport/EventRing.h +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +// SEG_EVENT: the server -> client reverse channel. Owner: package s1. +// +// WHAT P5 OWES HERE AND NOTHING MORE (BRIEF R-12, and s1's brief): it must be +// able to CARRY OnBufferWriteback, OnGpuWritten and OnSurfaceChanged. The other +// seven MGPipeCallbacks members are off P5's reduced path, and THE OVERFLOW +// POLICY IS P9's - what is here is the mechanism (a full ring latches +// eventRingFull, a lossy post that is dropped counts in eventDropped) and not a +// policy that decides between them. +// +// IT IS A SECOND RingControl, NOT A THIRD CURSOR SET. RingControl carries two +// cursor triples (SEG_CMD and SEG_STAGE) and adding a third would resize the +// shared page that Ring.h static_asserts at exactly 4096 bytes. So SEG_EVENT +// gets its OWN control page at its own head and drives it with the Cmd cursor +// set - the same RingProducer/RingConsumer code, in the opposite direction. The +// two EVENT FLAGS still live in the SEG_CMD page, because that is where Ring.h +// declares them and where the client's own waits already look. +// +// THE PAYLOAD SHAPES ARE FIXED-WIDTH AND LIVE HERE, not in MG_Pipe's headers. +// Nothing under Transport/ may reach MobileGL/Includes.h (WireLog.h:9-24, and +// the purity gate's `wire-header` probe), so MGPipeHandle / MGPRange / +// MGPSurfaceInfo cannot be named in this file. The wire shapes below mirror them +// field for field and the SESSIONS assert the two agree, which is the same +// discipline the rest of the wire is held to: a wire struct is fixed-width, and +// the conversion happens where the frontend types are legal. + +#pragma once + +#include "Doorbell.h" +#include "Ring.h" + +#include +#include + +namespace MobileGL::MG_Remote::Transport { + + // Record kinds on SEG_EVENT. 0 is kRingPadRecordKind and can never be an + // event, which is why the list starts at 1. + enum EventKind : std::uint16_t { + kEventNone = 0, + kEventBufferWriteback = 1, // MGPipeCallbacks::OnBufferWriteback + kEventGpuWritten = 2, // MGPipeCallbacks::OnGpuWritten + kEventSurfaceChanged = 3, // MGPipeCallbacks::OnSurfaceChanged + }; + + // The 8-byte {slot, gen} pair, mirrored (MGPipeHandles.h:54-65). + struct EventHandle { + std::uint32_t Slot; + std::uint32_t Gen; + }; + static_assert(sizeof(EventHandle) == 8, "the handle is the 8-byte {slot, gen} pair"); + + // MGPRange, mirrored (MGPipeTypes.h:63-67). + struct EventRange { + std::uint64_t Offset; + std::uint64_t Size; + }; + static_assert(sizeof(EventRange) == 16, "MGPRange is 16 bytes on the wire"); + + // OnBufferWriteback(res, offset, MGPBlobRef bytes). The bytes follow this + // head INSIDE THE RECORD: the blobref the client hands the frontend names + // SEG_EVENT and the in-segment offset of those inline bytes, which is what + // makes "the destination is the client's shadow" (contract table 1 row 22) + // reachable without a second segment. `Size` is therefore the record's own + // tail length and is cross-checked against it. + struct EventBufferWritebackHead { + EventHandle Resource; + std::uint64_t Offset; // destination offset inside the resource + std::uint64_t Size; // inline byte count that follows + }; + static_assert(sizeof(EventBufferWritebackHead) == 24, "wire shape"); + + // OnGpuWritten(res, rangeCount, ranges). EventRange[RangeCount] follows. + struct EventGpuWrittenHead { + EventHandle Resource; + std::uint32_t RangeCount; + std::uint32_t Pad0; + }; + static_assert(sizeof(EventGpuWrittenHead) == 16, "wire shape"); + + // OnSurfaceChanged(const MGPSurfaceInfo*), mirrored (MGPipeTypes.h:1394-1401). + struct EventSurfaceChangedHead { + std::uint32_t Width; + std::uint32_t Height; + std::uint32_t InternalFormat; + std::uint16_t Samples; + std::uint16_t Layers; + std::uint8_t IsDefault; + std::uint8_t Pad0[7]; + }; + static_assert(sizeof(EventSurfaceChangedHead) == 24, "MGPSurfaceInfo is 24 bytes on the wire"); + + // The server's end. One producer: the apply thread, by construction. + class EventRingProducer { + public: + EventRingProducer() = default; + + // `eventControl` is SEG_EVENT's own control page; `cmdControl` is the + // SEG_CMD page, because Ring.h declares eventRingFull / eventDropped + // there and the client's waits already look at it. + EventRingProducer(RingControl* eventControl, RingControl* cmdControl, void* base, + std::uint64_t capacityBytes) + : m_cmdControl(cmdControl), + m_producer(eventControl, base, capacityBytes, RingCursorSet::Cmd) {} + + bool Valid() const { return m_producer.Valid() && m_cmdControl != nullptr; } + + // Reserves one event record. nullptr means the ring is full: the caller + // decides, and the two flags are how it says which decision it took. + // THIS FUNCTION DOES NOT DECIDE - that is P9's. + void* Reserve(EventKind kind, std::uint64_t payloadBytes) { + void* slot = m_producer.Reserve(static_cast(kind), kRecNone, payloadBytes); + if (slot == nullptr && m_cmdControl != nullptr) { + // "SEG_EVENT full, server stopped applying" - Ring.h:123. Latched + // here, cleared by the consumer once it has drained. + m_cmdControl->eventRingFull.store(1, std::memory_order_release); + } + return slot; + } + + // For a LOSSY event the caller could not place. Lossless events must + // never call this; they wait for the client to drain instead. + void CountDrop() { + if (m_cmdControl != nullptr) { + m_cmdControl->eventDropped.fetch_add(1, std::memory_order_relaxed); + } + } + + // Publish, THEN ring - the same order as the forward direction, and for + // the same reason (Doorbell.h:186-193: the fence only orders what + // precedes it, so ringing first reopens the lost-wakeup window). + void PublishAndNotify(Doorbell& clientBell, std::atomic& producerParked) { + m_producer.Publish(); + NotifyIfParked(clientBell, producerParked); + } + + RingProducer& Ring() { return m_producer; } + + private: + RingControl* m_cmdControl = nullptr; + RingProducer m_producer; + }; + + // The client's end. One consumer: the GL thread, which drains between verbs. + class EventRingConsumer { + public: + EventRingConsumer() = default; + + EventRingConsumer(RingControl* eventControl, RingControl* cmdControl, void* base, + std::uint64_t capacityBytes, const void* segmentBase) + : m_cmdControl(cmdControl), + m_consumer(eventControl, base, capacityBytes, RingCursorSet::Cmd), + m_segmentBase(static_cast(segmentBase)) {} + + bool Valid() const { return m_consumer.Valid() && m_cmdControl != nullptr; } + + bool Pop(RingRecordView& out, bool* outCorrupt = nullptr) { + return m_consumer.Pop(out, outCorrupt); + } + + // Release the bytes and clear the full latch. Only after the caller has + // finished with every payload pointer it popped: a writeback's bytes live + // in the ring itself, so retiring early is the R-11 violation one level + // down. + void Drained() { + m_consumer.PublishRetired(); + if (m_cmdControl != nullptr) { + m_cmdControl->eventRingFull.store(0, std::memory_order_release); + } + } + + // Byte offset of `payload` inside SEG_EVENT, which is what an + // OnBufferWriteback MGPBlobRef must carry (Seg = kSegEvent, Offset = + // this, Size = the head's Size). Never a host address - R-2's rule B. + std::uint64_t OffsetInSegment(const void* payload) const { + return static_cast(static_cast(payload) - + m_segmentBase); + } + + std::uint64_t DroppedEvents() const { + return m_cmdControl == nullptr + ? 0 + : m_cmdControl->eventDropped.load(std::memory_order_relaxed); + } + bool RingIsFull() const { + return m_cmdControl != nullptr && + m_cmdControl->eventRingFull.load(std::memory_order_acquire) != 0; + } + + RingConsumer& Ring() { return m_consumer; } + + private: + RingControl* m_cmdControl = nullptr; + RingConsumer m_consumer; + const std::uint8_t* m_segmentBase = nullptr; + }; + +} // namespace MobileGL::MG_Remote::Transport diff --git a/MobileGL/MG_Remote/Transport/ReplySlot.h b/MobileGL/MG_Remote/Transport/ReplySlot.h new file mode 100644 index 00000000..068dbb72 --- /dev/null +++ b/MobileGL/MG_Remote/Transport/ReplySlot.h @@ -0,0 +1,251 @@ +// MobileGL - MobileGL/MG_Remote/Transport/ReplySlot.h +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +// SEG_REPLY: the slot pool the server writes a kReplySlot answer into, and the +// client reads it back out of. Owner: package s1. +// +// THE ID IS THE RECORD SEQUENCE NUMBER (P5 R-3). There is no second id space and +// no allocator: the ten kReplySlot calls carry no MGPReplySlot in their payloads +// (that is why the id has to be DERIVED rather than carried), the wire has no +// per-record seq field (ARCHITECTURE.md:124), so the record's ordinal IS its +// reply-slot id. The slot is addressed `seq % slotCount` and the server STAMPS +// THE SEQ BACK INTO THE SLOT HEADER, which is what makes a wrong-slot read +// detectable rather than merely plausible. Seq is 1-based; 0 means "no record". +// +// THE SLOT HEADER IS CONTRACT-P5 TABLE 0's ROW, verbatim: +// { Uint64 Seq; Int32 Status; Uint32 Size; } // 16 bytes, then the payload +// Status: 0 = OK, 1 = DECLINED, 2 = ERROR +// +// DECLINED IS A REAL ANSWER, NOT A FAILURE. It is how MapPersistent says nullptr +// (R-6) and how the four Bool acceptance entry points - ResourceCreate, +// ResourceRespecify, ResourceSubData, SetTextureParams - say false (R-5). A +// client that folds DECLINED into "the call failed" re-creates ID-39's 66 lost +// uploads from the other side, and a client that folds it into OK accepts a +// pointer the server never handed out. +// +// A REPLY LARGER THAN ONE SLOT IS FATAL, NOT CHUNKED. P5's only large answer is +// ReadPixels, and the client knows its size before it emits the record, so an +// overflow means the two sides disagree about the frame rather than that the +// pool is too small. Chunking is P8's; growing the pool is an operator's. +// +// ORDERING. The client only looks at a slot after it has seen +// RingControl::appliedSeq >= its own seq with an ACQUIRE load, and the server +// advances appliedSeq with a RELEASE store AFTER posting the reply +// (PipeApplier::ApplyOne's order: decode -> stamp -> apply -> post -> advance). +// That pair is what publishes the slot's bytes; the fences below are the +// belt-and-braces for a caller - a unit test, or P9's async pool - that reads a +// slot without going through appliedSeq first. + +#pragma once + +#include "WireLog.h" + +#include +#include +#include +#include + +namespace MobileGL::MG_Remote::Transport { + + // CONTRACT-P5 table 0, "reply slot header". + struct ReplySlotHeader { + std::uint64_t Seq; // the record ordinal, stamped back for self-check + std::int32_t Status; // ReplyStatus + std::uint32_t Size; // payload bytes following this header + }; + static_assert(sizeof(ReplySlotHeader) == 16, "the reply slot header is 16 bytes on the wire"); + static_assert(alignof(ReplySlotHeader) == 8, "the reply slot header must not gain padding"); + + enum ReplyStatus : std::int32_t { + kReplyStatusOk = 0, + // Not an error. MapPersistent's nullptr and the four Bool acceptance + // returns' `false` both arrive as this. + kReplyStatusDeclined = 1, + kReplyStatusError = 2, + }; + + // Eight slots of a 8 MiB SEG_REPLY is 1 MiB per answer. + // + // Why eight and not sixty-four: while the verb barrier holds (R-1) the client + // blocks at every verb boundary, so the in-flight depth is exactly ONE and + // every extra slot buys nothing but a smaller maximum answer. The trade is + // the other way round - fewer slots, bigger replies - and P5's only large + // answer is a blocking ReadPixels. 1 MiB covers a 512x512 RGBA8 read. P9, + // which is what makes the pool asynchronous, re-chooses this geometry with + // real depth to size it against. + inline constexpr std::uint32_t kDefaultReplySlotCount = 8; + + // Slot 0 exists and is used: seq is 1-based, so seq % slotCount hits slot 0 + // on seq == slotCount, not on "no record". + class ReplySlotPool { + public: + ReplySlotPool() = default; + + // `base`/`sizeBytes` are SEG_REPLY's mapping. `slotCount` must be a power + // of two - the addressing is a mask, and a non-power-of-two modulus on + // the apply thread is a division in the reply path of every blocking + // call. Anything else leaves Valid() false rather than half-working. + ReplySlotPool(void* base, std::uint64_t sizeBytes, std::uint32_t slotCount) { + if (base == nullptr || slotCount == 0 || (slotCount & (slotCount - 1)) != 0) { + WireLogError("MG_Remote reply pool: rejected, slotCount %u must be a non-zero power " + "of two over a non-null mapping", + static_cast(slotCount)); + return; + } + const std::uint64_t slotBytes = sizeBytes / slotCount; + if (slotBytes <= sizeof(ReplySlotHeader)) { + WireLogError("MG_Remote reply pool: rejected, %llu bytes over %u slots leaves no " + "room for a payload past the %llu byte slot header", + static_cast(sizeBytes), + static_cast(slotCount), + static_cast(sizeof(ReplySlotHeader))); + return; + } + m_base = static_cast(base); + m_size = sizeBytes; + m_slots = slotCount; + m_mask = slotCount - 1; + // Truncated to 32 bits deliberately: the header's Size field is + // 32-bit, so a slot no 32-bit count could describe would let a + // legal-looking Size name bytes past the slot. + m_slotBytes = slotBytes > 0xFFFFFFFFull ? 0xFFFFFFFFu + : static_cast(slotBytes); + } + + bool Valid() const { return m_base != nullptr; } + std::uint32_t SlotCount() const { return m_slots; } + std::uint32_t SlotBytes() const { return m_slotBytes; } + // What a single answer may carry. The client checks against this BEFORE + // it emits a ReadPixels, which is the whole reason a fixed slot size is + // legitimate rather than a guess. + std::uint32_t MaxReplyBytes() const { + return m_slotBytes == 0 ? 0u + : m_slotBytes - static_cast(sizeof(ReplySlotHeader)); + } + + // Zeroes every header, so a stale seq from a previous session cannot be + // mistaken for this session's answer. Called on the server side at Accept. + void Clear() { + if (m_base == nullptr) { + return; + } + for (std::uint32_t slot = 0; slot < m_slots; ++slot) { + ReplySlotHeader header{}; + std::memcpy(m_base + static_cast(slot) * m_slotBytes, &header, + sizeof(header)); + } + } + + // Server side. `size` bytes of `bytes` become the answer for `seq`. + // An answer larger than one slot is FATAL, never truncated and never + // chunked - see the file header. + void Post(std::uint64_t seq, std::int32_t status, const void* bytes, std::uint64_t size) { + if (m_base == nullptr) { + WireLogError("MG_Remote reply pool: Post(seq=%llu) on an unconfigured pool", + static_cast(seq)); + std::abort(); + } + if (seq == 0) { + WireLogError("MG_Remote reply pool: seq 0 is \"no record\" and can never name a " + "slot (R-3: seq is 1-based)"); + std::abort(); + } + if (size > MaxReplyBytes()) { + WireLogError("MG_Remote reply pool: Fatal{ProtocolCorruption} - a %llu byte answer " + "for seq %llu does not fit a %u byte slot (payload cap %u). P5 does " + "not chunk replies: the client knows an answer's size before it emits " + "the record, so this means the two sides disagree about the frame", + static_cast(size), + static_cast(seq), static_cast(m_slotBytes), + static_cast(MaxReplyBytes())); + std::abort(); + } + std::uint8_t* slot = SlotAt(seq); + if (size != 0 && bytes != nullptr) { + std::memcpy(slot + sizeof(ReplySlotHeader), bytes, static_cast(size)); + } + ReplySlotHeader header{}; + header.Seq = seq; + header.Status = status; + header.Size = static_cast(size); + // The payload must be visible before the stamp that says it is there. + std::atomic_thread_fence(std::memory_order_release); + std::memcpy(slot, &header, sizeof(header)); + } + + // Client side. Returns false when the slot does not carry THIS seq - the + // self-check the stamp exists for. `outBytes` may be null for an answer + // with no payload (every DECLINE, and the four Bool acceptances). + // + // A payload larger than the caller's buffer is a caller bug rather than a + // wire fault (the caller sized it from the call it made), so it returns + // false with *outSize set to what was there, the ReceiveFrame shape. + bool Read(std::uint64_t seq, void* outBytes, std::uint64_t outCapacity, + std::int32_t* outStatus, std::uint64_t* outSize) const { + if (outStatus != nullptr) { + *outStatus = kReplyStatusError; + } + if (outSize != nullptr) { + *outSize = 0; + } + if (m_base == nullptr || seq == 0) { + return false; + } + const std::uint8_t* slot = SlotAt(seq); + ReplySlotHeader header{}; + std::memcpy(&header, slot, sizeof(header)); + std::atomic_thread_fence(std::memory_order_acquire); + if (header.Seq != seq) { + // Not "retry": under the verb barrier the answer is already + // there by the time appliedSeq passed this record, so a stamp + // that disagrees is a drifted sequence space (R-9's kRecPad + // rule) or a wrong-slot read, and both are faults. + WireLogError("MG_Remote reply pool: slot %llu carries seq %llu, not %llu - the two " + "sides' sequence spaces have drifted (a counted kRecPad, R-9) or the " + "addressing disagrees", + static_cast(seq & m_mask), + static_cast(header.Seq), + static_cast(seq)); + return false; + } + if (header.Size > MaxReplyBytes()) { + WireLogError("MG_Remote reply pool: slot for seq %llu declares %u payload bytes in " + "a %u byte slot", + static_cast(seq), header.Size, + static_cast(m_slotBytes)); + return false; + } + if (outStatus != nullptr) { + *outStatus = header.Status; + } + if (outSize != nullptr) { + *outSize = header.Size; + } + if (header.Size == 0) { + return true; + } + if (outBytes == nullptr || outCapacity < header.Size) { + return false; + } + std::memcpy(outBytes, slot + sizeof(ReplySlotHeader), header.Size); + return true; + } + + private: + std::uint8_t* SlotAt(std::uint64_t seq) const { + return m_base + (seq & m_mask) * static_cast(m_slotBytes); + } + + std::uint8_t* m_base = nullptr; + std::uint64_t m_size = 0; + std::uint32_t m_slots = 0; + std::uint32_t m_mask = 0; + std::uint32_t m_slotBytes = 0; + }; + +} // namespace MobileGL::MG_Remote::Transport diff --git a/MobileGL/MG_Remote/Transport/Ring.cpp b/MobileGL/MG_Remote/Transport/Ring.cpp index defee42d..b54f4635 100644 --- a/MobileGL/MG_Remote/Transport/Ring.cpp +++ b/MobileGL/MG_Remote/Transport/Ring.cpp @@ -8,6 +8,8 @@ #include "Ring.h" +#include "SessionRings.h" + #include #include @@ -303,4 +305,257 @@ namespace MobileGL::MG_Remote::Transport { } } + // ======================================================================= + // P5: the five watermarks, the two session endpoints, and the ABI mixer. + // ======================================================================= + + std::uint64_t LargestPowerOfTwoAtMost(std::uint64_t bytes) { + if (bytes == 0) { + return 0; + } + std::uint64_t value = 1; + while (value <= (bytes >> 1)) { + value <<= 1; + } + return value; + } + + std::uint64_t RingCapacityForSegment(std::uint64_t segmentBytes) { + if (segmentBytes <= sizeof(RingControl)) { + return 0; + } + const std::uint64_t usable = LargestPowerOfTwoAtMost(segmentBytes - sizeof(RingControl)); + return usable < kMinRingCapacity ? 0 : usable; + } + + namespace Watermark { + + namespace { + // A watermark may be published LATE but never EARLY, and it may never + // move BACKWARDS. Backwards is the half that is mechanically + // detectable from inside, so it is refused loudly here; "early" can + // only be caught at the call site, which is why every advance below + // has exactly one caller and a named unit case. + void AdvanceMonotonic(std::atomic& watermark, std::uint64_t to, + const char* name) { + const std::uint64_t current = watermark.load(std::memory_order_relaxed); + if (to < current) { + MGLOG_E("MG_Remote watermark: refusing to move %s backwards, %llu -> %llu; a " + "waiter that already resumed on the higher value cannot be un-resumed", + name, static_cast(current), + static_cast(to)); + return; + } + if (to == current) { + return; + } + // Release: everything the advance is a statement ABOUT - the + // record that was applied, the staged bytes that were drained, + // the reply that was posted - must be visible to the acquiring + // waiter before the number that says it happened. + watermark.store(to, std::memory_order_release); + } + } // namespace + + void AdvanceSubmitted(RingControl& control, std::uint64_t seq) { + AdvanceMonotonic(control.submittedSeq, seq, "submittedSeq"); + } + + void AdvanceApplied(RingControl& control, std::uint64_t seq) { + AdvanceMonotonic(control.appliedSeq, seq, "appliedSeq"); + } + + void AdvanceRetired(RingControl& control, std::uint64_t seq) { + // retiredSeq may never overtake appliedSeq: the staging allocator + // reclaims behind it, so a retire ahead of the apply hands live bytes + // back to the producer. Clamped rather than refused, because a + // caller that retires "everything applied" is the normal shape. + const std::uint64_t applied = control.appliedSeq.load(std::memory_order_acquire); + AdvanceMonotonic(control.retiredSeq, seq > applied ? applied : seq, "retiredSeq"); + } + + void AdvanceCompletedFrame(RingControl& control, std::uint64_t serial) { + AdvanceMonotonic(control.completedFrameSerial, serial, "completedFrameSerial"); + } + + void AdvancePresentAck(RingControl& control, std::uint64_t serial) { + AdvanceMonotonic(control.presentAckSerial, serial, "presentAckSerial"); + } + + } // namespace Watermark + + // ----------------------------------------------------------------------- + // SessionProducer + // ----------------------------------------------------------------------- + + void SessionProducer::Attach(RingControl* control, RingProducer* cmd, RingProducer* stage, + Doorbell* peerBell, Doorbell* selfBell, std::uint32_t spinUs) { + m_control = control; + m_cmd = cmd; + m_stage = stage; + m_peerBell = peerBell; + m_selfBell = selfBell; + m_spinUs = spinUs; + } + + void SessionProducer::Detach() { + m_control = nullptr; + m_cmd = nullptr; + m_stage = nullptr; + m_peerBell = nullptr; + m_selfBell = nullptr; + } + + void SessionProducer::PublishAndNotify(std::uint64_t submittedSeq) { + if (!Valid()) { + return; + } + // 1. the records themselves. + m_cmd->Publish(); + if (m_stage != nullptr) { + m_stage->Publish(); + } + // 2. the diagnostic watermark, after the bytes it describes. + Watermark::AdvanceSubmitted(*m_control, submittedSeq); + // 3. and only now the bell. Publish-then-ring, never ring-then-publish. + if (m_peerBell != nullptr) { + NotifyIfParked(*m_peerBell, m_control->consumerParked); + } + } + + template + SessionWait SessionProducer::Park(Ready&& ready, std::uint32_t timeoutMs) { + if (!Valid() || m_selfBell == nullptr) { + return SessionWait::TimedOut; + } + if (m_selfBell->Wait(m_control->producerParked, ready, m_spinUs, timeoutMs)) { + return SessionWait::Reached; + } + // Wait == false && Dead() is "the session was shut down", and it is the + // only thing that returns from a kWaitForever park. Anything else is the + // deadline. + return m_selfBell->Dead() ? SessionWait::ShutDown : SessionWait::TimedOut; + } + + SessionWait SessionProducer::WaitForApplied(std::uint64_t seq, std::uint32_t timeoutMs) { + if (!Valid()) { + return SessionWait::TimedOut; + } + RingControl* control = m_control; + return Park([control, seq] { return Watermark::Reached(control->appliedSeq, seq); }, + timeoutMs); + } + + SessionWait SessionProducer::WaitForPresentAck(std::uint64_t serial, std::uint32_t timeoutMs) { + if (!Valid()) { + return SessionWait::TimedOut; + } + RingControl* control = m_control; + return Park([control, serial] { return Watermark::Reached(control->presentAckSerial, serial); }, + timeoutMs); + } + + SessionWait SessionProducer::WaitForCmdSpace(std::uint64_t bytes, std::uint32_t timeoutMs) { + if (!Valid()) { + return SessionWait::TimedOut; + } + RingProducer* cmd = m_cmd; + return Park([cmd, bytes] { return cmd->FreeBytes() >= bytes; }, timeoutMs); + } + + SessionWait SessionProducer::WaitForStageSpace(std::uint64_t bytes, std::uint32_t timeoutMs) { + if (!Valid() || m_stage == nullptr) { + return SessionWait::TimedOut; + } + RingProducer* stage = m_stage; + return Park([stage, bytes] { return stage->FreeBytes() >= bytes; }, timeoutMs); + } + + // ----------------------------------------------------------------------- + // SessionConsumer + // ----------------------------------------------------------------------- + + void SessionConsumer::Attach(RingControl* control, RingConsumer* cmd, Doorbell* peerBell, + Doorbell* selfBell, std::uint32_t spinUs) { + m_control = control; + m_cmd = cmd; + m_peerBell = peerBell; + m_selfBell = selfBell; + m_spinUs = spinUs; + m_appliedSeq = control == nullptr ? 0 : control->appliedSeq.load(std::memory_order_acquire); + } + + void SessionConsumer::Detach() { + m_control = nullptr; + m_cmd = nullptr; + m_peerBell = nullptr; + m_selfBell = nullptr; + } + + SessionWait SessionConsumer::WaitForWork(std::uint32_t timeoutMs) { + if (!Valid() || m_selfBell == nullptr) { + return SessionWait::TimedOut; + } + RingControl* control = m_control; + RingConsumer* cmd = m_cmd; + const bool woke = m_selfBell->Wait( + control->consumerParked, + [control, cmd] { + return control->cmdHead.load(std::memory_order_acquire) != cmd->LocalTail(); + }, + m_spinUs, timeoutMs); + if (woke) { + return SessionWait::Reached; + } + return m_selfBell->Dead() ? SessionWait::ShutDown : SessionWait::TimedOut; + } + + void SessionConsumer::RetireThrough(std::uint64_t seq) { + if (!Valid()) { + return; + } + Watermark::AdvanceRetired(*m_control, seq); + m_cmd->PublishRetired(); + NotifyClient(); + } + + void SessionConsumer::NotifyClient() { + if (m_control != nullptr && m_peerBell != nullptr) { + NotifyIfParked(*m_peerBell, m_control->producerParked); + } + } + + // ----------------------------------------------------------------------- + // The ABI fingerprint's mixer + // ----------------------------------------------------------------------- + + std::uint64_t MixAbiFingerprint(std::uint64_t dynamicParamsSize, std::uint64_t capsSize, + std::uint64_t functionTableSize, std::uint32_t abiVersion, + const char* buildStamp) { + // FNV-1a over the four numbers and the stamp. Not a hash with any + // security property and not meant to be one: it has to (a) change when + // ANY input changes and (b) be computable identically in two processes + // built from one source tree, which rules out anything seeded at runtime. + std::uint64_t hash = 1469598103934665603ull; + const auto mix = [&hash](std::uint64_t value) { + for (int byte = 0; byte < 8; ++byte) { + hash ^= static_cast((value >> (byte * 8)) & 0xFF); + hash *= 1099511628211ull; + } + }; + mix(dynamicParamsSize); + mix(capsSize); + mix(functionTableSize); + mix(abiVersion); + if (buildStamp != nullptr) { + for (const char* c = buildStamp; *c != '\0'; ++c) { + hash ^= static_cast(static_cast(*c)); + hash *= 1099511628211ull; + } + } + // 0 is reserved for "not stated": a peer that forgot to fill the field + // must not accidentally agree with one that did. + return hash == 0 ? 1ull : hash; + } + } // namespace MobileGL::MG_Remote::Transport diff --git a/MobileGL/MG_Remote/Transport/RoleMemory.h b/MobileGL/MG_Remote/Transport/RoleMemory.h new file mode 100644 index 00000000..fcbe30ad --- /dev/null +++ b/MobileGL/MG_Remote/Transport/RoleMemory.h @@ -0,0 +1,85 @@ +// MobileGL - MobileGL/MG_Remote/Transport/RoleMemory.h +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +// Peak-RSS accounting for the two roles. Owner: package s1; CONSUMER: package t1, +// which puts the numbers in MEASUREMENTS. +// +// WHY BOTH HALVES ARE NEEDED, AND WHY NEITHER ALONE IS THE ANSWER. +// +// VmHWM is the kernel's own high-water mark of resident set size, in +// /proc/self/status. It is the only number that cannot be argued with - it +// counts what the process actually touched, including the pages the allocator +// never gave back. But under `inproc` BOTH ROLES ARE ONE PROCESS, so a single +// VmHWM cannot be split between them and reporting it as "the client's" would +// be a lie that only becomes visible in P6. +// +// The segment ledger is the other half: every ShmSegment this process mapped, +// by kind and by role. It is exact, it IS separable by role, and under `spawn` +// it is the part that appears in both processes at once (one mapping, two +// address spaces, one set of physical pages) - which is precisely the number a +// naive "sum the two VmHWMs" double-counts. +// +// So the pair is the measurement: VmHWM for what the process really cost, the +// ledger for how much of it is shared mapping that a second process will not pay +// for again. t1 reports both, per role, and the split's memory claim is +// (client VmHWM + server VmHWM - shared ledger), never either half on its own. +// +// A SAMPLE IS A SYSCALL AND A PARSE. Take it at phase boundaries - after the +// handshake, after the first frame, at teardown - never per record. + +#pragma once + +#include + +namespace MobileGL::MG_Remote::Transport { + + enum class MemoryRole : std::uint32_t { + Client = 0, + Server = 1, + kMemoryRoleCount = 2, + }; + + // Resident-set high-water mark of THIS PROCESS in bytes, from + // /proc/self/status's VmHWM line. 0 when the platform has no such file + // (Windows, and Android's /proc is readable but the caller should still + // treat 0 as "not measured" rather than "measured zero"). + std::uint64_t ProcessPeakRssBytes(); + + // Current resident set (VmRSS), same source and same 0 convention. Sampled + // beside the peak so a phase that never grew the peak is distinguishable + // from one that was not sampled. + std::uint64_t ProcessCurrentRssBytes(); + + // The ledger. ShmSegment does NOT update it itself: a segment is also created + // by tests and by P6's adopt path, and a ledger that counted those would stop + // meaning "this session's footprint". The SESSION books its own segments. + void LedgerAddSegment(MemoryRole role, std::uint64_t bytes); + void LedgerRemoveSegment(MemoryRole role, std::uint64_t bytes); + std::uint64_t LedgerMappedBytes(MemoryRole role); + // Every role's mapped bytes. Under inproc the two roles map THE SAME pages, + // so this over-counts on purpose: the two per-role numbers are what t1 + // subtracts with, and a single total that silently deduplicated them would + // hide exactly the spawn-vs-inproc difference the measurement is for. + std::uint64_t LedgerMappedBytesAllRoles(); + + // One sample, both halves, for one role. + struct RoleMemorySample { + std::uint64_t PeakRssBytes = 0; + std::uint64_t CurrentRssBytes = 0; + std::uint64_t MappedSegmentBytes = 0; // this role's ledger + MemoryRole Role = MemoryRole::Client; + }; + + RoleMemorySample SampleRoleMemory(MemoryRole role); + + // Emits one line at ERROR level (the wire layer's only level - WireLog.h) so + // t1's harness can grep it out of a lane log without a new log sink. + // `phase` is a short tag: "handshake", "first-frame", "teardown". + void LogRoleMemory(const char* phase, const RoleMemorySample& sample); + +} // namespace MobileGL::MG_Remote::Transport diff --git a/MobileGL/MG_Remote/Transport/SessionRings.h b/MobileGL/MG_Remote/Transport/SessionRings.h new file mode 100644 index 00000000..a52429e3 --- /dev/null +++ b/MobileGL/MG_Remote/Transport/SessionRings.h @@ -0,0 +1,369 @@ +// MobileGL - MobileGL/MG_Remote/Transport/SessionRings.h +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +// The ring-owning half of a session: the four segments, the two ring endpoints, +// and the five watermarks. Owner: package s1. +// +// This is the object the P5 gate is really about. ROADMAP.md:21 asks that +// `inproc` run THE SAME G3 codec as `spawn`, and InProcessTransport cannot +// deliver that no matter how it is edited: it is two deque> plus +// two condvar doorbells (InProcessTransport.cpp:38-97), it owns THE BELLS BUT +// NOT THE RING, and it runs no codec at all. So the rings live here, above +// ITransport, in one implementation both delivery modes use - and the transport +// supplies the control plane and the two bells, which is exactly what its own +// header says it is for (ITransport.h:16-20: "everything on the hot path +// bypasses this interface entirely"). +// +// INPROC USES ShmSegment TOO, NOT new[]. In one address space a heap allocation +// would work and would be faster to write. It is refused deliberately: it is half +// of what makes "the same code path" true rather than nominal. A `new` here means +// the mapping, the alignment, the size rounding, the read-only peer view and the +// lifetime are all exercised for the first time in P6, on the day the second +// process appears - which is the shape of every "it was green in CI" failure this +// phase is trying not to repeat. +// +// --------------------------------------------------------------------------- +// THE RING CAPACITY IS HALF THE SEGMENT, AND THAT IS ARITHMETIC, NOT A CHOICE. +// +// Ring.h:11-13 puts RingControl at the HEAD of SEG_CMD, and RingProducer requires +// a POWER-OF-TWO capacity (Ring.cpp:89-103, the mask is the indexing). A segment +// of 8 MiB therefore has 8 MiB - 4096 bytes left for records, and the largest +// power of two that fits is 4 MiB. A record may be at most half the ring +// (RingProducer::MaxRecordBytes), so the real cap on one record is 2 MiB. +// +// CONTRACT-P5 §5 and Config.h's MOBILEGL_IPC_RING_MB comment both say "8 MiB caps +// one record at 4 MiB". That arithmetic assumed the whole segment is ring bytes +// and did not subtract the control page. The number here is HALF of theirs, and +// the deviation is deliberately in the SAFE direction: R-10's obligation is to +// PROVE no record ever approaches the cap, and a lower cap makes that proof fire +// earlier and louder rather than later and silently. The alternatives were both +// worse - announcing SegmentRef.sizeBytes as 4096 + 8 MiB breaks the four sizes +// ProtocolSmokeTest.cpp:72 pins, and moving RingControl out of SEG_CMD needs a +// fifth SegmentRef that Welcome does not have. +// +// SEG_STAGE has no control page of its own: RingControl carries TWO cursor +// triples (Ring.h:101-109) and the stage triple is the second. So SEG_STAGE's +// capacity is its whole segment, and 32 MiB is already a power of two. +// --------------------------------------------------------------------------- + +#pragma once + +#include "Doorbell.h" +#include "EventRing.h" +#include "ReplySlot.h" +#include "Ring.h" +#include "RoleMemory.h" +#include "ShmSegment.h" + +#include +#include + +namespace MobileGL::MG_Remote::Transport { + + // The four sizes are CONTRACT-P5's and are pinned by ProtocolSmokeTest.cpp:72. + // MOBILEGL_IPC_RING_MB / MOBILEGL_IPC_STAGE_MB move the first two. + struct SessionSegmentSizes { + std::uint64_t CmdBytes = 8ull * 1024 * 1024; + std::uint64_t StageBytes = 32ull * 1024 * 1024; + std::uint64_t ReplyBytes = 8ull * 1024 * 1024; + std::uint64_t EventBytes = 256ull * 1024; + std::uint32_t ReplySlotCount = kDefaultReplySlotCount; + }; + + // Largest power of two <= `bytes`, or 0 when there is none. The ring's + // indexing is a mask, so this is what any segment's usable ring area is. + std::uint64_t LargestPowerOfTwoAtMost(std::uint64_t bytes); + + // Usable ring capacity of a segment that carries a RingControl page at its + // head. See the header block above for why this is half the segment. + std::uint64_t RingCapacityForSegment(std::uint64_t segmentBytes); + + enum class SessionSegmentSlot : std::uint32_t { + Cmd = 0, + Stage = 1, + Reply = 2, + Event = 3, + kSessionSegmentCount = 4, + }; + + // The four ShmSegments of one session, created and mapped read/write. + // + // WHO CREATES THEM: the SERVER, because Welcome announces all four + // (protocol.fbs's Welcome table) and Welcome is server -> client. "Client + // owned" in the schema's comments is about who WRITES a segment, not who + // allocates it. Under `inproc` the client then attaches to the same mapping + // (AttachInProcess); under `spawn` it will adopt the fds the server passed by + // SCM_RIGHTS, which is P6's and is why Adopt is on ShmSegment already. + class SessionSegments { + public: + SessionSegments() = default; + ~SessionSegments(); + + SessionSegments(const SessionSegments&) = delete; + SessionSegments& operator=(const SessionSegments&) = delete; + + // Creates and maps all four, initialises BOTH control pages (SEG_CMD's + // and SEG_EVENT's), and books the mapping in `role`'s ledger. + MobileGLResult Create(const SessionSegmentSizes& sizes, MemoryRole role); + + // The inproc peer's view: the SAME mapping, booked under the OTHER role. + // It does not re-init the control pages - there is one shared page and + // re-initialising it would zero the owner's cursors under it. + MobileGLResult AttachInProcess(SessionSegments& owner, MemoryRole role); + + void Close(); + bool Valid() const { return m_valid; } + + RingControl* CmdControl() const { return m_cmdControl; } + void* CmdRingBase() const { return m_cmdRingBase; } + std::uint64_t CmdRingCapacity() const { return m_cmdRingCapacity; } + + void* StageBase() const { return m_stageBase; } + std::uint64_t StageCapacity() const { return m_stageCapacity; } + + void* ReplyBase() const { return m_replyBase; } + std::uint64_t ReplyBytes() const { return m_replyBytes; } + std::uint32_t ReplySlotCount() const { return m_replySlotCount; } + + RingControl* EventControl() const { return m_eventControl; } + void* EventSegmentBase() const { return m_eventSegmentBase; } + void* EventRingBase() const { return m_eventRingBase; } + std::uint64_t EventRingCapacity() const { return m_eventRingCapacity; } + + // For Welcome's four SegmentRefs. The announced size is the MAPPING size, + // which is what a peer must map - not the ring capacity inside it. + std::uint64_t AnnouncedSize(SessionSegmentSlot slot) const; + const char* AnnouncedName(SessionSegmentSlot slot) const; + int DescriptorFor(SessionSegmentSlot slot) const; // POSIX; -1 elsewhere + + std::uint64_t MappedBytes() const { return m_mappedBytes; } + + private: + void DeriveViews(); + + ShmSegment m_owned[4]; // empty on an attached (peer) view + ShmSegment* m_segments[4] = {nullptr, nullptr, nullptr, nullptr}; + + RingControl* m_cmdControl = nullptr; + void* m_cmdRingBase = nullptr; + std::uint64_t m_cmdRingCapacity = 0; + void* m_stageBase = nullptr; + std::uint64_t m_stageCapacity = 0; + void* m_replyBase = nullptr; + std::uint64_t m_replyBytes = 0; + std::uint32_t m_replySlotCount = kDefaultReplySlotCount; + RingControl* m_eventControl = nullptr; + void* m_eventSegmentBase = nullptr; + void* m_eventRingBase = nullptr; + std::uint64_t m_eventRingCapacity = 0; + + std::uint64_t m_mappedBytes = 0; + MemoryRole m_role = MemoryRole::Client; + bool m_valid = false; + bool m_owns = false; + bool m_booked = false; + }; + + // ----------------------------------------------------------------------- + // The five watermarks (R-9). Every write and every wait goes through here, + // so the rules in Ring.h's header have exactly one implementation. + // ----------------------------------------------------------------------- + // + // THE ONE RULE THAT MATTERS: a watermark may be published LATE but NEVER + // EARLY. Late costs a waiter some latency; early makes every waiter a silent + // use of work that has not happened, and there is no checksum anywhere on + // this ring that would catch it. So the advances below REFUSE to move a + // watermark backwards (that is the detectable half) and the callers are + // responsible for never calling them before the work is done (that is the + // half only a call-site review and R-9's unit cases can enforce). + namespace Watermark { + + // Producer, after Publish. Nobody waits on it - it is the answer to "how + // far ahead of the server is the client right now". + void AdvanceSubmitted(RingControl& control, std::uint64_t seq); + // Consumer, ONCE PER APPLIED RECORD. P5 forbids the 64-record batching + // this ring was designed for: the verb barrier and every reply wait read + // it. kRecPad does not count - RingConsumer::Pop skips fillers, so the + // rule is kept by counting Pops rather than bytes. + void AdvanceApplied(RingControl& control, std::uint64_t seq); + // Consumer, once the SEG_STAGE bytes a record referenced are finished + // with. The staging allocator reclaims behind it and nothing else may. + void AdvanceRetired(RingControl& control, std::uint64_t seq); + // Server, when a present completes. Trails appliedSeq by the GPU's own + // depth; never conflate the two. + void AdvanceCompletedFrame(RingControl& control, std::uint64_t serial); + // Server, when it returns a present credit. The only back-pressure that + // bounds latency rather than bytes. + void AdvancePresentAck(RingControl& control, std::uint64_t serial); + + // Every wait is >=, never ==: both sides advance in jumps, and an + // equality waiter misses its wakeup and hangs until the next coincidence. + inline bool Reached(const std::atomic& watermark, std::uint64_t target) { + return watermark.load(std::memory_order_acquire) >= target; + } + + } // namespace Watermark + + enum class SessionWait : std::uint32_t { + Reached = 0, + // The doorbell died: the peer shut the session down. The ONLY thing that + // can un-park a waiter on kWaitForever (Doorbell.h:211-221), and the + // reason a bounded join is possible at all. + ShutDown = 1, + TimedOut = 2, + }; + + // ----------------------------------------------------------------------- + // The client's end of the rings. + // + // THE TWO DOORBELL ACCESSORS LIVE ON THE SESSION, NOT ON ITransport + // (contract §3.9, the ruling s1 is asked to make now rather than let P6 + // discover). The session takes the two references InProcessTransport hands + // out and is the only thing that knows which is which; ITransport stays the + // dumb control-plane interface its header claims to be, and P6's + // SocketTransport does not grow two accessors it has no natural home for. + // ----------------------------------------------------------------------- + class SessionProducer { + public: + SessionProducer() = default; + + // `peerBell` is the bell the SERVER parks on and this side rings; + // `selfBell` is this side's own. InProcessTransport::PeerDoorbell() and + // SelfDoorbell() are exactly that pair, from the client endpoint. + void Attach(RingControl* control, RingProducer* cmd, RingProducer* stage, Doorbell* peerBell, + Doorbell* selfBell, std::uint32_t spinUs); + void Detach(); + bool Valid() const { return m_control != nullptr && m_cmd != nullptr; } + + // Publish the command ring's head, record submittedSeq, THEN ring - in + // that order and never any other. Doorbell.h:186-193: the fence only + // orders what precedes it, so ringing before publishing reopens the very + // lost-wakeup window the fences exist to close. RingTest.cpp:446 pins the + // call order; this is the one place production code performs it. + void PublishAndNotify(std::uint64_t submittedSeq); + + // The verb barrier's wait, AND the reply's wait: they are the same wait + // (R-3/R-5), which is why a blocking ReadPixels, MapPersistent's decline + // and the four Bool acceptances cost ZERO extra round trips. + SessionWait WaitForApplied(std::uint64_t seq, std::uint32_t timeoutMs); + // Present throttle. + SessionWait WaitForPresentAck(std::uint64_t serial, std::uint32_t timeoutMs); + // Back-pressure when Reserve returned nullptr. NEVER call this when + // FreeBytes() is already >= the record: Ring.h:226-233 - a nullptr with + // enough free bytes can only mean "too big, chunk", and waiting on it + // stalls forever. + SessionWait WaitForCmdSpace(std::uint64_t bytes, std::uint32_t timeoutMs); + SessionWait WaitForStageSpace(std::uint64_t bytes, std::uint32_t timeoutMs); + + RingControl* Control() const { return m_control; } + RingProducer* Cmd() const { return m_cmd; } + RingProducer* Stage() const { return m_stage; } + Doorbell* PeerDoorbell() const { return m_peerBell; } + Doorbell* SelfDoorbell() const { return m_selfBell; } + std::uint32_t SpinUs() const { return m_spinUs; } + + private: + template + SessionWait Park(Ready&& ready, std::uint32_t timeoutMs); + + RingControl* m_control = nullptr; + RingProducer* m_cmd = nullptr; + RingProducer* m_stage = nullptr; + Doorbell* m_peerBell = nullptr; + Doorbell* m_selfBell = nullptr; + std::uint32_t m_spinUs = kDefaultSpinUs; + }; + + // ----------------------------------------------------------------------- + // The server's end of the rings: the apply thread's loop, minus the applier. + // ----------------------------------------------------------------------- + class SessionConsumer { + public: + SessionConsumer() = default; + + void Attach(RingControl* control, RingConsumer* cmd, Doorbell* peerBell, Doorbell* selfBell, + std::uint32_t spinUs); + void Detach(); + bool Valid() const { return m_control != nullptr && m_cmd != nullptr; } + + // Park until a record is waiting, the session is shut down, or the + // deadline passes. Pass kWaitForever for the steady state; a dead bell is + // what ends it, which is why Doorbell::Kill() is load-bearing for the + // join (InProcessTransportTest.cpp:344 pins the shape). + SessionWait WaitForWork(std::uint32_t timeoutMs); + + // Pop ONE record and hand it to `apply`. Returns false when the ring is + // empty. On a corrupt header it returns false and sets *outCorrupt, which + // the CALLER escalates to Fatal{ProtocolCorruption} rather than retrying. + // + // This is the only place appliedSeq is advanced, and it advances it by + // EXACTLY ONE per record - never a batch (R-9). kRecPad cannot reach + // `apply`: RingConsumer::Pop skips fillers before returning, so a filler + // is never counted here and the two sides' sequence spaces cannot drift. + // Order: apply -> appliedSeq -> PublishApplied -> ring the client. + template + bool ApplyOne(Apply&& apply, bool* outCorrupt = nullptr) { + if (outCorrupt != nullptr) { + *outCorrupt = false; + } + if (!Valid()) { + return false; + } + RingRecordView view{}; + if (!m_cmd->Pop(view, outCorrupt)) { + return false; + } + apply(view); + ++m_appliedSeq; + Watermark::AdvanceApplied(*m_control, m_appliedSeq); + m_cmd->PublishApplied(); + NotifyClient(); + return true; + } + + // Records without kRecBorrowSlot retire as soon as they are applied; a + // borrowed slot retires on completedFrameSerial, which is why this is a + // separate call and not folded into ApplyOne. + void RetireThrough(std::uint64_t seq); + + // Ring the client's bell, but only when it said it is parked: a store to + // a shared cache line otherwise burns a big core for a whole frame on a + // phone (Doorbell.h:13-22). + void NotifyClient(); + + std::uint64_t AppliedSeq() const { return m_appliedSeq; } + RingControl* Control() const { return m_control; } + RingConsumer* Cmd() const { return m_cmd; } + Doorbell* PeerDoorbell() const { return m_peerBell; } + Doorbell* SelfDoorbell() const { return m_selfBell; } + std::uint32_t SpinUs() const { return m_spinUs; } + + private: + RingControl* m_control = nullptr; + RingConsumer* m_cmd = nullptr; + Doorbell* m_peerBell = nullptr; + Doorbell* m_selfBell = nullptr; + std::uint32_t m_spinUs = kDefaultSpinUs; + std::uint64_t m_appliedSeq = 0; + }; + + // ----------------------------------------------------------------------- + // The ABI fingerprint's mixer. + // + // It lives under Transport/ rather than in CapsCodec.cpp so that it can be + // tested without the GL frontend's umbrella header, and so that the SIZES it + // mixes are the caller's - CapsCodec.cpp passes the three real sizeofs, a + // unit test passes made-up ones and can then prove a one-byte difference + // changes the answer. A fingerprint that cannot be shown to change is + // indistinguishable from one that is never compared. + // ----------------------------------------------------------------------- + std::uint64_t MixAbiFingerprint(std::uint64_t dynamicParamsSize, std::uint64_t capsSize, + std::uint64_t functionTableSize, std::uint32_t abiVersion, + const char* buildStamp); + +} // namespace MobileGL::MG_Remote::Transport diff --git a/MobileGL/MG_Remote/Transport/ShmSegment.cpp b/MobileGL/MG_Remote/Transport/ShmSegment.cpp index 298062dd..54a7f715 100644 --- a/MobileGL/MG_Remote/Transport/ShmSegment.cpp +++ b/MobileGL/MG_Remote/Transport/ShmSegment.cpp @@ -8,9 +8,20 @@ // Platform-independent half of ShmSegment. The create/map/close bodies live in // ShmSegmentPosix.cpp and ShmSegmentWin32.cpp. +// +// P5 adds two things that are about a SET of segments rather than about one: +// SessionSegments (the four a session owns, and the ring geometry derived from +// them) and the role memory ledger that t1 reports against. #include "ShmSegment.h" +#include "RoleMemory.h" +#include "SessionRings.h" + +#include + +#include +#include #include #include @@ -46,4 +57,302 @@ namespace MobileGL::MG_Remote::Transport { bool ShmSegment::Valid() const { return m_size != 0 && (m_fd >= 0 || m_nativeHandle != nullptr); } + // ======================================================================= + // P5: the role memory ledger and the VmHWM sample + // ======================================================================= + + namespace { + constexpr std::size_t kMemoryRoleCount = static_cast(MemoryRole::kMemoryRoleCount); + + std::atomic& LedgerSlot(MemoryRole role) { + static std::atomic ledger[kMemoryRoleCount]; + const std::size_t index = static_cast(role); + return ledger[index < kMemoryRoleCount ? index : 0]; + } + + const char* RoleName(MemoryRole role) { + return role == MemoryRole::Server ? "server" : "client"; + } + + // One pass over /proc/self/status for a "VmHWM:" / "VmRSS:" line. The + // values are in kB and the unit suffix is part of the line, so it is + // parsed rather than assumed. + std::uint64_t ProcStatusBytes(const char* key) { +#if defined(__linux__) || defined(__ANDROID__) + std::FILE* file = std::fopen("/proc/self/status", "re"); + if (file == nullptr) { + return 0; + } + const std::size_t keyLength = std::strlen(key); + char line[256]; + std::uint64_t bytes = 0; + while (std::fgets(line, sizeof(line), file) != nullptr) { + if (std::strncmp(line, key, keyLength) != 0) { + continue; + } + unsigned long long kilobytes = 0; + // The format is ":\t kB". Anything else is a + // kernel this code has not seen, and 0 ("not measured") is the + // honest answer for it. + if (std::sscanf(line + keyLength, ": %llu kB", &kilobytes) == 1) { + bytes = static_cast(kilobytes) * 1024ull; + } + break; + } + std::fclose(file); + return bytes; +#else + (void)key; + return 0; +#endif + } + } // namespace + + std::uint64_t ProcessPeakRssBytes() { return ProcStatusBytes("VmHWM"); } + + std::uint64_t ProcessCurrentRssBytes() { return ProcStatusBytes("VmRSS"); } + + void LedgerAddSegment(MemoryRole role, std::uint64_t bytes) { + LedgerSlot(role).fetch_add(bytes, std::memory_order_relaxed); + } + + void LedgerRemoveSegment(MemoryRole role, std::uint64_t bytes) { + std::atomic& slot = LedgerSlot(role); + const std::uint64_t current = slot.load(std::memory_order_relaxed); + // Clamped rather than wrapped: a double-unbook would otherwise report a + // role holding sixteen exabytes, which is a number nobody reads as a bug. + slot.store(bytes > current ? 0 : current - bytes, std::memory_order_relaxed); + } + + std::uint64_t LedgerMappedBytes(MemoryRole role) { + return LedgerSlot(role).load(std::memory_order_relaxed); + } + + std::uint64_t LedgerMappedBytesAllRoles() { + std::uint64_t total = 0; + for (std::size_t index = 0; index < kMemoryRoleCount; ++index) { + total += LedgerSlot(static_cast(index)).load(std::memory_order_relaxed); + } + return total; + } + + RoleMemorySample SampleRoleMemory(MemoryRole role) { + RoleMemorySample sample; + sample.Role = role; + sample.PeakRssBytes = ProcessPeakRssBytes(); + sample.CurrentRssBytes = ProcessCurrentRssBytes(); + sample.MappedSegmentBytes = LedgerMappedBytes(role); + return sample; + } + + void LogRoleMemory(const char* phase, const RoleMemorySample& sample) { + // INFO, not DEBUG: t1 greps this out of a lane log and MGLOG_D is compiled out at the + // INFO level every P5 lane builds at. It is a handful of lines per session - the + // handshake, the first frame and teardown - so it is not per-frame noise either. + // + // VmHWM is the PROCESS's, so under inproc both roles report the same + // number and only the ledger differs. The line says so rather than + // leaving a reader to work out why two roles have one peak. + MGLOG_I("MG_Remote memory[%s/%s]: peakRss=%llu currentRss=%llu roleMapped=%llu " + "allRolesMapped=%llu (peakRss is the PROCESS's; under inproc both roles share it)", + phase == nullptr ? "?" : phase, RoleName(sample.Role), + static_cast(sample.PeakRssBytes), + static_cast(sample.CurrentRssBytes), + static_cast(sample.MappedSegmentBytes), + static_cast(LedgerMappedBytesAllRoles())); + } + + // ======================================================================= + // P5: SessionSegments + // ======================================================================= + + namespace { + constexpr std::size_t kSlotCount = + static_cast(SessionSegmentSlot::kSessionSegmentCount); + + std::size_t SlotIndex(SessionSegmentSlot slot) { + const std::size_t index = static_cast(slot); + return index < kSlotCount ? index : 0; + } + } // namespace + + SessionSegments::~SessionSegments() { Close(); } + + MobileGLResult SessionSegments::Create(const SessionSegmentSizes& sizes, MemoryRole role) { + Close(); + + struct Spec { + const char* name; + std::uint64_t bytes; + }; + const Spec specs[kSlotCount] = { + {"mgl-cmd", sizes.CmdBytes}, + {"mgl-stage", sizes.StageBytes}, + {"mgl-reply", sizes.ReplyBytes}, + {"mgl-event", sizes.EventBytes}, + }; + + for (std::size_t index = 0; index < kSlotCount; ++index) { + const MobileGLResult created = + ShmSegment::Create(specs[index].name, specs[index].bytes, m_owned[index]); + if (created != MOBILEGL_OK) { + MGLOG_E("MG_Remote session: could not create segment %s of %llu bytes (rc=%d)", + specs[index].name, static_cast(specs[index].bytes), + static_cast(created)); + Close(); + return created; + } + // Read/write on both roles under inproc: they are the same mapping. + // P6's read-only peer view is a property of the ADOPT path, not of + // this one, and pretending otherwise here would give the inproc lane + // a protection the spawn lane does not reproduce. + const MobileGLResult mapped = m_owned[index].Map(false); + if (mapped != MOBILEGL_OK) { + MGLOG_E("MG_Remote session: could not map segment %s (rc=%d)", specs[index].name, + static_cast(mapped)); + Close(); + return mapped; + } + } + + m_owns = true; + m_replySlotCount = sizes.ReplySlotCount; + DeriveViews(); + if (!m_valid) { + Close(); + return MOBILEGL_ERR_INVALID_ARGUMENT; + } + + // Both control pages, zeroed with their generations at 1. The owner does + // this exactly once; the inproc peer must NOT, or it would zero the + // cursors out from under whoever is already using them. + InitRingControl(*m_cmdControl); + InitRingControl(*m_eventControl); + + m_role = role; + LedgerAddSegment(role, m_mappedBytes); + m_booked = true; + return MOBILEGL_OK; + } + + MobileGLResult SessionSegments::AttachInProcess(SessionSegments& owner, MemoryRole role) { + Close(); + if (!owner.Valid()) { + return MOBILEGL_ERR_NOT_INITIALIZED; + } + for (std::size_t index = 0; index < kSlotCount; ++index) { + m_segments[index] = owner.m_segments[index]; + } + m_owns = false; + m_replySlotCount = owner.m_replySlotCount; + DeriveViews(); + if (!m_valid) { + Close(); + return MOBILEGL_ERR_INVALID_ARGUMENT; + } + m_role = role; + // Booked under this role as well, and that double-counting is the point: + // the pages are shared under inproc and are NOT shared under spawn, so + // the per-role numbers are what t1 subtracts with. + LedgerAddSegment(role, m_mappedBytes); + m_booked = true; + return MOBILEGL_OK; + } + + void SessionSegments::DeriveViews() { + m_valid = false; + if (m_owns) { + for (std::size_t index = 0; index < kSlotCount; ++index) { + m_segments[index] = &m_owned[index]; + } + } + for (std::size_t index = 0; index < kSlotCount; ++index) { + if (m_segments[index] == nullptr || m_segments[index]->Data() == nullptr) { + return; + } + } + + auto* cmdBase = static_cast(m_segments[0]->Data()); + m_cmdControl = reinterpret_cast(cmdBase); + m_cmdRingBase = cmdBase + sizeof(RingControl); + m_cmdRingCapacity = RingCapacityForSegment(m_segments[0]->Size()); + + // SEG_STAGE carries no control page of its own: RingControl holds TWO + // cursor triples and the stage triple is the second (Ring.h:106-109). + m_stageBase = m_segments[1]->Data(); + m_stageCapacity = LargestPowerOfTwoAtMost(m_segments[1]->Size()); + + m_replyBase = m_segments[2]->Data(); + m_replyBytes = m_segments[2]->Size(); + + auto* eventBase = static_cast(m_segments[3]->Data()); + m_eventSegmentBase = eventBase; + m_eventControl = reinterpret_cast(eventBase); + m_eventRingBase = eventBase + sizeof(RingControl); + m_eventRingCapacity = RingCapacityForSegment(m_segments[3]->Size()); + + m_mappedBytes = 0; + for (std::size_t index = 0; index < kSlotCount; ++index) { + m_mappedBytes += m_segments[index]->Size(); + } + + if (m_cmdRingCapacity == 0 || m_stageCapacity == 0 || m_eventRingCapacity == 0 || + m_replyBytes == 0) { + MGLOG_E("MG_Remote session: segment sizes leave no usable ring (cmd cap=%llu stage " + "cap=%llu event cap=%llu reply=%llu). A ring is the largest POWER OF TWO that " + "fits after the 4096 byte control page, so a segment must be strictly larger " + "than one page plus the smallest ring", + static_cast(m_cmdRingCapacity), + static_cast(m_stageCapacity), + static_cast(m_eventRingCapacity), + static_cast(m_replyBytes)); + return; + } + m_valid = true; + } + + void SessionSegments::Close() { + if (m_booked) { + LedgerRemoveSegment(m_role, m_mappedBytes); + m_booked = false; + } + if (m_owns) { + for (ShmSegment& segment : m_owned) { + segment.Close(); + } + } + for (std::size_t index = 0; index < kSlotCount; ++index) { + m_segments[index] = nullptr; + } + m_cmdControl = nullptr; + m_cmdRingBase = nullptr; + m_cmdRingCapacity = 0; + m_stageBase = nullptr; + m_stageCapacity = 0; + m_replyBase = nullptr; + m_replyBytes = 0; + m_eventControl = nullptr; + m_eventSegmentBase = nullptr; + m_eventRingBase = nullptr; + m_eventRingCapacity = 0; + m_mappedBytes = 0; + m_owns = false; + m_valid = false; + } + + std::uint64_t SessionSegments::AnnouncedSize(SessionSegmentSlot slot) const { + const ShmSegment* segment = m_segments[SlotIndex(slot)]; + return segment == nullptr ? 0 : segment->Size(); + } + + const char* SessionSegments::AnnouncedName(SessionSegmentSlot slot) const { + const ShmSegment* segment = m_segments[SlotIndex(slot)]; + return segment == nullptr ? "" : segment->Name(); + } + + int SessionSegments::DescriptorFor(SessionSegmentSlot slot) const { + const ShmSegment* segment = m_segments[SlotIndex(slot)]; + return segment == nullptr ? -1 : segment->Fd(); + } + } // namespace MobileGL::MG_Remote::Transport