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