From 377356f8caa3bbd1225ca5e3b731b4cbe18b37a5 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Wed, 16 Sep 2026 05:45:15 -0400 Subject: [PATCH 1/6] [Feat] (DirectGLES/Managers, MG_Remote/Server): R-11 - copy staged bytes into a server-owned StagedShadowStore with exact coverage; Fatal{StageSnapshotTooNarrow} before the flush drain instead of a widened INVALIDATE_RANGE --- MobileGL/MG_Backend/DirectGLES/Managers.cpp | 151 +++++++++++++++- MobileGL/MG_Remote/Server/StagedShadow.h | 182 ++++++++++++++++++++ 2 files changed, 326 insertions(+), 7 deletions(-) create mode 100644 MobileGL/MG_Remote/Server/StagedShadow.h diff --git a/MobileGL/MG_Backend/DirectGLES/Managers.cpp b/MobileGL/MG_Backend/DirectGLES/Managers.cpp index 00ec6b81..a9adb1d4 100644 --- a/MobileGL/MG_Backend/DirectGLES/Managers.cpp +++ b/MobileGL/MG_Backend/DirectGLES/Managers.cpp @@ -16,6 +16,12 @@ #include #include #endif +#if MOBILEGL_BUILD_DISAGGREGATED +// R-11's server-owned staging copy. Header-only and package v1's; see its own header block for +// why GLESBufferResource does not simply gain a member. +#include +#endif + #include "Utils.h" #include "DirectGLES.h" #include "BackendObject_DirectGLES.h" @@ -1008,6 +1014,77 @@ namespace MobileGL::MG_Backend::DirectGLES { return StorageMatchesSize(resource, bufferObject.GetSize()); } +#if MOBILEGL_BUILD_DISAGGREGATED + // ------------------------------------------------------------------------------- + // R-11 - THE SERVER'S OWN COPY OF THE STAGED BYTES. Package v1. + // + // The rule, the evidence and the reason the storage is a side table rather than a + // member of GLESBufferResource are all in MG_Remote/Server/StagedShadow.h. This is + // only the instance and the four call sites. + // + // ONE PER PROCESS, and its copying arm is decided ONCE at first use: the two arms + // hold the authoritative bytes in DIFFERENT places, so an answer that changed + // mid-run would strand every resource already staged - the same reason + // ResolveResourceSubsystemArm latches (Managers.h). Leaked at exit like every other + // MG_Remote singleton (ID-8): ~BufferObject reaches the destroy path from exit + // handlers, after this TU's globals would already be gone. + // ------------------------------------------------------------------------------- + MG_Remote::Server::StagedShadowStore& ServerStaged() { + static MG_Remote::Server::StagedShadowStore& store = + *new MG_Remote::Server::StagedShadowStore( + MG_Config::Transport != MG_Config::TransportMode::Monolith); + return store; + } + + // THE COVERAGE RULE FOR THE THREE-TIER FLUSH DRAIN, ASSERTED BEFORE THE CALL AND NOT + // INSIDE IT. FlushPendingRangesFrom is a G5-pinned body (p3a_untouched_regions.sh's + // PINNED_FUNCTIONS, ID-41): the split arm CALLS it, it does not re-spell it, and it + // does not add a line to it either. Its tier-1 arm - the range-invalidating map - + // copies with its own Memcpy and never reaches UploadRangeFrom, so the one tier that + // DECLARES THE OLD BYTES DEAD is the one tier no later check can see; the only place + // left to say "every queued range is inside the staged coverage" is the instant before + // the drain takes the queue. The clamp is the drain's own (limit = the smaller of the + // frontend size and the backend store; bytes past either end have nowhere to land and + // are not this rule's subject), so the two agree about which bytes are meant. + // + // Fires only for a base that IS this resource's server shadow: RequireCoverage + // answers nothing for the legacy arm's MappedData(), which is valid for the whole + // store. `pendingMutex` is taken here and released before the drain takes it again. + void RequireStagedCoverageForPendingRanges(GLESBufferResource& resource, const Uint8* hostBase, + SizeT frontendSize, const char* site) { + if (hostBase == nullptr) return; + const SizeT limit = std::min(frontendSize, resource.storageSize); + const std::lock_guard lock(resource.pendingMutex); + for (const auto& range : resource.pendingRanges) { + const SizeT end = std::min(range.end, limit); + const SizeT start = std::min(range.start, end); + if (start == end) continue; + ServerStaged().RequireCoverage(&resource, hostBase, start, end, site); + } + } +#endif // MOBILEGL_BUILD_DISAGGREGATED + +// The four hostBytes sites read the same in both builds. The non-split expansion is the +// ORIGINAL EXPRESSION, character for character - `raw - offset` - so a push or verify build +// compiles exactly what it compiled before R-11 and the split arm is the only new behaviour. +#if MOBILEGL_BUILD_DISAGGREGATED +#define MGL_SERVER_STAGED_ADOPT(res, width, bytes, offset, size) \ + ServerStaged().Adopt(&(res), (width), (bytes), (offset), (size)) +#define MGL_SERVER_STAGED_DROP(res) ServerStaged().Drop(&(res)) +#define MGL_SERVER_STAGED_DROP_ALL() ServerStaged().DropAll() +#define MGL_SERVER_STAGED_REQUIRE(res, base, start, end, site) \ + ServerStaged().RequireCoverage(&(res), (base), (start), (end), (site)) +#define MGL_SERVER_STAGED_REQUIRE_PENDING(res, base, frontendSize, site) \ + RequireStagedCoverageForPendingRanges((res), (base), (frontendSize), (site)) +#else +#define MGL_SERVER_STAGED_ADOPT(res, width, bytes, offset, size) \ + (static_cast(bytes) - (offset)) +#define MGL_SERVER_STAGED_DROP(res) ((void)0) +#define MGL_SERVER_STAGED_DROP_ALL() ((void)0) +#define MGL_SERVER_STAGED_REQUIRE(res, base, start, end, site) ((void)0) +#define MGL_SERVER_STAGED_REQUIRE_PENDING(res, base, frontendSize, site) ((void)0) +#endif + // The host bytes a range upload/flush reads. On the legacy arm the frontend object's // shadow; on the handle arm the base the last content-carrying call handed over. void UploadRangeFrom(GLESBufferResource& resource, const Uint8* hostBase, SizeT start, SizeT end) { @@ -1015,6 +1092,9 @@ namespace MobileGL::MG_Backend::DirectGLES { ZoneScopedC(TRACY_ZONECOLOR_BACKEND); #endif if (start >= end || hostBase == nullptr) return; +#if MOBILEGL_BUILD_DISAGGREGATED + MGL_SERVER_STAGED_REQUIRE(resource, hostBase, start, end, "upload_range"); +#endif BindBufferId(TempBufferTarget, resource.id); g_GLESFuncs.glBufferSubData(TempBufferTarget, (GLintptr)start, (GLsizeiptr)(end - start), hostBase + start); @@ -1946,9 +2026,21 @@ namespace MobileGL::MG_Backend::DirectGLES { // every reader below treats a null base as "no bytes to move", which is the // honest answer, and the ensure path re-reads the live base from the // frontend object it still holds. - resource->hostBytes = (desc.HasDefinedContent != 0 && initialBytes != nullptr) - ? static_cast(initialBytes) - : nullptr; + if (desc.HasDefinedContent != 0 && initialBytes != nullptr) { + // R-11: in monolith this is the client's shadow base, unchanged; under + // split it is copied into server-owned storage first. A respecify's + // companion pointer is the base at offset 0 and covers the whole store. + // Under split it is ALWAYS null (contract table 1 row 19 - + // initialBytes does not cross, and the content arrives as + // resource_subdata records right behind this record), so in practice + // this arm is the monolith one and the else arm is the split one. + resource->hostBytes = MGL_SERVER_STAGED_ADOPT(*resource, static_cast(desc.Width), + initialBytes, 0, + static_cast(desc.Width)); + } else { + MGL_SERVER_STAGED_DROP(*resource); + resource->hostBytes = nullptr; + } } if (!resource) return; // lazy: the ensure path full-uploads on creation if (resource->immutableStorage) { @@ -2018,7 +2110,12 @@ namespace MobileGL::MG_Backend::DirectGLES { // and the ensure path's republication (the 3c55e027 fix) is what runs at a draw. if (bytes != nullptr) { const std::lock_guard lock(resource->pendingMutex); - resource->hostBytes = static_cast(bytes) - offset; + // R-11: MONOLITH keeps the client's base; SPLIT copies [offset, offset+size) + // into server-owned storage and points hostBytes at that. Inside the SAME + // lock as the queued range, because the base and the range are one fact + // ("these bytes, at this base") and the drain takes this mutex to lift them. + resource->hostBytes = + MGL_SERVER_STAGED_ADOPT(*resource, ResourceWidthOf(res), bytes, offset, size); } if (resource->pendingRespecify) return; // full re-upload pending anyway if (!CanTouchGLNow() || resource->id == 0 || @@ -2067,10 +2164,18 @@ namespace MobileGL::MG_Backend::DirectGLES { if (!resource) return; const SizeT start = static_cast(record.Offset); const SizeT end = start + static_cast(record.Size); - // M-2, the second store site - same lock, same reason as Ops_H_SubData's. + // M-2, the second store site - same lock, same reason as Ops_H_SubData's, and + // the same R-11 copy. UNDER SPLIT `bytes` IS ALWAYS NULL HERE by ruling (C-6 / + // contract table 1 row 20: resource_flush_range carries no bytes at all - the + // ladder it drives rewrites its range from the authoritative shadow, which rule + // C makes server-owned, so resource_subdata is already the only way bytes reach + // it and a second carrier would be a forgeable way to say the same thing). So + // this arm keeps whatever the preceding subdata records staged, which is + // exactly what the ladder must read. if (bytes != nullptr) { const std::lock_guard lock(resource->pendingMutex); - resource->hostBytes = static_cast(bytes) - start; + resource->hostBytes = MGL_SERVER_STAGED_ADOPT(*resource, ResourceWidthOf(res), bytes, + start, end - start); } if (resource->pendingRespecify) return; if (!CanTouchGLNow() || resource->id == 0 || @@ -2100,6 +2205,14 @@ namespace MobileGL::MG_Backend::DirectGLES { resource->hostBytes != nullptr) { #ifdef TRACY_ENABLE ZoneScopedC(TRACY_ZONECOLOR_BACKEND); +#endif +#if MOBILEGL_BUILD_DISAGGREGATED + // The kill-switch arm's own Memcpy, and the one that passes + // GL_MAP_INVALIDATE_RANGE_BIT - i.e. the one that tells the driver the old + // bytes are dead. Bytes outside the staged coverage are exactly the ones + // that claim is false for. + MGL_SERVER_STAGED_REQUIRE(*resource, resource->hostBytes, start, end, + "flush_range_invalidate_map"); #endif BindBufferId(TempBufferTarget, resource->id); void* mappedData = g_GLESFuncs.glMapBufferRange( @@ -2145,7 +2258,12 @@ namespace MobileGL::MG_Backend::DirectGLES { if (size == 0) return; // Queued app writes must land in the backend store before it is read back, or - // the writeback below would revert them in the shadow. + // the writeback below would revert them in the shadow. Under split every queued + // range must lie inside the server shadow's staged coverage first (R-11 / the + // M-6 ruling): the drain's tier-1 map would otherwise declare live GPU bytes + // dead over a range nothing staged. + MGL_SERVER_STAGED_REQUIRE_PENDING(*resource, resource->hostBytes, ResourceWidthOf(res), + "readback_flush_pending"); FlushPendingRangesFrom(*resource, resource->hostBytes, ResourceWidthOf(res)); BindBufferId(TempBufferTarget, resource->id); @@ -2187,6 +2305,12 @@ namespace MobileGL::MG_Backend::DirectGLES { } void Ops_H_Destroy(MG_Pipe::MGPipeHandle res) { + // R-11's server copy dies with the resource, and BEFORE the twin leaves the + // table - the side map is keyed by the twin's address, so this is the last + // moment that address can be looked up. + if (GLESBufferResource* dying = FindBufferResourceForHandle(res); dying != nullptr) { + MGL_SERVER_STAGED_DROP(*dying); + } // The twin comes OUT of the table first, so the three outcomes below are // reached with the entry already retired. The SLOT is the client's to free, // after this returns (D-L). @@ -2272,6 +2396,9 @@ namespace MobileGL::MG_Backend::DirectGLES { // PipeResource::AdoptPersistentMap then does m_shadow->clear() + // shrink_to_fit(), so the shadow base any earlier content-carrying call // recorded is a FREED allocation from here on. The live bytes are persistentPtr. + // R-11's server copy goes with it: the bytes are the coherent map's now, and a + // stale server shadow would answer a later drain with pre-map content. + MGL_SERVER_STAGED_DROP(*resource); resource->hostBytes = nullptr; resource->storageSize = static_cast(size); resource->storageInitialized = true; @@ -2631,6 +2758,13 @@ namespace MobileGL::MG_Backend::DirectGLES { // handles (no GL) and let the next draw / texture upload recreate them. ResetRingForNewContext(g_uboRing); ResetRingForNewContext(g_unpackRing); +#if MOBILEGL_PIPE_PUSH + // R-11's server copies die with the context for the same reason the rings' ids do: + // the resource twins they are keyed by are about to be rebuilt against a new + // generation, and a shadow that outlived its twin would be looked up by a RECYCLED + // address on the next allocation - which is the quietest possible wrong answer. + MGL_SERVER_STAGED_DROP_ALL(); +#endif } void ProcessDeferredBufferReleases() { @@ -2920,6 +3054,9 @@ namespace MobileGL::MG_Backend::DirectGLES { if (resource->pendingRespecify || !resource->storageInitialized || resource->storageSize != size) { RespecifyStorageWith(*resource, size, usage, initialData, serial); } else if (!resource->pendingRanges.empty()) { + // Same rule as Ops_H_Readback's, at the draw-time drain: with no frontend object + // `hostBase` is the server shadow and every queued range must be staged. + MGL_SERVER_STAGED_REQUIRE_PENDING(*resource, hostBase, size, "ensure_flush_pending"); FlushPendingRangesFrom(*resource, hostBase, size); resource->syncedChangeSerial = serial; } else if (resource->syncedChangeSerial != serial) { diff --git a/MobileGL/MG_Remote/Server/StagedShadow.h b/MobileGL/MG_Remote/Server/StagedShadow.h new file mode 100644 index 00000000..77e63b75 --- /dev/null +++ b/MobileGL/MG_Remote/Server/StagedShadow.h @@ -0,0 +1,182 @@ +// MobileGL - MobileGL/MG_Remote/Server/StagedShadow.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 + +// R-11 - THE SERVER'S OWN COPY OF THE STAGED BYTES. Owner: package v1. +// +// GLESBufferResource::hostBytes (Managers.h:839) is the tree's ONE violation of rule C ("an +// applier entry point may not hold a pointer past its return"): Ops_H_SubData (Managers.cpp: +// 1980-1983) and Ops_H_FlushRange (:2035) record the client's shadow base and six later drains +// read it (:2000, :2062, :2080, :2111, :2741, :2843). In monolith that is correct - the bytes +// belong to a frontend object that outlives the call. Under split the pointer names SEG_STAGE, +// which is valid only until retiredSeq passes the record that named it, and w1's +// MOBILEGL_IPC_AUDIT=1 fills retired staging bytes with 0xDD precisely so an implementation +// that kept the pointer is DISTINGUISHABLE from one that copied. So this copies. +// +// THE SNAPSHOT EXTENT IS EXACTLY WHAT THE RECORD DECLARED, NEVER WIDENED (the integrator's +// ruling on b1's open M-6 half). Widening looked free once before and was not: a page-aligned +// INVALIDATE_RANGE clobbered GPU-written data - an SSBO counter beside the app's SubData - with +// stale shadow bytes (Managers.cpp:1126-1129). Tier 1's INVALIDATE_RANGE is an ASSERTION that +// the old bytes are dead, so declaring bytes covered that nothing staged is not a missing +// optimisation, it is a silent data loss. The coverage set below is therefore exact, and a +// drain that reaches outside it is Fatal rather than a re-read of whatever happens to be there. +// +// WHY IT IS A HEADER AND NOT A BLOCK INSIDE Managers.cpp. Two reasons, and the second is the +// one that matters. Managers.h is package b1's, so v1's R-11 edit may not add a member to +// GLESBufferResource and the storage has to live beside it rather than in it. And a block +// inside Managers.cpp could only ever be exercised by a test that also has a GL context, a +// resource twin and a live session - which is exactly how a rule ends up with no check that +// can fail for its own reason (R-16). Here, CopiesIntoServerStorage is a parameter rather than +// a read of MG_Config::Transport, so a unit case builds one store of each kind and asserts the +// DIFFERENCE between them. + +#pragma once +#include + +#include +#include + +#include +#include +#include +#include + +namespace MobileGL::MG_Remote::Server { + + // Keyed by the resource twin's ADDRESS, which is stable: the twins are heap-allocated and + // held by SharedPtr in the backend slot table, and every event that ends a base's life - + // an orphaning respecify, a successful map_persistent, destroy, context death - already has + // a call site in Managers.cpp to drop it from. + class StagedShadowStore { + public: + // `copies` is "this process is really split". False reproduces the monolith expression + // character for character (`raw - offset`), which is what keeps every push and verify + // lane byte-identical to what it was before R-11. + explicit StagedShadowStore(Bool copies) : m_copies(copies) {} + + Bool CopiesIntoServerStorage() const { return m_copies; } + + // Copies [offset, offset+size) of the record's staged bytes into server-owned storage + // and returns the SERVER base (offset 0 of the resource). Under monolith it returns the + // client base unchanged and allocates nothing. + const Uint8* Adopt(const void* key, SizeT width, const void* bytes, SizeT offset, SizeT size) { + const auto* raw = static_cast(bytes); + if (!m_copies) return raw - offset; + const std::lock_guard lock(m_mutex); + Shadow& shadow = m_shadows[key]; + const SizeT needed = std::max(width, offset + size); + if (shadow.Bytes.size() < needed) shadow.Bytes.resize(needed, 0); + if (size != 0 && raw != nullptr) { + std::memcpy(shadow.Bytes.data() + offset, raw, size); + CoverageAdd(shadow.Covered, offset, offset + size); + } + m_any.store(true, std::memory_order_release); + // Growing REALLOCATES, so every caller assigns the returned base to hostBytes on + // the same call. No other resource's base moves: each Shadow owns its own vector, + // and a rehash of the map MOVES that vector, which preserves its data pointer. + return shadow.Bytes.data(); + } + + void Drop(const void* key) { + if (!m_any.load(std::memory_order_acquire)) return; + const std::lock_guard lock(m_mutex); + m_shadows.erase(key); + } + + void DropAll() { + if (!m_any.load(std::memory_order_acquire)) return; + const std::lock_guard lock(m_mutex); + m_shadows.clear(); + } + + // Fatal when a drain reaches bytes no record staged. It fires ONLY for a base that is + // this key's server shadow: the legacy arm passes the frontend object's own + // MappedData(), which is valid for the whole store and is not this rule's subject. + void RequireCoverage(const void* key, const Uint8* hostBase, SizeT start, SizeT end, + const char* site) const { + if (!m_any.load(std::memory_order_acquire) || hostBase == nullptr) return; + const std::lock_guard lock(m_mutex); + const auto it = m_shadows.find(key); + if (it == m_shadows.end() || it->second.Bytes.data() != hostBase) return; + if (CoverageHas(it->second.Covered, start, end)) return; + MGLOG_F("MGPipe: Fatal{StageSnapshotTooNarrow, \"%s\"} - the server's ladder wants " + "[%zu, %zu) of a buffer whose staged coverage does not include it. Under " + "split the authoritative shadow is SERVER-OWNED (rule C) and " + "resource_subdata is the only way bytes reach it, so bytes outside a staged " + "range have never existed on this side. Re-reading them would move zeroes " + "into the store, and an INVALIDATE_RANGE over them would declare live " + "GPU-written bytes dead (Managers.cpp:1126-1129). This is a missing record, " + "not a missing widening", + site, start, end); + std::abort(); + } + + // Diagnostics the unit cases read, so that a check can assert WHAT HAPPENED rather than + // that nothing blew up. + Bool IsCovered(const void* key, SizeT start, SizeT end) const { + const std::lock_guard lock(m_mutex); + const auto it = m_shadows.find(key); + if (it == m_shadows.end()) return false; + return CoverageHas(it->second.Covered, start, end); + } + SizeT CoveredRunCount(const void* key) const { + const std::lock_guard lock(m_mutex); + const auto it = m_shadows.find(key); + return it == m_shadows.end() ? 0 : it->second.Covered.size(); + } + SizeT TrackedResources() const { + const std::lock_guard lock(m_mutex); + return m_shadows.size(); + } + + // Adjacent ranges merge - there is no gap between them, so the union really is one run. + // Ranges with a gap do NOT merge, and that is the whole mechanism: it is what makes a + // missing record detectable instead of papered over. + static void CoverageAdd(Vector& covered, SizeT start, SizeT end) { + if (start >= end) return; + Vector merged; + merged.reserve(covered.size() + 1); + SizeT s = start; + SizeT e = end; + for (const auto& range : covered) { + if (range.end < s || range.start > e) { + merged.push_back(range); + continue; + } + s = std::min(s, range.start); + e = std::max(e, range.end); + } + merged.push_back({s, e}); + std::sort(merged.begin(), merged.end(), + [](const Range1D& a, const Range1D& b) { return a.start < b.start; }); + covered = std::move(merged); + } + + static Bool CoverageHas(const Vector& covered, SizeT start, SizeT end) { + if (start >= end) return true; + for (const auto& range : covered) { + if (range.start <= start && end <= range.end) return true; + } + return false; + } + + private: + struct Shadow { + Vector Bytes; + // Sorted, disjoint, EXACT. + Vector Covered; + }; + + const Bool m_copies; + mutable std::mutex m_mutex; + ska::flat_hash_map m_shadows; + // Read on every UploadRangeFrom, so the monolith cost is one acquire load of a + // never-written flag rather than a mutex and a hash lookup. + std::atomic m_any{false}; + }; + +} // namespace MobileGL::MG_Remote::Server From 8dbe230ae3d152e89126533d3a4c20cf87ab9fdd Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Wed, 16 Sep 2026 05:45:15 -0400 Subject: [PATCH 2/6] [Feat] (MG_Remote/Server): PipeApplier - attach the decoder on the apply thread, stamp/apply/clear per record, and ServerVerbSink for the five class-B verbs --- MobileGL/MG_Remote/Server/PipeApplier.cpp | 336 ++++++++++++++++++++-- MobileGL/MG_Remote/Server/PipeApplier.h | 122 +++++++- 2 files changed, 435 insertions(+), 23 deletions(-) diff --git a/MobileGL/MG_Remote/Server/PipeApplier.cpp b/MobileGL/MG_Remote/Server/PipeApplier.cpp index 2961477e..2afe9416 100644 --- a/MobileGL/MG_Remote/Server/PipeApplier.cpp +++ b/MobileGL/MG_Remote/Server/PipeApplier.cpp @@ -6,31 +6,26 @@ // SPDX-License-Identifier: LGPL-3.0-only // End of Source File Header -// P5 c0 stubs for package v1 (with p1 for the stamp rule). +// P5 package v1: the applier bridge, and the consumer for contract 7's five class-B verbs. #include "PipeApplier.h" #include "../Transport/ReplySlot.h" +#include +#include #include #include +#include namespace MobileGL::MG_Remote::Server { -#define MGP5_C0_STUB(what) \ - do { \ - MGLOG_F("MGPipe: Fatal{UnimplementedPipeApplier, \"%s\"} - P5 package v1 has not landed " \ - "this yet; c0 shipped the signature only", \ - what); \ - std::abort(); \ - } while (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, + // 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 @@ -47,21 +42,324 @@ namespace MobileGL::MG_Remote::Server { Uint32 ReplyPool::SlotBytes() const { return m_slotBytes; } + // ----------------------------------------------------------------------------------- + // ServerVerbSink - the five class-B verbs + // ----------------------------------------------------------------------------------- + + void ServerVerbSink::SetBackend(MG_Backend::BackendObject* backend) { m_backend = backend; } + + const MG_Backend::GlobalBackendFunctionsTable* ServerVerbSink::Table(const char* verb) const { + if (m_backend == nullptr) { + // DECLINE BY NAME, DO NOT DEREFERENCE. A verb that arrives before + // ServerLoop::CreateBackend has run means the hook order changed under us, and the + // honest answer is "this build did not apply it" - which DecodeAndApply reports as + // false and the lane sees as a record that did not render, rather than as a crash + // with no line saying which verb was first. + MGLOG_E_ONCE("MG_Remote server: %s arrived with no backend object; the verb is " + "DECLINED. ServerLoop::CreateBackend runs from MG_Backend::Init()'s " + "hook, before ClientSession::Start", + verb); + return nullptr; + } + return &m_backend->GetBackendFunctions(); + } + + Bool ServerVerbSink::OnClear(const MG_Pipe::MGPClear& clear) { + const MG_Backend::GlobalBackendFunctionsTable* table = Table("clear"); + if (table == nullptr) return false; + const MG_Backend::GLFunctionsTable& gl = table->GL; + + // THE FBO HANDLE IS NOT RESOLVED HERE, AND THAT IS THE RULING RATHER THAN AN OMISSION. + // MGPClear::Fbo names the framebuffer the clear belongs to, but the BINDING is already + // server state: set_framebuffer_state (op 33) arrives ahead of the clear and the + // applier has bound it. Re-resolving the handle to a frontend FramebufferObject here + // would need the SharedPtr the four ClearNamedFramebuffer* entries take - a frontend + // heap reference that table 2 lists as one of the six fields with no wire carrier. So + // P5 clears THE BOUND FRAMEBUFFER, which for the reduced path (default FBO) is exactly + // right, and the named form is P7's along with the handle it needs. + switch (clear.Kind) { + case kMGPClearKindWhole: + if (gl.Clear == nullptr) return false; + gl.Clear(static_cast(clear.BufferMask)); + break; + case kMGPClearKindColor: + switch (clear.ValueClass) { + case kMGPClearValueClassFloat: + if (gl.ClearBufferfv == nullptr) return false; + gl.ClearBufferfv(GL_COLOR, clear.DrawBufferIndex, + reinterpret_cast(clear.ColorValue)); + break; + case kMGPClearValueClassInt: + if (gl.ClearBufferiv == nullptr) return false; + gl.ClearBufferiv(GL_COLOR, clear.DrawBufferIndex, + reinterpret_cast(clear.ColorValue)); + break; + case kMGPClearValueClassUint: + if (gl.ClearBufferuiv == nullptr) return false; + gl.ClearBufferuiv(GL_COLOR, clear.DrawBufferIndex, + reinterpret_cast(clear.ColorValue)); + break; + default: + // A value class outside the three is a wire fault, not a fallback: all three + // representations of a clear colour are numerically populated by the frontend + // and only this field says which one the backend must use, so guessing renders + // a plausible wrong colour. + Wire::WireProtocolFatalAt("MGPClear::ValueClass", clear.ValueClass, 3); + } + break; + case kMGPClearKindDepth: + if (gl.ClearBufferfv == nullptr) return false; + gl.ClearBufferfv(GL_DEPTH, 0, &clear.DepthValue); + break; + case kMGPClearKindStencil: + if (gl.ClearBufferiv == nullptr) return false; + gl.ClearBufferiv(GL_STENCIL, 0, &clear.StencilValue); + break; + case kMGPClearKindDepthStencil: + if (gl.ClearBufferfi == nullptr) return false; + gl.ClearBufferfi(GL_DEPTH_STENCIL, 0, clear.DepthValue, clear.StencilValue); + break; + default: + Wire::WireProtocolFatalAt("MGPClear::Kind", clear.Kind, kMGPClearKindDepthStencil + 1); + } + ++m_clears; + return true; + } + + Bool ServerVerbSink::OnBlit(const MG_Pipe::MGPBlit& blit) { + const MG_Backend::GlobalBackendFunctionsTable* table = Table("blit"); + if (table == nullptr) return false; + if (table->GL.BlitFramebuffer == nullptr) return false; + // Same ruling as OnClear's: the read and draw framebuffers are already bound by the + // set_framebuffer_state records that preceded this one, so the unnamed entry point is + // the one that matches what the server's state actually is. BlitNamedFramebuffer needs + // two frontend SharedPtrs, which table 2 lists as uncarried. + table->GL.BlitFramebuffer(blit.SrcX0, blit.SrcY0, blit.SrcX1, blit.SrcY1, blit.DstX0, + blit.DstY0, blit.DstX1, blit.DstY1, + static_cast(blit.Mask), + static_cast(blit.Filter)); + ++m_blits; + return true; + } + + 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 insists present <-> eglSwapBuffers stays strictly + // 1:1. It is NOT eglSwapBuffers itself: the swap is the client's EGL call and crosses + // as the SwapEGLBuffers control request, which runs Present on this thread through this + // same table. Both paths therefore end here and the 1:1 is structural. + table->Present(); + ++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. + m_lastPresentSerial = present.FrameSerial != 0 ? present.FrameSerial : m_presents; + return true; + } + + Bool ServerVerbSink::OnReadPixels(const MG_Pipe::MGPReadbackInfo& info, Uint64 seq, + Wire::ReplySink* replies) { + if (replies == nullptr) { + // The decoder always passes its ReplySink; a null one means the applier was built + // without a reply pool, and answering nothing would leave the client's barrier + // waiting for a slot that never gets stamped - a hang, not a wrong picture. + MGLOG_F("MGPipe: Fatal{ProtocolCorruption, \"read_pixels without a reply sink\"} - " + "the pixels' only destination in P5 is SEG_REPLY (contract table 1 row 23) " + "and a client blocked on seq %llu would never be answered", + static_cast(seq)); + std::abort(); + } + const MG_Backend::GlobalBackendFunctionsTable* table = Table("read_pixels"); + if (table == nullptr || table->GL.ReadPixels == nullptr) { + // DECLINED IS A REAL ANSWER (table 0's slot-header row) and it is the RIGHT one + // here: the client is parked on this seq inside the verb barrier, so returning + // false without posting would convert "not implemented" into "never returns". + replies->PostReply(seq, Wire::ReplySink::kStatusDeclined, nullptr, 0); + return false; + } + if (info.DstSize == 0) { + replies->PostReply(seq, Wire::ReplySink::kStatusError, nullptr, 0); + return false; + } + // THE CLIENT DECLARES THE BYTE COUNT AND THE SERVER DOES NOT RECOMPUTE IT. DstSize is + // sized on the client from the same GL_PACK_* state the frontend owns, and it is what + // the client will read back out of the slot; a server that recomputed from Box x + // Format x Type would be a SECOND opinion about a pack alignment the client half owns, + // and the two disagreeing is a short read with plausible pixels in it. + if (info.DstSize > m_readbackScratch.size()) { + m_readbackScratch.resize(static_cast(info.DstSize)); + } + table->GL.ReadPixels(info.Box.X, info.Box.Y, static_cast(info.Box.W), + static_cast(info.Box.H), static_cast(info.Format), + static_cast(info.Type), m_readbackScratch.data()); + replies->PostReply(seq, Wire::ReplySink::kStatusOk, m_readbackScratch.data(), info.DstSize); + m_readbackBytes += info.DstSize; + ++m_readbacks; + return true; + } + + Bool ServerVerbSink::OnDrawVbo(const MG_Pipe::MGPDrawInfo& info, + const MG_Pipe::MGPDrawRange* ranges, + const MG_Pipe::MGHostSpan* userIndices) { + const MG_Backend::GlobalBackendFunctionsTable* table = Table("draw_vbo"); + if (table == nullptr) return false; + if (userIndices != nullptr) { + // kCapNeedsHostIndexBytes is 0 for the whole of P5 by ruling (table 0's cap-bit + // row) precisely so this tail never appears; a span that arrived anyway means the + // client's cap gate did not hold, and filling one is P8's. + MGLOG_E_ONCE("MG_Remote server: draw_vbo carries an MGHostSpan of user indices. P5 " + "rules kCapNeedsHostIndexBytes and kCapNeedsHostUboBytes to 0 so that " + "no host span reaches the first IPC frame (contract table 0); filling " + "one under split is P8's. The draw is DECLINED rather than drawn from " + "a pointer that does not belong to this process"); + return false; + } + if (ranges == nullptr || info.NumDraws == 0) return false; + + // P5 IMPLEMENTS THE TWO SHAPES ITS REDUCED PATH USES AND DECLINES THE REST BY NAME. + // draw_vbo collapses all twenty draw entry points, and picking the right one needs the + // instancing / base-vertex / base-instance / multi-draw cross product. TriangleScenario + // is a single non-instanced array draw and OpenRA's are single indexed draws from a + // bound element buffer; the rest are P8's, together with the MGPDrawIndirect record + // that has no producer yet. + const MG_Backend::GLFunctionsTable& gl = table->GL; + const Bool instanced = info.InstanceCount > 1 || info.StartInstance != 0; + if (info.NumDraws != 1 || instanced) { + MGLOG_E_ONCE("MG_Remote server: draw_vbo with NumDraws=%u InstanceCount=%u " + "StartInstance=%u is DECLINED - P5's reduced path is the single " + "non-instanced draw (BRIEF 4); the multi-draw and instanced arms are " + "P8's", + info.NumDraws, info.InstanceCount, info.StartInstance); + return false; + } + const MG_Pipe::MGPDrawRange& range = ranges[0]; + if (info.IndexSize == 0) { + if (gl.DrawArrays == nullptr) return false; + gl.DrawArrays(static_cast(info.Mode), static_cast(range.Start), + static_cast(range.Count)); + } else { + if (gl.DrawElementsBaseVertex == nullptr) return false; + GLenum indexType = GL_UNSIGNED_INT; + switch (info.IndexSize) { + case 1: indexType = GL_UNSIGNED_BYTE; break; + case 2: indexType = GL_UNSIGNED_SHORT; break; + case 4: indexType = GL_UNSIGNED_INT; break; + default: + // IndexSize is "0 = arrays, else 1 / 2 / 4" (MGPipeTypes.h:1323) and nothing + // else is a legal width; defaulting to 4 would read past the element buffer. + Wire::WireProtocolFatalAt("MGPDrawInfo::IndexSize", info.IndexSize, 4); + } + // Start is the FIRST INDEX, so the byte offset into the bound element buffer is + // Start * IndexSize - the same arithmetic PipeFill's emitter inverted. + const auto offset = static_cast(range.Start) * info.IndexSize; + gl.DrawElementsBaseVertex(static_cast(info.Mode), + static_cast(range.Count), indexType, + reinterpret_cast(offset), range.IndexBias); + } + ++m_draws; + return true; + } + + // ----------------------------------------------------------------------------------- + // PipeApplier + // ----------------------------------------------------------------------------------- + PipeApplier::PipeApplier(Wire::SegmentTable* segments, ReplyPool* replies) : m_segments(segments), m_replies(replies) {} - Bool PipeApplier::ApplyOne(const Transport::RingRecordView&) { MGP5_C0_STUB("PipeApplier::ApplyOne"); } - - void PipeApplier::StampVerbBoundary(MG_Pipe::MGPWireOp) { - MGP5_C0_STUB("PipeApplier::StampVerbBoundary"); + void PipeApplier::Attach(Transport::RingControl* control, MG_Backend::BackendObject* backend) { + if (control == nullptr || m_segments == nullptr) { + MGLOG_F("MGPipe: Fatal{ProtocolCorruption, \"PipeApplier::Attach\"} - no control " + "page or no segment table; ServerSession::Accept builds both before the " + "apply thread starts"); + std::abort(); + } + m_verbs.SetBackend(backend); + m_decoder = Wire::PipeWireDecoder(control, m_segments, m_replies); + m_decoder.SetVerbSink(&m_verbs); + m_attached = true; } - Uint64 PipeApplier::ResidualPullCount() const { return m_residualPulls; } - - void PipeApplier::PoisonRetiredStageBytes(Uint64, Uint64) { - MGP5_C0_STUB("PipeApplier::PoisonRetiredStageBytes"); + void PipeApplier::Detach() { + m_decoder = Wire::PipeWireDecoder(); + m_verbs.SetBackend(nullptr); + m_attached = false; } -#undef MGP5_C0_STUB + Bool PipeApplier::Attached() const { return m_attached; } + + Bool PipeApplier::ApplyOne(const Transport::RingRecordView& record) { + if (!m_attached) { + MGLOG_F("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"); + std::abort(); + } + // 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 diff --git a/MobileGL/MG_Remote/Server/PipeApplier.h b/MobileGL/MG_Remote/Server/PipeApplier.h index 48401b3c..c11ca7e5 100644 --- a/MobileGL/MG_Remote/Server/PipeApplier.h +++ b/MobileGL/MG_Remote/Server/PipeApplier.h @@ -40,6 +40,7 @@ #pragma once #include +#include #include #include "../Transport/Ring.h" @@ -69,34 +70,147 @@ namespace MobileGL::MG_Remote::Server { Uint32 m_slotBytes = 0; }; + // ---- MGPClear's two discriminants --------------------------------------------------- + // + // MGPClear (MGPipeTypes.h:1271) names `Kind` "Whole | Color | Depth | Stencil | + // DepthStencil" and `ValueClass` "Float | Int | Uint" IN A COMMENT AND NOWHERE ELSE: the + // catalogue ships no enum for either, and the record has no producer or consumer in the + // tree, so P5 writes both halves and the two halves have to agree on a number. Declaring + // them here rather than open-coding 0..4 on each side is table 0's own rule for exactly + // this shape ("a decoder that open-codes it is the class-1 defect"), applied to a field + // table 0 did not reach. + // + // THE ORDER IS THE COMMENT'S, LEFT TO RIGHT, and ValueClass reuses the numbering + // MG_State/GLState/Core.h:39-41 already gives the identical three-way split on + // MGPAttribValue::ValueClass. c1 encodes against these constants; a disagreement is a + // clear of the wrong attachment with the wrong value type, which renders plausibly. + // FLAGGED FOR THE INTEGRATOR: this belongs in MGPipeTypes.h, which is c0's file. + inline constexpr Uint32 kMGPClearKindWhole = 0; // glClear(mask) + inline constexpr Uint32 kMGPClearKindColor = 1; // glClearBuffer{f,i,ui}v(GL_COLOR, i, v) + inline constexpr Uint32 kMGPClearKindDepth = 2; // glClearBufferfv(GL_DEPTH, 0, &d) + inline constexpr Uint32 kMGPClearKindStencil = 3; // glClearBufferiv(GL_STENCIL, 0, &s) + inline constexpr Uint32 kMGPClearKindDepthStencil = 4; // glClearBufferfi(GL_DEPTH_STENCIL,...) + inline constexpr Uint32 kMGPClearValueClassFloat = 0; + inline constexpr Uint32 kMGPClearValueClassInt = 1; + inline constexpr Uint32 kMGPClearValueClassUint = 2; + + // ---- the five class-B verbs' consumer ------------------------------------------------ + // + // Contract §7 class B is Clear (57), Blit (56), ReadPixels (58), DrawVbo (59) and Present + // (67), and NONE of them has an MGPipeApply* entry point - the 37 that exist are the object + // and state families. So w1's decoder validates and hands over a checked argument list and + // stops, and this is the other half: the SERVER'S OWN BACKEND CALL, through the private + // GlobalBackendFunctionsTable ServerLoop holds. It is not gBackendFunctionsTable, which in + // a split process is the client's emit table (table 3) - calling THAT here would re-emit + // the record the server is in the middle of applying, which is an infinite loop that + // renders nothing and looks like a hang. + class ServerVerbSink final : public Wire::WireVerbSink { + public: + // The server's private backend. Null until ServerLoop::CreateBackend has run, and a + // verb that arrives before then declines by name rather than dereferencing. + void SetBackend(MG_Backend::BackendObject* backend); + + Bool OnClear(const MG_Pipe::MGPClear& clear) override; + Bool OnBlit(const MG_Pipe::MGPBlit& blit) override; + Bool OnPresent(const MG_Pipe::MGPPresent& present) override; + Bool OnReadPixels(const MG_Pipe::MGPReadbackInfo& info, Uint64 seq, + Wire::ReplySink* replies) override; + Bool OnDrawVbo(const MG_Pipe::MGPDrawInfo& info, const MG_Pipe::MGPDrawRange* ranges, + const MG_Pipe::MGHostSpan* userIndices) override; + + // Per-verb tallies. The lane asserts these moved, because "the scenario passed" on a + // split build is also what a scenario that ran entirely on the monolith path looks + // like (R-16: a probe may not arm against a stub). + Uint64 Clears() const { return m_clears; } + Uint64 Draws() const { return m_draws; } + Uint64 Readbacks() const { return m_readbacks; } + Uint64 Blits() const { return m_blits; } + Uint64 Presents() const { return m_presents; } + Uint64 LastPresentSerial() const { return m_lastPresentSerial; } + Uint64 ReadbackBytes() const { return m_readbackBytes; } + + private: + const MG_Backend::GlobalBackendFunctionsTable* Table(const char* verb) const; + + MG_Backend::BackendObject* m_backend = nullptr; + Uint64 m_clears = 0; + Uint64 m_draws = 0; + Uint64 m_readbacks = 0; + Uint64 m_blits = 0; + Uint64 m_presents = 0; + Uint64 m_lastPresentSerial = 0; + Uint64 m_readbackBytes = 0; + // ReadPixels' destination. The pixels go into the reply slot, but GLFunctionsTable:: + // ReadPixels writes into a caller buffer, so one staging vector per session sits + // between them. Grown, never shrunk, and never handed out past the call. + Vector m_readbackScratch; + }; + class PipeApplier { public: PipeApplier() = default; PipeApplier(Wire::SegmentTable* segments, ReplyPool* replies); - // Decode one record, stamp the verb, apply, post the reply if the call has one, then - // advance appliedSeq by exactly one. P5 FORBIDS BATCHING appliedSeq (R-9): the barrier's - // waiter reads it, and a batched watermark promises work that has not run. + // Builds the decoder over the session's control page and points it at this applier's + // verb sink. Separate from the constructor because ServerSession::Accept constructs the + // applier before it has decided anything about the apply thread, and the decoder needs + // the RingControl the constructor was never given. + // + // CALLED ON THE APPLY THREAD, ONCE, BEFORE THE FIRST RECORD. PipeWireDecoder is "not + // thread safe: one decoder on the apply thread, by construction", and its constructor + // installs the process-wide apply hook. + void Attach(Transport::RingControl* control, MG_Backend::BackendObject* backend); + void Detach(); + Bool Attached() const; + + // Decode one record, stamp the verb, apply, post the reply if the call has one. THE + // CALLER advances appliedSeq by exactly one, through s1's SessionConsumer::ApplyOne, + // which is that watermark's single writer; P5 FORBIDS BATCHING it (R-9), because the + // barrier's waiter reads it and a batched watermark promises work that has not run. Bool ApplyOne(const Transport::RingRecordView& record); // p1's rule, v1's call site. Called at the verb boundary, before the record's applier // runs, with the verb the record belongs to. void StampVerbBoundary(MG_Pipe::MGPWireOp op); + // MANDATORY on leaving the applier (p1's M-5, PipeInputs.h's ServerStampedVerb block). + // The client's MGPipeValidateForVerb / MGPipeLeaveVerb also clear it, which is enough + // for inproc and NOT enough for a spawned server, where MG_Impl is not in the process: + // there the flag would latch TRUE for the server's life, every later read anywhere + // would be judged against the last verb's mask, and the sticky forwards would start + // aborting under strict on exactly the case their exemption exists for. + void LeaveApplier(); + // R-7.2's counter, read by the gate. A BARRIER-PULLED field read on the server side // increments PipeStats::CallClass::ResidualPulls (short name `rsp`); its value at the // end of P5 IS the size of the P6/P7/P8 debt and goes into MEASUREMENTS. + // + // IT FORWARDS TO MGPipeResidualPullCount() AND KEEPS NO MEMBER OF ITS OWN. The member + // c0's signature block declared is deleted rather than wired: the counter is + // process-wide in PipeInputs.cpp because the reads that increment it happen inside the + // BACKEND, arbitrarily deep under an MGPipeApply* call, with no PipeApplier in scope. + // A second copy here could only ever be a number that disagreed with the one the exit + // gate reads (p1-v1 5). Uint64 ResidualPullCount() const; // R-11's audit: after a record retires, fill the SEG_STAGE bytes it referenced with // 0xDD. Only under MOBILEGL_IPC_AUDIT=1, because it costs a write of every staged byte. void PoisonRetiredStageBytes(Uint64 offset, Uint64 size); + // How many staged bytes the decoder has poisoned, and how many records it has applied - + // the two numbers the audit lane asserts are non-zero, since an instrumentation that + // cannot be observed to have run is decoration. + Uint64 PoisonedStageBytes() const; + Uint64 DecoderAppliedSeq() const; + ServerVerbSink& Verbs() { return m_verbs; } + const ServerVerbSink& Verbs() const { return m_verbs; } + private: Wire::SegmentTable* m_segments = nullptr; ReplyPool* m_replies = nullptr; Wire::PipeWireDecoder m_decoder; - Uint64 m_residualPulls = 0; + ServerVerbSink m_verbs; + Bool m_attached = false; }; } // namespace MobileGL::MG_Remote::Server From 23c046877529c234750129a8bbece705aeca4dae Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Wed, 16 Sep 2026 05:45:15 -0400 Subject: [PATCH 3/6] [Feat] (MG_Remote/Server): ServerLoop - the mgl-srv-apply thread, its affinity mask, the three-way park predicate, the blocking control mailbox, the bounded Stop, and the twelve EGL forwarders --- MobileGL/MG_Remote/Server/ServerLoop.cpp | 749 ++++++++++++++++++++++- MobileGL/MG_Remote/Server/ServerLoop.h | 125 +++- 2 files changed, 856 insertions(+), 18 deletions(-) diff --git a/MobileGL/MG_Remote/Server/ServerLoop.cpp b/MobileGL/MG_Remote/Server/ServerLoop.cpp index 29ed1af8..ed1d488d 100644 --- a/MobileGL/MG_Remote/Server/ServerLoop.cpp +++ b/MobileGL/MG_Remote/Server/ServerLoop.cpp @@ -6,36 +6,500 @@ // SPDX-License-Identifier: LGPL-3.0-only // End of Source File Header -// P5 c0 stubs for package v1 - the phase's highest-risk package. +// P5 package v1: the apply thread, its affinity, its parking, and the EGL ownership move. #include "ServerLoop.h" +#include +#include #include +#include #include +#include + +#if defined(__linux__) || defined(__ANDROID__) +#include +#include +#endif namespace MobileGL::MG_Remote::Server { -#define MGP5_C0_STUB(what) \ - do { \ - MGLOG_F("MGPipe: Fatal{UnimplementedServerLoop, \"%s\"} - P5 package v1 has not landed " \ - "this yet; c0 shipped the signature only", \ - what); \ - std::abort(); \ - } while (0) + namespace { - MobileGLResult ServerLoop::Start(ServerSession&) { MGP5_C0_STUB("ServerLoop::Start"); } + // The bounded join. InProcessTransportTest.cpp:344 uses five seconds for the same + // reason: a lost wakeup must be a RED TEST and not a hung CI job. + constexpr Uint32 kJoinTimeoutMs = 5000; - void ServerLoop::Stop() { MGP5_C0_STUB("ServerLoop::Stop"); } + // A core counts as "big" if its cpufreq ceiling is within 15% of the fastest core's - + // the identical rule and the identical constant as ShaderCompilePool.cpp:28 and :73-95. + // The probe is re-derived here rather than exported from there because the two want + // DIFFERENT ANSWERS from the same data: the pool wants a COUNT (how many workers), and + // an affinity wants a MASK (which cpus). DetectBigCoreCount() cannot answer the second, + // so exporting it would have meant either a second function in another package's file + // or a mask reconstructed from a count, which is wrong on any asymmetric topology whose + // big cores are not cpu0..cpuN-1. + constexpr Uint64 kBigCoreFrequencyPercent = 85; - // Not a stub: teardown asks this to decide whether to Kill and join at all, and a teardown - // helper that aborts when the thread was never started is a hang in the shutdown path. - Bool ServerLoop::Running() const { return m_running; } + Uint64 ReadCpuMaxFrequencyKHz(const Uint cpu) { + char path[128]; + std::snprintf(path, sizeof(path), + "/sys/devices/system/cpu/cpu%u/cpufreq/cpuinfo_max_freq", cpu); + std::FILE* file = std::fopen(path, "r"); + if (file == nullptr) return 0; + unsigned long long value = 0; + const int scanned = std::fscanf(file, "%llu", &value); + std::fclose(file); + return scanned == 1 ? static_cast(value) : 0; + } - MG_Backend::BackendObject* ServerLoop::Backend() { MGP5_C0_STUB("ServerLoop::Backend"); } + // The set of cpus whose ceiling is within 15% of the peak, as a bit per cpu. Zero when + // the topology cannot be read - Windows, macOS, a container that hides the cpufreq + // tree, or a partially readable one - because with no asymmetry information the honest + // answer is "do not pin", not "pin to a guess". + Uint64 DetectBigCoreMask() { + const Uint cpuCount = std::min(64u, std::max(1u, std::thread::hardware_concurrency())); + Uint64 frequencies[64] = {}; + for (Uint cpu = 0; cpu < cpuCount; ++cpu) { + frequencies[cpu] = ReadCpuMaxFrequencyKHz(cpu); + if (frequencies[cpu] == 0) return 0; + } + Uint64 peak = 0; + for (Uint cpu = 0; cpu < cpuCount; ++cpu) peak = std::max(peak, frequencies[cpu]); + if (peak == 0) return 0; + const Uint64 threshold = peak * kBigCoreFrequencyPercent / 100; + Uint64 mask = 0; + for (Uint cpu = 0; cpu < cpuCount; ++cpu) { + if (frequencies[cpu] >= threshold) mask |= (1ull << cpu); + } + return mask; + } - MobileGLResult ServerLoop::RunOnApplyThread(ControlWork, void*) { - MGP5_C0_STUB("ServerLoop::RunOnApplyThread"); + // MOBILEGL_IPC_SERVER_AFFINITY = `auto` | `off` | an explicit mask (0x... or decimal). + // The RAW STRING is what Config keeps, because the resolved mask is what gets logged - + // "an affinity that silently did nothing looks exactly like one that worked" + // (CONTRACT-P5 5). + Uint64 RequestedAffinityMask(const char* raw, Bool* outRecognised) { + *outRecognised = true; + if (raw == nullptr || raw[0] == '\0') return DetectBigCoreMask(); + if (std::strcmp(raw, "auto") == 0) return DetectBigCoreMask(); + if (std::strcmp(raw, "off") == 0) return 0; + char* end = nullptr; + const unsigned long long parsed = std::strtoull(raw, &end, 0); + if (end == raw || (end != nullptr && *end != '\0')) { + *outRecognised = false; + return 0; + } + return static_cast(parsed); + } + + void NameThisThread(const char* name) { +#if defined(__linux__) || defined(__ANDROID__) + pthread_setname_np(pthread_self(), name); +#else + (void)name; +#endif + } + + // Returns the mask that was actually APPLIED, which is 0 when nothing was. + Uint64 ApplyAffinity(Uint64 requested) { + if (requested == 0) return 0; +#if defined(__linux__) || defined(__ANDROID__) + cpu_set_t set; + CPU_ZERO(&set); + Uint applied = 0; + for (Uint cpu = 0; cpu < 64; ++cpu) { + if ((requested & (1ull << cpu)) != 0) { + CPU_SET(cpu, &set); + ++applied; + } + } + if (applied == 0) return 0; + if (sched_setaffinity(0, sizeof(set), &set) != 0) return 0; + return requested; +#else + return 0; +#endif + } + + Uint32 SpinUsFromConfig() { +#if MOBILEGL_BUILD_DISAGGREGATED + return MG_Config::Ipc.SpinUs; +#else + return Transport::kDefaultSpinUs; +#endif + } + + const char* AffinityStringFromConfig() { +#if MOBILEGL_BUILD_DISAGGREGATED + return MG_Config::Ipc.ServerAffinity.c_str(); +#else + return "off"; +#endif + } + + } // namespace + + // --------------------------------------------------------------------------------- + // The private backend object + // --------------------------------------------------------------------------------- + + MobileGLResult ServerLoop::CreateBackend(BackendType type) { + if (m_backend != nullptr) { + return MOBILEGL_ERR_INVALID_ARGUMENT; + } + switch (type) { + case BackendType::DirectGLES: + m_backend = MakeUnique(); + break; + case BackendType::DirectVulkan: + m_backend = MakeUnique(); + break; + default: + // NOT a fallback to DirectGLES. An unknown backend under split has to be refused by + // name: the alternative is a lane that says "split, DirectVulkan" and renders with + // the other backend. + MGLOG_E("MG_Remote server: MG_Config::ActiveBackendType is Unknown; the server role " + "has no backend to own a context with and the session is refused"); + return MOBILEGL_ERR_UNSUPPORTED; + } + // Loads the driver entry points and registers the resource op table + // (BackendObject_DirectGLES.cpp:844-851). NO GL CALL AND NO EGL CALL HAPPENS HERE - the + // native context is created and made current later, on the apply thread, when the + // client's eglMakeCurrent crosses as a blocking control request. + m_backend->Initialize(); + return MOBILEGL_OK; + } + + MG_Backend::BackendObject* ServerLoop::Backend() { return m_backend.get(); } + + // --------------------------------------------------------------------------------- + // Start / the loop / Stop + // --------------------------------------------------------------------------------- + + MobileGLResult ServerLoop::Start(ServerSession& session) { + if (m_running.load(std::memory_order_acquire)) { + return MOBILEGL_ERR_INVALID_ARGUMENT; + } + if (!session.Accepted()) { + MGLOG_E("MG_Remote server: ServerLoop::Start before ServerSession::Accept - there " + "are no rings to park on"); + return MOBILEGL_ERR_NOT_INITIALIZED; + } + m_session = &session; + m_stopRequested.store(false, std::memory_order_release); + { + const std::lock_guard lock(m_exitMutex); + m_exited = false; + } + m_running.store(true, std::memory_order_release); + m_thread = std::thread([this] { ApplyThreadMain(); }); + return MOBILEGL_OK; + } + + Bool ServerLoop::Running() const { return m_running.load(std::memory_order_acquire); } + + Bool ServerLoop::OnApplyThread() { + ServerLoop& loop = ServerLoopInstance(); + return loop.m_running.load(std::memory_order_acquire) && + loop.m_applyThreadId.load(std::memory_order_acquire) == std::this_thread::get_id(); + } + + Uint64 ServerLoop::ResolvedAffinityMask() const { return m_affinityMask; } + Uint64 ServerLoop::DrainedRecords() const { return m_drained.load(std::memory_order_acquire); } + Uint64 ServerLoop::ParkCount() const { return m_parks.load(std::memory_order_acquire); } + + void ServerLoop::ApplyThreadMain() { + m_applyThreadId.store(std::this_thread::get_id(), std::memory_order_release); + NameThisThread("mgl-srv-apply"); + + Bool recognised = true; + const char* raw = AffinityStringFromConfig(); + const Uint64 requested = RequestedAffinityMask(raw, &recognised); + m_affinityMask = ApplyAffinity(requested); + if (!recognised) { + MGLOG_E("MG_Remote server: MOBILEGL_IPC_SERVER_AFFINITY='%s' is not `auto`, `off` or " + "a number; NO affinity was applied. This is logged rather than defaulted " + "because an affinity that silently did nothing is indistinguishable from " + "one that worked", + raw == nullptr ? "" : raw); + } + // THE RESOLVED MASK, ALWAYS, INCLUDING 0. Contract 5's whole point: the string is what + // an operator typed and the mask is what the kernel took. + MGLOG_I("MG_Remote server: mgl-srv-apply started. MOBILEGL_IPC_SERVER_AFFINITY='%s' " + "requested mask 0x%llx, RESOLVED mask 0x%llx (0 = no affinity applied), spin " + "%u us", + raw == nullptr ? "" : raw, static_cast(requested), + static_cast(m_affinityMask), SpinUsFromConfig()); + + ServerSession& session = *m_session; + // The decoder is built HERE, on this thread, because PipeWireDecoder is "not thread + // safe: one decoder on the apply thread, by construction" and its constructor installs + // the process-wide apply hook. + session.Applier().Attach(&session.Control(), m_backend.get()); + + Transport::RingControl& control = session.Control(); + Transport::Doorbell& bell = session.ConsumerDoorbell(); + Transport::RingConsumer& ring = session.CommandRing(); + const Uint32 spinUs = SpinUsFromConfig(); + + // THE PARK CONDITION IS THREE THINGS, NOT ONE, AND THAT IS THE WHOLE REASON THIS LOOP + // DOES NOT CALL SessionConsumer::WaitForWork. + // + // WaitForWork's predicate is "a record is waiting" and nothing else. Park a thread on + // kWaitForever with that predicate and neither a control request nor a Stop() can get + // it out: Doorbell::Wait consumes the Notify with one Park, re-tests a condition + // nothing published, finds the bell alive, and parks again - forever. Only + // CondVarDoorbell::Kill() breaks that, and Kill is the CLIENT's teardown call, not + // something an EGL make-current request may perform. So the predicate carries all + // three arming conditions and the doorbell wakes the thread for any of them. + const auto ready = [this, &control, &ring] { + return control.cmdHead.load(std::memory_order_acquire) != ring.LocalTail() || + m_stopRequested.load(std::memory_order_acquire) || ControlIsPending(); + }; + + for (;;) { + PumpControlRequest(); + if (m_stopRequested.load(std::memory_order_acquire)) break; + DrainRing(); + if (m_stopRequested.load(std::memory_order_acquire)) break; + if (ready()) continue; + + m_parks.fetch_add(1, std::memory_order_acq_rel); + const bool woke = bell.Wait(control.consumerParked, ready, spinUs, Transport::kWaitForever); + if (!woke) { + // Wait returns false only on a dead bell or an expired deadline, and this park + // has no deadline. A dead bell IS the shutdown signal (table 3's teardown step + // 2); treating it as anything else would spin at full clock for ever, since + // parking on a dead bell no longer blocks. + if (bell.Dead()) { + MGLOG_D("MG_Remote server: the consumer doorbell is dead; mgl-srv-apply is " + "shutting down"); + break; + } + MGLOG_E("MG_Remote server: Doorbell::Wait(kWaitForever) returned false on a live " + "bell - that is impossible by Doorbell.h:139-178 and means the bell's " + "Dead() answer moved under the waiter. Shutting the loop down rather " + "than spinning"); + break; + } + } + + // Whatever is still queued gets applied before the context goes. The client's own + // drain (ClientSession::Stop step 1) normally empties the ring first and is BOUNDED, so + // a timeout there leaves records here; applying them costs nothing and dropping them + // would leave the emitter's var-tails referenced by records nobody ever read. + PumpControlRequest(); + DrainRing(); + + // THE BACKEND IS DESTROYED ON THIS THREAD, WHILE IT IS STILL THE CONTEXT OWNER. + // ~BackendObject_DirectGLES calls DestroyEGLContext (BackendObject_DirectGLES.cpp: + // 833-835), which runs eglMakeCurrent(NO_SURFACE) / eglDestroyContext / eglTerminate. + // Those must happen on the thread eglMakeCurrent was issued from; doing it on the app + // thread - which is what pActiveBackendObject.reset() at MobileGL/Init.cpp:68 would do + // - destroys a context this thread still holds current. + session.Applier().Detach(); + if (m_backend != nullptr) { + MGLOG_I("MG_Remote server: destroying the server's BackendObject on mgl-srv-apply, " + "which is the context owner"); + m_backend.reset(); + } + + // Anything still parked in RunOnApplyThread has to be answered rather than left + // waiting: this thread is the only thing that could have run it. + { + const std::lock_guard lock(m_controlMutex); + if (m_controlPending) { + m_controlPending = false; + m_controlFinished = true; + m_controlResult = MOBILEGL_ERR_NOT_INITIALIZED; + MGLOG_E("MG_Remote server: a control request was still posted when mgl-srv-apply " + "exited; it is answered NOT_INITIALIZED rather than left blocking"); + } + } + m_controlDone.notify_all(); + + m_running.store(false, std::memory_order_release); + SignalExited(); + } + + Bool ServerLoop::ControlIsPending() const { + const std::lock_guard lock(const_cast(m_controlMutex)); + return m_controlPending; + } + + Bool ServerLoop::PumpControlRequest() { + ControlWork work = nullptr; + void* user = nullptr; + { + const std::lock_guard lock(m_controlMutex); + if (!m_controlPending) return false; + work = m_controlWork; + user = m_controlUser; + } + const MobileGLResult result = work == nullptr ? MOBILEGL_ERR_INVALID_ARGUMENT : work(user); + { + const std::lock_guard lock(m_controlMutex); + m_controlResult = result; + m_controlPending = false; + m_controlFinished = true; + } + m_controlDone.notify_all(); + return true; + } + + Uint64 ServerLoop::DrainRing() { + ServerSession& session = *m_session; + Transport::SessionConsumer& consumer = session.Consumer(); + PipeApplier& applier = session.Applier(); + Uint64 applied = 0; + for (;;) { + bool corrupt = false; + const bool popped = consumer.ApplyOne( + [this, &applier](const Transport::RingRecordView& record) { + applier.ApplyOne(record); + // THE TALLY MOVES HERE, INSIDE THE CALLBACK, AND NOT AFTER THE BATCH. s1's + // SessionConsumer::ApplyOne publishes appliedSeq the instant this callback + // returns, and publishing appliedSeq is what releases the client from the + // verb barrier (R-1). Anything this thread records about the record AFTER + // that publish is visible to the client only eventually - and a diagnostic + // that can lag the watermark it describes is the exact shape of the + // LeaveApplier race PipeApplier::ApplyOne's block describes, one level up. + // The first version of this function tallied once per batch, below, and + // AClearRecordCrossesAndIsStampedAsAVerbBoundary / + // TheSessionWatermarkAndTheDecoderTallyAgreeAfterEveryRecord read + // DrainedRecords() one behind appliedSeq on 1 run in ~40 under `-j 8`. + // Per-record, before the publish, it can never be behind. + m_drained.fetch_add(1, std::memory_order_acq_rel); + }, + &corrupt); + if (corrupt) { + // Ring.h's own rule: a header the producer could not have written is + // Fatal{ProtocolCorruption}, never a retry. Retrying re-reads the same bytes + // for ever; skipping desynchronises seq, and seq IS the reply-slot id. + MGLOG_F("MGPipe: Fatal{ProtocolCorruption, \"SEG_CMD record header\"} - the " + "consumer refused a record header at applied seq %llu", + static_cast(consumer.AppliedSeq())); + std::abort(); + } + if (!popped) break; + ++applied; + } + if (applied != 0) { + // THE TWO TALLIES MUST AGREE, AND THAT IS WHAT MAKES R-9's BATCHING BAN CHECKABLE. + // RingControl::appliedSeq has one writer (SessionConsumer::ApplyOne, +1 per record) + // and PipeWireDecoder keeps its own count; a batched publish would move one and not + // the other, which a single counter could not have told apart (w1-v1 5). + if (applier.DecoderAppliedSeq() != consumer.AppliedSeq()) { + MGLOG_F("MGPipe: Fatal{ProtocolCorruption, \"appliedSeq batched\"} - the " + "session's watermark is %llu and the decoder applied %llu records. P5 " + "forbids batching appliedSeq (R-9): the verb barrier's waiter reads it, " + "and a watermark ahead of the decoder promises work that has not run", + static_cast(consumer.AppliedSeq()), + static_cast(applier.DecoderAppliedSeq())); + std::abort(); + } + // RETIRE. MANDATORY, not optional: RingProducer::FreeBytes() reclaims against + // retiredTail only, and w1's SEG_STAGE linear allocator reclaims on retiredSeq - so + // a loop that applies and never retires ends the first MOBILEGL_IPC_STAGE_MB of + // staging in Fatal{RingOverrun, "SEG_STAGE"} (w1-v1 5). Once per drain batch, not + // once per record: retiring LATE is always legal, retiring EARLY never is. + consumer.RetireThrough(consumer.AppliedSeq()); + // LEAVING THE APPLIER (p1's M-5) IS *NOT* DONE HERE. It is done inside + // PipeApplier::ApplyOne, before s1's SessionConsumer::ApplyOne publishes appliedSeq + // - see the block there. A clear at this point races the client, which the barrier + // has already released by then. + } + return applied; + } + + MobileGLResult ServerLoop::RunOnApplyThread(ControlWork work, void* user) { + if (work == nullptr) return MOBILEGL_ERR_INVALID_ARGUMENT; + // RE-ENTRANCY IS NOT A DEADLOCK. The teardown path posts from the apply thread itself - + // ~BackendObject_DirectGLES reaches ReleaseEGLResources - and so does anything the + // applier calls that wants "run this where the context is". Running inline is the + // correct answer there and the only non-hanging one. + if (OnApplyThread()) return work(user); + // NO THREAD YET, OR ALREADY GONE: run inline on the caller. That is right for the + // window between MG_Backend::Init()'s hook and ClientSession::Start (the backend object + // exists, the thread does not, and nothing has made a context current), and for the + // window after Stop(). It is NOT a silent fallback to monolith: the thread's absence in + // those two windows is a fact about the lifecycle, not about the transport. + if (!m_running.load(std::memory_order_acquire)) return work(user); + + const std::lock_guard callerLock(m_callerMutex); + { + std::unique_lock lock(m_controlMutex); + m_controlWork = work; + m_controlUser = user; + m_controlPending = true; + m_controlFinished = false; + } + // Publish THEN ring, in that order and never the other (Doorbell.h:186-193). The bell + // is rung unconditionally rather than through NotifyIfParked because this side does not + // know whether the apply thread is parked or spinning, and CondVarDoorbell remembers a + // wakeup that arrives while nobody is waiting. + if (m_session != nullptr) { + m_session->ConsumerDoorbell().Notify(); + } + std::unique_lock lock(m_controlMutex); + m_controlDone.wait(lock, [this] { return m_controlFinished; }); + return m_controlResult; + } + + void ServerLoop::SignalExited() { + { + const std::lock_guard lock(m_exitMutex); + m_exited = true; + } + m_exitCv.notify_all(); + } + + void ServerLoop::Stop() { + if (!m_thread.joinable()) { + // Never started, or already stopped. The backend may still exist - the hook builds + // it before the thread - and it has to go somewhere, so it goes here, on whatever + // thread called Stop. No context was ever made current from another thread in that + // case, which is exactly the condition that makes this safe. + if (m_backend != nullptr) m_backend.reset(); + m_running.store(false, std::memory_order_release); + return; + } + m_stopRequested.store(true, std::memory_order_release); + if (m_session != nullptr) { + // The bell may already be dead - ClientSession::Stop calls transport->Shutdown() + // first, which is what table 3's step 2 requires - and Notify on a dead bell is + // harmless. Ringing anyway covers the paths that Stop without a Kill. + m_session->ConsumerDoorbell().Notify(); + } + + // THE JOIN IS BOUNDED. std::thread::join has no deadline, so a lost wakeup would wedge + // CI rather than fail it; the thread signals m_exited last and this waits with the same + // five seconds InProcessTransportTest.cpp:344 uses. + Bool exited = false; + { + std::unique_lock lock(m_exitMutex); + exited = m_exitCv.wait_for(lock, std::chrono::milliseconds(kJoinTimeoutMs), + [this] { return m_exited; }); + } + if (!exited) { + // ABORT, NOT DETACH. A detached apply thread still owns the EGL context and would + // run on into the client's teardown, reading rings the client is about to unmap - + // a use-after-free whose only symptom is an intermittent crash somewhere else. + // Aborting here is red, immediate, and names the cause. + MGLOG_F("MGPipe: Fatal{ApplyThreadJoinTimeout} - mgl-srv-apply did not exit within " + "%u ms of Stop(). It parks on Doorbell::Wait(kWaitForever), which only " + "CondVarDoorbell::Kill() can break (Doorbell.h:211-221, table 3 step 2), so " + "this is a lost wakeup and not a slow thread. Aborting rather than " + "detaching: a detached apply thread still owns the context and would read " + "rings the client is about to unmap", + kJoinTimeoutMs); + std::abort(); + } + m_thread.join(); + m_applyThreadId.store(std::thread::id{}, std::memory_order_release); + m_running.store(false, std::memory_order_release); + m_session = nullptr; } ServerLoop& ServerLoopInstance() { @@ -44,6 +508,257 @@ namespace MobileGL::MG_Remote::Server { return instance; } -#undef MGP5_C0_STUB + // --------------------------------------------------------------------------------- + // The twelve EGL forwarders + // --------------------------------------------------------------------------------- + + namespace { + + // One captureless trampoline for every call: ControlWork is a raw function pointer + // plus a void*, not a std::function, because these run on the TEARDOWN path and the + // teardown path may not allocate (ID-8 exists because frontend destructors reach here + // from exit handlers). + template + MobileGLResult RunOnApply(Args& args) { + return ServerLoopInstance().RunOnApplyThread( + +[](void* user) -> MobileGLResult { return static_cast(user)->Run(); }, + &args); + } + + // Null means the hook never ran or the session is already down. Every forwarder + // answers false / no-op rather than dereferencing: a client that calls eglMakeCurrent + // on a process whose split bring-up failed must see a refusal, not a crash. + MG_Backend::BackendObject* ServerBackendOrNull() { return ServerLoopInstance().Backend(); } + + } // namespace + + Bool ServerInitializeEGLDisplay(EGLDisplay dpy, EGLint* major, EGLint* minor) { + struct Args { + EGLDisplay dpy; + EGLint* major; + EGLint* minor; + Bool ok = false; + MobileGLResult Run() { + MG_Backend::BackendObject* backend = ServerBackendOrNull(); + if (backend == nullptr) return MOBILEGL_ERR_NOT_INITIALIZED; + ok = backend->InitializeEGLDisplay(dpy, major, minor); + return MOBILEGL_OK; + } + } args{dpy, major, minor}; + return RunOnApply(args) == MOBILEGL_OK && args.ok; + } + + Bool ServerCreateEGLWindowSurface(EGLSurface surface, const MG_Backend::WindowHandle& handle) { + struct Args { + EGLSurface surface; + const MG_Backend::WindowHandle* handle; + Bool ok = false; + MobileGLResult Run() { + MG_Backend::BackendObject* backend = ServerBackendOrNull(); + if (backend == nullptr) return MOBILEGL_ERR_NOT_INITIALIZED; + ok = backend->CreateEGLWindowSurface(surface, *handle); + return MOBILEGL_OK; + } + } args{surface, &handle}; + return RunOnApply(args) == MOBILEGL_OK && args.ok; + } + + Bool ServerResizeEGLWindowSurface(EGLSurface surface, Uint32 width, Uint32 height) { + struct Args { + EGLSurface surface; + Uint32 width; + Uint32 height; + Bool ok = false; + MobileGLResult Run() { + MG_Backend::BackendObject* backend = ServerBackendOrNull(); + if (backend == nullptr) return MOBILEGL_ERR_NOT_INITIALIZED; + ok = backend->ResizeEGLWindowSurface(surface, width, height); + return MOBILEGL_OK; + } + } args{surface, width, height}; + return RunOnApply(args) == MOBILEGL_OK && args.ok; + } + + Bool ServerCreateEGLPbufferSurface(EGLSurface surface, EGLint width, EGLint height) { + struct Args { + EGLSurface surface; + EGLint width; + EGLint height; + Bool ok = false; + MobileGLResult Run() { + MG_Backend::BackendObject* backend = ServerBackendOrNull(); + if (backend == nullptr) return MOBILEGL_ERR_NOT_INITIALIZED; + ok = backend->CreateEGLPbufferSurface(surface, width, height); + return MOBILEGL_OK; + } + } args{surface, width, height}; + return RunOnApply(args) == MOBILEGL_OK && args.ok; + } + + Bool ServerMakeEGLCurrent(EGLDisplay dpy, EGLSurface draw, EGLSurface read, EGLContext ctx) { + struct Args { + EGLDisplay dpy; + EGLSurface draw; + EGLSurface read; + EGLContext ctx; + Bool ok = false; + MobileGLResult Run() { + MG_Backend::BackendObject* backend = ServerBackendOrNull(); + if (backend == nullptr) return MOBILEGL_ERR_NOT_INITIALIZED; + // THE ONE eglMakeCurrent OF THE PROCESS'S LIFE, ON THIS THREAD. Every later + // client-side eglMakeCurrent onto the same surface finds the context already + // current here and costs nothing; the owner slot is written once. + ok = backend->MakeEGLCurrent(dpy, draw, read, ctx); + if (!ok) return MOBILEGL_OK; + // R-12, arm (a): the caps snapshot is REPUBLISHED because InitCapabilities has + // now run for real. DirectGLES has no OnCapsInvalidated producer at all, and + // c0's answer is that a SECOND arrival IS the invalidation - so the client's + // mirror is refreshed with no dev-shaped backend edit and with no eleventh + // MGPipeCallbacks slot (MGPipeCallbacks.h:56-58's static_assert exists to make + // that cost visible). + ServerSession* session = ServerSession::Active(); + if (session != nullptr && session->Accepted()) { + const MobileGLResult published = session->PublishCapsSnapshot(); + if (published != MOBILEGL_OK) { + MGLOG_E("MG_Remote server: the post-make-current CapsSnapshot could not " + "be published (rc=%d); the client's mirror still holds the empty " + "snapshot Accept() sent before any context existed", + static_cast(published)); + } + } + return MOBILEGL_OK; + } + } args{dpy, draw, read, ctx}; + return RunOnApply(args) == MOBILEGL_OK && args.ok; + } + + Bool ServerSwapEGLBuffers(EGLDisplay dpy, EGLSurface draw) { + struct Args { + EGLDisplay dpy; + EGLSurface draw; + Bool ok = false; + MobileGLResult Run() { + MG_Backend::BackendObject* backend = ServerBackendOrNull(); + if (backend == nullptr) return MOBILEGL_ERR_NOT_INITIALIZED; + ok = backend->SwapEGLBuffers(dpy, draw); + return MOBILEGL_OK; + } + } args{dpy, draw}; + return RunOnApply(args) == MOBILEGL_OK && args.ok; + } + + void ServerSetEGLSwapInterval(Int interval) { + struct Args { + Int interval; + MobileGLResult Run() { + MG_Backend::BackendObject* backend = ServerBackendOrNull(); + if (backend == nullptr) return MOBILEGL_ERR_NOT_INITIALIZED; + backend->SetEGLSwapInterval(interval); + return MOBILEGL_OK; + } + } args{interval}; + (void)RunOnApply(args); + } + + void ServerReleaseEGLSurface(EGLSurface surface) { + struct Args { + EGLSurface surface; + MobileGLResult Run() { + MG_Backend::BackendObject* backend = ServerBackendOrNull(); + if (backend == nullptr) return MOBILEGL_ERR_NOT_INITIALIZED; + backend->ReleaseEGLSurface(surface); + return MOBILEGL_OK; + } + } args{surface}; + (void)RunOnApply(args); + } + + void ServerReleaseEGLResources() { + // BLOCKING BY CONTRACT. EGLImpl.cpp:326 calls this on the app thread and then, if no + // display and no context are left, calls MobileGL::Destroy() at :333. For DirectGLES it + // runs DestroyEGLContext - eglMakeCurrent(NO_SURFACE), eglDestroyContext, eglTerminate - + // and those must happen on the thread that made the context current. A fire-and-forget + // here lets Destroy() walk on while the server still holds the context, which is + // scout-install 5.3's named hazard. + struct Args { + MobileGLResult Run() { + MG_Backend::BackendObject* backend = ServerBackendOrNull(); + if (backend == nullptr) return MOBILEGL_ERR_NOT_INITIALIZED; + backend->ReleaseEGLResources(); + return MOBILEGL_OK; + } + } args{}; + (void)RunOnApply(args); + } + + Bool ServerInitCapabilities() { + struct Args { + Bool ok = false; + MobileGLResult Run() { + MG_Backend::BackendObject* backend = ServerBackendOrNull(); + if (backend == nullptr) return MOBILEGL_ERR_NOT_INITIALIZED; + ok = backend->InitCapabilities(); + if (!ok) return MOBILEGL_OK; + ServerSession* session = ServerSession::Active(); + if (session != nullptr && session->Accepted()) { + (void)session->PublishCapsSnapshot(); + } + return MOBILEGL_OK; + } + } args{}; + return RunOnApply(args) == MOBILEGL_OK && args.ok; + } + + Bool ServerInitWindowSurface() { + struct Args { + Bool ok = false; + MobileGLResult Run() { + MG_Backend::BackendObject* backend = ServerBackendOrNull(); + if (backend == nullptr) return MOBILEGL_ERR_NOT_INITIALIZED; + ok = backend->InitWindowSurface(); + return MOBILEGL_OK; + } + } args{}; + return RunOnApply(args) == MOBILEGL_OK && args.ok; + } + + void ServerSetWindowHandle(const MG_Backend::WindowHandle& handle) { + struct Args { + const MG_Backend::WindowHandle* handle; + MobileGLResult Run() { + MG_Backend::BackendObject* backend = ServerBackendOrNull(); + if (backend == nullptr) return MOBILEGL_ERR_NOT_INITIALIZED; + backend->SetWindowHandle(*handle); + return MOBILEGL_OK; + } + } args{&handle}; + (void)RunOnApply(args); + } } // namespace MobileGL::MG_Remote::Server + +// --------------------------------------------------------------------------------- +// The weak placeholder for package c1's remote backend object +// --------------------------------------------------------------------------------- +// +// MG_Backend/Init.cpp's hook calls MG_Remote::Client::CreateRemoteBackendObject(), which is +// c1's BackendObject_Remote and does not exist while v1 is written. A WEAK definition here +// lets v1 compile, link and be tested today, and c1's strong definition displaces it at link +// time with no edit anywhere. +// +// IT ABORTS BY NAME AND DOES NOT RETURN A WORKING MONOLITH OBJECT. That distinction is the +// whole point: a placeholder that handed back a BackendObject_DirectGLES would give a lane +// called "split" a correct picture produced entirely by the monolith path, which is +// ARCHITECTURE.md 10.3's failure and the one this phase exists to make impossible. +#if defined(__GNUC__) || defined(__clang__) +namespace MobileGL::MG_Remote::Client { + __attribute__((weak)) UniquePtr CreateRemoteBackendObject() { + MGLOG_F("MGPipe: Fatal{UnimplementedRemoteBackendObject} - MG_Backend::Init()'s split " + "hook asked for the client's BackendObject_Remote and only v1's weak " + "placeholder is linked in. That object is package c1's (BRIEF 5); until it " + "lands there is no client role, and this build refuses to substitute the " + "monolith backend for it"); + std::abort(); + } +} // namespace MobileGL::MG_Remote::Client +#endif diff --git a/MobileGL/MG_Remote/Server/ServerLoop.h b/MobileGL/MG_Remote/Server/ServerLoop.h index 724d58ef..05a85fec 100644 --- a/MobileGL/MG_Remote/Server/ServerLoop.h +++ b/MobileGL/MG_Remote/Server/ServerLoop.h @@ -45,6 +45,11 @@ #include "ServerSession.h" +#include +#include +#include +#include + namespace MobileGL::MG_Remote::Server { class ServerLoop { @@ -81,11 +86,129 @@ namespace MobileGL::MG_Remote::Server { using ControlWork = MobileGLResult (*)(void* user); MobileGLResult RunOnApplyThread(ControlWork work, void* user); + // ---- v1's additions beyond c0's signature block --------------------------------- + + // Constructs the SERVER ROLE's private BackendObject and runs its non-GL Initialize(). + // Called from MG_Backend::Init()'s single hook, BEFORE ClientSession::Start(), because + // ServerSession::Accept() publishes the first CapsSnapshot from it and because the two + // CallMask halves - which have no default and Fatal when unset (ServerSession.h) - are + // answered from what this backend is. + // + // NO GL AND NO EGL HAPPENS HERE. BackendObject_DirectGLES::Initialize() loads the + // driver's entry points; the native context does not exist until the client's first + // eglMakeCurrent crosses as a blocking control request and runs on the apply thread. + // That split is what lets the backend object be BUILT on the app thread while its + // context is never OWNED by it. + MobileGLResult CreateBackend(BackendType type); + + // True on the apply thread itself. RunOnApplyThread uses it to run inline rather than + // deadlock when the apply thread posts to itself - which the EGL teardown path does, + // because ~BackendObject_DirectGLES runs THERE and reaches ReleaseEGLResources. + static Bool OnApplyThread(); + + // The CPU mask MOBILEGL_IPC_SERVER_AFFINITY resolved to and sched_setaffinity accepted. + // 0 means "no affinity was applied" - the honest answer for `off`, for a platform with + // no affinity call, and for a failed syscall - and is exactly why the RESOLVED mask is + // logged rather than the string an operator typed. + Uint64 ResolvedAffinityMask() const; + + // Diagnostics the tests read. DrainedRecords is how many records this thread has handed + // to the applier; ParkCount how many times it actually parked. A shutdown test that + // asserts only "Stop() returned" cannot tell a thread that parked and was woken by Kill + // from one that never parked at all - which is the R-16 shape of a check that cannot + // fail for its own reason. + Uint64 DrainedRecords() const; + Uint64 ParkCount() const; + private: + void ApplyThreadMain(); + // Part of the apply thread's park predicate: a posted control request must be able to + // un-park a thread waiting on kWaitForever, which a Notify alone cannot do. + Bool ControlIsPending() const; + // Runs a posted control request, if there is one. Returns true if it ran one. + Bool PumpControlRequest(); + // Pops and applies every record currently in the ring; returns how many it applied. + Uint64 DrainRing(); + void SignalExited(); + ServerSession* m_session = nullptr; - Bool m_running = false; + std::atomic m_running{false}; + std::atomic m_stopRequested{false}; + std::thread m_thread; + std::atomic m_applyThreadId{}; + + // The private backend object. Destroyed ON the apply thread while it still owns the + // context - see Stop(). + UniquePtr m_backend; + + // The blocking control mailbox. ONE slot, because the verb barrier already leaves one + // client thread runnable at a time; m_callerMutex serialises anything that is not. + std::mutex m_callerMutex; + std::mutex m_controlMutex; + std::condition_variable m_controlPosted; + std::condition_variable m_controlDone; + ControlWork m_controlWork = nullptr; + void* m_controlUser = nullptr; + MobileGLResult m_controlResult = MOBILEGL_OK; + Bool m_controlPending = false; + Bool m_controlFinished = false; + + // The BOUNDED join's other half. std::thread::join has no deadline, so a lost wakeup + // would wedge CI rather than fail it; the thread signals here last and Stop() waits + // with a deadline (InProcessTransportTest.cpp:344's five seconds). + std::mutex m_exitMutex; + std::condition_variable m_exitCv; + Bool m_exited = false; + + Uint64 m_affinityMask = 0; + std::atomic m_drained{0}; + std::atomic m_parks{0}; }; ServerLoop& ServerLoopInstance(); + // --------------------------------------------------------------------------------- + // THE EGL OWNERSHIP MOVE - the part that can sink the phase, expressed as twelve calls + // --------------------------------------------------------------------------------- + // + // Under split the app thread must never reach the driver's eglMakeCurrent. Today it does: + // EGLImpl.cpp:284 -> BackendObject_DirectGLES.cpp:963 -> DirectGLES.cpp:11925, and the + // owner slot g_backendContextOwnerThread (DirectGLES.cpp:11865) is stamped with whatever + // thread got there. So BackendObject_Remote's nine EGL virtuals - package c1's - call these + // twelve, each of which is a BLOCKING control request that runs the SERVER's backend object + // on mgl-srv-apply. eglMakeCurrent then runs ONCE, on that thread, and is never released: + // g_backendContextOwnerThread is written once, DirectGLES.cpp:11933-11953's six cache + // invalidations become a one-off startup cost instead of a per-migration storm, the + // per-frame EGL re-verification stamp is permanently true, and the 16 + // IsBackendContextCurrentOnThisThread() sites plus the 16 CanTouchGLNow() sites answer TRUE + // on the server instead of silently degrading. + // + // TWO OF THEM MUST BLOCK OR THE PROCESS TEARS ITS OWN CONTEXT DOWN UNDER ITSELF: + // ReleaseEGLResources (reached from EGLImpl.cpp:326, which for DirectGLES runs + // DestroyEGLContext) and ~BackendObject_DirectGLES (reached from + // pActiveBackendObject.reset() at MobileGL/Init.cpp:68). Both are blocking here - the first + // by being one of these calls, the second because ServerLoop::Stop() destroys the private + // backend ON the apply thread before that thread exits and Stop() itself waits. + // + // WHY THEY ARE FREE FUNCTIONS AND NOT MEMBERS: c1 needs exactly this surface and nothing + // else of the server, so the seam between the two packages is a list of twelve signatures + // rather than a class with a lifecycle. + Bool ServerInitializeEGLDisplay(EGLDisplay dpy, EGLint* major, EGLint* minor); + Bool ServerCreateEGLWindowSurface(EGLSurface surface, const MG_Backend::WindowHandle& handle); + Bool ServerResizeEGLWindowSurface(EGLSurface surface, Uint32 width, Uint32 height); + Bool ServerCreateEGLPbufferSurface(EGLSurface surface, EGLint width, EGLint height); + // Also RE-PUBLISHES THE CAPS SNAPSHOT on success (R-12). BackendObject::MakeEGLCurrent runs + // InitCapabilities() on the first make-current per surface (BackendObject.cpp:341-347), so + // this is the moment the server's answers stop being the empty ones Accept() published - + // and a SECOND arrival IS the invalidation signal, which is how DirectGLES, which has no + // OnCapsInvalidated producer at all, tells the client without a dev-shaped backend edit. + Bool ServerMakeEGLCurrent(EGLDisplay dpy, EGLSurface draw, EGLSurface read, EGLContext ctx); + Bool ServerSwapEGLBuffers(EGLDisplay dpy, EGLSurface draw); + void ServerSetEGLSwapInterval(Int interval); + void ServerReleaseEGLSurface(EGLSurface surface); + void ServerReleaseEGLResources(); + Bool ServerInitCapabilities(); + Bool ServerInitWindowSurface(); + void ServerSetWindowHandle(const MG_Backend::WindowHandle& handle); + } // namespace MobileGL::MG_Remote::Server From d623118485b8d464d941367ba04559839a5400cc Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Wed, 16 Sep 2026 05:45:15 -0400 Subject: [PATCH 4/6] [Feat] (MG_Backend, MobileGL/Init): the single split hook in MG_Backend::Init - server backend, CallMask halves, ClientSession::Start, remote object - and ShutdownSplitRoles at the top of Destroy --- MobileGL/Init.cpp | 5 + MobileGL/MG_Backend/BackendObjects.h | 23 ++++ MobileGL/MG_Backend/Init.cpp | 161 +++++++++++++++++++++++++++ 3 files changed, 189 insertions(+) diff --git a/MobileGL/Init.cpp b/MobileGL/Init.cpp index 48028625..dde56bdd 100644 --- a/MobileGL/Init.cpp +++ b/MobileGL/Init.cpp @@ -43,6 +43,11 @@ namespace MobileGL { if (logLifecycle) { MGLOG_I("MobileGL closing..."); } + // P5: the split roles come down FIRST - ARCHITECTURE.md:537's order puts the whole + // of it before MobileGL::Destroy(), and this function IS MobileGL::Destroy. A no-op + // in a monolith build and in a monolith run; see BackendObjects.h for why the + // position rather than the call is the load-bearing part. + MG_Backend::ShutdownSplitRoles(); // Before any subsystem the counters name goes away, and before the last frame's // numbers can be lost: emits the final summary line and, when // MOBILEGL_PIPE_STATS_FILE is set, the JSON dump. A no-op when the counters are diff --git a/MobileGL/MG_Backend/BackendObjects.h b/MobileGL/MG_Backend/BackendObjects.h index 1a148824..fcff0086 100644 --- a/MobileGL/MG_Backend/BackendObjects.h +++ b/MobileGL/MG_Backend/BackendObjects.h @@ -15,4 +15,27 @@ namespace MobileGL::MG_Backend { extern UniquePtr& pActiveBackendObject; extern GlobalBackendFunctionsTable gBackendFunctionsTable; + + // P5 v1. The counterpart of Init()'s single split hook, and a NO-OP in every build and + // every run that is not split - which is why MobileGL/Init.cpp can call it unconditionally + // without a second #if in a file no package owns. + // + // IT MUST RUN FIRST, BEFORE ANYTHING ELSE IN DestroyImpl. ARCHITECTURE.md:537's order is + // publish -> the server drains and acks -> stop the apply thread (Kill, then join) -> close + // the transport -> the client drains the compile pool -> MobileGL::Destroy() -> release the + // sync/query handles; DestroyImpl IS MobileGL::Destroy, so everything before that arrow has + // to happen at its top. Two consequences are load-bearing rather than tidy: + // + // * the apply thread is JOINED before PipeStats::Shutdown() dumps, so nothing is writing + // a counter while the final line is produced; + // * the apply thread is joined before pActiveBackendObject.reset(), so the server's own + // BackendObject - which is NOT that global (table 3) - is destroyed on the thread that + // owns the context, by ServerLoop::Stop, rather than on the app thread. + // + // The sync/query registries stay where they are, drained at MobileGL/Init.cpp:62 and :67 + // BEFORE pActiveBackendObject.reset(). ARCHITECTURE.md:537 puts them after + // MobileGL::Destroy(); the two are only reconcilable if a split sync handle is + // client-minted and needs no backend call, which is P10's. CONTRACT-P5 4 flags this as a + // KNOWN OPEN ITEM and asks v1 to record which way it went: P5 keeps today's order. + void ShutdownSplitRoles(); } // namespace MobileGL::MG_Backend diff --git a/MobileGL/MG_Backend/Init.cpp b/MobileGL/MG_Backend/Init.cpp index fec43324..6e09bbe9 100644 --- a/MobileGL/MG_Backend/Init.cpp +++ b/MobileGL/MG_Backend/Init.cpp @@ -11,6 +11,26 @@ #include #include +#if MOBILEGL_BUILD_DISAGGREGATED +#include +#include +#include +#include +#include +#endif + +#if MOBILEGL_BUILD_DISAGGREGATED +namespace MobileGL::MG_Remote::Client { + // PACKAGE c1's, DECLARED HERE RATHER THAN INCLUDED. The client's BackendObject_Remote is + // c1's file and does not exist while v1 is written, so v1 ships a WEAK definition of this + // beside ServerLoop that aborts by name. c1's strong definition displaces it at link time + // with no edit to this file - and until then the hook cannot silently succeed, which is the + // only property that matters: a split lane that installed a WORKING monolith backend object + // here would render correctly for entirely the wrong reason (ARCHITECTURE.md 10.3). + UniquePtr CreateRemoteBackendObject(); +} // namespace MobileGL::MG_Remote::Client +#endif + namespace MobileGL::MG_Backend { void LogBackendInfo() { if (!pActiveBackendObject) { @@ -45,9 +65,150 @@ namespace MobileGL::MG_Backend { return true; } +#if MOBILEGL_BUILD_DISAGGREGATED + namespace { + // WHICH MGPipe SUBSYSTEMS THIS SERVER HAS A CONSUMER FOR - CallMask bits 32..47 (R-8 / + // C-4). It is stated from what the server's own backend IS, and NOT derived from + // MGPipeGetResourceOps(): that is a PROCESS-WIDE global, so under inproc a derivation + // would answer with whatever the client half of the same process registered and under + // spawn it would collapse to P2's 0x7f. Either way CapsMirror::ServerConsumes would + // then answer a client-side liveness gate with a guess, the client would stop emitting + // whole record families, clear its dirty flags on acceptance anyway, and the lane would + // go green with the uploads lost - ID-39's 66 lost uploads, reflected (ServerSession.h). + // + // DirectGLES consumes all thirteen migrated families (P2's 0..6, P3a's 7..8, P4a's + // 9..12): it registers the resource op table in Initialize() + // (BackendObject_DirectGLES.cpp:849) and reads every other family out of gPipeInputs. + // DirectVulkan registers NO resource ops - MGPipeSetResourceOps has exactly one caller + // in the whole tree and it is Managers.cpp:2594 - so bit 7 is CLEAR for it, which is + // the same fact ObjectSubsystemControlScenario already pins from the client side. + Uint64 ConsumedSubsystemsFor(BackendType type) { + switch (type) { + case BackendType::DirectGLES: return MG_Pipe::kMGPipeSubsystemsMigratedAtP4a; + case BackendType::DirectVulkan: + return MG_Pipe::kMGPipeSubsystemsMigratedAtP4a & ~MG_Pipe::kMGPipeSubsystemResources; + default: return 0; + } + } + + // THE CROSS-CHECK THAT MAKES A WRONG ANSWER LOUD. Claiming bit 7 while no resource op + // table is registered is the exact failure the mask exists to prevent, one level down: + // the client would keep emitting the resource family and the server would drop every + // record of it. The check runs AFTER Initialize(), which is where DirectGLES registers + // the table, so it can see the real answer rather than a promise. + void AssertConsumerMaskIsHonest(Uint64 mask) { + const Bool claimsResources = (mask & MG_Pipe::kMGPipeSubsystemResources) != 0; + const Bool hasResourceOps = MG_Pipe::MGPipeGetResourceOps() != nullptr; + if (claimsResources && !hasResourceOps) { + MGLOG_F("MGPipe: Fatal{ConsumerMaskLie, \"kMGPipeSubsystemResources\"} - the " + "server published a consumer bit for the resource family while " + "MGPipeGetResourceOps() is null. The client's R-8 liveness gate would " + "keep emitting resource_create / resource_subdata records that this " + "server drops on the floor, and the client clears its dirty flags on " + "acceptance anyway (ID-39). A mask is a statement about this backend, " + "not a hope"); + std::abort(); + } + if (!claimsResources && hasResourceOps) { + // The safe direction: the legacy pull path keeps running. Said out loud anyway, + // because it silently costs the whole P3a family its migration. + MGLOG_W("MG_Remote server: a resource op table is registered but the consumer " + "mask withholds kMGPipeSubsystemResources; the buffer family will fall " + "back to the legacy path for this session"); + } + } + + // The single hook (ARCHITECTURE.md:29). Returns false when the split could not be + // brought up, and the caller then REFUSES TO CONTINUE rather than falling back to the + // switch below - a fallback here is "the split lane ran monolith and went green". + Bool InitSplitRoles() { + using namespace MobileGL::MG_Remote; + + // 1. the SERVER role's private backend object, on the app thread, with no GL and no + // EGL. The context is created and made current later, on mgl-srv-apply, when the + // client's first eglMakeCurrent crosses as a blocking control request. + Server::ServerLoop& loop = Server::ServerLoopInstance(); + const MobileGLResult created = loop.CreateBackend(MG_Config::ActiveBackendType); + if (created != MOBILEGL_OK) return false; + + // 2. the two CallMask halves. NEITHER HAS A DEFAULT and CallMask() is a named Fatal + // on an unset one (s1's BLOCKER fix), so this is the "somebody" that block names. + Server::ServerSession& session = Server::ServerSessionInstance(); + const Uint64 consumed = ConsumedSubsystemsFor(MG_Config::ActiveBackendType); + AssertConsumerMaskIsHonest(consumed); + session.SetConsumedSubsystems(consumed); + // ZERO IS THE EXPLICIT ANSWER FOR P5, not an omission (ServerSession.h's block): + // every optional capability bit belongs to the package that owns its question, and + // withholding one leaves the legacy path running, which is the safe direction. + // kCapNeedsHostIndexBytes and kCapNeedsHostUboBytes must be 0 for the whole of P5 + // by ruling - they are the only two things that ask for an MGHostSpan, and 0 is + // what keeps every one of them out of the first IPC frame (contract table 0). + session.SetCapabilityBits(0); + session.SetBackend(loop.Backend()); + + // 3. the handshake, the four segments, and - at its end - the apply thread. + const MobileGLResult started = + Client::ClientSessionInstance().Start(MG_Config::Transport, MG_Config::TransportEndpoint); + if (started != MOBILEGL_OK) { + MGLOG_E("MG_Remote: the split session failed to start (rc=%d); MobileGL will not " + "fall back to monolith - a lane named split that ran monolith is the one " + "failure this phase is built to make impossible", + static_cast(started)); + return false; + } + + // 4. and only now the CLIENT's backend object in the one global that holds it. + // Table 3: pActiveBackendObject holds BackendObject_Remote and the server's + // BackendObject_DirectGLES stays private to ServerLoop. + pActiveBackendObject = MG_Remote::Client::CreateRemoteBackendObject(); + if (!pActiveBackendObject) { + MGLOG_E("MG_Remote: CreateRemoteBackendObject returned null"); + return false; + } + return true; + } + } // namespace +#endif + + void ShutdownSplitRoles() { +#if MOBILEGL_BUILD_DISAGGREGATED + if (MG_Config::Transport == MG_Config::TransportMode::Monolith) return; + // ClientSession::Stop IS table 3's whole order and it is idempotent: publish and wait + // for the server to drain (bounded - a lost record must be a red lane, not a hung + // exit), Doorbell::Kill through the transport's Shutdown, ServerLoop::Stop's bounded + // join - which also destroys the server's private BackendObject ON the apply thread + // while it still owns the context - the transport, and only THEN anything an emitter + // owns. A var-tail still named by an unapplied record is a use-after-free the join is + // what prevents, which is why the order is not a preference. + MG_Remote::Client::ClientSessionInstance().Stop(); +#endif + } + void Init() { MGLOG_D("Initializing MobileGL Backend..."); +#if MOBILEGL_BUILD_DISAGGREGATED + // THE SINGLE HOOK. In a build without MOBILEGL_BUILD_DISAGGREGATED, MG_Config::Transport + // is a `constexpr Monolith` (Config.h) and this whole statement is discarded, so the + // pull build gains no symbol, no branch and no byte - which is what G1 measures. + if (MG_Config::Transport != MG_Config::TransportMode::Monolith) { + if (!InitSplitRoles()) { + // NOT a fallback to the switch. pActiveBackendObject stays null and the next GL + // call fails loudly, which is the only honest outcome: the operator asked for a + // transport this process could not bring up. + pActiveBackendObject = nullptr; + return; + } + Bool remoteResult = InitSpecificBackendLibs(); + if (!remoteResult) { + MGLOG_W("Failed to initialize MobileGL backend libraries for the remote object"); + return; + } + LogBackendInfo(); + return; + } +#endif + switch (MG_Config::ActiveBackendType) { case BackendType::DirectGLES: pActiveBackendObject = MakeUnique(); From 7ea3b1d1f1edc5560355b97f00fc1f13349910c1 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Wed, 16 Sep 2026 05:45:15 -0400 Subject: [PATCH 5/6] [Test] (MG_Test/Wire): ServerLoopTest - the apply thread, the control mailbox, the verb stamp, the audit poison, the bounded shutdown, and StagedShadowTest --- MobileGL/MG_Test/Wire/CMakeLists.txt | 28 + MobileGL/MG_Test/Wire/ServerLoopTest.cpp | 625 +++++++++++++++++++++++ 2 files changed, 653 insertions(+) create mode 100644 MobileGL/MG_Test/Wire/ServerLoopTest.cpp diff --git a/MobileGL/MG_Test/Wire/CMakeLists.txt b/MobileGL/MG_Test/Wire/CMakeLists.txt index 280acb07..952dcbfc 100644 --- a/MobileGL/MG_Test/Wire/CMakeLists.txt +++ b/MobileGL/MG_Test/Wire/CMakeLists.txt @@ -65,3 +65,31 @@ if (MSVC) endif () gtest_discover_tests(PipeWireCodecTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit) + +# P5 v1's suite: the apply thread, the blocking control mailbox, the verb stamp and R-11's +# server-owned staging copy. Registered on its own for PipeWireCodecTest's reason - it links +# gtest and carries its own main(), because the Fatal arms report through MGLOG_F + std::abort +# and a case that asserts one has to name the log file BEFORE anything logs. It also reaches +# MG_Pipe, MG_Backend and MG_State, so it carries their include paths. +add_executable(ServerLoopTest ServerLoopTest.cpp) + +target_include_directories(ServerLoopTest PRIVATE + ${MGL_ROOT}/include + ${MGL_ROOT}/MobileGL + ${MGL_ROOT}/MobileGL/MG_Pipe + ${MGL_ROOT}/3rdparty/flatbuffers/include + ${MGL_ROOT}/3rdparty/xxHash + ${MGL_ROOT}/3rdparty/Vulkan-Headers/include + ${MGL_ROOT}/3rdparty/SPIRV-Reflect +) + +target_link_libraries(ServerLoopTest PRIVATE + GTest::gtest + ${LINK_LIBRARIES} +) + +if (MSVC) + target_compile_options(ServerLoopTest PRIVATE /Zc:preprocessor) +endif () + +gtest_discover_tests(ServerLoopTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit) diff --git a/MobileGL/MG_Test/Wire/ServerLoopTest.cpp b/MobileGL/MG_Test/Wire/ServerLoopTest.cpp new file mode 100644 index 00000000..db84ea37 --- /dev/null +++ b/MobileGL/MG_Test/Wire/ServerLoopTest.cpp @@ -0,0 +1,625 @@ +// MobileGL - MobileGL/MG_Test/Wire/ServerLoopTest.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 + +// Package v1's suite: the apply thread, the blocking control mailbox, the verb stamp, the +// bounded shutdown, and R-11's server-owned staging copy. +// +// WHAT THIS SUITE REFUSES TO DO, and why it is written the way it is (R-16). +// +// Every case here drives the REAL path: a real ServerSession over four real ShmSegment-backed +// segments, a real apply thread parked on a real Doorbell, and records that go in through w1's +// encoder and come out through w1's decoder. None of them writes a record field by hand, none +// of them calls PipeApplier::ApplyOne directly, and none of them sets a flag the case then +// observes. That matters because the three defects the first P5 wave shipped were all of that +// shape - a persistent-map case that wrote the record field itself still passed with the +// producer deleted, and a lane probe armed against stub SOURCE TEXT went green having run +// monolith on 8 of 11 lanes. +// +// The two things this suite deliberately CANNOT reach are named rather than faked: +// * there is no GL context here, so ServerLoop::CreateBackend is not called and the five +// class-B verbs DECLINE. That is asserted as a decline, not skipped - a Clear that was +// APPLIED with no backend would mean the sink found a table it should not have. +// * R-11's end-to-end control is MOBILEGL_IPC_AUDIT=1 over the reduced path, which needs the +// client's emit table (package c1). What IS reachable here is the property that control +// exists to test: bytes that were copied survive the source being overwritten with 0xDD. + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(_WIN32) +#include +#else +#include +#endif + +using namespace MobileGL; + +// NOT `using namespace MobileGL::MG_Remote`. MobileGL::Wire (the flatbuffers control-plane +// schema) and MobileGL::MG_Remote::Wire (the G3 codec) are two different namespaces with the +// same last name, and a using-directive over the second makes every mention of `Wire` +// ambiguous - including the ones in the generated header. +namespace Transport = MobileGL::MG_Remote::Transport; +namespace Server = MobileGL::MG_Remote::Server; +namespace Codec = MobileGL::MG_Remote::Wire; +using MobileGL::MG_Remote::CapsAbiFingerprint; + +namespace { + + std::string g_logPath; + + std::string ReadLog() { + std::ifstream in(g_logPath, std::ios::binary); + if (!in) return {}; + return std::string(std::istreambuf_iterator(in), std::istreambuf_iterator()); + } + + unsigned ProcessId() { +#if defined(_WIN32) + return static_cast(_getpid()); +#else + return static_cast(::getpid()); +#endif + } + + Transport::SessionSegmentSizes TestSizes() { + Transport::SessionSegmentSizes sizes; + sizes.CmdRingBytes = 64ull * 1024; + sizes.StageBytes = 64ull * 1024; + sizes.ReplyBytes = 64ull * 1024; + sizes.EventRingBytes = 16ull * 1024; + sizes.ReplySlotCount = 8; + return sizes; + } + + // The client half of a session, wired exactly the way ClientSession::Start wires it: the + // transport pair, a real Hello, the server's Accept, the client's attach to the SAME + // mapping, and w1's encoder over the shared SEG_CMD ring. It is NOT ClientSession itself, + // because ClientSession::Start finishes by installing package c1's BackendObject_Remote, + // which does not exist yet - and a fixture that skipped the handshake to get around that + // would be testing a session this tree does not build. + struct ServerFixture { + std::unique_ptr clientTransport; + std::unique_ptr serverTransport; + Transport::SessionSegments clientSegments; + Transport::RingProducer cmd; + Transport::SessionProducer producer; + Codec::SegmentTable clientTable; + Codec::PipeWireEncoder encoder; + Server::ServerSession* session = nullptr; + + bool Handshake() { + Transport::InProcessTransport::CreatePair(clientTransport, serverTransport); + { + ::flatbuffers::FlatBufferBuilder builder(512); + auto stamp = builder.CreateString(GIT_COMMIT_HASH_SHORT); + auto hello = ::MobileGL::Wire::CreateHello( + builder, MOBILEGL_PROTOCOL_ABI_MAJOR, MOBILEGL_PROTOCOL_ABI_MINOR, stamp, + /*backendType=*/0u, /*pid=*/0u, /*configBlob=*/0, CapsAbiFingerprint()); + auto root = ::MobileGL::Wire::CreateCtrlEnvelope( + builder, ::MobileGL::Wire::CtrlMsg::Hello, hello.Union()); + ::MobileGL::Wire::FinishCtrlEnvelopeBuffer(builder, root); + if (clientTransport->SendFrame(MobileGLByteSpan{builder.GetBufferPointer(), + builder.GetSize()}) != MOBILEGL_OK) { + return false; + } + } + session = &Server::ServerSessionInstance(); + session->SetSegmentSizes(TestSizes()); + // The two halves v1 owns. Neither has a default and CallMask() Fatals on an unset + // one, which is s1's BLOCKER fix and the reason this is stated rather than derived. + session->SetCapabilityBits(0); + session->SetConsumedSubsystems(MG_Pipe::kMGPipeSubsystemsMigratedAtP4a); + if (session->Accept(*serverTransport) != MOBILEGL_OK) return false; + if (clientSegments.AttachInProcess(session->Shm(), Transport::MemoryRole::Client) != + MOBILEGL_OK) { + return false; + } + Transport::RingControl* control = clientSegments.CmdControl(); + cmd = Transport::RingProducer(control, clientSegments.CmdRingBase(), + clientSegments.CmdRingCapacity(), Transport::RingCursorSet::Cmd); + if (!cmd.Valid()) return false; + producer.Attach(control, &cmd, &clientTransport->PeerDoorbell(), + &clientTransport->SelfDoorbell(), MG_Config::Ipc.SpinUs); + clientTable.Install(Codec::kSegCmd, Codec::SegmentView{clientSegments.CmdRingBase(), + clientSegments.CmdRingCapacity()}); + clientTable.Install(Codec::kSegStage, + Codec::SegmentView{clientSegments.StageBase(), clientSegments.StageBytes()}); + clientTable.Install(Codec::kSegReply, + Codec::SegmentView{clientSegments.ReplyBase(), clientSegments.ReplyBytes()}); + encoder = Codec::PipeWireEncoder(control, &cmd, nullptr, &clientTable); + return true; + } + + bool StartLoop() { + return Server::ServerLoopInstance().Start(*session) == MOBILEGL_OK; + } + + // Emit one record and wait for the server to apply it. This is the verb barrier's own + // wait (R-3/R-5) - appliedSeq, not a sleep - so a case that goes green here has really + // seen the apply thread move the watermark. + bool EmitAndWait(MG_Pipe::MGPWireOp op, const void* payload, Uint64 payloadBytes, + Uint32 timeoutMs = 5000) { + const Uint64 seq = encoder.EncodeRecord(op, payload, payloadBytes); + if (seq == Codec::kInvalidSeq) return false; + encoder.Publish(); + producer.PublishAndNotify(seq); + return producer.WaitForApplied(seq, timeoutMs) == Transport::SessionWait::Reached; + } + + bool EmitAndWaitWithTail(MG_Pipe::MGPWireOp op, const void* payload, Uint64 payloadBytes, + const void* tail, Uint64 tailBytes, Uint32 timeoutMs = 5000) { + const Uint64 seq = encoder.EncodeRecord(op, payload, payloadBytes, tail, tailBytes); + if (seq == Codec::kInvalidSeq) return false; + encoder.Publish(); + producer.PublishAndNotify(seq); + return producer.WaitForApplied(seq, timeoutMs) == Transport::SessionWait::Reached; + } + + void Stop() { + // Table 3's order: the client publishes and lets the server drain (EmitAndWait + // already did), then Doorbell::Kill through the transport's Shutdown, THEN the + // bounded join, and only then is anything an emitter owns released. + if (clientTransport) clientTransport->Shutdown(); + Server::ServerLoopInstance().Stop(); + producer.Detach(); + encoder = Codec::PipeWireEncoder(); + cmd = Transport::RingProducer(); + clientSegments.Close(); + if (session != nullptr) session->Close(); + clientTransport.reset(); + serverTransport.reset(); + } + }; + + MG_Pipe::MGPClear WholeFramebufferClear() { + MG_Pipe::MGPClear clear{}; + clear.Kind = Server::kMGPClearKindWhole; + clear.BufferMask = 0x00004000; // GL_COLOR_BUFFER_BIT + clear.DrawBufferIndex = -1; + clear.ValueClass = Server::kMGPClearValueClassFloat; + return clear; + } + +} // namespace + +// ===================================================================================== +// The thread +// ===================================================================================== + +// A control request must be able to un-park a thread waiting on kWaitForever. A Notify alone +// cannot do that - Doorbell::Wait consumes it with one Park, re-tests a condition nothing +// published, finds the bell alive and parks again - so the park predicate has to carry the +// control flag too. The case asserts the thread REALLY PARKED first, because a loop that +// spun instead would pass this without the predicate ever mattering. +TEST(ServerLoopTest, AControlRequestRunsOnTheApplyThreadAndUnparksIt) { + ServerFixture fixture; + ASSERT_TRUE(fixture.Handshake()); + ASSERT_TRUE(fixture.StartLoop()); + + Server::ServerLoop& loop = Server::ServerLoopInstance(); + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5); + while (loop.ParkCount() == 0 && std::chrono::steady_clock::now() < deadline) { + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + ASSERT_GT(loop.ParkCount(), 0u) + << "the apply thread never parked, so this case would prove nothing about waking it"; + + struct Probe { + std::thread::id ranOn{}; + Bool onApplyThread = false; + } probe; + const MobileGLResult rc = loop.RunOnApplyThread( + +[](void* user) -> MobileGLResult { + auto* p = static_cast(user); + p->ranOn = std::this_thread::get_id(); + p->onApplyThread = Server::ServerLoop::OnApplyThread(); + return MOBILEGL_OK; + }, + &probe); + + EXPECT_EQ(rc, MOBILEGL_OK); + EXPECT_NE(probe.ranOn, std::this_thread::get_id()) + << "the control request ran on the CALLER, which means the EGL lifecycle calls would " + "reach the driver from the app thread and the context would never migrate"; + EXPECT_TRUE(probe.onApplyThread); + EXPECT_FALSE(Server::ServerLoop::OnApplyThread()); + + fixture.Stop(); +} + +// Re-entrancy is not a deadlock: ~BackendObject_DirectGLES reaches ReleaseEGLResources FROM the +// apply thread, so a post from there must run inline. +TEST(ServerLoopTest, APostFromTheApplyThreadItselfRunsInlineRatherThanDeadlocking) { + ServerFixture fixture; + ASSERT_TRUE(fixture.Handshake()); + ASSERT_TRUE(fixture.StartLoop()); + + struct Outer { + Bool innerRan = false; + MobileGLResult innerRc = MOBILEGL_ERR_INVALID_ARGUMENT; + } outer; + const MobileGLResult rc = Server::ServerLoopInstance().RunOnApplyThread( + +[](void* user) -> MobileGLResult { + auto* o = static_cast(user); + o->innerRc = Server::ServerLoopInstance().RunOnApplyThread( + +[](void* inner) -> MobileGLResult { + *static_cast(inner) = true; + return MOBILEGL_OK; + }, + &o->innerRan); + return MOBILEGL_OK; + }, + &outer); + + EXPECT_EQ(rc, MOBILEGL_OK); + EXPECT_EQ(outer.innerRc, MOBILEGL_OK); + EXPECT_TRUE(outer.innerRan); + + fixture.Stop(); +} + +// The bounded join. The thread is parked on kWaitForever when Stop() is called, so this is the +// exact lost-wakeup shape table 3 step 2 is about - and the bound is what turns a regression +// into a red test rather than a wedged CI job. +TEST(ServerLoopTest, StopKillsTheDoorbellJoinsAndTheThreadReallyExits) { + ServerFixture fixture; + ASSERT_TRUE(fixture.Handshake()); + ASSERT_TRUE(fixture.StartLoop()); + Server::ServerLoop& loop = Server::ServerLoopInstance(); + + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5); + while (loop.ParkCount() == 0 && std::chrono::steady_clock::now() < deadline) { + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + ASSERT_GT(loop.ParkCount(), 0u) << "the thread must be parked for this to be a shutdown test"; + ASSERT_TRUE(loop.Running()); + + const auto began = std::chrono::steady_clock::now(); + fixture.Stop(); + const auto took = std::chrono::steady_clock::now() - began; + + EXPECT_FALSE(loop.Running()); + EXPECT_LT(std::chrono::duration_cast(took).count(), 5000) + << "Stop() took the whole bound, which means it hit the join timeout rather than a " + "wakeup"; +} + +// MOBILEGL_IPC_SERVER_AFFINITY is kept as the raw string BECAUSE the resolved mask is what gets +// logged: an affinity that silently did nothing looks exactly like one that worked. So the +// resolved mask has to be readable, and `off` has to resolve to zero rather than to "auto". +TEST(ServerLoopTest, TheAffinityStringResolvesToAMaskAndTheMaskIsLogged) { + const String saved = MG_Config::Ipc.ServerAffinity; + MG_Config::Ipc.ServerAffinity = "off"; + + ServerFixture fixture; + ASSERT_TRUE(fixture.Handshake()); + ASSERT_TRUE(fixture.StartLoop()); + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5); + while (Server::ServerLoopInstance().ParkCount() == 0 && + std::chrono::steady_clock::now() < deadline) { + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + EXPECT_EQ(Server::ServerLoopInstance().ResolvedAffinityMask(), 0u) + << "`off` did not resolve to no-affinity"; + + const std::string log = ReadLog(); + EXPECT_NE(log.find("RESOLVED mask"), std::string::npos) + << "the apply thread did not log its resolved affinity mask; an affinity that silently " + "did nothing is indistinguishable from one that worked"; + EXPECT_NE(log.find("mgl-srv-apply started"), std::string::npos); + + fixture.Stop(); + MG_Config::Ipc.ServerAffinity = saved; +} + +// ===================================================================================== +// The record path: stamp, apply, watermark +// ===================================================================================== + +// The whole phase's prerequisite. MGPipeApplyAccess deliberately does not stamp the poison +// generations, so under split NOTHING stamps unless the applier does - every FilledGen[] stays +// 0, MGPipeInputFieldIsFresh answers false for everything, and a server-side read aborts on the +// FIRST field inside SyncRenderState. This case asserts the stamp happened by reading the verb +// the stamp SET, which the case itself never writes. +TEST(ServerLoopTest, AClearRecordCrossesAndIsStampedAsAVerbBoundary) { + ServerFixture fixture; + ASSERT_TRUE(fixture.Handshake()); + ASSERT_TRUE(fixture.StartLoop()); + + // The pre-state is the honest one: nothing has stamped yet in this process. + ASSERT_NE(MG_Pipe::gPipeInputs.CurrentVerb(), MG_Pipe::MGPipeVerb::Clear); + + const MG_Pipe::MGPClear clear = WholeFramebufferClear(); + ASSERT_TRUE(fixture.EmitAndWait(MG_Pipe::MGPWireOp::Clear, &clear, sizeof(clear))); + + Server::ServerLoop& loop = Server::ServerLoopInstance(); + EXPECT_EQ(loop.DrainedRecords(), 1u); + EXPECT_EQ(MG_Pipe::gPipeInputs.CurrentVerb(), MG_Pipe::MGPipeVerb::Clear) + << "PipeApplier::StampVerbBoundary did not run, so every server-side PipeInputs read " + "would abort on the first field inside SyncRenderState"; + + // MANDATORY on leaving the applier (p1's M-5): without it a SPAWNED server latches the flag + // for its whole life, every later read is judged against the last verb's mask, and the + // sticky forwards start aborting under strict on the very case their exemption exists for. + EXPECT_FALSE(MG_Pipe::gPipeInputs.ServerStampedVerb()) + << "MGPipeServerClearVerbBoundary was not called when the apply thread left the applier"; + + // No backend object in this process, so the five class-B verbs DECLINE. Asserted rather + // than skipped: a Clear that was APPLIED here would mean the sink found a function table + // it had no business finding. + EXPECT_EQ(fixture.session->Applier().Verbs().Clears(), 0u); + + fixture.Stop(); +} + +// R-9's batching ban, made checkable rather than merely stated. RingControl::appliedSeq has ONE +// writer (SessionConsumer::ApplyOne, +1 per record) and PipeWireDecoder keeps its own tally; a +// batched publish moves one and not the other, which a single counter could never tell apart. +TEST(ServerLoopTest, TheSessionWatermarkAndTheDecoderTallyAgreeAfterEveryRecord) { + ServerFixture fixture; + ASSERT_TRUE(fixture.Handshake()); + ASSERT_TRUE(fixture.StartLoop()); + + const MG_Pipe::MGPClear clear = WholeFramebufferClear(); + for (int i = 0; i < 8; ++i) { + ASSERT_TRUE(fixture.EmitAndWait(MG_Pipe::MGPWireOp::Clear, &clear, sizeof(clear))) << i; + EXPECT_EQ(fixture.session->Consumer().AppliedSeq(), static_cast(i + 1)); + EXPECT_EQ(fixture.session->Applier().DecoderAppliedSeq(), static_cast(i + 1)); + } + EXPECT_EQ(Server::ServerLoopInstance().DrainedRecords(), 8u); + + // retiredSeq is the watermark w1's SEG_STAGE allocator reclaims against; a loop that + // applies and never retires ends the first MOBILEGL_IPC_STAGE_MB in Fatal{RingOverrun}. + // + // IT IS POLLED, NOT READ ONCE, and that is R-9's own rule rather than a papered-over race: + // appliedSeq is the ONLY watermark P5 forbids batching, and every other one "may be + // published LATE but never EARLY". The apply thread retires after the drain batch and + // before it parks, so the barrier can release the client between the two - reading it + // instantly would be asserting a promise R-9 deliberately does not make. The BOUND is what + // keeps this a check: a loop that never retires times out here instead of passing. + const auto retireDeadline = std::chrono::steady_clock::now() + std::chrono::seconds(2); + while (fixture.clientSegments.CmdControl()->retiredSeq.load() < 8u && + std::chrono::steady_clock::now() < retireDeadline) { + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + EXPECT_GE(fixture.clientSegments.CmdControl()->retiredSeq.load(), 8u) + << "the apply loop advanced appliedSeq but never retired within 2 s, so SEG_STAGE would " + "never be reclaimed and the first MOBILEGL_IPC_STAGE_MB would end in " + "Fatal{RingOverrun}"; + + fixture.Stop(); +} + +// ===================================================================================== +// R-2.5 / rule C's mechanical control, running for real +// ===================================================================================== + +// MOBILEGL_IPC_AUDIT=1 fills a retired SEG_STAGE run with 0xDD once the applier has RETURNED, +// so an applier that kept the pointer reads the poison on the next frame instead of bytes that +// happen to still be there. Without this, an inproc implementation that kept a pointer is +// INDISTINGUISHABLE from one that copied - which is R-2's entire argument. +// +// The case asserts three separate things, because each one alone can be true for the wrong +// reason: that the record's bytes really crossed (memcmp before the poison), that the poison +// really ran (PoisonedStageBytes moved), and that it landed on the run the record named (the +// bytes read 0xDD afterwards, through the CLIENT's mapping of the same segment). +TEST(ServerLoopTest, TheAuditPoisonFillsExactlyTheStagedRunAfterTheApplierReturns) { + const Bool savedAudit = MG_Config::Ipc.Audit; + // Set BEFORE the loop starts: PipeWireDecoder reads it in its constructor, and its + // constructor runs on the apply thread inside ServerLoop::Start. + MG_Config::Ipc.Audit = true; + + ServerFixture fixture; + ASSERT_TRUE(fixture.Handshake()); + ASSERT_TRUE(fixture.StartLoop()); + + MG_Pipe::MGPResourceDesc create{}; + create.Resource.Slot = 61; + create.Resource.Gen = 1; + create.Target = static_cast(MG_Pipe::MGPipeResourceTarget::Tex2D); + create.InternalFormat = 1; + create.Width = 4; + create.Height = 4; + create.Depth = 1; + create.ArrayLayers = 1; + create.Levels = 1; + create.Samples = 1; + ASSERT_TRUE(fixture.EmitAndWait(MG_Pipe::MGPWireOp::ResourceCreate, &create, sizeof(create))); + + Vector texels(4 * 4 * 4, 0x5A); + MG_Pipe::MGPSubData upload{}; + upload.Res = create.Resource; + upload.Target = MG_Pipe::MGPipePackSubDataTarget( + static_cast(MG_Pipe::MGPipeResourceTarget::Tex2D), 0u); + upload.Level = 0; + upload.UnionBox = MG_Pipe::MGPBox{0, 0, 0, 4, 4, 1}; + upload.RegionCount = 1; + upload.Blob = fixture.encoder.StageBytes(texels.data(), texels.size()); + ASSERT_NE(upload.Blob.Size, 0u) << "rule A: a content record must declare non-zero bytes"; + + // The bytes are in SEG_STAGE and readable through the CLIENT's own mapping right now - the + // record has not been published yet, so nothing has retired it. + const void* stagedBefore = + fixture.clientTable.Resolve(upload.Blob.Seg, upload.Blob.Offset, upload.Blob.Size); + ASSERT_NE(stagedBefore, nullptr); + ASSERT_EQ(std::memcmp(stagedBefore, texels.data(), texels.size()), 0); + + MG_Pipe::MGPSubRegion region{}; + region.W = 4; + region.H = 4; + region.D = 1; + ASSERT_TRUE(fixture.EmitAndWaitWithTail(MG_Pipe::MGPWireOp::ResourceSubData, &upload, + sizeof(upload), ®ion, sizeof(region))); + + EXPECT_GT(fixture.session->Applier().PoisonedStageBytes(), 0u) + << "MOBILEGL_IPC_AUDIT=1 poisoned nothing, so R-2.5's only mechanical control against a " + "retained SEG_STAGE pointer never ran"; + + const auto* poisoned = static_cast( + fixture.clientTable.Resolve(upload.Blob.Seg, upload.Blob.Offset, upload.Blob.Size)); + ASSERT_NE(poisoned, nullptr); + for (Uint64 i = 0; i < upload.Blob.Size; ++i) { + ASSERT_EQ(poisoned[i], 0xDD) << "staged byte " << i << " was not poisoned; an applier " + "that kept this pointer would still read real bytes and " + "the control would prove nothing"; + } + + fixture.Stop(); + MG_Config::Ipc.Audit = savedAudit; +} + +// ===================================================================================== +// R-11 - the server's own copy of the staged bytes +// ===================================================================================== + +// THIS IS THE PROPERTY MOBILEGL_IPC_AUDIT=1's 0xDD FILL EXISTS TO TEST, at unit scope: after +// the source bytes are overwritten - which is exactly what the decoder does to a retired +// SEG_STAGE run - the server's copy still reads the original. An implementation that returned +// `raw - offset` cannot pass this, and the monolith arm below is the control that says so: it +// is the SAME call with the same inputs, and it must see the 0xDD. +TEST(StagedShadowTest, TheSplitArmCopiesAndSurvivesTheSourceBeingPoisoned) { + Server::StagedShadowStore splitStore(/*copies=*/true); + Server::StagedShadowStore monolithStore(/*copies=*/false); + const int key = 0; + + Vector staged(32, 0xAB); + const Uint8* splitBase = splitStore.Adopt(&key, 64, staged.data(), 16, staged.size()); + const Uint8* monolithBase = monolithStore.Adopt(&key, 64, staged.data(), 16, staged.size()); + + ASSERT_NE(splitBase, nullptr); + EXPECT_EQ(monolithBase, staged.data() - 16) + << "the monolith arm must be the ORIGINAL expression, character for character, or every " + "push and verify lane is running new code it was never measured against"; + EXPECT_NE(splitBase, monolithBase); + + // w1's retired-stage poison, by hand and at the right moment: the record has retired, so + // the staging run is dead. + std::fill(staged.begin(), staged.end(), Uint8{0xDD}); + + for (SizeT i = 0; i < 32; ++i) { + EXPECT_EQ(splitBase[16 + i], 0xAB) << "byte " << i << " of the server's copy is the " + "poison, so the copy never happened"; + } + // And the control: the monolith arm reads the poison, which is what makes the assertion + // above a statement about copying rather than about the test's own buffer. + EXPECT_EQ(monolithBase[16], 0xDD); +} + +// The extent is EXACTLY what the record declared. Adjacent runs merge because there is no gap +// between them; runs with a gap do not, and that is the whole mechanism - it is what makes a +// missing record detectable instead of papered over by a widened INVALIDATE_RANGE. +TEST(StagedShadowTest, CoverageIsExactAndAGapIsNotCovered) { + Server::StagedShadowStore store(/*copies=*/true); + const int key = 0; + Vector bytes(16, 0x11); + + store.Adopt(&key, 64, bytes.data(), 0, 16); + store.Adopt(&key, 64, bytes.data(), 32, 16); + EXPECT_EQ(store.CoveredRunCount(&key), 2u) << "two runs with a 16-byte gap merged into one"; + EXPECT_TRUE(store.IsCovered(&key, 0, 16)); + EXPECT_TRUE(store.IsCovered(&key, 32, 48)); + EXPECT_FALSE(store.IsCovered(&key, 16, 32)) << "the gap reads as covered"; + EXPECT_FALSE(store.IsCovered(&key, 0, 48)) << "a span across the gap reads as covered"; + EXPECT_FALSE(store.IsCovered(&key, 8, 40)); + + // Filling the gap merges all three into one run, which is the proof that adjacency (not + // proximity) is the merge rule. + store.Adopt(&key, 64, bytes.data(), 16, 16); + EXPECT_EQ(store.CoveredRunCount(&key), 1u); + EXPECT_TRUE(store.IsCovered(&key, 0, 48)); +} + +TEST(StagedShadowTest, DropForgetsOneResourceAndDropAllForgetsEveryOne) { + Server::StagedShadowStore store(/*copies=*/true); + const int a = 0; + const int b = 0; + Vector bytes(8, 0x22); + store.Adopt(&a, 8, bytes.data(), 0, 8); + store.Adopt(&b, 8, bytes.data(), 0, 8); + ASSERT_EQ(store.TrackedResources(), 2u); + store.Drop(&a); + EXPECT_EQ(store.TrackedResources(), 1u); + EXPECT_FALSE(store.IsCovered(&a, 0, 8)); + EXPECT_TRUE(store.IsCovered(&b, 0, 8)); + store.DropAll(); + EXPECT_EQ(store.TrackedResources(), 0u); +} + +// The Fatal, and it asserts ITS OWN failure string rather than "the process died". A death +// test that only checks for a crash goes green on any other abort in the same body, which is +// exactly the shape this wave shipped three of. +#if !defined(_WIN32) +TEST(StagedShadowTest, ADrainOutsideTheStagedCoverageIsFatalByName) { + Server::StagedShadowStore store(/*copies=*/true); + const int key = 0; + Vector bytes(16, 0x33); + const Uint8* base = store.Adopt(&key, 64, bytes.data(), 0, 16); + + // In coverage: no Fatal, and this is asserted first so that the death below cannot be a + // function that aborts on everything. + store.RequireCoverage(&key, base, 0, 16, "unit"); + // A base that is not this resource's shadow is not this rule's subject - that is the + // legacy arm, whose MappedData() is valid for the whole store. + store.RequireCoverage(&key, bytes.data(), 0, 64, "unit"); + + EXPECT_DEATH(store.RequireCoverage(&key, base, 0, 64, "unit_out_of_range"), ""); + const std::string log = ReadLog(); + EXPECT_NE(log.find("Fatal{StageSnapshotTooNarrow, \"unit_out_of_range\"}"), std::string::npos) + << "the abort happened but not for this rule's reason; the log says: " << log; +} +#endif + +int main(int argc, char** argv) { + // Before anything logs: MG_Util::Debug::InitFile() reads the variable once, on the first + // write, and caches the FILE*. The name carries this process's pid, because + // gtest_discover_tests runs every case as its own process, in parallel under ctest -j. + namespace fs = std::filesystem; + const fs::path path = + fs::temp_directory_path() / ("mobilegl-serverloop-test-" + std::to_string(ProcessId()) + ".log"); + std::error_code ec; + fs::remove(path, ec); + g_logPath = path.string(); +#if defined(_WIN32) + _putenv_s("MOBILEGL_LOG_FILE_PATH", g_logPath.c_str()); +#else + setenv("MOBILEGL_LOG_FILE_PATH", g_logPath.c_str(), 1); +#endif + // THIS PROCESS IS A SPLIT ONE. Everything under test reads MG_Config::Transport - the + // staged-shadow arm, the applier's stamp path, ConfigLoader's own knobs - and a suite that + // left it at Monolith would be a server test running the monolith answers, which is the + // failure this phase is built to make impossible. + MG_Config::Transport = MG_Config::TransportMode::InProcess; + ::testing::InitGoogleTest(&argc, argv); + const int rc = RUN_ALL_TESTS(); + fs::remove(path, ec); + return rc; +} From 571cbabb5310b42c0cd2ca3d5be83708ddee229d Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Wed, 16 Sep 2026 05:54:33 -0400 Subject: [PATCH 6/6] [Fix] (MobileGL/Init, MG_Backend): ShutdownSplitRoles exists only under MOBILEGL_BUILD_DISAGGREGATED - G1 admits no new pull symbol and the unconditional call moved DestroyImpl by 32 bytes --- MobileGL/Init.cpp | 8 ++++++-- MobileGL/MG_Backend/BackendObjects.h | 8 +++++--- MobileGL/MG_Backend/Init.cpp | 4 ++-- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/MobileGL/Init.cpp b/MobileGL/Init.cpp index dde56bdd..2436d792 100644 --- a/MobileGL/Init.cpp +++ b/MobileGL/Init.cpp @@ -43,11 +43,15 @@ namespace MobileGL { if (logLifecycle) { MGLOG_I("MobileGL closing..."); } +#if MOBILEGL_BUILD_DISAGGREGATED // P5: the split roles come down FIRST - ARCHITECTURE.md:537's order puts the whole // of it before MobileGL::Destroy(), and this function IS MobileGL::Destroy. A no-op - // in a monolith build and in a monolith run; see BackendObjects.h for why the - // position rather than the call is the load-bearing part. + // in a monolith RUN; absent from a monolith BUILD, because G1 admits no new pull + // symbol and no resized one (the first version called it unconditionally and moved + // DestroyImpl by 32 bytes). See BackendObjects.h for why the position rather than + // the call is the load-bearing part. MG_Backend::ShutdownSplitRoles(); +#endif // Before any subsystem the counters name goes away, and before the last frame's // numbers can be lost: emits the final summary line and, when // MOBILEGL_PIPE_STATS_FILE is set, the JSON dump. A no-op when the counters are diff --git a/MobileGL/MG_Backend/BackendObjects.h b/MobileGL/MG_Backend/BackendObjects.h index fcff0086..8d71c674 100644 --- a/MobileGL/MG_Backend/BackendObjects.h +++ b/MobileGL/MG_Backend/BackendObjects.h @@ -16,9 +16,10 @@ namespace MobileGL::MG_Backend { extern UniquePtr& pActiveBackendObject; extern GlobalBackendFunctionsTable gBackendFunctionsTable; - // P5 v1. The counterpart of Init()'s single split hook, and a NO-OP in every build and - // every run that is not split - which is why MobileGL/Init.cpp can call it unconditionally - // without a second #if in a file no package owns. +#if MOBILEGL_BUILD_DISAGGREGATED + // P5 v1. The counterpart of Init()'s single split hook, and a NO-OP in every run that is + // not split. It exists ONLY in a split build: G1 admits no new symbol in the pull build, + // so MobileGL/Init.cpp's call sits under the same #if rather than calling a no-op. // // IT MUST RUN FIRST, BEFORE ANYTHING ELSE IN DestroyImpl. ARCHITECTURE.md:537's order is // publish -> the server drains and acks -> stop the apply thread (Kill, then join) -> close @@ -38,4 +39,5 @@ namespace MobileGL::MG_Backend { // client-minted and needs no backend call, which is P10's. CONTRACT-P5 4 flags this as a // KNOWN OPEN ITEM and asks v1 to record which way it went: P5 keeps today's order. void ShutdownSplitRoles(); +#endif } // namespace MobileGL::MG_Backend diff --git a/MobileGL/MG_Backend/Init.cpp b/MobileGL/MG_Backend/Init.cpp index 6e09bbe9..e3d7f9ab 100644 --- a/MobileGL/MG_Backend/Init.cpp +++ b/MobileGL/MG_Backend/Init.cpp @@ -170,8 +170,8 @@ namespace MobileGL::MG_Backend { } // namespace #endif - void ShutdownSplitRoles() { #if MOBILEGL_BUILD_DISAGGREGATED + void ShutdownSplitRoles() { if (MG_Config::Transport == MG_Config::TransportMode::Monolith) return; // ClientSession::Stop IS table 3's whole order and it is idempotent: publish and wait // for the server to drain (bounded - a lost record must be a red lane, not a hung @@ -181,8 +181,8 @@ namespace MobileGL::MG_Backend { // owns. A var-tail still named by an unapplied record is a use-after-free the join is // what prevents, which is why the order is not a preference. MG_Remote::Client::ClientSessionInstance().Stop(); -#endif } +#endif void Init() { MGLOG_D("Initializing MobileGL Backend...");