Merge branch 'p5/v1' into p5/c1

# Conflicts:
#	MobileGL/MG_Test/Wire/CMakeLists.txt
This commit is contained in:
2026-09-16 06:43:30 -04:00
11 changed files with 2471 additions and 70 deletions
+9
View File
@@ -43,6 +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 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
+25
View File
@@ -15,4 +15,29 @@
namespace MobileGL::MG_Backend {
extern UniquePtr<BackendObject>& pActiveBackendObject;
extern GlobalBackendFunctionsTable gBackendFunctionsTable;
#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
// 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();
#endif
} // namespace MobileGL::MG_Backend
+144 -7
View File
@@ -16,6 +16,12 @@
#include <MG_Pipe/MGPipeHostSpan.h>
#include <MG_Pipe/PipeApply.h>
#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 <MG_Remote/Server/StagedShadow.h>
#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<std::mutex> 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<const Uint8*>(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<const Uint8*>(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<SizeT>(desc.Width),
initialBytes, 0,
static_cast<SizeT>(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<std::mutex> lock(resource->pendingMutex);
resource->hostBytes = static_cast<const Uint8*>(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<SizeT>(record.Offset);
const SizeT end = start + static_cast<SizeT>(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<std::mutex> lock(resource->pendingMutex);
resource->hostBytes = static_cast<const Uint8*>(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<SizeT>(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) {
+161
View File
@@ -11,6 +11,26 @@
#include <MG_Util/BackendLoaders/OpenGL/Loader.h>
#include <MG_Util/Converters/MGToStr/GLExtensionConverter.h>
#if MOBILEGL_BUILD_DISAGGREGATED
#include <MG_Pipe/MGPipe.h>
#include <MG_Pipe/PipeApply.h>
#include <MG_Remote/Client/ClientSession.h>
#include <MG_Remote/Server/ServerLoop.h>
#include <MG_Remote/Server/ServerSession.h>
#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<MG_Backend::BackendObject> 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<int>(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
#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
// 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<DirectGLES::BackendObject_DirectGLES>();
+317 -19
View File
@@ -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 <Config.h>
#include <MG_Backend/MGPipe/PipeInputs.h>
#include <MG_Util/Debug/Log.h>
#include <cstdlib>
#include <cstring>
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<Uint8*>(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<GLbitfield>(clear.BufferMask));
break;
case kMGPClearKindColor:
switch (clear.ValueClass) {
case kMGPClearValueClassFloat:
if (gl.ClearBufferfv == nullptr) return false;
gl.ClearBufferfv(GL_COLOR, clear.DrawBufferIndex,
reinterpret_cast<const GLfloat*>(clear.ColorValue));
break;
case kMGPClearValueClassInt:
if (gl.ClearBufferiv == nullptr) return false;
gl.ClearBufferiv(GL_COLOR, clear.DrawBufferIndex,
reinterpret_cast<const GLint*>(clear.ColorValue));
break;
case kMGPClearValueClassUint:
if (gl.ClearBufferuiv == nullptr) return false;
gl.ClearBufferuiv(GL_COLOR, clear.DrawBufferIndex,
reinterpret_cast<const GLuint*>(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<GLbitfield>(blit.Mask),
static_cast<GLenum>(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<unsigned long long>(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<SizeT>(info.DstSize));
}
table->GL.ReadPixels(info.Box.X, info.Box.Y, static_cast<GLsizei>(info.Box.W),
static_cast<GLsizei>(info.Box.H), static_cast<GLenum>(info.Format),
static_cast<GLenum>(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<GLenum>(info.Mode), static_cast<GLint>(range.Start),
static_cast<GLsizei>(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<std::uintptr_t>(range.Start) * info.IndexSize;
gl.DrawElementsBaseVertex(static_cast<GLenum>(info.Mode),
static_cast<GLsizei>(range.Count), indexType,
reinterpret_cast<const void*>(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<MG_Pipe::MGPWireOp>(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<void*>(run), 0xDD, static_cast<SizeT>(size));
}
Uint64 PipeApplier::PoisonedStageBytes() const { return m_decoder.PoisonedStageBytes(); }
Uint64 PipeApplier::DecoderAppliedSeq() const { return m_decoder.AppliedSeq(); }
} // namespace MobileGL::MG_Remote::Server
+118 -4
View File
@@ -40,6 +40,7 @@
#pragma once
#include <Includes.h>
#include <MG_Backend/BackendObject.h>
#include <MG_Pipe/MGPipe.h>
#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<Uint8> 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
+732 -17
View File
@@ -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 <Config.h>
#include <MG_Backend/BackendObjects.h>
#include <MG_Util/Debug/Log.h>
#include <chrono>
#include <cstdlib>
#include <cstdio>
#if defined(__linux__) || defined(__ANDROID__)
#include <pthread.h>
#include <sched.h>
#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<Uint64>(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<Uint64>(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<MG_Backend::DirectGLES::BackendObject_DirectGLES>();
break;
case BackendType::DirectVulkan:
m_backend = MakeUnique<MG_Backend::DirectVulkan::BackendObject_DirectVulkan>();
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<std::mutex> 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<unsigned long long>(requested),
static_cast<unsigned long long>(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<std::mutex> 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<std::mutex> lock(const_cast<std::mutex&>(m_controlMutex));
return m_controlPending;
}
Bool ServerLoop::PumpControlRequest() {
ControlWork work = nullptr;
void* user = nullptr;
{
const std::lock_guard<std::mutex> 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<std::mutex> 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<unsigned long long>(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<unsigned long long>(consumer.AppliedSeq()),
static_cast<unsigned long long>(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<std::mutex> callerLock(m_callerMutex);
{
std::unique_lock<std::mutex> 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<std::mutex> lock(m_controlMutex);
m_controlDone.wait(lock, [this] { return m_controlFinished; });
return m_controlResult;
}
void ServerLoop::SignalExited() {
{
const std::lock_guard<std::mutex> 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<std::mutex> 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 <class Args>
MobileGLResult RunOnApply(Args& args) {
return ServerLoopInstance().RunOnApplyThread(
+[](void* user) -> MobileGLResult { return static_cast<Args*>(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<int>(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<MG_Backend::BackendObject> 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
+124 -1
View File
@@ -45,6 +45,11 @@
#include "ServerSession.h"
#include <atomic>
#include <condition_variable>
#include <mutex>
#include <thread>
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<Bool> m_running{false};
std::atomic<Bool> m_stopRequested{false};
std::thread m_thread;
std::atomic<std::thread::id> m_applyThreadId{};
// The private backend object. Destroyed ON the apply thread while it still owns the
// context - see Stop().
UniquePtr<MG_Backend::BackendObject> 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<Uint64> m_drained{0};
std::atomic<Uint64> 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
+182
View File
@@ -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 <Includes.h>
#include <MG_Util/Debug/Log.h>
#include <MG_Util/Math/VectorTypes.h>
#include <algorithm>
#include <cstdlib>
#include <cstring>
#include <mutex>
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<const Uint8*>(bytes);
if (!m_copies) return raw - offset;
const std::lock_guard<std::mutex> lock(m_mutex);
Shadow& shadow = m_shadows[key];
const SizeT needed = std::max<SizeT>(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<std::mutex> lock(m_mutex);
m_shadows.erase(key);
}
void DropAll() {
if (!m_any.load(std::memory_order_acquire)) return;
const std::lock_guard<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<Range1D>& covered, SizeT start, SizeT end) {
if (start >= end) return;
Vector<Range1D> 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<Range1D>& 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<Uint8> Bytes;
// Sorted, disjoint, EXACT.
Vector<Range1D> Covered;
};
const Bool m_copies;
mutable std::mutex m_mutex;
ska::flat_hash_map<const void*, Shadow> 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<Bool> m_any{false};
};
} // namespace MobileGL::MG_Remote::Server
+34 -22
View File
@@ -66,29 +66,41 @@ endif ()
gtest_discover_tests(PipeWireCodecTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
# P5 c1's suite: the 71-slot emit table, the caps mirror and R-8's liveness gates. Registered
# on its own for PipeWireCodecTest's reason - MGLOG_F writes to stdout and to a named file and
# NEVER to stderr, so a Fatal arm can only be asserted by forking and reading that file back,
# which needs a main() of its own.
add_executable(RemoteClientTest RemoteClientTest.cpp)
# P5 c1's and v1's suites. Both are registered on their own, and both for
# PipeWireCodecTest's reason - MGLOG_F writes to stdout and to a named file and NEVER to
# stderr, so a Fatal arm can only be asserted by forking and reading that file back, which
# needs a main() of its own. Both also reach MG_Pipe, MG_Backend and MG_State, so both carry
# those include paths.
#
# RemoteClientTest the 71-slot emit table, the caps mirror, R-8's liveness gates and
# R-17's routing (c1)
# ServerLoopTest the apply thread, the blocking control mailbox, the verb stamp and
# R-11's server-owned staging copy (v1)
#
# THE ONLY CONFLICT IN THE c1/v1 MERGE, and it is the same-point-append shape BRIEF §5's
# ownership table exists to prevent: two packages each added a target at the end of one file.
# Both blocks are kept verbatim; nothing is chosen over anything.
foreach (wiretest IN ITEMS RemoteClientTest ServerLoopTest)
add_executable(${wiretest} ${wiretest}.cpp)
target_include_directories(RemoteClientTest 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_include_directories(${wiretest} 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(RemoteClientTest PRIVATE
GTest::gtest
${LINK_LIBRARIES}
)
target_link_libraries(${wiretest} PRIVATE
GTest::gtest
${LINK_LIBRARIES}
)
if (MSVC)
target_compile_options(RemoteClientTest PRIVATE /Zc:preprocessor)
endif ()
if (MSVC)
target_compile_options(${wiretest} PRIVATE /Zc:preprocessor)
endif ()
gtest_discover_tests(RemoteClientTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
gtest_discover_tests(${wiretest} DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
endforeach ()
+625
View File
@@ -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 <Config.h>
#include <MG_Backend/MGPipe/PipeInputs.h>
#include <MG_Pipe/MGPipe.h>
#include <MG_Remote/CapsCodec.h>
#include <MG_Remote/Protocol/generated/protocol_generated.h>
#include <MG_Remote/Server/PipeApplier.h>
#include <MG_Remote/Server/ServerLoop.h>
#include <MG_Remote/Server/ServerSession.h>
#include <MG_Remote/Server/StagedShadow.h>
#include <MG_Remote/Transport/InProcessTransport.h>
#include <MG_Remote/Transport/Ring.h>
#include <MG_Remote/Transport/SessionRings.h>
#include <MG_Remote/Wire/PipeWireCodec.h>
#include <MGGitHash.h>
#include <gtest/gtest.h>
#include <chrono>
#include <cstring>
#include <cstdio>
#include <filesystem>
#include <fstream>
#include <memory>
#include <string>
#include <thread>
#include <vector>
#if defined(_WIN32)
#include <process.h>
#else
#include <unistd.h>
#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<char>(in), std::istreambuf_iterator<char>());
}
unsigned ProcessId() {
#if defined(_WIN32)
return static_cast<unsigned>(_getpid());
#else
return static_cast<unsigned>(::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<Transport::InProcessTransport> clientTransport;
std::unique_ptr<Transport::InProcessTransport> 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<Probe*>(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<Outer*>(user);
o->innerRc = Server::ServerLoopInstance().RunOnApplyThread(
+[](void* inner) -> MobileGLResult {
*static_cast<Bool*>(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<std::chrono::milliseconds>(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<Uint64>(i + 1));
EXPECT_EQ(fixture.session->Applier().DecoderAppliedSeq(), static_cast<Uint64>(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<Uint8>(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<Uint8> texels(4 * 4 * 4, 0x5A);
MG_Pipe::MGPSubData upload{};
upload.Res = create.Resource;
upload.Target = MG_Pipe::MGPipePackSubDataTarget(
static_cast<Uint32>(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), &region, 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<const Uint8*>(
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<Uint8> 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<Uint8> 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<Uint8> 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<Uint8> 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;
}