// MobileGL - MobileGL/MG_Remote/Server/PipeApplier.cpp // 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 // P5 package v1: the applier bridge, and the consumer for contract 7's five class-B verbs. #include "PipeApplier.h" #include #include "ServerSession.h" #include "ServerLoop.h" #include "../Transport/ReplySlot.h" #include #include #include // P5c ct: object_death's per-kind release names the Espryt twin tables (CONTRACT-P5C.md // §5.2). The same dependency ServerLoop.cpp already takes for CreateBackend; a server built // on Magma simply holds no twins in these tables and every release resolves to nothing. #include #include #include #include #include #include #include #include #include #if !defined(_WIN32) #include #endif #include #include namespace MobileGL::MG_Remote::Server { // P5e (ra, CONTRACT-P5E §1 / §6): DOES THIS SERVER PUBLISH kCapRunAheadApply? The // question is asked of the server's OWN CallMask and not of a build constant, because // "the client may run ahead" is exactly what that bit says and Magma never sets it. // A session with no CallMask yet (the bring-up window, a fixture that never called // SetCapabilityBits) answers false: no client can have latched run-ahead against a // snapshot that was never published. // // P5e (gl, ID-111): PROMOTED OUT OF THE ANONYMOUS NAMESPACE, unchanged in body. It is now // the second conjunct of the barriered stamp as well as Present's frame-serial gate, and // the red-once has to be able to assert what it answers for the session it built. Bool MGPipeServerPublishesRunAhead() { const ServerSession* session = ServerSession::Active(); if (session == nullptr || !session->CallMaskIsSet()) return false; return (session->CallMask() & static_cast(MG_Pipe::kCapRunAheadApply)) != 0; } ReplyPool::ReplyPool(void* base, Uint64 sizeBytes, Uint32 slotCount, Uint32 slotBytes) : m_base(static_cast(base)), m_size(sizeBytes), m_slots(slotCount), m_slotBytes(slotBytes) {} // PACKAGE s1's, not v1's, even though the class is declared in v1's header: the SEG_REPLY // slot pool is s1's deliverable (BRIEF 5) and its addressing lives in one place, // Transport/ReplySlot.h, which the CLIENT reads the same slots back through. Duplicating // `seq % slots` on this side is how the two halves come to disagree about which slot an // answer is in - and because seq IS the reply-slot id (R-3), a disagreement reads another // call's answer instead of failing. // // The view is rebuilt per call rather than stored, so that this body does not change // ReplyPool's four members and therefore does not touch v1's header at all. void ReplyPool::PostReply(Uint64 seq, Int32 status, const void* bytes, Uint64 size) { if (m_link) { const auto result = m_link->PostReply(seq, status, bytes, size); if (result == MOBILEGL_OK) { ++m_posted; return; } ++m_failed; if (result == MOBILEGL_ERR_BUFFER_TOO_SMALL) SessionFail(MGFatalFamily::ReplyTooLarge, "MGPipe: Fatal{ReplyTooLarge, stream reply}"); // EVERY OTHER FAILURE IS NAMED AND COUNTED RATHER THAN SWALLOWED (P12). This used to // `return` on anything that was not BUFFER_TOO_SMALL, so a TRANSPORT_CLOSED reply // vanished with no line, no counter and no Fatal - and the client's only symptom is a // deferred answer that never arrives, which is indistinguishable from a buffer that // overwrote it. Once per call site rather than once per reply: a broken link fails // every time, and a log per answer would drown the run it is there to explain. MGLOG_E_ONCE("MGPipe: reply seq %llu was NOT delivered (%d); the client will not read" " this answer. FailedReplies() counts them", static_cast(seq), static_cast(result)); return; } Transport::ReplySlotPool pool(m_base, m_size, m_slots); // Fatal inside Post when the answer does not fit a slot: P5 does not chunk replies, // and the client knows an answer's size before it emits the record. pool.Post(seq, status, bytes, size); ++m_posted; } Uint32 ReplyPool::SlotBytes() const { return m_slotBytes; } // ----------------------------------------------------------------------------------- // ServerVerbSink - the five class-B verbs // ----------------------------------------------------------------------------------- void ServerVerbSink::OnBackendChanged() { ReleaseQueries(); ReleaseFences(); } ServerVerbSink::FenceEntry& ServerVerbSink::FindFence(MG_Pipe::MGPipeHandle handle) { const auto it = m_fences.find(handle.Slot); if (handle.Slot == 0 || it == m_fences.end() || !it->second.Live || it->second.Gen != handle.Gen) { Wire::WireProtocolFatal("Fence.handle", "missing, destroyed or stale fence handle"); } return it->second; } Bool ServerVerbSink::OnMapPersistent(const MG_Pipe::MGPHandleOnly& handle, Uint64 seq, Int32& status) { ServerSession* session = ServerSession::Active(); return session != nullptr && session->AdoptStoreT0(handle, seq, status); } Bool ServerVerbSink::OnFenceCreate(const MG_Pipe::MGPHandleOnly& desc) { if (desc.Kind != static_cast(MG_Pipe::MGPipeKind::Fence)) Wire::WireProtocolFatal("Fence.Kind", "expected Fence namespace"); const auto* table = Table("FenceCreate"); if (table == nullptr) return false; const auto handle = desc.Handle; if (handle.Slot == 0) { Wire::WireProtocolFatal("FenceCreate.handle", "reserved fence handle"); } const Bool seen = m_fences.find(handle.Slot) != m_fences.end(); auto& entry = m_fences[handle.Slot]; if (entry.Live || (seen && handle.Gen <= entry.Gen)) { Wire::WireProtocolFatal("FenceCreate.handle", "duplicate or stale fence generation"); } entry.Gen = handle.Gen; entry.Live = true; // GL_Sync.cpp treats an absent slot or a null creation result as always signaled. entry.Native = table->GL.FenceSync == nullptr ? nullptr : table->GL.FenceSync(); // P10: in creation order, for ReportSignaledFences - the order the GPU finishes them in. m_unreportedFences.push_back(handle); return true; } Bool ServerVerbSink::OnFenceDestroy(const MG_Pipe::MGPHandleOnly& desc) { if (desc.Kind != static_cast(MG_Pipe::MGPipeKind::Fence)) Wire::WireProtocolFatal("Fence.Kind", "expected Fence namespace"); auto& entry = FindFence(desc.Handle); const auto* table = Table("FenceDestroy"); if (table == nullptr) return false; if (entry.Native != nullptr && table->GL.DeleteSync != nullptr) table->GL.DeleteSync(entry.Native); entry.Native = nullptr; entry.Live = false; return true; } Bool ServerVerbSink::OnFenceStatus(const MG_Pipe::MGPHandleOnly& desc, Uint32& result) { if (desc.Kind != static_cast(MG_Pipe::MGPipeKind::Fence)) Wire::WireProtocolFatal("Fence.Kind", "expected Fence namespace"); auto& entry = FindFence(desc.Handle); const auto* table = Table("FenceStatus"); if (table == nullptr) return false; result = entry.Native == nullptr || table->GL.GetSyncStatus == nullptr || table->GL.GetSyncStatus(entry.Native); return true; } Bool ServerVerbSink::OnFenceWait(const MG_Pipe::MGPFenceWait& request, Uint32& result) { if ((request.Flags & ~static_cast(GL_SYNC_FLUSH_COMMANDS_BIT)) != 0) { Wire::WireProtocolFatal("FenceWait.Flags", "unknown client-wait flag"); } auto& entry = FindFence(request.Fence); const auto* table = Table("FenceWait"); if (table == nullptr) return false; result = entry.Native == nullptr || table->GL.ClientWaitSync == nullptr ? GL_ALREADY_SIGNALED : table->GL.ClientWaitSync(entry.Native, request.Flags, request.TimeoutNs); return true; } Bool ServerVerbSink::OnFenceWaitServer(const MG_Pipe::MGPFenceWait& request) { if (request.Flags != 0 || request.TimeoutNs != GL_TIMEOUT_IGNORED) { Wire::WireProtocolFatal("FenceWaitServer.arguments", "invalid server wait arguments"); } auto& entry = FindFence(request.Fence); const auto* table = Table("FenceWaitServer"); if (table == nullptr) return false; if (entry.Native != nullptr && table->GL.WaitSync != nullptr) table->GL.WaitSync(entry.Native, request.Flags, request.TimeoutNs); return true; } void ServerVerbSink::ReleaseFences() { // Detach runs on the apply thread before its private backend/context is destroyed. if (m_backend != nullptr) { const auto destroy = m_backend->GetBackendFunctions().GL.DeleteSync; if (destroy != nullptr) { for (auto& [slot, entry] : m_fences) if (entry.Live && entry.Native != nullptr) destroy(entry.Native); } } m_fences.clear(); m_unreportedFences.clear(); } // P10 (CONTRACT-P10.md §1): WHICH OF THE CLIENT'S FENCES HAS THE GPU FINISHED. // // The client answers a poll of a fence it knows is signaled without asking - that is the // whole of package A - and it learns what is signaled only from here. Walked in CREATION // order and stopped at the first one still pending: one context's GPU work completes in // the order it was submitted, so a later fence cannot be done while an earlier one is not, // and the steady-state cost is one status query per drain batch. (Were a backend ever to // finish them out of order, the later fence would only be reported late - an answer the // client then gets by its own round trip - never early.) // // `flush`: the apply thread is about to go idle. A fence whose commands the driver still // holds never signals on its own, and an idle server submits nothing more, so the first // pending fence is asked through ClientWaitSync(GL_SYNC_FLUSH_COMMANDS_BIT, 0) - the one // backend-neutral way both backends take to submit what they hold - instead of the plain // status query. Only when idle: mid-stream the next records flush soon enough, and a // submit per batch would cost Magma its command-buffer batching. Uint32 ServerVerbSink::ReportSignaledFences(Bool flush) { if (m_unreportedFences.empty()) return 0; ServerSession* session = ServerSession::Active(); if (session == nullptr || m_backend == nullptr) return 0; const MG_Backend::GLFunctionsTable& gl = m_backend->GetBackendFunctions().GL; Uint32 reported = 0; while (!m_unreportedFences.empty()) { const MG_Pipe::MGPipeHandle handle = m_unreportedFences.front(); const auto it = m_fences.find(handle.Slot); if (it == m_fences.end() || !it->second.Live || it->second.Gen != handle.Gen) { // Deleted before it was seen signaled: nobody can poll it any more. m_unreportedFences.pop_front(); continue; } const FenceEntry& entry = it->second; Bool signaled = entry.Native == nullptr; if (!signaled) { if (flush && gl.ClientWaitSync != nullptr) { const GLenum status = gl.ClientWaitSync(entry.Native, GL_SYNC_FLUSH_COMMANDS_BIT, 0); signaled = status == GL_ALREADY_SIGNALED || status == GL_CONDITION_SATISFIED; flush = false; // one submit is all an idle server owes ++m_fenceIdleFlushes; } else { signaled = gl.GetSyncStatus == nullptr || gl.GetSyncStatus(entry.Native); } } if (!signaled) break; m_unreportedFences.pop_front(); session->PostFenceSignaled(handle); ++reported; } m_fencesReported += reported; return reported; } #include "QueryServer.inc" namespace { // A record's damage rectangles as a region; 0 rectangles is the whole surface. A count past // what the record holds is clamped (the rectangles it names are all there are). MG_Util::Damage::Region DamageOfRecord(Uint32 count, const Int32* rects) { const Int32 n = static_cast(std::min(count, MG_Pipe::kMGPMaxDamageRects)); return MG_Util::Damage::Region::FromEglRects(rects, n); } } // namespace Bool ServerVerbSink::OnPresent(const MG_Pipe::MGPPresent& present) { const MG_Backend::GlobalBackendFunctionsTable* table = Table("present"); if (table == nullptr) return false; if (table->Present == nullptr) return false; // Present is the ONLY frame-boundary drain the backend has (DirectGLES.cpp:12424-12470: // the fence poll, the four ring OnPresent hooks, TrimBufferPool, PipeStats::OnPresent), // which is why ARCHITECTURE.md:531 wants present <-> eglSwapBuffers to stay 1:1. // // m-1: THAT 1:1 IS A CONVENTION c1 UPHOLDS, NOT A STRUCTURAL GUARANTEE, and the earlier // claim that it was structural is wrong. This Present() is reached ONLY from a present // RECORD (ServerVerbSink::OnPresent). ServerSwapEGLBuffers does NOT reach it - it calls // backend->SwapEGLBuffers -> BackendObject::SwapEGLBuffers -> eglSwapBuffers, and never // Present() - so the two paths do NOT both end here. The frame count staying in step with // the swap count rests entirely on c1 emitting exactly one present record per swap; // nothing here compares Presents() to a swap count. If that drifts, the frame fence and // TrimBufferPool's recycle watermark stop tracking frames - which is the reason the 1:1 // was wanted, recorded here so a future swap-without-present is looked for rather than // assumed impossible. Presents() is exposed for a lane that wants to make the comparison. // The frame's damage rides to the native present for the length of this call. MG_Backend::SetCurrentPresentDamage(DamageOfRecord(present.DamageCount, present.Damage)); table->Present(); MG_Backend::SetCurrentPresentDamage(MG_Util::Damage::Region::Full()); ++m_presents; // FrameSerial 0 means "the server stamps its own" (c1-v1 8.3): P5 has no client-side // present credit, so the client sends 0 and the frame count on this side IS the serial. // // P5e (ra, CONTRACT-P5E §1, §2.4): IT IS THE CLIENT'S NOW, 1-BASED AND MINTED BY THE // PAYER. A credit can only be paced in an id space the waiter advances, and the waiter // is the client (`WaitForPresentAck(m_presentsSent + 1 - credit)`), so a server-stamped // serial would be the server acknowledging its own count. The 0 arm survives for a // peer that has not been re-built - and on a server that PUBLISHES the run-ahead cap it // is a protocol fault, because such a client is pacing on this answer and a 0 would // acknowledge a frame nobody asked about. if (present.FrameSerial == 0 && MGPipeServerPublishesRunAhead()) { // PH-1 (3): latches in an armed session child - no credit is returned for it, the // session closes and the peer's credit wait ends on the hang-up. return SessionLatch(MGFatalFamily::ProtocolCorruption, "MGPipe: Fatal{ProtocolCorruption, \"Present.FrameSerial\"} - a run-ahead " "server was handed present serial 0. The client mints this 1-based and " "waits on it for its credit (CONTRACT-P5E §2.4); returning a credit for " "serial 0 would release a wait that is asking about frame N"); } m_lastPresentSerial = present.FrameSerial != 0 ? present.FrameSerial : m_presents; // §2.4's other half, and the reason ServerSession::ReturnPresentCredit has had no // production caller since v1 wrote it: ONE CREDIT PER SWAP, returned after Present() // has returned rather than before it, because what the client is waiting for is the // swap and not the record's apply. It advances presentAckSerial AND rings the client's // bell - a client parked in WaitForPresentAck(kWaitForever) needs the pair. if (ServerSession* session = ServerSession::Active()) { // P10 (CONTRACT-P10.md §1): the fences finished by now are reported BEFORE the credit // goes back - a client released by the credit polls or waits on the fence of a frame // or two ago at once, and a report posted only after this batch would race it. A // status query, no submit: the swap has just submitted. if (session->EventRingHasRoom()) ReportSignaledFences(/*flush=*/false); session->ReturnPresentCredit(m_lastPresentSerial); } return true; } // ---- P5c ct (MG_Remote/CONTRACT-P5C.md §5) ------------------------------------------ // // TWO CONTROL RECORDS, NO BACKEND TABLE AND NO DECLINE ARM. Neither body consults // Table(): the reset belongs to the applier this process owns, and the death release // belongs to the twin tables - a backend that registered no slots (Magma's XFB shape) // still has an applier to reset and still answers a death with the same generation // check. A record that cannot be proved is Fatal, not declined: both refusals are // ProtocolCorruption because by the time the sink runs the codec has already proved the // record's SHAPE, and what is left to check are the contract facts about the peer (§1). Bool ServerVerbSink::OnApplierReset(const MG_Pipe::MGPApplierReset& reset) { // §1: the serial is ASSERTED, never dispatched on. P5c has exactly one context per // session, so the only legal sequence is 0, 1, 2, ... and the session's own count of // accepted resets IS the expected value; anything else means the two ends disagree // about how many make-current edges have crossed, which no backend answer can fix. // PH-1 (3): latches in an armed session child (the peer wrote the serial); dies unarmed. if (reset.ContextSerial != m_applierResetSerial) { return SessionLatch(MGFatalFamily::ProtocolCorruption, "MGPipe: Fatal{ProtocolCorruption, \"ApplierReset.ContextSerial\"} - the " "record carries %llu and this session has accepted %llu reset(s); the " "serial is asserted against the session's own count, not dispatched on " "(one context per session in P5c)", static_cast(reset.ContextSerial), static_cast(m_applierResetSerial)); } ++m_applierResetSerial; // THE WHOLE POINT OF THE RECORD: the reset runs HERE, on the apply thread, against // the g_applier this role owns (PipeApply.cpp:409). The layer-2 guard inside // MGPipeApplierReset passes because this IS the apply thread; the GL-thread direct // call it replaced is the Fatal arm. MG_Pipe::MGPipeApplierReset(); ++m_applierResets; return true; } ServerVerbSink::OwnedFd& ServerVerbSink::OwnedFd::operator=(OwnedFd&& other) noexcept { if (this != &other) Reset(std::exchange(other.fd, -1)); return *this; } ServerVerbSink::OwnedFd::~OwnedFd() { Reset(-1); } void ServerVerbSink::OwnedFd::Reset(int next) { #if !defined(_WIN32) if (fd >= 0) ::close(fd); #endif fd = next; } // P14 S1. THE RING'S DOOR INTO THE SESSION'S CONTEXT TABLE. // // The record carries the client's current context token and this is where a session's // attribution moves. It is deliberately NOT an assertion like OnApplierReset's serial: the // client's EGL layer mints the token and the server has no second count to compare against, // so what is checked is a FACT ABOUT THIS SESSION'S OWN TABLE - a token that CreateContext // never created is corruption, not a late record. Token 0 is the release edge and is always // legal (a session whose client released its context has no context, which is exactly the // value a session that never bound one already answers). Bool ServerVerbSink::OnSharedImage(const MG_Pipe::MGPSharedImageOp& op, Uint64 seq, MG_Pipe::MGPSharedImageReply& reply) { namespace SI = SharedImages; ServerSession* session = ServerSession::Active(); if (session == nullptr) return false; SI::SessionHolder& holder = session->SharedImageHolder(); const auto fill = [&reply](const SI::Image& image) { reply.ImageId = image.Id; reply.Modifier = image.Modifier; reply.Width = image.Width; reply.Height = image.Height; reply.Format = image.Fourcc; reply.Stride = image.Stride; reply.Offset = image.Offset; reply.Plane1Stride = image.Plane1Stride; reply.Plane1Offset = image.Plane1Offset; }; switch (op.Op) { case MG_Pipe::kMGPSharedImageAllocate: { std::string why; SI::ImageRef image = SI::Allocate(op.Width, op.Height, op.Format, why); if (image == nullptr) { MGLOG_E("MG_Remote server: shared image %ux%u (fourcc 0x%08x) not allocated: %s", op.Width, op.Height, op.Format, why.c_str()); return false; } Transport::ITransport* transport = session->ControlTransport(); // The descriptor goes out BEFORE the reply is posted, so the client finds it queued. MG_Pipe::MGPSharedImageFdOffer offer{MG_Pipe::kMGPSharedImageFdMagic, 1, seq, image->Id, 0}; const MobileGLResult shared = transport != nullptr ? transport->ShareFd(image->Fd, MobileGLByteSpan{&offer, sizeof(offer)}) : MOBILEGL_ERR_NOT_INITIALIZED; if (shared != MOBILEGL_OK) { MGLOG_E("MG_Remote server: shared image %llu could not be sent on the aux socket (rc=%d)", static_cast(image->Id), static_cast(shared)); return false; } holder.Hold(image); fill(*image); MGLOG_D("MG_Remote server: shared image %llu allocated, %ux%u stride %u", static_cast(image->Id), image->Width, image->Height, image->Stride); return true; } case MG_Pipe::kMGPSharedImageImport: { int fd = -1; std::string why; if (session->T0Inbox().TakeSharedImageFd(seq, Transport::AdoptT0::kOfferWaitMs, &fd, why) != AdoptInbox::Outcome::Taken) { MGLOG_E("MG_Remote server: shared-image import (record %llu) has no descriptor: %s", static_cast(seq), why.c_str()); return false; } SI::ImageRef image = SI::Identify(fd, why); // YUV: the planes and colour hints ride in Damage (MGPImportPlaneWord). A buffer this // server did not allocate is taken too, through the labelled CPU-copy fallback // (SharedImageRegistry.h, Image::ForeignSource); RGBA ones never are. const Bool yuv = SI::FourccIsYuv(op.Format) && op.DamageCount == MG_Pipe::kMGPImportPlaneWords; if (image == nullptr && yuv) { SI::PlaneLayout layout; layout.Count = static_cast(op.Damage[MG_Pipe::kMGPImportPlaneCount]); layout.Offset[0] = static_cast(op.Damage[MG_Pipe::kMGPImportPlane0Offset]); layout.Offset[1] = static_cast(op.Damage[MG_Pipe::kMGPImportPlane1Offset]); layout.Pitch[0] = static_cast(op.Damage[MG_Pipe::kMGPImportPlane0Pitch]); layout.Pitch[1] = static_cast(op.Damage[MG_Pipe::kMGPImportPlane1Pitch]); // The copy reads rows: only a buffer said to be linear (or said nothing) is one. const Uint64 modifier = static_cast(static_cast(op.Damage[MG_Pipe::kMGPImportModifierLo])) | (static_cast(static_cast(op.Damage[MG_Pipe::kMGPImportModifierHi])) << 32); if (op.Damage[MG_Pipe::kMGPImportHasModifier] != 0 && modifier != SI::kModifierLinear && modifier != SI::kModifierInvalid) { char text[96]; std::snprintf(text, sizeof(text), "; a foreign buffer of modifier 0x%016llx is not linear", static_cast(modifier)); why += text; } else { std::string foreignWhy; image = SI::ImportForeignYuv(fd, op.Width, op.Height, op.Format, layout, foreignWhy); if (image == nullptr) why += "; as a foreign YUV buffer: " + foreignWhy; } } #if !defined(_WIN32) ::close(fd); #endif if (image == nullptr) { MGLOG_W_ONCE("MG_Remote server: a dma-buf import was refused: %s", why.c_str()); return false; } if (SI::FourccIsYuv(image->Fourcc) != SI::FourccIsYuv(op.Format) || (SI::FourccIsYuv(op.Format) && image->Fourcc != op.Format)) { MGLOG_W("MG_Remote server: a dma-buf import of image %llu (fourcc 0x%08x) as fourcc 0x%08x was refused", static_cast(image->Id), image->Fourcc, op.Format); return false; } if (yuv) { SI::Yuv::Hints hints; hints.ColorSpace = static_cast(op.Damage[MG_Pipe::kMGPImportYuvColorSpace]); hints.Range = static_cast(op.Damage[MG_Pipe::kMGPImportYuvRange]); hints.SitingX = static_cast(op.Damage[MG_Pipe::kMGPImportYuvSiting]) & 0xffffu; hints.SitingY = static_cast(op.Damage[MG_Pipe::kMGPImportYuvSiting]) >> 16; SI::SetYuvHints(*image, hints); } if (image->Width != op.Width || image->Height != op.Height || !SI::FourccSupported(op.Format)) { MGLOG_W("MG_Remote server: a dma-buf import of image %llu (%ux%u) as %ux%u fourcc 0x%08x was refused", static_cast(image->Id), image->Width, image->Height, op.Width, op.Height, op.Format); return false; } holder.Hold(image); fill(*image); return true; } case MG_Pipe::kMGPSharedImageRelease: reply.ImageId = op.ImageId; return holder.Release(op.ImageId); case MG_Pipe::kMGPSharedImagePresent: { SI::ImageRef image = holder.Get(op.ImageId); MG_Backend::BackendObject* backend = ServerLoopInstance().Backend(); if (image == nullptr || backend == nullptr) return false; MG_Backend::SharedImageView view; view.Id = image->Id; view.Width = image->Width; view.Height = image->Height; view.Fourcc = image->Fourcc; view.NativeBuffer = image->Native; if (!backend->BlitDefaultFramebufferToSharedImage(view, DamageOfRecord(op.DamageCount, op.Damage))) return false; fill(*image); return true; } case MG_Pipe::kMGPSharedImageQueryBufferAge: { MG_Backend::BackendObject* backend = ServerLoopInstance().Backend(); if (backend == nullptr) return false; reply.BufferAge = backend->QueryCurrentBufferAge((op.Format & MG_Pipe::kMGPBufferAgeDamageRegionFollows) != 0); return true; } case MG_Pipe::kMGPSharedImageSetDamageRegion: { MG_Backend::BackendObject* backend = ServerLoopInstance().Backend(); if (backend == nullptr) return false; return backend->SetCurrentDamageRegion(DamageOfRecord(op.DamageCount, op.Damage)); } case MG_Pipe::kMGPSharedImageFlush: { // The answer is posted after the fence is published: the client commits the buffer to // its compositor only after this returns, so the compositor's session finds the write. MG_Backend::BackendObject* backend = ServerLoopInstance().Backend(); if (backend == nullptr) return false; return backend->PublishSharedImageAccesses(); } case MG_Pipe::kMGPSharedImageNativeFence: { // A descriptor of the session's work, and an image writer's flush (MGPipeTypes.h). Like // an Allocate's, the descriptor goes out BEFORE the reply is posted. MG_Backend::BackendObject* backend = ServerLoopInstance().Backend(); if (backend == nullptr) return false; int fence = -1; if (!backend->ExportNativeFence(&fence)) return false; #if !defined(_WIN32) if (fence >= 0) { m_lastNativeFence.Reset(::dup(fence)); } else if (m_lastNativeFence.fd >= 0) { fence = ::dup(m_lastNativeFence.fd); } #endif reply.Format = 0; if (fence < 0) return true; Transport::ITransport* transport = session->ControlTransport(); MG_Pipe::MGPSharedImageFdOffer offer{MG_Pipe::kMGPSharedImageFdMagic, 1, seq, 0, 0}; const MobileGLResult shared = transport != nullptr ? transport->ShareFd(fence, MobileGLByteSpan{&offer, sizeof(offer)}) : MOBILEGL_ERR_NOT_INITIALIZED; #if !defined(_WIN32) ::close(fence); #endif if (shared != MOBILEGL_OK) { MGLOG_E_ONCE("MG_Remote server: a native fence could not be sent on the aux socket (rc=%d)", static_cast(shared)); return false; } reply.Format = MG_Pipe::kMGPNativeFenceFdFollows; return true; } case MG_Pipe::kMGPSharedImageAttach: { SI::ImageRef image = holder.Get(op.ImageId); if (image == nullptr) return false; if (!MG_Pipe::MGPipeApplyAttachSharedImage(op.Texture, image->Id)) { MGLOG_E("MG_Remote server: shared image %llu could not be attached to texture {slot=%u, gen=%u}: the " "texture has no record", static_cast(image->Id), op.Texture.Slot, op.Texture.Gen); return false; } fill(*image); return true; } default: MGLOG_E("MG_Remote server: shared_image operation %u is not one this server knows", op.Op); return false; } } Bool ServerVerbSink::OnBindContext(const MG_Pipe::MGPBindContext& bind) { // NO SESSION IS A DECLINE, NOT A FAULT, and it is the one case here that a peer's bytes // cannot cause: a bind is answered by the session's own table, so with no accepted // session there is nothing to attribute the record to and nothing to refuse. (The window // is Close() clearing the active session while the apply thread is still draining.) ServerSession* session = ServerSession::Active(); if (session == nullptr) return false; // PH-1 (3): the refusal latches in an armed session child (the peer wrote the token); // unarmed it dies, like every other contract fact about a peer. if (!session->BindContext(bind.ClientContextToken)) { return SessionLatch(MGFatalFamily::ProtocolCorruption, "MGPipe: Fatal{ProtocolCorruption, \"BindContext.ClientContextToken\"} - the " "record binds token %llu, which this session never created; a bind_context " "names a context CreateContext put in the session's table, and 0 (the " "release) is the only token that needs no entry", static_cast(bind.ClientContextToken)); } ++m_bindContexts; #if MOBILEGL_BUILD_DISAGGREGATED // P14 S4 (docs/Disaggregated/design/11-state-ownership.md). THE NATIVE HALF OF THE SWITCH. // // `bind_context` is where the session's context attribution moves, and it is also the ONLY // point at which the apply thread's native context may move: the MakeCurrent control frame // that precedes it still carries the OLD context (the client emits the binding after the // binding has taken effect, which is the ordering S1 chose on purpose), so a native switch // driven from there would bind the context the client just left. Frontend GL state is the // backend's per-tuple business; this call makes the tuple the resolver now names current. // // It is a no-op for the shapes S4 did not change: with no resolver installed (monolith, a // unit case, the inproc one-process shape) the key is {0,0}, and the single-context world's // bind does not move the native context - MakeNativeContextCurrentForBoundToken returns // without a driver call when the context it resolves to is already the one bound here. if (MG_Config::Transport != MG_Config::TransportMode::Monolith) { (void)MG_Backend::DirectGLES::MakeNativeContextCurrentForBoundToken(); // The backend half for one that keeps a single native surface binding (Magma). if (MG_Backend::BackendObject* backend = ServerLoopInstance().Backend()) { backend->OnClientContextBound(bind.ClientContextToken); } } #endif return true; } Bool ServerVerbSink::OnObjectDeath(const MG_Pipe::MGPHandleOnly& death) { // §1's zero ruling: a null handle means "the object never crossed", and the client // emits NOTHING in that case (§5.2) - so a null handle arriving here is corruption, // not a no-op. // PH-1 (3): both refusals latch in an armed session child; unarmed they die as before. if (MG_Pipe::MGPipeHandleIsNull(death.Handle)) { return SessionLatch(MGFatalFamily::ProtocolCorruption, "MGPipe: Fatal{ProtocolCorruption, \"ObjectDeath.Handle\"} - a null " "handle never crosses: the client emits nothing for an object its own " "allocator cannot resolve (CONTRACT-P5C.md §5.2)"); } if (death.Kind >= static_cast(MG_Pipe::MGPipeKind::KindCount)) { return SessionLatch(MGFatalFamily::ProtocolCorruption, "MGPipe: Fatal{ProtocolCorruption, \"ObjectDeath.Kind\"} - %u is not an " "MGPipeKind", static_cast(death.Kind)); } // The per-kind release, keyed by the handle the record carried. A false answer is // NOT a decline: the kind's own delete opcode may already have released the twin // (the idempotent second path every notice arm documents), and a kind this backend // does not twin (Buffer, whose death crosses as resource_destroy) legally resolves // to nothing. MG_Backend::DirectGLES::ReleaseTwinsForWireObjectDeath( death.Handle, static_cast(death.Kind)); ++m_objectDeaths; return true; } // ----------------------------------------------------------------------------------- // PipeApplier // ----------------------------------------------------------------------------------- PipeApplier::PipeApplier(Wire::SegmentTable* segments, ReplyPool* replies) : m_segments(segments), m_replies(replies) {} void PipeApplier::Attach(Transport::ILink* link, MG_Backend::BackendObject* backend) { if (!link || !link->Attached() || !m_segments) SessionFail(MGFatalFamily::ProtocolCorruption, "MGPipe: Fatal{ProtocolCorruption, PipeApplier::Attach missing link}"); m_verbs.SetBackend(backend); m_verbs.SetMaxReplyBytes(link->Capabilities().MaxReplyBytes); m_decoder = Wire::PipeWireDecoder(link, m_segments, m_replies); m_decoder.SetVerbSink(&m_verbs); MG_Pipe::MGPipeServerBlockNoteIdentity(); m_attached = true; } void PipeApplier::Attach(Transport::RingControl* control, MG_Backend::BackendObject* backend) { if (control == nullptr || m_segments == nullptr) { SessionFail(MGFatalFamily::ProtocolCorruption, "MGPipe: Fatal{ProtocolCorruption, \"PipeApplier::Attach\"} - no control " "page or no segment table; ServerSession::Accept builds both before the " "apply thread starts"); } m_verbs.SetBackend(backend); m_decoder = Wire::PipeWireDecoder(control, m_segments, m_replies); m_decoder.SetVerbSink(&m_verbs); // P5f (f1), CONTRACT-P5E §3.2: the server block's identity, once per session (the // per-verb stamp refreshes it against the served-context serial). A no-op unless the // dual-block rehearsal is armed; without it the server block's ContextIdentity() stays // nullptr and the backend's identity-keyed memo caches read that as a HIT on their // zero-initialised slot - an unnamed null dereference instead of a named marker. MG_Pipe::MGPipeServerBlockNoteIdentity(); m_attached = true; } void PipeApplier::Detach() { m_decoder = Wire::PipeWireDecoder(); m_verbs.SetBackend(nullptr); m_attached = false; } Bool PipeApplier::Attached() const { return m_attached; } Bool PipeApplier::ApplyOne(const Transport::RingRecordView& record) { if (!m_attached) { SessionFail(MGFatalFamily::ProtocolCorruption, "MGPipe: Fatal{ProtocolCorruption, \"PipeApplier::ApplyOne before Attach\"} " "- a record reached the applier with no decoder; the apply thread calls " "Attach once before its first pop"); } // R-1's INVARIANT, THE SERVER'S HALF (table 3's gPipeInputs row, c1-v1 8.1). The flag // is raised for the WHOLE of this function and not only around DecodeAndApply: the // stamp below and LeaveApplier at the end are both writes to gPipeInputs, and the client // asserts the flag is down before it publishes (ClientSession::EmitAndWait), so a // bracket that excluded either would leave a real write outside the check. It is // dropped before this function returns, and s1's SessionConsumer::ApplyOne publishes // appliedSeq only after that - so by the time the client is runnable the flag is down. const Client::ClientSession::ScopedApplierEntry insideApplier; // P5e (id), CONTRACT-P5E §2.1 / §4.4: STAMP WHETHER THE CLIENT IS PARKED BEHIND THIS // RECORD, before anything can ask. It is the input to the allocator guard's exemption // (SlotAllocator.cpp) and to every frontend-keyed twin member that survives as monolith // glue (SlotTables.h), and it has to be up before DecodeAndApply because the sinks those // reach are exactly the askers. MGPipeBarriered answers true for every record until ra // lands the wait rule, so this line changes nothing this phase and is the line ra // rebases onto rather than adds. // // AND THE STAMP IS `true` UNTIL ra LANDS THE CLIENT'S HALF (ID-103). The predicate // describes what the client WILL do once EmitAndWaitTails follows the wait classes; // today it still blocks after every record, so a `false` stamp here would withdraw the // §4.4 exemptions from a probe the client's own wait still makes safe - a refusal with // no defect behind it. The predicate is computed on every record all the same, so it is // exercised for the whole phase rather than first run on the day it starts deciding. // // AND THE SERVER'S OWN CAPABILITY IS THE SECOND CONJUNCT (P5e gl, ID-111). The constant // above says what the CLIENT will do once ra's wait rule is live; it says nothing about // which server this is. kCapRunAheadApply is never published on DirectVulkan (ID-90, // MGPipeRunAheadCapBitsFor), yet draw_vbo / blit / clear / launch_grid are all // kWaitNone - so a stamp that read the build constant alone would, on the day it flips, // have a MAGMA server mark every draw record UNBARRIERED while its client is still // lockstep. CountBarrierPull (PipeInputs.cpp) is an unconditional Fatal on an // unbarriered pull, no knob involved, and Magma's residual fill is its ONLY source for // the seven pointer-backed fields: Magma would die on its first draw and take // MagmaP7AllocatorDebtScope's exemption with it. // // BOTH PREDICATES ARE COMPUTED UNCONDITIONALLY, as arguments rather than as the arms of // a short-circuit, which is ID-103's reason extended to the capability probe: they are // exercised on every record for the whole phase rather than first running on the day // they start deciding. // // PH-1 (3): BUT NOT BEFORE THE RECORD HAS BEEN ADMITTED. MGPipeBarriered reads payload // fields (a DrawVbo's MGPDrawInfo::Flags) and the stamps index per-opcode tables, so a // record whose ring header is shorter than its own type - or names no opcode - would be // read past its end here, before DecodeAndApply's pre-gate could refuse it. In an armed // session child the pre-gate therefore runs FIRST: a refused record latches by name and // is declined with nothing stamped (ServerLoopTest's short-record case reads the stamp). // Unarmed it admits everything and the generated gate keeps its death, as before. if (!m_decoder.AdmitOrDecline(record)) return false; const Bool wireSaysBarriered = MG_Pipe::MGPipeBarriered( static_cast(record.kind), record.payload, MG_Pipe::MGPipeApplier()); MG_Pipe::MGPipeApplierSetCurrentRecordBarriered( MGPipeApplierStampsBarriered(MG_Pipe::kMGPipeP5eClientWaitRuleLanded, MGPipeServerPublishesRunAhead(), wireSaysBarriered)); // P5e (gl), ID-128: AND WHETHER IT WAS BARRIERED BY ESCALATION RATHER THAN BY ITS CLASS. // The predicate above is the static wait class plus two payload-derived escalations // (ID-83: an open transform-feedback span, a draw carrying client vertex arrays), so the // difference between it and the table IS the escalation - both halves are already // computed here, and the second flag costs one compare. The strict knob is the only // reader: a pull on an escalated record is a debt the phase that owns XFB (§5.7) or // client arrays (ID-82) owes, neither of which is this one. MG_Pipe::MGPipeApplierSetCurrentRecordBarrieredByEscalation( wireSaysBarriered && MG_Pipe::MGPipeWaitClassFor(static_cast(record.kind)) == MG_Pipe::kWaitNone); // ORDER IS THE CONTRACT'S: stamp, then apply. The stamp is what makes any server-side // read of gPipeInputs legal at all (PipeApplier.h's block 1), so a record applied // before it aborts on the FIRST field inside SyncRenderState. StampVerbBoundary(static_cast(record.kind)); const Bool applied = m_decoder.DecodeAndApply(record); // AND THE CLEAR IS INSIDE ApplyOne, NOT AFTER THE DRAIN BATCH. That is not tidiness, // it is the barrier invariant. s1's SessionConsumer::ApplyOne publishes appliedSeq the // instant this returns, and publishing appliedSeq is what makes the CLIENT runnable // again (R-1: the barrier waits on exactly that watermark). A clear that ran after the // batch would therefore be a second writer of gPipeInputs while the client is already // touching it - the one thing table 3 says may not be introduced before the barrier // retires - and the first version of this file had it there. It was caught by // AClearRecordCrossesAndIsStampedAsAVerbBoundary failing INTERMITTENTLY, which is what // a race looks like from the outside. // // THE COST, STATED: a record that is NOT a verb boundary now applies with the flag // disarmed, so a sticky forward pulled from inside such a record's applier is not // counted in `rsp`. Closing that needs an "enter the applier" entry point beside // MGPipeServerStampVerbBoundary that arms the flag WITHOUT re-stamping - re-stamping on // a non-verb op is what p1 forbids outright - and PipeInputs.cpp is p1's file. Left for // the integrator to sequence; it makes `rsp` larger, never smaller, so the number this // phase reports is a floor. LeaveApplier(); return applied; } // p1's rule verbatim (p1-v1 2). MGPipeVerbForWireOp is generated from MGP_VERB_OP_LIST in // FieldOwnership.def and answers kVerbCount for every op that is NOT a verb boundary, so // calling it unconditionally on every record is both correct and cheap. Four ops stamp: // Clear -> Clear, DrawVbo -> DrawArrays, ReadPixels -> ReadPixels, Blit -> BlitFramebuffer. // // PRESENT IS DELIBERATELY NOT ONE, although contract 7 puts it in class B: FillPoints.def:21 // says Present and SetSwapInterval "go through BackendObject virtuals and read no frontend // state, so they are not verbs here". There is no MGPipeVerb::Present, and stamping there // would retire the previous verb's answers with nothing to put in their place. void PipeApplier::StampVerbBoundary(MG_Pipe::MGPWireOp op) { const MG_Pipe::MGPipeVerb verb = MG_Pipe::MGPipeVerbForWireOp(op); if (verb == MG_Pipe::MGPipeVerb::kVerbCount) return; // not a verb boundary: stamp nothing MG_Pipe::MGPipeServerStampVerbBoundary(verb); } void PipeApplier::LeaveApplier() { MG_Pipe::MGPipeServerClearVerbBoundary(); } Uint64 PipeApplier::ResidualPullCount() const { return MG_Pipe::MGPipeResidualPullCount(); } // The decoder poisons EXACTLY the runs it resolved, from inside DecodeAndApply, once the // applier has returned - so this entry point is the manual one, for a caller that knows a // range is dead and is not the decoder. It is kept because c0's signature block declares // it and because the R-11 copy in Managers.cpp is verified by poisoning a range by hand in // a unit case; nothing on the live path calls it. void PipeApplier::PoisonRetiredStageBytes(Uint64 offset, Uint64 size) { if (size == 0 || m_segments == nullptr) return; #if MOBILEGL_BUILD_DISAGGREGATED if (!MG_Config::Ipc.Audit) return; #endif const void* run = m_segments->Resolve(Wire::kSegStage, offset, size); if (run == nullptr) return; std::memset(const_cast(run), 0xDD, static_cast(size)); } Uint64 PipeApplier::PoisonedStageBytes() const { return m_decoder.PoisonedStageBytes(); } Uint64 PipeApplier::DecoderAppliedSeq() const { return m_decoder.AppliedSeq(); } } // namespace MobileGL::MG_Remote::Server