diff --git a/CMakeLists.txt b/CMakeLists.txt index c354766f..b029d34e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -533,6 +533,13 @@ if (MOBILEGL_PIPE_PUSH) # by pointer beside the record), so the codec is live code only in the VERIFY lane, # where the applier serialises, deserialises and field-compares before storing. MobileGL/MG_State/GLState/ProgramState/ProgramArtifactsCodec.cpp + # P5's contract (integrator ruling R-17, package c1): the MONOLITH arm of the + # client->wire routing - the thirty-seven adapters that install gMGPipeScreen / + # gMGPipeContext over the MGPipeApply* entry points, and the reply mailbox the four + # acceptance rows answer through. Push-only for the same G1 reason as the five above: + # the two tables are inline variables that are zero in a pull build and nothing there + # can reach a thunk. + MobileGL/MG_Pipe/PipeRoute.cpp ) endif() @@ -571,6 +578,15 @@ if (MOBILEGL_BUILD_DISAGGREGATED) MobileGL/MG_Remote/Client/ClientSession.cpp MobileGL/MG_Remote/Client/EmitTables.cpp MobileGL/MG_Remote/Client/CapsMirror.cpp + # P5 c1: the client role's BackendObject. pActiveBackendObject holds one of these + # under split (table 3); the hook that installs it is v1's, in MG_Backend/Init.cpp. + MobileGL/MG_Remote/Client/BackendObject_Remote.cpp + # P5 c1, ruling R-17: the ENCODE TWIN of gMGPipeWireRecordApply - the thirty-seven + # emitters installed over the two generated tables, which is what stops every + # resource/CSO/texture/program record executing synchronously on the GL thread under + # inproc. The monolith arm of the same routing is MG_Pipe/PipeRoute.cpp, in the + # PIPE_PUSH list above, because it must exist in a push build that has no MG_Remote. + MobileGL/MG_Remote/Client/WireTables.cpp # P5 b1's two: the conservative GPU-write set the client must build because all six # MarkGpuWritten producers are on the server's side of the line, and the # block-granularity persistent-map push that tier T2 makes mandatory. diff --git a/MobileGL/Init.cpp b/MobileGL/Init.cpp index 48028625..2436d792 100644 --- a/MobileGL/Init.cpp +++ b/MobileGL/Init.cpp @@ -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 diff --git a/MobileGL/MG_Backend/BackendObjects.h b/MobileGL/MG_Backend/BackendObjects.h index 1a148824..8d71c674 100644 --- a/MobileGL/MG_Backend/BackendObjects.h +++ b/MobileGL/MG_Backend/BackendObjects.h @@ -15,4 +15,29 @@ namespace MobileGL::MG_Backend { extern UniquePtr& 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 diff --git a/MobileGL/MG_Backend/DirectGLES/Managers.cpp b/MobileGL/MG_Backend/DirectGLES/Managers.cpp index 00ec6b81..a9adb1d4 100644 --- a/MobileGL/MG_Backend/DirectGLES/Managers.cpp +++ b/MobileGL/MG_Backend/DirectGLES/Managers.cpp @@ -16,6 +16,12 @@ #include #include #endif +#if MOBILEGL_BUILD_DISAGGREGATED +// R-11's server-owned staging copy. Header-only and package v1's; see its own header block for +// why GLESBufferResource does not simply gain a member. +#include +#endif + #include "Utils.h" #include "DirectGLES.h" #include "BackendObject_DirectGLES.h" @@ -1008,6 +1014,77 @@ namespace MobileGL::MG_Backend::DirectGLES { return StorageMatchesSize(resource, bufferObject.GetSize()); } +#if MOBILEGL_BUILD_DISAGGREGATED + // ------------------------------------------------------------------------------- + // R-11 - THE SERVER'S OWN COPY OF THE STAGED BYTES. Package v1. + // + // The rule, the evidence and the reason the storage is a side table rather than a + // member of GLESBufferResource are all in MG_Remote/Server/StagedShadow.h. This is + // only the instance and the four call sites. + // + // ONE PER PROCESS, and its copying arm is decided ONCE at first use: the two arms + // hold the authoritative bytes in DIFFERENT places, so an answer that changed + // mid-run would strand every resource already staged - the same reason + // ResolveResourceSubsystemArm latches (Managers.h). Leaked at exit like every other + // MG_Remote singleton (ID-8): ~BufferObject reaches the destroy path from exit + // handlers, after this TU's globals would already be gone. + // ------------------------------------------------------------------------------- + MG_Remote::Server::StagedShadowStore& ServerStaged() { + static MG_Remote::Server::StagedShadowStore& store = + *new MG_Remote::Server::StagedShadowStore( + MG_Config::Transport != MG_Config::TransportMode::Monolith); + return store; + } + + // THE COVERAGE RULE FOR THE THREE-TIER FLUSH DRAIN, ASSERTED BEFORE THE CALL AND NOT + // INSIDE IT. FlushPendingRangesFrom is a G5-pinned body (p3a_untouched_regions.sh's + // PINNED_FUNCTIONS, ID-41): the split arm CALLS it, it does not re-spell it, and it + // does not add a line to it either. Its tier-1 arm - the range-invalidating map - + // copies with its own Memcpy and never reaches UploadRangeFrom, so the one tier that + // DECLARES THE OLD BYTES DEAD is the one tier no later check can see; the only place + // left to say "every queued range is inside the staged coverage" is the instant before + // the drain takes the queue. The clamp is the drain's own (limit = the smaller of the + // frontend size and the backend store; bytes past either end have nowhere to land and + // are not this rule's subject), so the two agree about which bytes are meant. + // + // Fires only for a base that IS this resource's server shadow: RequireCoverage + // answers nothing for the legacy arm's MappedData(), which is valid for the whole + // store. `pendingMutex` is taken here and released before the drain takes it again. + void RequireStagedCoverageForPendingRanges(GLESBufferResource& resource, const Uint8* hostBase, + SizeT frontendSize, const char* site) { + if (hostBase == nullptr) return; + const SizeT limit = std::min(frontendSize, resource.storageSize); + const std::lock_guard lock(resource.pendingMutex); + for (const auto& range : resource.pendingRanges) { + const SizeT end = std::min(range.end, limit); + const SizeT start = std::min(range.start, end); + if (start == end) continue; + ServerStaged().RequireCoverage(&resource, hostBase, start, end, site); + } + } +#endif // MOBILEGL_BUILD_DISAGGREGATED + +// The four hostBytes sites read the same in both builds. The non-split expansion is the +// ORIGINAL EXPRESSION, character for character - `raw - offset` - so a push or verify build +// compiles exactly what it compiled before R-11 and the split arm is the only new behaviour. +#if MOBILEGL_BUILD_DISAGGREGATED +#define MGL_SERVER_STAGED_ADOPT(res, width, bytes, offset, size) \ + ServerStaged().Adopt(&(res), (width), (bytes), (offset), (size)) +#define MGL_SERVER_STAGED_DROP(res) ServerStaged().Drop(&(res)) +#define MGL_SERVER_STAGED_DROP_ALL() ServerStaged().DropAll() +#define MGL_SERVER_STAGED_REQUIRE(res, base, start, end, site) \ + ServerStaged().RequireCoverage(&(res), (base), (start), (end), (site)) +#define MGL_SERVER_STAGED_REQUIRE_PENDING(res, base, frontendSize, site) \ + RequireStagedCoverageForPendingRanges((res), (base), (frontendSize), (site)) +#else +#define MGL_SERVER_STAGED_ADOPT(res, width, bytes, offset, size) \ + (static_cast(bytes) - (offset)) +#define MGL_SERVER_STAGED_DROP(res) ((void)0) +#define MGL_SERVER_STAGED_DROP_ALL() ((void)0) +#define MGL_SERVER_STAGED_REQUIRE(res, base, start, end, site) ((void)0) +#define MGL_SERVER_STAGED_REQUIRE_PENDING(res, base, frontendSize, site) ((void)0) +#endif + // The host bytes a range upload/flush reads. On the legacy arm the frontend object's // shadow; on the handle arm the base the last content-carrying call handed over. void UploadRangeFrom(GLESBufferResource& resource, const Uint8* hostBase, SizeT start, SizeT end) { @@ -1015,6 +1092,9 @@ namespace MobileGL::MG_Backend::DirectGLES { ZoneScopedC(TRACY_ZONECOLOR_BACKEND); #endif if (start >= end || hostBase == nullptr) return; +#if MOBILEGL_BUILD_DISAGGREGATED + MGL_SERVER_STAGED_REQUIRE(resource, hostBase, start, end, "upload_range"); +#endif BindBufferId(TempBufferTarget, resource.id); g_GLESFuncs.glBufferSubData(TempBufferTarget, (GLintptr)start, (GLsizeiptr)(end - start), hostBase + start); @@ -1946,9 +2026,21 @@ namespace MobileGL::MG_Backend::DirectGLES { // every reader below treats a null base as "no bytes to move", which is the // honest answer, and the ensure path re-reads the live base from the // frontend object it still holds. - resource->hostBytes = (desc.HasDefinedContent != 0 && initialBytes != nullptr) - ? static_cast(initialBytes) - : nullptr; + if (desc.HasDefinedContent != 0 && initialBytes != nullptr) { + // R-11: in monolith this is the client's shadow base, unchanged; under + // split it is copied into server-owned storage first. A respecify's + // companion pointer is the base at offset 0 and covers the whole store. + // Under split it is ALWAYS null (contract table 1 row 19 - + // initialBytes does not cross, and the content arrives as + // resource_subdata records right behind this record), so in practice + // this arm is the monolith one and the else arm is the split one. + resource->hostBytes = MGL_SERVER_STAGED_ADOPT(*resource, static_cast(desc.Width), + initialBytes, 0, + static_cast(desc.Width)); + } else { + MGL_SERVER_STAGED_DROP(*resource); + resource->hostBytes = nullptr; + } } if (!resource) return; // lazy: the ensure path full-uploads on creation if (resource->immutableStorage) { @@ -2018,7 +2110,12 @@ namespace MobileGL::MG_Backend::DirectGLES { // and the ensure path's republication (the 3c55e027 fix) is what runs at a draw. if (bytes != nullptr) { const std::lock_guard lock(resource->pendingMutex); - resource->hostBytes = static_cast(bytes) - offset; + // R-11: MONOLITH keeps the client's base; SPLIT copies [offset, offset+size) + // into server-owned storage and points hostBytes at that. Inside the SAME + // lock as the queued range, because the base and the range are one fact + // ("these bytes, at this base") and the drain takes this mutex to lift them. + resource->hostBytes = + MGL_SERVER_STAGED_ADOPT(*resource, ResourceWidthOf(res), bytes, offset, size); } if (resource->pendingRespecify) return; // full re-upload pending anyway if (!CanTouchGLNow() || resource->id == 0 || @@ -2067,10 +2164,18 @@ namespace MobileGL::MG_Backend::DirectGLES { if (!resource) return; const SizeT start = static_cast(record.Offset); const SizeT end = start + static_cast(record.Size); - // M-2, the second store site - same lock, same reason as Ops_H_SubData's. + // M-2, the second store site - same lock, same reason as Ops_H_SubData's, and + // the same R-11 copy. UNDER SPLIT `bytes` IS ALWAYS NULL HERE by ruling (C-6 / + // contract table 1 row 20: resource_flush_range carries no bytes at all - the + // ladder it drives rewrites its range from the authoritative shadow, which rule + // C makes server-owned, so resource_subdata is already the only way bytes reach + // it and a second carrier would be a forgeable way to say the same thing). So + // this arm keeps whatever the preceding subdata records staged, which is + // exactly what the ladder must read. if (bytes != nullptr) { const std::lock_guard lock(resource->pendingMutex); - resource->hostBytes = static_cast(bytes) - start; + resource->hostBytes = MGL_SERVER_STAGED_ADOPT(*resource, ResourceWidthOf(res), bytes, + start, end - start); } if (resource->pendingRespecify) return; if (!CanTouchGLNow() || resource->id == 0 || @@ -2100,6 +2205,14 @@ namespace MobileGL::MG_Backend::DirectGLES { resource->hostBytes != nullptr) { #ifdef TRACY_ENABLE ZoneScopedC(TRACY_ZONECOLOR_BACKEND); +#endif +#if MOBILEGL_BUILD_DISAGGREGATED + // The kill-switch arm's own Memcpy, and the one that passes + // GL_MAP_INVALIDATE_RANGE_BIT - i.e. the one that tells the driver the old + // bytes are dead. Bytes outside the staged coverage are exactly the ones + // that claim is false for. + MGL_SERVER_STAGED_REQUIRE(*resource, resource->hostBytes, start, end, + "flush_range_invalidate_map"); #endif BindBufferId(TempBufferTarget, resource->id); void* mappedData = g_GLESFuncs.glMapBufferRange( @@ -2145,7 +2258,12 @@ namespace MobileGL::MG_Backend::DirectGLES { if (size == 0) return; // Queued app writes must land in the backend store before it is read back, or - // the writeback below would revert them in the shadow. + // the writeback below would revert them in the shadow. Under split every queued + // range must lie inside the server shadow's staged coverage first (R-11 / the + // M-6 ruling): the drain's tier-1 map would otherwise declare live GPU bytes + // dead over a range nothing staged. + MGL_SERVER_STAGED_REQUIRE_PENDING(*resource, resource->hostBytes, ResourceWidthOf(res), + "readback_flush_pending"); FlushPendingRangesFrom(*resource, resource->hostBytes, ResourceWidthOf(res)); BindBufferId(TempBufferTarget, resource->id); @@ -2187,6 +2305,12 @@ namespace MobileGL::MG_Backend::DirectGLES { } void Ops_H_Destroy(MG_Pipe::MGPipeHandle res) { + // R-11's server copy dies with the resource, and BEFORE the twin leaves the + // table - the side map is keyed by the twin's address, so this is the last + // moment that address can be looked up. + if (GLESBufferResource* dying = FindBufferResourceForHandle(res); dying != nullptr) { + MGL_SERVER_STAGED_DROP(*dying); + } // The twin comes OUT of the table first, so the three outcomes below are // reached with the entry already retired. The SLOT is the client's to free, // after this returns (D-L). @@ -2272,6 +2396,9 @@ namespace MobileGL::MG_Backend::DirectGLES { // PipeResource::AdoptPersistentMap then does m_shadow->clear() + // shrink_to_fit(), so the shadow base any earlier content-carrying call // recorded is a FREED allocation from here on. The live bytes are persistentPtr. + // R-11's server copy goes with it: the bytes are the coherent map's now, and a + // stale server shadow would answer a later drain with pre-map content. + MGL_SERVER_STAGED_DROP(*resource); resource->hostBytes = nullptr; resource->storageSize = static_cast(size); resource->storageInitialized = true; @@ -2631,6 +2758,13 @@ namespace MobileGL::MG_Backend::DirectGLES { // handles (no GL) and let the next draw / texture upload recreate them. ResetRingForNewContext(g_uboRing); ResetRingForNewContext(g_unpackRing); +#if MOBILEGL_PIPE_PUSH + // R-11's server copies die with the context for the same reason the rings' ids do: + // the resource twins they are keyed by are about to be rebuilt against a new + // generation, and a shadow that outlived its twin would be looked up by a RECYCLED + // address on the next allocation - which is the quietest possible wrong answer. + MGL_SERVER_STAGED_DROP_ALL(); +#endif } void ProcessDeferredBufferReleases() { @@ -2920,6 +3054,9 @@ namespace MobileGL::MG_Backend::DirectGLES { if (resource->pendingRespecify || !resource->storageInitialized || resource->storageSize != size) { RespecifyStorageWith(*resource, size, usage, initialData, serial); } else if (!resource->pendingRanges.empty()) { + // Same rule as Ops_H_Readback's, at the draw-time drain: with no frontend object + // `hostBase` is the server shadow and every queued range must be staged. + MGL_SERVER_STAGED_REQUIRE_PENDING(*resource, hostBase, size, "ensure_flush_pending"); FlushPendingRangesFrom(*resource, hostBase, size); resource->syncedChangeSerial = serial; } else if (resource->syncedChangeSerial != serial) { diff --git a/MobileGL/MG_Backend/Init.cpp b/MobileGL/MG_Backend/Init.cpp index fec43324..e3d7f9ab 100644 --- a/MobileGL/MG_Backend/Init.cpp +++ b/MobileGL/MG_Backend/Init.cpp @@ -11,6 +11,26 @@ #include #include +#if MOBILEGL_BUILD_DISAGGREGATED +#include +#include +#include +#include +#include +#endif + +#if MOBILEGL_BUILD_DISAGGREGATED +namespace MobileGL::MG_Remote::Client { + // PACKAGE c1's, DECLARED HERE RATHER THAN INCLUDED. The client's BackendObject_Remote is + // c1's file and does not exist while v1 is written, so v1 ships a WEAK definition of this + // beside ServerLoop that aborts by name. c1's strong definition displaces it at link time + // with no edit to this file - and until then the hook cannot silently succeed, which is the + // only property that matters: a split lane that installed a WORKING monolith backend object + // here would render correctly for entirely the wrong reason (ARCHITECTURE.md 10.3). + UniquePtr CreateRemoteBackendObject(); +} // namespace MobileGL::MG_Remote::Client +#endif + namespace MobileGL::MG_Backend { void LogBackendInfo() { if (!pActiveBackendObject) { @@ -45,9 +65,150 @@ namespace MobileGL::MG_Backend { return true; } +#if MOBILEGL_BUILD_DISAGGREGATED + namespace { + // WHICH MGPipe SUBSYSTEMS THIS SERVER HAS A CONSUMER FOR - CallMask bits 32..47 (R-8 / + // C-4). It is stated from what the server's own backend IS, and NOT derived from + // MGPipeGetResourceOps(): that is a PROCESS-WIDE global, so under inproc a derivation + // would answer with whatever the client half of the same process registered and under + // spawn it would collapse to P2's 0x7f. Either way CapsMirror::ServerConsumes would + // then answer a client-side liveness gate with a guess, the client would stop emitting + // whole record families, clear its dirty flags on acceptance anyway, and the lane would + // go green with the uploads lost - ID-39's 66 lost uploads, reflected (ServerSession.h). + // + // DirectGLES consumes all thirteen migrated families (P2's 0..6, P3a's 7..8, P4a's + // 9..12): it registers the resource op table in Initialize() + // (BackendObject_DirectGLES.cpp:849) and reads every other family out of gPipeInputs. + // DirectVulkan registers NO resource ops - MGPipeSetResourceOps has exactly one caller + // in the whole tree and it is Managers.cpp:2594 - so bit 7 is CLEAR for it, which is + // the same fact ObjectSubsystemControlScenario already pins from the client side. + Uint64 ConsumedSubsystemsFor(BackendType type) { + switch (type) { + case BackendType::DirectGLES: return MG_Pipe::kMGPipeSubsystemsMigratedAtP4a; + case BackendType::DirectVulkan: + return MG_Pipe::kMGPipeSubsystemsMigratedAtP4a & ~MG_Pipe::kMGPipeSubsystemResources; + default: return 0; + } + } + + // THE CROSS-CHECK THAT MAKES A WRONG ANSWER LOUD. Claiming bit 7 while no resource op + // table is registered is the exact failure the mask exists to prevent, one level down: + // the client would keep emitting the resource family and the server would drop every + // record of it. The check runs AFTER Initialize(), which is where DirectGLES registers + // the table, so it can see the real answer rather than a promise. + void AssertConsumerMaskIsHonest(Uint64 mask) { + const Bool claimsResources = (mask & MG_Pipe::kMGPipeSubsystemResources) != 0; + const Bool hasResourceOps = MG_Pipe::MGPipeGetResourceOps() != nullptr; + if (claimsResources && !hasResourceOps) { + MGLOG_F("MGPipe: Fatal{ConsumerMaskLie, \"kMGPipeSubsystemResources\"} - the " + "server published a consumer bit for the resource family while " + "MGPipeGetResourceOps() is null. The client's R-8 liveness gate would " + "keep emitting resource_create / resource_subdata records that this " + "server drops on the floor, and the client clears its dirty flags on " + "acceptance anyway (ID-39). A mask is a statement about this backend, " + "not a hope"); + std::abort(); + } + if (!claimsResources && hasResourceOps) { + // The safe direction: the legacy pull path keeps running. Said out loud anyway, + // because it silently costs the whole P3a family its migration. + MGLOG_W("MG_Remote server: a resource op table is registered but the consumer " + "mask withholds kMGPipeSubsystemResources; the buffer family will fall " + "back to the legacy path for this session"); + } + } + + // The single hook (ARCHITECTURE.md:29). Returns false when the split could not be + // brought up, and the caller then REFUSES TO CONTINUE rather than falling back to the + // switch below - a fallback here is "the split lane ran monolith and went green". + Bool InitSplitRoles() { + using namespace MobileGL::MG_Remote; + + // 1. the SERVER role's private backend object, on the app thread, with no GL and no + // EGL. The context is created and made current later, on mgl-srv-apply, when the + // client's first eglMakeCurrent crosses as a blocking control request. + Server::ServerLoop& loop = Server::ServerLoopInstance(); + const MobileGLResult created = loop.CreateBackend(MG_Config::ActiveBackendType); + if (created != MOBILEGL_OK) return false; + + // 2. the two CallMask halves. NEITHER HAS A DEFAULT and CallMask() is a named Fatal + // on an unset one (s1's BLOCKER fix), so this is the "somebody" that block names. + Server::ServerSession& session = Server::ServerSessionInstance(); + const Uint64 consumed = ConsumedSubsystemsFor(MG_Config::ActiveBackendType); + AssertConsumerMaskIsHonest(consumed); + session.SetConsumedSubsystems(consumed); + // ZERO IS THE EXPLICIT ANSWER FOR P5, not an omission (ServerSession.h's block): + // every optional capability bit belongs to the package that owns its question, and + // withholding one leaves the legacy path running, which is the safe direction. + // kCapNeedsHostIndexBytes and kCapNeedsHostUboBytes must be 0 for the whole of P5 + // by ruling - they are the only two things that ask for an MGHostSpan, and 0 is + // what keeps every one of them out of the first IPC frame (contract table 0). + session.SetCapabilityBits(0); + session.SetBackend(loop.Backend()); + + // 3. the handshake, the four segments, and - at its end - the apply thread. + const MobileGLResult started = + Client::ClientSessionInstance().Start(MG_Config::Transport, MG_Config::TransportEndpoint); + if (started != MOBILEGL_OK) { + MGLOG_E("MG_Remote: the split session failed to start (rc=%d); MobileGL will not " + "fall back to monolith - a lane named split that ran monolith is the one " + "failure this phase is built to make impossible", + static_cast(started)); + return false; + } + + // 4. and only now the CLIENT's backend object in the one global that holds it. + // Table 3: pActiveBackendObject holds BackendObject_Remote and the server's + // BackendObject_DirectGLES stays private to ServerLoop. + pActiveBackendObject = MG_Remote::Client::CreateRemoteBackendObject(); + if (!pActiveBackendObject) { + MGLOG_E("MG_Remote: CreateRemoteBackendObject returned null"); + return false; + } + return true; + } + } // namespace +#endif + +#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(); diff --git a/MobileGL/MG_Impl/GLImpl/Getter/GL_Getter.cpp b/MobileGL/MG_Impl/GLImpl/Getter/GL_Getter.cpp index 7a96b6a0..dec921e9 100644 --- a/MobileGL/MG_Impl/GLImpl/Getter/GL_Getter.cpp +++ b/MobileGL/MG_Impl/GLImpl/Getter/GL_Getter.cpp @@ -31,6 +31,11 @@ #include #include #include +// CONTRACT-P5.md §7 / ID-14: a null check on a GLFunctionsTable slot may not survive into the +// client under split - it becomes a caps-mirror read. SlotCaps.h carries the rule and the test +// that decides which of its two spellings a site takes; in a pull build both expand to exactly +// the check they replaced. +#include namespace MobileGL::MG_Impl::GLImpl { // Declared rather than #included from GL_RenderState.h on purpose: that header also declares @@ -1354,7 +1359,16 @@ namespace MobileGL::MG_Impl::GLImpl { // full 64-bit GPU timestamp survives; LWJGL reads it this way. Int64 timestamp = 0; if (!MG_Config::Features.DisableTimerQuery) { - if (const auto getGpuTimestampNs = MG_Backend::gBackendFunctionsTable.GL.GetGpuTimestampNs) { + // glGetInteger64v(GL_TIMESTAMP). GetGpuTimestampNs is class C - a LIVE GPU + // timestamp is not a static property, so R-15 does not reach it - and the + // documented answer when it is unavailable is 0 (BackendObject.h:192), which + // is correct rather than merely quiet. kCapTimerQuery is the published bit. + // + // The POINTER-valued macro, so the init-statement below is unchanged in a pull + // build and G1 cannot see this edit: the Bool-valued spelling moved this + // function by -150 bytes for no behavioural reason at all. + if (const auto getGpuTimestampNs = + MGL_BACKEND_SLOT_PTR_CAP(GetGpuTimestampNs, MG_Pipe::kCapTimerQuery)) { MGP_FILL(GetGpuTimestampNs); timestamp = getGpuTimestampNs(); } @@ -2265,7 +2279,16 @@ namespace MobileGL::MG_Impl::GLImpl { case GL_TIMESTAMP: { Int64 timestamp = 0; if (!MG_Config::Features.DisableTimerQuery) { - if (const auto getGpuTimestampNs = MG_Backend::gBackendFunctionsTable.GL.GetGpuTimestampNs) { + // glGetInteger64v(GL_TIMESTAMP). GetGpuTimestampNs is class C - a LIVE GPU + // timestamp is not a static property, so R-15 does not reach it - and the + // documented answer when it is unavailable is 0 (BackendObject.h:192), which + // is correct rather than merely quiet. kCapTimerQuery is the published bit. + // + // The POINTER-valued macro, so the init-statement below is unchanged in a pull + // build and G1 cannot see this edit: the Bool-valued spelling moved this + // function by -150 bytes for no behavioural reason at all. + if (const auto getGpuTimestampNs = + MGL_BACKEND_SLOT_PTR_CAP(GetGpuTimestampNs, MG_Pipe::kCapTimerQuery)) { MGP_FILL(GetGpuTimestampNs); timestamp = getGpuTimestampNs(); } diff --git a/MobileGL/MG_Impl/GLImpl/Query/GL_Query.cpp b/MobileGL/MG_Impl/GLImpl/Query/GL_Query.cpp index dd21c0e3..64c990fc 100644 --- a/MobileGL/MG_Impl/GLImpl/Query/GL_Query.cpp +++ b/MobileGL/MG_Impl/GLImpl/Query/GL_Query.cpp @@ -13,6 +13,10 @@ #include #include #include +// CONTRACT-P5.md §7 / ID-14: a null check on a GLFunctionsTable slot may not survive into the +// client under split - it becomes a caps-mirror read, whatever class the slot itself is in. +// The two macros carry that rule; in a pull build each expands to exactly the check it replaced. +#include namespace MobileGL::MG_Impl::GLImpl { namespace { @@ -478,7 +482,7 @@ namespace MobileGL::MG_Impl::GLImpl { const Bool isOcclusionQuery = (target == GL_SAMPLES_PASSED || target == GL_ANY_SAMPLES_PASSED || target == GL_ANY_SAMPLES_PASSED_CONSERVATIVE) && - MG_Backend::gBackendFunctionsTable.GL.BeginOcclusionQuery != nullptr; + MGL_BACKEND_SLOT_CAP(BeginOcclusionQuery, MG_Pipe::kCapOcclusionQuery); const Bool isPipelineStatisticsQuery = IsPipelineStatisticsQueryTarget(target); if (target != GL_TIME_ELAPSED && !isTransformFeedbackQuery && !isOcclusionQuery && !isPipelineStatisticsQuery) { @@ -528,10 +532,12 @@ namespace MobileGL::MG_Impl::GLImpl { } else if (isTransformFeedbackQuery) { // Prefer real GPU transform-feedback queries (exact with geometry shaders); // the CPU accounting delta stays as the fallback when the backend lacks them. + const Bool xfbQuerySupported = + MGL_BACKEND_SLOT_CAP(BeginXfbPrimitivesQuery, MG_Pipe::kCapXfbPrimitivesQuery); const auto beginXfbPrimitivesQuery = MG_Backend::gBackendFunctionsTable.GL.BeginXfbPrimitivesQuery; MGP_FILL(BeginXfbPrimitivesQuery); queryObject->backendHandle = - beginXfbPrimitivesQuery ? beginXfbPrimitivesQuery(target == GL_PRIMITIVES_GENERATED) : nullptr; + xfbQuerySupported ? beginXfbPrimitivesQuery(target == GL_PRIMITIVES_GENERATED) : nullptr; queryObject->counterSnapshot = TransformFeedbackCounterForTarget(target); queryObject->accountedCaptureDrawSnapshot = MG_State::pGLContext->GetTransformFeedbackAccountedCaptureDraws(); @@ -541,10 +547,12 @@ namespace MobileGL::MG_Impl::GLImpl { MGP_FILL(BeginOcclusionQuery); queryObject->backendHandle = MG_Backend::gBackendFunctionsTable.GL.BeginOcclusionQuery(); } else { + const Bool timerQuerySupported = + MGL_BACKEND_SLOT_CAP(BeginTimeElapsedQuery, MG_Pipe::kCapTimerQuery); const auto beginTimeElapsedQuery = MG_Backend::gBackendFunctionsTable.GL.BeginTimeElapsedQuery; MGP_FILL(BeginTimeElapsedQuery); queryObject->backendHandle = - (!TimerQueryDisabled() && beginTimeElapsedQuery) ? beginTimeElapsedQuery() : nullptr; + (!TimerQueryDisabled() && timerQuerySupported) ? beginTimeElapsedQuery() : nullptr; } activeQueryId = id; } @@ -555,7 +563,7 @@ namespace MobileGL::MG_Impl::GLImpl { const Bool isOcclusionQuery = (target == GL_SAMPLES_PASSED || target == GL_ANY_SAMPLES_PASSED || target == GL_ANY_SAMPLES_PASSED_CONSERVATIVE) && - MG_Backend::gBackendFunctionsTable.GL.BeginOcclusionQuery != nullptr; + MGL_BACKEND_SLOT_CAP(BeginOcclusionQuery, MG_Pipe::kCapOcclusionQuery); const Bool isPipelineStatisticsQuery = IsPipelineStatisticsQueryTarget(target); if (target != GL_TIME_ELAPSED && !isTransformFeedbackQuery && !isOcclusionQuery && !isPipelineStatisticsQuery) { @@ -657,7 +665,10 @@ namespace MobileGL::MG_Impl::GLImpl { ResetQueryObjectLocked(queryObject); // discard any previous result queryObject->target = target; - const auto queryCounterTimestamp = MG_Backend::gBackendFunctionsTable.GL.QueryCounterTimestamp; + const Bool timerQuerySupported = + MGL_BACKEND_SLOT_CAP(QueryCounterTimestamp, MG_Pipe::kCapTimerQuery); + const auto queryCounterTimestamp = + timerQuerySupported ? MG_Backend::gBackendFunctionsTable.GL.QueryCounterTimestamp : nullptr; MGP_FILL(QueryCounterTimestamp); queryObject->backendHandle = (!TimerQueryDisabled() && queryCounterTimestamp) ? queryCounterTimestamp() : nullptr; @@ -782,7 +793,7 @@ namespace MobileGL::MG_Impl::GLImpl { } if (target == GL_SAMPLES_PASSED || target == GL_ANY_SAMPLES_PASSED || target == GL_ANY_SAMPLES_PASSED_CONSERVATIVE) { - const Bool occlusionSupported = MG_Backend::gBackendFunctionsTable.GL.BeginOcclusionQuery != nullptr; + const Bool occlusionSupported = MGL_BACKEND_SLOT_CAP(BeginOcclusionQuery, MG_Pipe::kCapOcclusionQuery); *params = occlusionSupported ? (target == GL_SAMPLES_PASSED ? 32 : 1) : 0; return; } diff --git a/MobileGL/MG_Impl/GLImpl/Sync/GL_Sync.cpp b/MobileGL/MG_Impl/GLImpl/Sync/GL_Sync.cpp index 60813976..9e8e02e9 100644 --- a/MobileGL/MG_Impl/GLImpl/Sync/GL_Sync.cpp +++ b/MobileGL/MG_Impl/GLImpl/Sync/GL_Sync.cpp @@ -10,6 +10,11 @@ #include #include #include +// CONTRACT-P5.md §7 / ID-14: a null check on a GLFunctionsTable slot may not survive into the +// client under split - it becomes a caps-mirror read. SlotCaps.h carries the rule and the test +// that decides which of its two spellings a site takes; in a pull build both expand to exactly +// the check they replaced. +#include namespace MobileGL::MG_Impl::GLImpl { namespace { @@ -56,7 +61,11 @@ namespace MobileGL::MG_Impl::GLImpl { auto* syncObject = new SyncObject; syncObject->condition = condition; syncObject->flags = flags; - if (const auto backendFenceSync = MG_Backend::gBackendFunctionsTable.GL.FenceSync) { + // The family's ONE gate. FenceSync is class C under split, and "absent" is the + // answer the whole fallback chain below is written against: every later site already + // checks syncObject->backendHandle, which stays null from here. The POINTER-valued + // macro keeps the init-statement byte-identical in a pull build (G1). + if (const auto backendFenceSync = MGL_BACKEND_SLOT_PTR_LOCAL(FenceSync)) { MGP_FILL(FenceSync); syncObject->backendHandle = backendFenceSync(); } diff --git a/MobileGL/MG_Impl/GLImpl/Texture/GL_Texture.cpp b/MobileGL/MG_Impl/GLImpl/Texture/GL_Texture.cpp index 2792e400..43b10a02 100644 --- a/MobileGL/MG_Impl/GLImpl/Texture/GL_Texture.cpp +++ b/MobileGL/MG_Impl/GLImpl/Texture/GL_Texture.cpp @@ -31,6 +31,11 @@ #include #include #include +// CONTRACT-P5.md §7 / ID-14: a null check on a GLFunctionsTable slot may not survive into the +// client under split - it becomes a caps-mirror read. SlotCaps.h carries the rule and the test +// that decides which of its two spellings a site takes; in a pull build both expand to exactly +// the check they replaced. +#include // P4a, ID-18 M2. The ONE door MG_State and MG_Impl have into the client's emitters; the three // call sites below are declarations only, exactly as the frontend's mutators are. #include @@ -6534,7 +6539,7 @@ namespace MobileGL::MG_Impl::GLImpl { GLenum type, GLsizei bufSize, void* pixels, const char* caller) { if (MG_Backend::pActiveBackendObject != nullptr && MG_Backend::pActiveBackendObject->GetBackendType() == BackendType::DirectVulkan && - MG_Backend::gBackendFunctionsTable.GL.GetTextureImage != nullptr) { + MGL_BACKEND_SLOT_LOCAL(GetTextureImage)) { MGP_FILL(GetTextureImage); MG_Backend::gBackendFunctionsTable.GL.GetTextureImage(textureObject, uploadTarget, level, format, type, bufSize, pixels); @@ -6796,7 +6801,7 @@ namespace MobileGL::MG_Impl::GLImpl { void GetTexImage(GLenum target, GLint level, GLenum format, GLenum type, GLvoid* pixels) { if (!GetTexImage_State(target, level, format, type, pixels)) return; - if (MG_Backend::gBackendFunctionsTable.GL.GetTexImage != nullptr) { + if (MGL_BACKEND_SLOT_LOCAL(GetTexImage)) { GetTexImage_Backend(target, level, format, type, pixels); return; } diff --git a/MobileGL/MG_Impl/Pipe/CsoCache.h b/MobileGL/MG_Impl/Pipe/CsoCache.h index 43a0886c..fb159353 100644 --- a/MobileGL/MG_Impl/Pipe/CsoCache.h +++ b/MobileGL/MG_Impl/Pipe/CsoCache.h @@ -44,6 +44,7 @@ #include #include #include +#include #include #include @@ -154,7 +155,7 @@ namespace MobileGL::MG_Pipe { // minted from a neighbour rather than from nothing. desc.ChunkMask = kAllPipelineChunks; desc.Blob.Size = kMGPipePipelineChunkBytes; - MGPipeApplyCreateRenderState(desc, bytes.data()); + MGPipeRouteCreateRenderState(desc, bytes.data()); payloadBytes += sizeof(MGPRenderStateDesc) + kMGPipePipelineChunkBytes; Entry entry; @@ -175,7 +176,7 @@ namespace MobileGL::MG_Pipe { MGPHandleOnly handle{}; handle.Handle = m_entries[index].Cso; handle.Kind = static_cast(MGPipeKind::RenderStateCso); - MGPipeApplyDeleteRenderState(handle); + MGPipeRouteDeleteRenderState(handle); MGPipeSlots().Free(MGPipeKind::RenderStateCso, m_entries[index].Cso); m_entries[index] = m_entries.back(); m_entries.pop_back(); diff --git a/MobileGL/MG_Impl/Pipe/FramebufferEmit.h b/MobileGL/MG_Impl/Pipe/FramebufferEmit.h index 90bc294d..887ff886 100644 --- a/MobileGL/MG_Impl/Pipe/FramebufferEmit.h +++ b/MobileGL/MG_Impl/Pipe/FramebufferEmit.h @@ -32,6 +32,7 @@ #include #include #include +#include #include #include @@ -456,7 +457,7 @@ namespace MobileGL::MG_Pipe { m_lastDraw = state; if (state.Target == static_cast(MGPipeFramebufferTarget::Both)) m_lastRead = state; } - MGPipeApplySetFramebufferState(state); + MGPipeRouteSetFramebufferState(state); ++m_emissions; if (MG_Util::PipeStats::Enabled()) { MG_Util::PipeStats::AddCalls(MG_Util::PipeStats::CallClass::FramebufferEmissions, 1); diff --git a/MobileGL/MG_Impl/Pipe/ImageEmit.h b/MobileGL/MG_Impl/Pipe/ImageEmit.h index 3b1b2d7b..811143e5 100644 --- a/MobileGL/MG_Impl/Pipe/ImageEmit.h +++ b/MobileGL/MG_Impl/Pipe/ImageEmit.h @@ -38,6 +38,7 @@ #include #include #include +#include #include #include #include @@ -126,7 +127,7 @@ namespace MobileGL::MG_Pipe { m_lastImages.Start = 0; m_lastImages.Count = count; m_lastImages.ContentHash = hash; - MGPipeApplySetShaderImages(m_lastImages, m_entries.data()); + MGPipeRouteSetShaderImages(m_lastImages, m_entries.data()); ++m_imageSets; if (MG_Util::PipeStats::Enabled()) { MG_Util::PipeStats::AddCalls(MG_Util::PipeStats::CallClass::ShaderImageEmissions, 1); diff --git a/MobileGL/MG_Impl/Pipe/PipeFill.cpp b/MobileGL/MG_Impl/Pipe/PipeFill.cpp index ca41fc34..7a58544c 100644 --- a/MobileGL/MG_Impl/Pipe/PipeFill.cpp +++ b/MobileGL/MG_Impl/Pipe/PipeFill.cpp @@ -36,9 +36,20 @@ #include #include #include +#include #include #include +#if MOBILEGL_BUILD_DISAGGREGATED +// R-8 (c1): the client's liveness gates read the caps mirror, never MGPipeGetResourceOps(). +// Behind the build option for G1's reason - nothing under MG_Remote may be reachable from a +// pull build - and every use below is additionally gated on the resolved TRANSPORT, because +// build-split runs MOBILEGL_TRANSPORT=monolith in every unit and integration-gpu lane and those +// lanes must keep answering exactly what they answered before. +#include +#include +#endif + #include #include #include @@ -609,12 +620,38 @@ namespace MobileGL::MG_Pipe { } } // namespace + // R-8 (c1). THE SECOND CONJUNCT MOVES UNDER SPLIT, AND ONLY UNDER SPLIT. + // + // `MGPipeGetResourceOps() != nullptr` asks "has a backend registered the consumer". That + // table is the SERVER's registration and it is a PROCESS-WIDE global (PipeApply.cpp:402): + // under inproc a client reading it answers correctly BY ACCIDENT, and under spawn the + // client process has no backend at all, so the read answers null and five record families + // stop emitting - silently, while the emitters go on clearing their per-level dirty flags + // on the acceptance they never asked for. That is ID-39's 66 lost DirectVulkan uploads with + // a wire in between. The client asks the caps mirror instead, which carries the answer the + // SERVER gave at the handshake (CallMask bits 32..47). + // + // s1 made the server end Fatal when nobody sets the mask; this is the client end. Bool MGPipeResourceSubsystemEnabled() { - return (MG_Config::Features.PipePush & kMGPipeSubsystemResources) != 0 && - MGPipeGetResourceOps() != nullptr; + if ((MG_Config::Features.PipePush & kMGPipeSubsystemResources) == 0) return false; +#if MOBILEGL_BUILD_DISAGGREGATED + if (MG_Config::Transport != MG_Config::TransportMode::Monolith) { + return MG_Remote::Client::CapsMirrorInstance().ServerConsumes(kMGPipeSubsystemResources); + } +#endif + return MGPipeGetResourceOps() != nullptr; } Bool MGPipeResourceOpsHaveSubDataResident() { +#if MOBILEGL_BUILD_DISAGGREGATED + if (MG_Config::Transport != MG_Config::TransportMode::Monolith) { + // CONTRACT-P5.md §7's THIRD NAMED CAPABILITY PROBE. `ops->SubDataResident != nullptr` + // is not a safety check - it decides whether the resident-upload path EXISTS - and + // under split there is no op table here to probe. kCapResidentSubData is the bit the + // server publishes for exactly this question. + return MG_Remote::Client::CapsMirrorInstance().HasCap(kCapResidentSubData); + } +#endif const MGPipeResourceOps* ops = MGPipeGetResourceOps(); return ops != nullptr && ops->SubDataResident != nullptr; } @@ -642,7 +679,7 @@ namespace MobileGL::MG_Pipe { // LATCHED, so the destroy is gated on whether this create actually went out rather // than on whether a table is still registered when the object dies (D-L, m12). tracker.NotePublished(handle); - MGPipeApplyResourceCreate(desc); + MGPipeRouteResourceCreate(desc); } void MGPipeEmitResourceRespecify(BufferObject& buffer) { @@ -675,7 +712,7 @@ namespace MobileGL::MG_Pipe { MGPipeBuildResourceDesc(buffer, handle, bindMask, /*storageDefined=*/false); tracker.NoteDesc(createDesc, true); tracker.NotePublished(handle); - MGPipeApplyResourceCreate(createDesc); + MGPipeRouteResourceCreate(createDesc); } const MGPResourceDesc desc = MGPipeBuildResourceDesc(buffer, handle, bindMask, true); tracker.NoteDesc(desc, false); @@ -688,7 +725,58 @@ namespace MobileGL::MG_Pipe { // allocation and only it is allowed one. In monolith the acknowledgement is // ((void)0), because the applier is one function call away and has already run by // the time this returns; the transport wires the doorbell to that same predicate. - MGPipeApplyResourceRespecify(desc, initialBytes); +#if MOBILEGL_BUILD_DISAGGREGATED + // R-13.3's MISSING PRODUCER, and it is the reason the first joint inproc run died. + // CONTRACT-P5 §2 row 19 rules that `initialBytes` is ALWAYS nullptr under split and + // that "initial content arrives as ResourceSubData records immediately after this + // one" - but nothing emitted those records, so the split arm's refusal + // (Fatal{UncarriedInitialBytes}) fired on the first glBufferData with data, which is + // the first thing every scenario does. + // + // IT IS THE CONTRACT'S OWN PRESCRIBED ROUTE, not a new one: "the chosen route reuses a + // path that is already chunked (MGPipeForEachSubDataRecordRange) and already + // acceptance-gated; it costs one extra record". So the respecify defines the storage + // and the walk below ships the bytes, through the same emitter every later + // glBufferSubData uses - which also means the HasLiveHostWrites bit and the + // acceptance latch are computed in exactly one place instead of two. + // + // SPLIT-ONLY, and that is load-bearing for G2: under monolith the applier reads + // `initialBytes` directly and a second upload would be a real behaviour change in the + // arm the split arm is measured against. + // + // ROLE-AWARE (M5). Under inproc the apply thread reaches this very emitter when the + // server's own backend respecifies a buffer (c1-v2 §4 R-17.3: that is where + // Fatal{BarrierTimeout, "ResourceRespecify"} from mgl-srv-apply came from). On that + // thread this branch would emit CLIENT wire records - which the server role does not + // produce, so ClientWireRecordsEmitted() would not move and the self-check below would + // abort the SERVER by name; it would also run the respecify(nullptr)+follow-up shape, + // giving the server role a path monolith does not have. So the server role takes the + // ELSE below, exactly as monolith does. RunsAsTheServerRole() is v1's + // ServerLoop::OnApplyThread(), false on the GL thread that owns this fill. + if (MG_Config::Transport != MG_Config::TransportMode::Monolith && + !MG_Remote::Client::RunsAsTheServerRole() && initialBytes != nullptr) { + MGPipeRouteResourceRespecify(desc, nullptr); + // COUNTED, NOT ASSUMED, and this is the only thing that can gate the follow-up at + // all. Dropping the walk below leaves a respecify that went out with nullptr and + // bytes that nothing carried - and no P5 scenario's PICTURE changes, because every + // one of them re-uploads its vertices through the ordinary dirty path afterwards. + // So the statement "the content followed" is made HERE, against the client's own + // record ordinal, rather than left to a lane that cannot see it. Remove the call + // below and this aborts by name on the first glBufferData that carries data. + const Uint64 before = MG_Remote::Client::ClientWireRecordsEmitted(); + MGPipeEmitResourceSubData(buffer, 0, static_cast(buffer.GetSize())); + if (MG_Remote::Client::ClientWireRecordsEmitted() == before) { + MGLOG_F("MGPipe: Fatal{InitialBytesNotCarried, \"resource_respecify\"} - the " + "respecify crossed with initialBytes = nullptr (R-13.3) and the " + "resource_subdata records that were supposed to follow it emitted " + "NOTHING, so %llu bytes of initial content exist on no side of the wire", + static_cast(buffer.GetSize())); + std::abort(); + } + return; + } +#endif + MGPipeRouteResourceRespecify(desc, initialBytes); } void MGPipeEmitResourceSubData(BufferObject& buffer, SizeT offset, SizeT size) { @@ -711,7 +799,11 @@ namespace MobileGL::MG_Pipe { // take the object, which is why it is set here and not in the builder. record.HasLiveHostWrites = buffer.HasLiveHostWritesForWire() ? 1 : 0; #endif - MGPipeApplyResourceSubData(record, base + at); + // `length` is this chunk's byte count, which the record also declares + // (MGPipeBuildSubDataRecord writes it into the destination range) - passed + // rather than re-read so the staged run and the record's own claim come from + // one number. + MGPipeRouteResourceSubData(record, base + at, length); }); if (!encodable) { MGLOG_E_ONCE("MGPipe: resource_subdata range [%llu, +%llu) on buffer %u cannot be encoded - " @@ -742,7 +834,7 @@ namespace MobileGL::MG_Pipe { record.HasLiveHostWrites = buffer.HasLiveHostWritesForWire() ? 1 : 0; #endif // The application's STAGING store, valid for the duration of the call only. - MGPipeApplyBufferSubDataResident(record, base + (at - offset)); + MGPipeRouteBufferSubDataResident(record, base + (at - offset), length); }); if (!encodable) { MGLOG_E_ONCE("MGPipe: buffer_subdata_resident range [%llu, +%llu) on buffer %u cannot be encoded", @@ -762,7 +854,31 @@ namespace MobileGL::MG_Pipe { // arm reads INVALIDATE_RANGE / INVALIDATE_BUFFER / UNSYNCHRONIZED per call to choose // between a map+memcpy+unmap and an upload, so merging them here would change which. record.AccessFlags = accessFlags; - MGPipeApplyResourceFlushRange(record, buffer.MappedData() + offset); +#if MOBILEGL_BUILD_DISAGGREGATED + // R-13.2's MISSING PRODUCER, the twin of the respecify one above, and the cause of the + // seven PersistentCoherentMapScenario aborts on the first joint inproc run: + // Fatal{ProtocolCorruption} resource_flush_range {slot=1, gen=0, glName=1}: + // a non-empty flush carries no bytes (offset=0, size=120, storage=120 bytes) + // + // CONTRACT-P5 §2 row 20 rules that this record carries NO bytes under split - it is a + // {range, AccessFlags} control record, and a blobref here "would be a second, + // forgeable way to say the same thing" - and that "the bytes of [Offset, Offset+Size) + // arrive AHEAD of it as ResourceSubData records covering exactly that range". Nothing + // emitted those records, so v1's StagedShadowStore had nothing staged for the range + // the flush names, which is precisely the refusal ID-37 asked it to make rather than + // silently reading the bytes again. + // + // EXACTLY THAT RANGE, not the whole buffer: the flush's own [offset, size) is what + // the ladder rewrites, and staging more would be the coverage WIDENING ID-37 forbids. + // Split-only, for the respecify's G2 reason - and ROLE-AWARE for M5's reason, the twin of + // the respecify branch above: the apply thread flushing the server's own buffer emits no + // client wire records, so it takes the plain route below rather than this split follow-up. + if (MG_Config::Transport != MG_Config::TransportMode::Monolith && + !MG_Remote::Client::RunsAsTheServerRole() && size != 0) { + MGPipeEmitResourceSubData(buffer, offset, size); + } +#endif + MGPipeRouteResourceFlushRange(record, buffer.MappedData() + offset); } void MGPipeEmitResourceReadback(BufferObject& buffer) { @@ -776,7 +892,7 @@ namespace MobileGL::MG_Pipe { record.Size = buffer.GetSize(); // The answer travels back through MGPipeClientOnBufferWriteback, and the server's // epoch bump happens AFTER that writeback, never before. - MGPipeApplyResourceReadback(record); + MGPipeRouteResourceReadback(record); } // NO UnmapPersistent PRODUCER IN P3a, AND THAT IS DELIBERATE. The catalogue has the call @@ -799,7 +915,7 @@ namespace MobileGL::MG_Pipe { if (MG_Util::PipeStats::Enabled()) { MG_Util::PipeStats::AddCalls(MG_Util::PipeStats::CallClass::MapPersistentRoundtrips, 1); } - return MGPipeApplyMapPersistent(BufferHandleOnly(handle), buffer.GetSize(), buffer.MappedData()); + return MGPipeRouteMapPersistent(BufferHandleOnly(handle), buffer.GetSize(), buffer.MappedData()); } Bool MGPipeEmitResourceDestroyAndFree(BufferObject& buffer) { @@ -814,7 +930,7 @@ namespace MobileGL::MG_Pipe { const Bool published = tracker.WasPublished(handle); if (published) { tracker.NoteDestroy(); - MGPipeApplyResourceDestroy(BufferHandleOnly(handle)); + MGPipeRouteResourceDestroy(BufferHandleOnly(handle)); } // THE ORDER IS FIXED (D-L): the applier clears the record and the backend drops its // twin while the handle still resolves, and only then does the slot go back. Free @@ -854,7 +970,7 @@ namespace MobileGL::MG_Pipe { MGPHandleOnly only{}; only.Handle = handle; only.Kind = static_cast(MGPipeKind::VertexElementsCso); - MGPipeApplyDeleteVertexElements(only); + MGPipeRouteDeleteVertexElements(only); emitter.NoteRecordDestroyed(handle); } @@ -936,8 +1052,21 @@ namespace MobileGL::MG_Pipe { // MGPipeEmitResourceRespecify above uses for buffers) publishes it. Nothing here needs // to remember the window. Bool P4aFamilyHasItsConsumer(Uint64 subsystem) { - return (subsystem & kMGPipeP4aFamilySubsystems) == 0 || - MGPipeGetResourceOps() != nullptr; + if ((subsystem & kMGPipeP4aFamilySubsystems) == 0) return true; +#if MOBILEGL_BUILD_DISAGGREGATED + // R-8 (c1), the same move as MGPipeResourceSubsystemEnabled's and for the same + // reason. ALL FOUR FAMILIES RIDE THE ONE SIGNAL, exactly as they do in monolith: + // the paragraph above explains why the resource consumer IS the texture family's + // consumer, and the split spelling of "a backend registered the resource op table" + // is "the server published the resource subsystem's consumer bit". Asking per + // family here would be a NEW rule, and a client that withheld more than the server + // refuses leaves the server's handle arm live with no records to read. + if (MG_Config::Transport != MG_Config::TransportMode::Monolith) { + return MG_Remote::Client::CapsMirrorInstance().ServerConsumes( + kMGPipeSubsystemResources); + } +#endif + return MGPipeGetResourceOps() != nullptr; } // ================================================================================ @@ -1395,9 +1524,21 @@ namespace MobileGL::MG_Pipe { // Step 1, shared: the wire delete goes out FIRST and only for a PUBLISHED handle, and // the latch is cleared with it so a second death path - a composite's two, a backend's // redundant notice - cannot emit a second delete for a record that is already gone. - Bool EmitDeleteIfPublished(MGPipeKind kind, MGPipeHandle handle, void (*apply)(const MGPHandleOnly&)) { + // + // `route` IS A MGPipeRoute AND NEVER A MGPipeApply (B1). R-17 converted the + // 40 direct applier CALLS to route calls by renaming `MGPipeApply(` -> but these + // five sites take the entry point BY ADDRESS, `&MGPipeApply`, so the call-expression + // rename missed them and four routed rows (DeleteSamplerView, DeleteShaderState, + // DeleteSamplerState, ResourceDestroy for textures/renderbuffers) still ran the applier + // synchronously on the GL thread under split - two writers on g_applier with the barrier + // not consulted, and under spawn a silent no-op that leaks every one of those objects. + // The parameter type is the route's, which is byte-identical to the applier's + // (const MGPHandleOnly&, void return), so the fix is `&MGPipeRoute` at the five call + // sites; the grep gate scripts/../p5-c1 redcheck refuses any `&MGPipeApply` under MG_Impl/. + Bool EmitDeleteIfPublished(MGPipeKind kind, MGPipeHandle handle, + void (*route)(const MGPHandleOnly&)) { if (!MGPipeHandleIsPublished(kind, handle)) return false; - apply(HandleOnly(kind, handle)); + route(HandleOnly(kind, handle)); MGPipeNoteHandleUnpublished(kind, handle); return true; } @@ -1418,7 +1559,7 @@ namespace MobileGL::MG_Pipe { const MGPipeHandle handle = MGPipeSlots().FindByLifetimeId(MGPipeKind::SamplerViewCso, lifetimeId); const Bool published = - EmitDeleteIfPublished(MGPipeKind::SamplerViewCso, handle, &MGPipeApplyDeleteSamplerView); + EmitDeleteIfPublished(MGPipeKind::SamplerViewCso, handle, &MGPipeRouteDeleteSamplerView); ForwardWhenWired( MGPipeSamplerEmitterInstance(), [&](auto& emitter) { emitter.NoteRecordDestroyed(handle); }); // THE NOTICE IS RAISED FOR THIS KIND TOO, and the reason it once was not is wrong: @@ -1439,7 +1580,7 @@ namespace MobileGL::MG_Pipe { Bool MGPipeEmitTextureDestroyAndFree(Uint64 lifetimeId) { const MGPipeHandle handle = MGPipeSlots().FindByLifetimeId(MGPipeKind::Texture, lifetimeId); const Bool published = - EmitDeleteIfPublished(MGPipeKind::Texture, handle, &MGPipeApplyResourceDestroy); + EmitDeleteIfPublished(MGPipeKind::Texture, handle, &MGPipeRouteResourceDestroy); // The emitter retires its entry while the handle still resolves (C-2): the drain list // drops the dead texture's levels, the raw pointer goes, the built-in sampler's cache // reference is given back, the latches and the sticky mask are cleared. @@ -1480,7 +1621,7 @@ namespace MobileGL::MG_Pipe { const MGPipeHandle handle = MGPipeSlots().FindByLifetimeId(MGPipeKind::Renderbuffer, lifetimeId); const Bool published = - EmitDeleteIfPublished(MGPipeKind::Renderbuffer, handle, &MGPipeApplyResourceDestroy); + EmitDeleteIfPublished(MGPipeKind::Renderbuffer, handle, &MGPipeRouteResourceDestroy); ForwardWhenWired( MGPipeTextureEmitterInstance(), [&](auto& emitter) { emitter.NoteRenderbufferDied(handle); }); NotifyAndFree(MGPipeKind::Renderbuffer, lifetimeId, handle); @@ -1517,7 +1658,7 @@ namespace MobileGL::MG_Pipe { const MGPipeHandle handle = MGPipeSlots().FindByLifetimeId(MGPipeKind::SamplerCso, lifetimeId); const Bool published = - EmitDeleteIfPublished(MGPipeKind::SamplerCso, handle, &MGPipeApplyDeleteSamplerState); + EmitDeleteIfPublished(MGPipeKind::SamplerCso, handle, &MGPipeRouteDeleteSamplerState); // NOTHING TO RETIRE IN AN EMITTER FOR THIS KIND, stated rather than implied: a sampler // CSO is content-addressed and belongs to a value, so no emitter keeps an entry under // a SamplerObject's handle - the cache's entries are keyed by value and reference @@ -1538,7 +1679,7 @@ namespace MobileGL::MG_Pipe { const MGPipeHandle handle = MGPipeSlots().FindByLifetimeId(MGPipeKind::ShaderCso, lifetimeId); const Bool published = - EmitDeleteIfPublished(MGPipeKind::ShaderCso, handle, &MGPipeApplyDeleteShaderState); + EmitDeleteIfPublished(MGPipeKind::ShaderCso, handle, &MGPipeRouteDeleteShaderState); ForwardWhenWired( MGPipeProgramEmitterInstance(), [&](auto& emitter) { emitter.NoteRecordDestroyed(handle); }); NotifyAndFree(MGPipeKind::ShaderCso, lifetimeId, handle); @@ -2055,7 +2196,7 @@ namespace MobileGL::MG_Pipe { Uint64 EmitPixelPackState(GLContext& ctx) { MGPPixelPackState pack{}; pack.Pack = ctx.GetPixelStoreParameters(false); - MGPipeApplySetPixelPackState(pack); + MGPipeRouteSetPixelPackState(pack); return sizeof(MGPPixelPackState); } @@ -2068,7 +2209,7 @@ namespace MobileGL::MG_Pipe { patch.Vertices = live.PatchVertices; for (SizeT i = 0; i < 4; ++i) patch.Outer[i] = live.PatchDefaultOuterLevel[i]; for (SizeT i = 0; i < 2; ++i) patch.Inner[i] = live.PatchDefaultInnerLevel[i]; - MGPipeApplySetPatchState(patch); + MGPipeRouteSetPatchState(patch); return sizeof(MGPPatchState); } @@ -2157,7 +2298,7 @@ namespace MobileGL::MG_Pipe { } if (header.Count == 0) return 0; g_attribDefaultLastHeader = header; - MGPipeApplySetVertexAttribDefaults(header, tail.data()); + MGPipeRouteSetVertexAttribDefaults(header, tail.data()); // Did the applier reproduce it? Byte for byte, over the attributes this call // named - anything less would be a mirror that disagrees with the frontend in a @@ -2226,7 +2367,7 @@ namespace MobileGL::MG_Pipe { block.CapabilityBits |= Uint64{1} << i; } } - MGPipeApplySetResidualValueState(block); + MGPipeRouteSetResidualValueState(block); if (MG_Util::PipeStats::Enabled()) { // ByteClass::ResidualValueBlock has been a placeholder that "stays at 0 // until P2" since P0. This is what makes it non-zero. @@ -2280,7 +2421,7 @@ namespace MobileGL::MG_Pipe { bind.Cso = cso; bind.Version = version; bind.PipelineVersion = pipelineVersion; - MGPipeApplyBindRenderState(bind); + MGPipeRouteBindRenderState(bind); payloadBytes += sizeof(MGPBindRenderState); if (MG_Util::PipeStats::Enabled()) { MG_Util::PipeStats::AddCalls(MG_Util::PipeStats::CallClass::RenderStateCsoBinds, 1); @@ -2302,7 +2443,7 @@ namespace MobileGL::MG_Pipe { dyn.ChunkMask = chunkMask; dyn.Version = version; dyn.Blob.Size = blobBytes; - MGPipeApplySetDynamicState(dyn, blob.data()); + MGPipeRouteSetDynamicState(dyn, blob.data()); payloadBytes += sizeof(MGPDynamicState) + blobBytes; } diff --git a/MobileGL/MG_Impl/Pipe/ProgramEmit.h b/MobileGL/MG_Impl/Pipe/ProgramEmit.h index 2d0d29a3..ce3d51e7 100644 --- a/MobileGL/MG_Impl/Pipe/ProgramEmit.h +++ b/MobileGL/MG_Impl/Pipe/ProgramEmit.h @@ -41,6 +41,7 @@ #include #include #include +#include #include #include #include @@ -137,19 +138,19 @@ namespace MobileGL::MG_Pipe { // exactly "nothing bound". const MGPipeHandle boundCso = !MGPipeHandleIsNull(drawCso) ? drawCso : dispatchCso; if (boundCso != m_boundCso) { - MGPipeApplyBindShaderState(HandleOnly(boundCso)); + MGPipeRouteBindShaderState(HandleOnly(boundCso)); m_boundCso = boundCso; ++m_binds; bytes += sizeof(MGPHandleOnly); } if (drawCso != m_drawCso) { - MGPipeApplySetDrawProgram(HandleOnly(drawCso)); + MGPipeRouteSetDrawProgram(HandleOnly(drawCso)); m_drawCso = drawCso; ++m_drawSets; bytes += sizeof(MGPHandleOnly); } if (dispatchCso != m_dispatchCso) { - MGPipeApplySetDispatchProgram(HandleOnly(dispatchCso)); + MGPipeRouteSetDispatchProgram(HandleOnly(dispatchCso)); m_dispatchCso = dispatchCso; ++m_dispatchSets; bytes += sizeof(MGPHandleOnly); @@ -192,7 +193,12 @@ namespace MobileGL::MG_Pipe { m_lastConstants.Blob.Seg = kMGHostSpanSegNone; m_lastConstants.Blob.Offset = reinterpret_cast(program->GetUBOData()); m_lastConstants.Blob.Size = 0; - MGPipeApplySetGlobalConstants(m_lastConstants, program->GetUBOData()); + // `size` is GetUBOSize(), and it is passed because the record declares 0 - the + // monolith convention (the bytes ride beside the record) that CONTRACT-P5 table 1 + // rule A cannot keep under split. It is the row's largest and least bounded blob, + // per program per frame, so it is also the one R-10's max-record counter watches. + MGPipeRouteSetGlobalConstants(m_lastConstants, program->GetUBOData(), + static_cast(size)); m_constantsCso = cso; m_constantsVersion = version; ++m_constantSets; @@ -254,7 +260,7 @@ namespace MobileGL::MG_Pipe { m_lastDesc.Reflection.Offset = reinterpret_cast(&link); m_lastDesc.Reflection.Size = 0; - MGPipeApplyCreateShaderState(m_lastDesc, &link, &spirv); + MGPipeRouteCreateShaderState(m_lastDesc, &link, &spirv); // THE CREATE WENT OUT, so the publication latch is taken here and nowhere else // (contract-v2 §3.1). MGPipeEmitShaderCsoDestroyAndFree reads it, and without it // delete_shader_state can never go out - for an ordinary program or for a diff --git a/MobileGL/MG_Impl/Pipe/SamplerEmit.h b/MobileGL/MG_Impl/Pipe/SamplerEmit.h index 3870ebc0..21318fb7 100644 --- a/MobileGL/MG_Impl/Pipe/SamplerEmit.h +++ b/MobileGL/MG_Impl/Pipe/SamplerEmit.h @@ -44,6 +44,7 @@ #include #include #include +#include #include #include #include @@ -455,7 +456,7 @@ namespace MobileGL::MG_Pipe { // The applier is handed the CACHE's copy, so the pointer stays valid for the whole // call and the bytes it stores are provably the bytes the memcmp will confirm // against later. - MGPipeApplyCreateSamplerState(desc, &m_entries.back().Params); + MGPipeRouteCreateSamplerState(desc, &m_entries.back().Params); // THE CREATE ACTUALLY WENT OUT, so the publication latch is taken here and nowhere // else (contract-v2 §3.1). It is what the six death helpers read, and without it a // delete_sampler_state can never go out for this kind. @@ -490,7 +491,7 @@ namespace MobileGL::MG_Pipe { // only then does the slot go back. There is no NotifyStateObjectDestroyed step // here - a content-addressed CSO has no frontend object whose death is being // announced, which is precisely why this eviction is the only death path it has. - MGPipeApplyDeleteSamplerState(handle); + MGPipeRouteDeleteSamplerState(handle); // AND THE LATCH GOES WITH THE DELETE. This is the "an emitter that drops a record // for its own reasons calls MGPipeNoteHandleUnpublished" half of the publication // protocol (contract-v2 §3.1): the record is gone, so a death helper reaching this @@ -783,7 +784,7 @@ namespace MobileGL::MG_Pipe { m_lastViews.Start = 0; m_lastViews.Count = count; m_lastViews.ContentHash = hash; - MGPipeApplySetSamplerViews(m_lastViews, m_views.data()); + MGPipeRouteSetSamplerViews(m_lastViews, m_views.data()); ++m_viewSets; if (MG_Util::PipeStats::Enabled()) { MG_Util::PipeStats::AddCalls(MG_Util::PipeStats::CallClass::SamplerViewEmissions, 1); @@ -857,7 +858,7 @@ namespace MobileGL::MG_Pipe { m_lastStates.Start = 0; m_lastStates.Count = count; m_lastStates.ContentHash = hash; - MGPipeApplyBindSamplerStates(m_lastStates, m_states.data()); + MGPipeRouteBindSamplerStates(m_lastStates, m_states.data()); ++m_stateSets; if (MG_Util::PipeStats::Enabled()) { MG_Util::PipeStats::AddCalls(MG_Util::PipeStats::CallClass::SamplerStateEmissions, 1); @@ -914,7 +915,7 @@ namespace MobileGL::MG_Pipe { m_lastView.NumLayers = static_cast(texture.GetViewNumLayers()); m_lastView.Samples = static_cast(texture.GetSamples() < 0 ? 0 : texture.GetSamples()); m_lastView.FixedSampleLocations = texture.HasFixedSampleLocations() ? 1 : 0; - MGPipeApplyCreateSamplerView(m_lastView); + MGPipeRouteCreateSamplerView(m_lastView); // THE CREATE WENT OUT, so the publication latch is taken (contract-v2 §3.1). The // texture's death helper reads it, and without it delete_sampler_view can never go // out - the C-1 leak, one kind later. Re-taking it on a re-issue is right and diff --git a/MobileGL/MG_Impl/Pipe/TextureEmit.h b/MobileGL/MG_Impl/Pipe/TextureEmit.h index b973c3bd..46f9c9fb 100644 --- a/MobileGL/MG_Impl/Pipe/TextureEmit.h +++ b/MobileGL/MG_Impl/Pipe/TextureEmit.h @@ -57,6 +57,7 @@ #include #include #include +#include #include #include #include @@ -802,7 +803,7 @@ namespace MobileGL::MG_Pipe { ++m_paramSets; // Not behind MGPipeTextureRecordsReachTheApplier() (see its comment): the call is // dispatched whenever this emitter runs, so the answer is always a real one. - Bool accepted = MGPipeApplySetTextureParams(params); + Bool accepted = MGPipeRouteSetTextureParams(params); if (!accepted) { // THE SELF-HEAL, the respecify path's shape, and the parameters are the one // publication that may be a texture's FIRST: the context's default textures are @@ -828,7 +829,7 @@ namespace MobileGL::MG_Pipe { // across it - `entry` is re-fetched below. EmitResourceRespecify(texture, MGPipeTextureRespecifyScope::WholeResource, 0, 0); } - accepted = MGPipeApplySetTextureParams(params); + accepted = MGPipeRouteSetTextureParams(params); } Entry& latched = EntryFor(m_textures, handle); if (!accepted) { @@ -1121,7 +1122,7 @@ namespace MobileGL::MG_Pipe { Bool dispatched = false; if constexpr (MGPipeTextureRecordsReachTheApplier()) { dispatched = true; - accepted = MGPipeApplyResourceCreate(desc); + accepted = MGPipeRouteResourceCreate(desc); } if (dispatched && !accepted) return; MGPipeNoteHandlePublished(kind, handle); @@ -1134,7 +1135,7 @@ namespace MobileGL::MG_Pipe { // replaces no storage at all. static Bool ApplyRespecify(const MGPResourceDesc& desc, const MGPRespecifiedLevel* level) { if constexpr (MGPipeTextureRecordsReachTheApplier()) { - return MGPipeApplyResourceRespecify(desc, nullptr, level); + return MGPipeRouteResourceRespecify(desc, nullptr, level); } (void)level; return false; @@ -1275,7 +1276,14 @@ namespace MobileGL::MG_Pipe { Bool dispatched = false; if constexpr (MGPipeTextureRecordsReachTheApplier()) { dispatched = true; - accepted = MGPipeApplyResourceSubData(m_lastSubData, shadow, + // `levelBytes` closes CONTRACT-P5 table 1 row 7's open half. The record still + // declares Blob.Size 0 on the monolith arm - where the applier reads the + // companion pointer and the destination box bounds the write - but under split + // the staged run needs a length, and the comment above already says what it + // is: "the bytes this record declares ARE the level shadow". The regions' own + // SrcOffsets index into exactly that run. + accepted = MGPipeRouteResourceSubData(m_lastSubData, shadow, + static_cast(levelBytes), m_regions.empty() ? nullptr : m_regions.data()); } ++m_subDatas; diff --git a/MobileGL/MG_Impl/Pipe/VertexInputEmit.h b/MobileGL/MG_Impl/Pipe/VertexInputEmit.h index 1b40e47f..716fb81e 100644 --- a/MobileGL/MG_Impl/Pipe/VertexInputEmit.h +++ b/MobileGL/MG_Impl/Pipe/VertexInputEmit.h @@ -43,6 +43,7 @@ #include #include #include +#include #include #include @@ -158,7 +159,7 @@ namespace MobileGL::MG_Pipe { const auto& vao = ctx.GetBoundVertexArray(); if (!vao) { if (!MGPipeHandleIsNull(m_boundHandle)) { - MGPipeApplyBindVertexElements(HandleOnly(kMGPipeNullHandle)); + MGPipeRouteBindVertexElements(HandleOnly(kMGPipeNullHandle)); ++m_binds; m_boundHandle = kMGPipeNullHandle; m_boundLifetimeId = 0; @@ -178,7 +179,7 @@ namespace MobileGL::MG_Pipe { latch.Gen != handle.Gen; if (configMoved) bytes += EmitCreate(*vao, handle, latch, configVersion); if (lifetimeId != m_boundLifetimeId || m_boundHandle != handle) { - MGPipeApplyBindVertexElements(HandleOnly(handle)); + MGPipeRouteBindVertexElements(HandleOnly(handle)); ++m_binds; bytes += sizeof(MGPHandleOnly); m_boundHandle = handle; @@ -242,7 +243,7 @@ namespace MobileGL::MG_Pipe { // emulation is server-owned. m_lastBuffers.BaseInstance = baseInstance; m_lastBuffers.ContentHash = hash; - MGPipeApplySetVertexBuffers(m_lastBuffers, m_entries.data()); + MGPipeRouteSetVertexBuffers(m_lastBuffers, m_entries.data()); ++m_bufferSets; return sizeof(MGPVertexBuffers) + static_cast(count) * sizeof(MGPVertexBuffer); } @@ -267,7 +268,7 @@ namespace MobileGL::MG_Pipe { MGPipeResourceTrackerInstance().NoteBoundAs(m_lastIndex.Res, BufferTarget::Index); } } - MGPipeApplySetIndexBuffer(m_lastIndex); + MGPipeRouteSetIndexBuffer(m_lastIndex); ++m_indexSets; return sizeof(MGPIndexBuffer); } @@ -396,7 +397,7 @@ namespace MobileGL::MG_Pipe { m_lastElements.Blob.Seg = kMGHostSpanSegNone; m_lastElements.Blob.Offset = 0; m_lastElements.Blob.Size = kAttribBytes + kBindingBytes; - MGPipeApplyCreateVertexElements(m_lastElements, m_blob.data()); + MGPipeRouteCreateVertexElements(m_lastElements, m_blob.data()); ++m_creates; latch.Published = true; latch.Gen = handle.Gen; diff --git a/MobileGL/MG_Pipe/PipeRoute.cpp b/MobileGL/MG_Pipe/PipeRoute.cpp new file mode 100644 index 00000000..ff67bbb0 --- /dev/null +++ b/MobileGL/MG_Pipe/PipeRoute.cpp @@ -0,0 +1,366 @@ +// MobileGL - MobileGL/MG_Pipe/PipeRoute.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 + +// The MONOLITH arm of R-17's routing: thirty-seven adapters that unpack a generated table +// row's parameters and call the MGPipeApply* entry point the call site used to call directly. +// Owner: package c1. See PipeRoute.h for why the arm exists and what R-17 actually cost. +// +// G2 IS THE WHOLE SPECIFICATION OF THIS FILE. Under monolith transport the thunks must reach +// EXACTLY the code they reach today - so every adapter below is a parameter shuffle and +// nothing else. There is no branch, no cache, no early return and no logging on any of them, +// because each of those is a way for `MOBILEGL_TRANSPORT=monolith` to stop being the control +// arm that the split arm is measured against. The one thing an adapter may do beyond calling +// through is POST A REPLY, and only the five rows whose call site consumes a value do that. +// +// WHO CALLS THE INSTALL, AND WHY IT IS NOT IN THIS FILE. `gMGPipeScreen` and `gMGPipeContext` +// are inline variables with CONSTANT initialisation, so they are zero - every entry null, +// which is the pre-migration state - before any dynamic initialiser runs. The installer is +// driven from an inline variable in PipeRoute.h rather than from a static initialiser here, +// and the comment beside `detail::gMonolithTablesInstalled` says why: a static initialiser in +// THIS file is only in the program if THIS object file is in the link, and over a static +// archive it was not. That makes "the tables are installed" an invariant rather than a step +// someone can forget, and it is why no MGP_* thunk needs a null check - which matters, because +// a null check on a table slot is precisely the shape CONTRACT-P5 §7 spends the 41 call sites +// killing. + +#include "PipeRoute.h" + +#if MOBILEGL_PIPE_PUSH + +#include + +#include +#include +#include + +namespace MobileGL::MG_Pipe { + + namespace { + + // --------------------------------------------------------------------------------- + // The reply mailbox + // --------------------------------------------------------------------------------- + + struct ReplyMailbox { + Uint64 Slot = 0; // the MGPReplySlot::Id this answer belongs to; 0 = empty + Int32 Status = 0; // 0 OK, 1 DECLINED, 2 ERROR (Transport::ReplyStatus) + Uint64 Value = 0; // the Bool, or the pointer, the row answered with + Uint64 NextTicket = 0; + Uint64 Taken = 0; + Uint64 Declined = 0; + }; + + // THREAD-LOCAL, and one entry deep, because R-1's verb barrier makes the in-flight + // depth exactly one. A second post before the first is taken is not a capacity problem: + // it is a barrier that has stopped holding, and it Fatals rather than overwriting. + thread_local ReplyMailbox g_reply; + + MGPipeRouteArm g_arm = MGPipeRouteArm::kNone; + + // The monolith adapters, kept as tables of their own so the split arm can reach them + // on the server role's thread. See PipeRoute.h. + MGPipeScreen g_monolithScreen{}; + MGPipeContext g_monolithContext{}; + MGPipeRouteEscapes g_monolithEscapes{}; + + } // namespace + + const MGPipeScreen& MGPipeMonolithScreen() { return g_monolithScreen; } + const MGPipeContext& MGPipeMonolithContext() { return g_monolithContext; } + const MGPipeRouteEscapes& MGPipeMonolithEscapes() { return g_monolithEscapes; } + + MGPReplySlot MGPipeMintReplySlot() { + // 1-based, for ReplySlot.h's reason: "Seq is 1-based; 0 means 'no record'". A zero id + // must stay unmintable so an un-posted mailbox is distinguishable from a posted one. + return MGPReplySlot{++g_reply.NextTicket}; + } + + void MGPipePostReply(const MGPReplySlot& slot, Int32 status, Uint64 value) { + if (slot.Id == 0) { + MGLOG_F("MGPipe: Fatal{ReplyToNoSlot} - a table row posted an answer against reply " + "slot 0, which ReplySlot.h reserves for \"no record\""); + std::abort(); + } + if (g_reply.Slot != 0 && g_reply.Slot != slot.Id) { + MGLOG_F("MGPipe: Fatal{ReplyOverrun} - slot %llu posted while slot %llu was still " + "unread. The mailbox is one deep because R-1's verb barrier makes the " + "in-flight depth one; two outstanding answers means the barrier is not " + "holding", + static_cast(slot.Id), + static_cast(g_reply.Slot)); + std::abort(); + } + g_reply.Slot = slot.Id; + g_reply.Status = status; + g_reply.Value = value; + } + + namespace { + // The one reader. Fatals rather than defaulting, in both directions - see PipeRoute.h. + Uint64 TakeReply(const MGPReplySlot& slot, const char* row, Int32* statusOut) { + if (g_reply.Slot == 0) { + MGLOG_F("MGPipe: Fatal{ReplyMissing, \"%s\"} - the row was routed and nothing " + "posted an answer. R-5 forbids re-deriving acceptance on the client and " + "forbids assuming it, so there is no default to fall back to", + row); + std::abort(); + } + if (g_reply.Slot != slot.Id) { + MGLOG_F("MGPipe: Fatal{ReplyMismatched, \"%s\"} - waiting on slot %llu, the " + "mailbox holds slot %llu", + row, static_cast(slot.Id), + static_cast(g_reply.Slot)); + std::abort(); + } + const Uint64 value = g_reply.Value; + if (statusOut != nullptr) *statusOut = g_reply.Status; + if (g_reply.Status == 1) ++g_reply.Declined; + ++g_reply.Taken; + g_reply.Slot = 0; + g_reply.Value = 0; + g_reply.Status = 0; + return value; + } + } // namespace + + Bool MGPipeTakeReplyBool(const MGPReplySlot& slot, const char* row) { + Int32 status = 0; + const Uint64 value = TakeReply(slot, row, &status); + // DECLINED IS A REAL ANSWER AND IT IS `false`, not a failure (ReplySlot.h). ERROR is + // not an acceptance answer at all and may not be folded into either. + if (status == 2) { + MGLOG_F("MGPipe: Fatal{ReplyError, \"%s\"} - the row answered ERROR, which is not an " + "acceptance answer; folding it into accepted or refused would make a " + "transport fault look like a resource decision", + row); + std::abort(); + } + if (status == 1) return false; + return value != 0; + } + + void* MGPipeTakeReplyPointer(const MGPReplySlot& slot, const char* row) { + Int32 status = 0; + const Uint64 value = TakeReply(slot, row, &status); + if (status == 2) { + MGLOG_F("MGPipe: Fatal{ReplyError, \"%s\"}", row); + std::abort(); + } + // DECLINED is the null pointer, and it is R-6's answer for map_persistent under split. + if (status == 1) return nullptr; + return reinterpret_cast(static_cast(value)); + } + + Uint64 MGPipeRepliesTaken() { return g_reply.Taken; } + Uint64 MGPipeRepliesDeclined() { return g_reply.Declined; } + + MGPipeRouteArm MGPipeInstalledArm() { return g_arm; } + void MGPipeNoteInstalledArm(MGPipeRouteArm arm) { g_arm = arm; } + Bool MGPipeTablesAreInstalled() { return g_arm != MGPipeRouteArm::kNone; } + + // --------------------------------------------------------------------------------- + // The thirty-seven monolith adapters + // --------------------------------------------------------------------------------- + // + // Three shapes, so the eye can check them against PipeTables.inc in one pass rather than + // reading thirty-seven bodies. A row that does not fit one of the three is written out by + // hand BELOW the macros, never by widening a macro - a macro that grew a special case is + // how one of these silently stops being a parameter shuffle. + + namespace { + +#define MGP_MONO_PLAIN(Name, Payload) \ + void Mono_##Name(const Payload* payload) { MGPipeApply##Name(*payload); } + +#define MGP_MONO_BLOB(Name, Payload) \ + void Mono_##Name(const Payload* payload, const void* blobBytes, Uint64) { \ + MGPipeApply##Name(*payload, blobBytes); \ + } + +#define MGP_MONO_TAIL(Name, Payload, TailType) \ + void Mono_##Name(const Payload* payload, const void* varTail, Uint32) { \ + MGPipeApply##Name(*payload, static_cast(varTail)); \ + } + + // -- screen ------------------------------------------------------------------- + MGP_MONO_PLAIN(ResourceDestroy, MGPHandleOnly) + MGP_MONO_PLAIN(UnmapPersistent, MGPHandleOnly) + + // -- context, plain ----------------------------------------------------------- + MGP_MONO_PLAIN(BindRenderState, MGPBindRenderState) + MGP_MONO_PLAIN(DeleteRenderState, MGPHandleOnly) + MGP_MONO_PLAIN(BindVertexElements, MGPHandleOnly) + MGP_MONO_PLAIN(DeleteVertexElements, MGPHandleOnly) + MGP_MONO_PLAIN(DeleteSamplerState, MGPHandleOnly) + MGP_MONO_PLAIN(CreateSamplerView, MGPSamplerView) + MGP_MONO_PLAIN(DeleteSamplerView, MGPHandleOnly) + MGP_MONO_PLAIN(BindShaderState, MGPHandleOnly) + MGP_MONO_PLAIN(DeleteShaderState, MGPHandleOnly) + MGP_MONO_PLAIN(SetDrawProgram, MGPHandleOnly) + MGP_MONO_PLAIN(SetDispatchProgram, MGPHandleOnly) + MGP_MONO_PLAIN(SetFramebufferState, MGPFramebufferState) + MGP_MONO_PLAIN(SetIndexBuffer, MGPIndexBuffer) + MGP_MONO_PLAIN(SetPixelPackState, MGPPixelPackState) + MGP_MONO_PLAIN(SetPatchState, MGPPatchState) + + // -- context, blob companion -------------------------------------------------- + MGP_MONO_BLOB(CreateRenderState, MGPRenderStateDesc) + MGP_MONO_BLOB(CreateVertexElements, MGPVertexElements) + MGP_MONO_BLOB(SetDynamicState, MGPDynamicState) + MGP_MONO_BLOB(SetGlobalConstants, MGPGlobalConstants) + MGP_MONO_BLOB(BufferSubDataResident, MGPSubData) + + // -- context, variable tail --------------------------------------------------- + MGP_MONO_TAIL(SetVertexBuffers, MGPVertexBuffers, MGPVertexBuffer) + MGP_MONO_TAIL(SetSamplerViews, MGPSamplerViews, MGPBoundView) + MGP_MONO_TAIL(BindSamplerStates, MGPSamplerStates, MGPipeHandle) + MGP_MONO_TAIL(SetShaderImages, MGPShaderImages, MGPImageView) + MGP_MONO_TAIL(SetVertexAttribDefaults, MGPVertexAttribDefaults, MGPAttribValue) + +#undef MGP_MONO_PLAIN +#undef MGP_MONO_BLOB +#undef MGP_MONO_TAIL + + // -- the rows that fit none of the three shapes -------------------------------- + + // create_sampler_state: the blob is a TYPED frontend struct, not bytes the applier + // reads through a void*. The cast is the whole adapter, and it is exact: the client + // stages sizeof(SamplerParameters) bytes of the same object (CONTRACT-P5 table 1 + // row 17, including its padding trap - the bytes staged must be the bytes a later + // memcmp compares). + void Mono_CreateSamplerState(const MGPSamplerDesc* payload, const void* blobBytes, Uint64) { + MGPipeApplyCreateSamplerState(*payload, + static_cast(blobBytes)); + } + + // set_residual_value_state: CONTRACT-P5 table 1 row 6, "the hardest row in the table". + // The applier takes `const ResidualValueBlock&` - a frontend type - and + // MGPResidualValueState is never instantiated on the live path. So the BLOCK IS THE + // BLOB, in both arms, and the adapter copies it back out. Requiring exact equality + // rather than ">=" is the decoder's rule too (PipeWireCodec.cpp): a size that only + // ever ratchets down makes a short read silently lose CapabilityBits. + void Mono_SetResidualValueState(const MGPResidualValueState*, const void* blobBytes, + Uint64 blobByteCount) { + if (blobByteCount != sizeof(ResidualValueBlock) || blobBytes == nullptr) { + MGLOG_F("MGPipe: Fatal{ResidualBlockSize} - set_residual_value_state carries %llu " + "bytes, the block is %llu", + static_cast(blobByteCount), + static_cast(sizeof(ResidualValueBlock))); + std::abort(); + } + ResidualValueBlock block{}; + std::memcpy(&block, blobBytes, sizeof(block)); + MGPipeApplySetResidualValueState(block); + } + + // resource_readback carries kReplySlot but its answer is COMPLETION, not a value: the + // bytes go server -> client through SEG_EVENT's OnBufferWriteback (CONTRACT-P5 table 1 + // row 22), and the split decoder posts exactly kStatusOk with a zero-length payload. + // The monolith arm says the same thing, so the two arms hand their caller the same + // answer rather than one of them handing it nothing. + void Mono_ResourceReadback(const MGPReadback* payload, MGPReplySlot* reply) { + MGPipeApplyResourceReadback(*payload); + MGPipePostReply(*reply, 0, 0); + } + + // The three acceptance rows that DO fit a generated signature. Each posts the + // applier's own answer; none of them invents one. + void Mono_ResourceCreate(const MGPResourceDesc* payload, MGPReplySlot* reply) { + const Bool accepted = MGPipeApplyResourceCreate(*payload); + MGPipePostReply(*reply, accepted ? 0 : 1, accepted ? 1u : 0u); + } + + void Mono_SetTextureParams(const MGPTextureParams* payload, MGPReplySlot* reply) { + const Bool accepted = MGPipeApplySetTextureParams(*payload); + MGPipePostReply(*reply, accepted ? 0 : 1, accepted ? 1u : 0u); + } + + void Mono_ResourceSubData(const MGPSubData* payload, const void* blobBytes, Uint64, + const void* varTail, Uint32, MGPReplySlot* reply) { + const Bool accepted = MGPipeApplyResourceSubData( + *payload, blobBytes, static_cast(varTail)); + MGPipePostReply(*reply, accepted ? 0 : 1, accepted ? 1u : 0u); + } + + // -- the four escapes ---------------------------------------------------------- + + Bool Mono_Escape_ResourceRespecify(const MGPResourceDesc* desc, const void* initialBytes, + const MGPRespecifiedLevel* level) { + return MGPipeApplyResourceRespecify(*desc, initialBytes, level); + } + + void Mono_Escape_ResourceFlushRange(const MGPFlushRange* record, const void* bytes) { + MGPipeApplyResourceFlushRange(*record, bytes); + } + + void* Mono_Escape_MapPersistent(const MGPHandleOnly* handle, Uint64 size, + const void* seedBytes) { + return MGPipeApplyMapPersistent(*handle, size, seedBytes); + } + + void Mono_Escape_CreateShaderState(const MGPProgramDesc* desc, + const MG_State::GLState::LinkArtifacts* link, + const MG_State::GLState::SpirvArtifacts* spirv) { + MGPipeApplyCreateShaderState(*desc, link, spirv); + } + + } // namespace + + void MGPipeInstallMonolithTables() { + gMGPipeScreen.ResourceCreate = &Mono_ResourceCreate; + gMGPipeScreen.ResourceDestroy = &Mono_ResourceDestroy; + gMGPipeScreen.UnmapPersistent = &Mono_UnmapPersistent; + + gMGPipeContext.CreateRenderState = &Mono_CreateRenderState; + gMGPipeContext.BindRenderState = &Mono_BindRenderState; + gMGPipeContext.DeleteRenderState = &Mono_DeleteRenderState; + gMGPipeContext.CreateVertexElements = &Mono_CreateVertexElements; + gMGPipeContext.BindVertexElements = &Mono_BindVertexElements; + gMGPipeContext.DeleteVertexElements = &Mono_DeleteVertexElements; + gMGPipeContext.CreateSamplerState = &Mono_CreateSamplerState; + gMGPipeContext.DeleteSamplerState = &Mono_DeleteSamplerState; + gMGPipeContext.CreateSamplerView = &Mono_CreateSamplerView; + gMGPipeContext.DeleteSamplerView = &Mono_DeleteSamplerView; + gMGPipeContext.BindShaderState = &Mono_BindShaderState; + gMGPipeContext.DeleteShaderState = &Mono_DeleteShaderState; + gMGPipeContext.SetDrawProgram = &Mono_SetDrawProgram; + gMGPipeContext.SetDispatchProgram = &Mono_SetDispatchProgram; + gMGPipeContext.SetDynamicState = &Mono_SetDynamicState; + gMGPipeContext.SetFramebufferState = &Mono_SetFramebufferState; + gMGPipeContext.SetVertexBuffers = &Mono_SetVertexBuffers; + gMGPipeContext.SetIndexBuffer = &Mono_SetIndexBuffer; + gMGPipeContext.SetSamplerViews = &Mono_SetSamplerViews; + gMGPipeContext.BindSamplerStates = &Mono_BindSamplerStates; + gMGPipeContext.SetShaderImages = &Mono_SetShaderImages; + gMGPipeContext.SetGlobalConstants = &Mono_SetGlobalConstants; + gMGPipeContext.SetVertexAttribDefaults = &Mono_SetVertexAttribDefaults; + gMGPipeContext.SetPixelPackState = &Mono_SetPixelPackState; + gMGPipeContext.SetPatchState = &Mono_SetPatchState; + gMGPipeContext.SetResidualValueState = &Mono_SetResidualValueState; + gMGPipeContext.SetTextureParams = &Mono_SetTextureParams; + gMGPipeContext.ResourceSubData = &Mono_ResourceSubData; + gMGPipeContext.BufferSubDataResident = &Mono_BufferSubDataResident; + gMGPipeContext.ResourceReadback = &Mono_ResourceReadback; + + gMGPipeRouteEscapes.ResourceRespecify = &Mono_Escape_ResourceRespecify; + gMGPipeRouteEscapes.ResourceFlushRange = &Mono_Escape_ResourceFlushRange; + gMGPipeRouteEscapes.MapPersistent = &Mono_Escape_MapPersistent; + gMGPipeRouteEscapes.CreateShaderState = &Mono_Escape_CreateShaderState; + + // KEPT, not merely installed. The client arm overwrites the three tables above; these + // three copies are what it forwards to when it finds itself on the server's own thread. + g_monolithScreen = gMGPipeScreen; + g_monolithContext = gMGPipeContext; + g_monolithEscapes = gMGPipeRouteEscapes; + + MGPipeNoteInstalledArm(MGPipeRouteArm::kMonolith); + } + +} // namespace MobileGL::MG_Pipe + +#endif // MOBILEGL_PIPE_PUSH diff --git a/MobileGL/MG_Pipe/PipeRoute.h b/MobileGL/MG_Pipe/PipeRoute.h new file mode 100644 index 00000000..5dee9a84 --- /dev/null +++ b/MobileGL/MG_Pipe/PipeRoute.h @@ -0,0 +1,400 @@ +// MobileGL - MobileGL/MG_Pipe/PipeRoute.h +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +// THE CLIENT -> WIRE ROUTING OF THE 37 MGPipeApply* ENTRY POINTS (P5, integrator ruling R-17). +// Owner: package c1. +// +// WHAT WAS MISSING. `gMGPipeWireRecordApply` is the DECODE hook and it has existed since w1: +// a record arrives, `PipeWireCodec` picks it apart and calls `MGPipeApply`. There was no +// encode twin. Every `MG_Impl/Pipe` emitter called `MGPipeApply` DIRECTLY, so under +// `MOBILEGL_TRANSPORT=inproc` every resource, CSO, texture and program record still executed +// synchronously on the GL thread against a context v1 is moving to the apply thread - where +// all sixteen `IsBackendContextCurrentOnThisThread()` and sixteen `CanTouchGLNow()` guards +// answer false. `ClearThenReadPixels` might have survived that; `TriangleScenario` needs a VBO +// and a program and could not. +// +// The two generated tables (`gMGPipeScreen`, `gMGPipeContext`, `generated/PipeTables.inc`) are +// the boundary this repository already had, and they were never installed - every entry null, +// which `MGPipe.h` correctly calls "precisely the pre-migration state". R-17 installs them. +// This header is the frontend-facing half: the names `MG_Impl/Pipe` calls, and the mechanism +// that gets a kReplySlot row's answer back to its call site. +// +// --------------------------------------------------------------------------------------- +// THE SIZE OF R-17, MEASURED RATHER THAN ESTIMATED +// --------------------------------------------------------------------------------------- +// +// R-17 costed this as "37 thin client emitters over EmitAndWait". The emitters are thin. The +// ROUTING was not, and the reason is that the generated table signature is the P2-era monolith +// interface: `void (*Name)(const Payload* payload, ...)`, async-with-handle, no return value, +// and NO COMPANION POINTER. Of the 37 entry points, as the tables stood: +// +// 24 the row could carry every argument the call site passes today +// 13 it could not +// +// The thirteen split three ways, and only one of the three is a generator question: +// +// (a) NINE carry a blob companion - `chunkBytes`, `blobBytes`, `parameters`, `bytes`, +// `initialBytes` - which is CONTRACT-P5 table 1's "companion pointer today" column. A +// kVarTail row already gains `const void* varTail, Uint32 varTailCount`; a kHasBlob row +// gained nothing. THE GENERATOR CHANGE IS THAT ONE ASYMMETRY, and nothing else: five +// lines in `Call.Signature` giving a kHasBlob row `const void* blobBytes, Uint64 +// blobByteCount`. Ten rows gained the pair (the nine plus `GetCaps`, which has no applier +// entry point). `--check` up to date, `--self-test` 9/9 trips. +// +// (b) FOUR return a value the row cannot: `ResourceCreate`, `ResourceRespecify`, +// `ResourceSubData` and `SetTextureParams` return Bool and `MapPersistent` returns void*. +// These are exactly CONTRACT-P5 §2's reply-slot rows, and R-5 forbids re-deriving any of +// them on the client. The answer therefore has to come back THROUGH the reply slot, which +// is what `MGPipeTakeReply*` below is, and it is the part of R-17 that no amount of +// "thin emitter" covers: `MGPReplySlot` is `{Uint64 Id;}` - an IDENTIFIER, not a value - +// so a reader had to exist for the identifier to be worth minting. +// +// (c) FOUR CANNOT GO THROUGH A GENERATED ROW AT ALL, and each one is a contract ruling +// rather than an oversight. They are the ESCAPES below. Saying "four" out loud is the +// honest answer to "tell me if it is bigger than it looks": it is, by four rows and one +// reply-reading mechanism. +// +// --------------------------------------------------------------------------------------- +// THE REPLY MAILBOX, AND WHY ONE ENTRY IS THE RIGHT DEPTH +// --------------------------------------------------------------------------------------- +// +// `MGPReplySlot` carries an id; the answer lands beside it. The mailbox is ONE ENTRY DEEP and +// thread-local, and that is not a simplification - it is R-1's verb barrier stated as a data +// structure. While the barrier holds, the in-flight depth is exactly one (`ReplySlot.h`: +// "under R-1's verb barrier the in-flight depth is one"), so a second posting before the first +// is taken is not a capacity problem, it is a barrier that has stopped working. It Fatals. +// +// THE MAILBOX HAS NO DEFAULT ANSWER AND THIS IS THE WHOLE POINT. `MGPipeTakeReplyBool` on a +// slot nothing posted to is `Fatal{ReplyMissing, ""}`, not `false` and emphatically not +// `true`. "Always accept" is ID-39's 66 lost DirectVulkan uploads with a wire in between, and +// "always refuse" is an emitter that re-sends for ever. A row that forgets to answer is a bug +// that must be impossible to ship, so it is impossible to READ. +// +// WHEN THE BARRIER RETIRES (R-1 opens family by family) this becomes a real slot pool keyed on +// seq, which is what `Transport::ReplySlot` already is on the wire side. The mailbox is the +// frontend-side handle onto it and nothing else; it is deliberately NOT a second id space, +// because the split arm stamps the record's own seq into `MGPReplySlot::Id` (R-3). + +#pragma once +#include + +#if MOBILEGL_PIPE_PUSH + +#include "MGPipe.h" +#include "PipeApply.h" + +namespace MobileGL::MG_Pipe { + + // --------------------------------------------------------------------------------- + // The reply mailbox + // --------------------------------------------------------------------------------- + + // Mints the id for one call's answer. Monolith's ids and the wire's seqs are separate + // spaces on purpose: under split the emitter OVERWRITES this with the record's own seq + // (R-3: "the reply slot id IS the record sequence number"), so the id a caller finally + // reads is the wire's, and under monolith it is a local ticket that only has to be unique + // against the one outstanding call the barrier permits. + MGPReplySlot MGPipeMintReplySlot(); + + // Posts one answer. Called by whichever table is installed - the monolith adapter with + // the applier's return value, the client emitter with the bytes the server put in + // SEG_REPLY. `status` is Transport::ReplyStatus' value space (0 OK, 1 DECLINED, 2 ERROR), + // restated as a plain Int32 so MG_Pipe does not have to see MG_Remote at all. + void MGPipePostReply(const MGPReplySlot& slot, Int32 status, Uint64 value); + + // Takes the answer for `slot`. Fatals - naming `row` - if nothing was posted, if what was + // posted belongs to a different slot, or if the answer has already been taken. + Bool MGPipeTakeReplyBool(const MGPReplySlot& slot, const char* row); + void* MGPipeTakeReplyPointer(const MGPReplySlot& slot, const char* row); + + // How many answers this thread has taken, and how many of those were DECLINED. Counted + // rather than inferred, for R-8's reason one level out: "the client accepted everything" + // and "the client never asked" are otherwise the same observation from outside. + Uint64 MGPipeRepliesTaken(); + Uint64 MGPipeRepliesDeclined(); + + // --------------------------------------------------------------------------------- + // The escapes: the four rows no generated signature can express + // --------------------------------------------------------------------------------- + // + // A hand-written third table, installed and uninstalled by the same two functions as the + // generated pair, so there is ONE mechanism and not two. Each row's signature is the + // applier's own, because that is the shape the monolith arm has to reproduce byte for + // byte (G2), and each row is here for a ruling that is written down: + // + // ResourceRespecify `initialBytes` has NO kHasBlob and R-13.3 forbids giving it one + // ("initialBytes is always nullptr under split; initial content + // arrives as ResourceSubData records immediately after this one"). + // So the row is correct to omit it AND monolith must still pass it. + // ResourceFlushRange `bytes` likewise, by R-13.2, and for a sharper reason: a blobref + // here would be "a second, forgeable way to say the same thing". + // MapPersistent `size` and `seedBytes` have no carrier at all - `MGPHandleOnly` is + // {Handle, Kind} - and the call returns void*. R-6 makes the split + // answer a constant DECLINE, so the carrier is not needed; the + // monolith arm needs both arguments. + // CreateShaderState SEVEN blobrefs (`Spirv[6]` + `Reflection`) and TWO typed frontend + // pointers. One `blobBytes` pair cannot express seven runs, and + // serialising under monolith to un-serialise in the adapter would put + // `EncodeProgramArtifacts` on the monolith path, which `PipeApply.h` + // explicitly promises it is not ("zero serialisation cost on the + // monolith path"). + // + // OVERTURN CONDITIONS, one per row: give `MGPResourceDesc` and `MGPFlushRange` a blobref + // (overturns R-13.2/R-13.3, and c0 owns `MGPipeTypes.h`); give `MGPHandleOnly` a size + // (same owner) and P6 a real remote map; measure that a per-stage SPIR-V run beats one + // archive, at which point `CreateShaderState` needs a multi-blob row rather than this one. + struct MGPipeRouteEscapes { + Bool (*ResourceRespecify)(const MGPResourceDesc* desc, const void* initialBytes, + const MGPRespecifiedLevel* level); + void (*ResourceFlushRange)(const MGPFlushRange* record, const void* bytes); + void* (*MapPersistent)(const MGPHandleOnly* handle, Uint64 size, const void* seedBytes); + void (*CreateShaderState)(const MGPProgramDesc* desc, + const MG_State::GLState::LinkArtifacts* link, + const MG_State::GLState::SpirvArtifacts* spirv); + }; + inline MGPipeRouteEscapes gMGPipeRouteEscapes{}; + + // --------------------------------------------------------------------------------- + // The two install functions + // --------------------------------------------------------------------------------- + + // Installs the monolith adapters into all three tables. Idempotent. + void MGPipeInstallMonolithTables(); + + // The monolith adapters, kept beside the installed tables rather than only written into + // them. THE SPLIT ARM NEEDS THEM AT RUNTIME, and the reason is table 3's role split made + // concrete: `gMGPipeScreen` / `gMGPipeContext` are PROCESS globals, and under `inproc` the + // server role lives in the same process on the apply thread. When that thread runs the + // server's own backend - the EGL bring-up, InitCapabilities, or the applier itself - it + // reaches the very same `MG_Impl/Pipe` emitters the client does, and a wire emitter there + // publishes a record and then waits for the apply thread to apply it. That thread IS the + // apply thread, so it waits for itself: `Fatal{BarrierTimeout, "ResourceRespecify"}`, + // logged by `mgl-srv-apply`, thirty seconds after bring-up starts. + // + // So the client emitters ask "am I the server role right now?" and, if so, run the + // monolith adapter - which is exactly what `PipeWireCodec` already does on the decode side + // by calling `MGPipeApply*` directly. The predicate is v1's `ServerLoop::OnApplyThread()`; + // these three accessors are what makes the other half reachable. + const MGPipeScreen& MGPipeMonolithScreen(); + const MGPipeContext& MGPipeMonolithContext(); + const MGPipeRouteEscapes& MGPipeMonolithEscapes(); + + namespace detail { + // THE INSTALL THAT CANNOT BE LINKED AWAY, and the first version of this WAS. + // + // It began as a static initialiser inside PipeRoute.cpp, on the reasoning that + // `gMGPipeScreen` and `gMGPipeContext` are inline variables with CONSTANT (zero) + // initialisation - sequenced before every dynamic initialiser - so an installer in + // dynamic init necessarily beats any GL entry point. That reasoning is correct and the + // mechanism still failed, because it assumed PipeRoute.o would be in the link at all: + // every MG_Test target is its OWN binary over a static archive, `CsoCache.h` reaches + // the table through an inline thunk and names no symbol from PipeRoute.o, so the + // linker dropped the object, the initialiser never ran, and five CsoCacheTest cases + // took a null function pointer. The shared library linked it and was fine, which is + // exactly the shape that ships. + // + // AN INLINE VARIABLE FIXES BOTH HALVES AT ONCE. Its initialiser is emitted as a COMDAT + // in every translation unit that includes this header, so any binary that can call a + // thunk has one copy of it; and naming `MGPipeInstallMonolithTables` from a header the + // call sites already include is an undefined reference that forces the archive member + // into the link. There is still exactly one install, and it still happens before main. + inline const Bool gMonolithTablesInstalled = (MGPipeInstallMonolithTables(), true); + } // namespace detail + + // True once the tables hold something. Exposed for the gates only: a probe that armed + // against an un-installed table would be arming against the pre-migration state. + Bool MGPipeTablesAreInstalled(); + + // Which arm is installed, so a case can assert the arm it thinks it is testing rather than + // trusting `MOBILEGL_TRANSPORT`. `MG_Config::Transport` says what was ASKED for; this says + // what the table actually does. + enum class MGPipeRouteArm : Uint8 { kNone, kMonolith, kClientWire }; + MGPipeRouteArm MGPipeInstalledArm(); + void MGPipeNoteInstalledArm(MGPipeRouteArm arm); + + // --------------------------------------------------------------------------------- + // The call-site names: MGPipeRoute for each of the thirty-seven + // --------------------------------------------------------------------------------- + // + // EVERY ONE TAKES THE APPLIER'S OWN SIGNATURE, so converting a call site is a rename and + // nothing else. That is not tidiness, it is the G1 lesson from round 1 stated as a rule: + // rewriting `if (const auto f = TABLE.GL.Slot)` into a Bool-valued macro is semantically + // identical and generates DIFFERENT code (-144 bytes, two symbols resized). A wrapper that + // took `&expr` would force every site holding a temporary - `HandleOnly(handle)`, + // `BufferHandleOnly(handle)` - to grow a named local, which is a second expression shape + // change at forty sites. A reference parameter keeps the call site's text identical but + // for the name. + // + // THREE OF THE THIRTY-SEVEN TAKE ONE ARGUMENT MORE THAN THEIR APPLIER, and the extra is + // always the same thing: A BYTE COUNT THE RECORD DOES NOT DECLARE. CONTRACT-P5 table 1 + // rule A requires every kHasBlob row to declare its length under split; five rows already + // do and the wrapper reads it back off the record, three do not and their call site is the + // only place the number exists: + // SetGlobalConstants declares 0 (ProgramEmit.h); the length is GetUBOSize() + // ResourceSubData buffer half declares real, TEXTURE half declares 0 on the + // grounds that the count is "the server's to compute" - which + // table 1 row 7 says "cannot be a bounds check". The level + // shadow's byte size is in scope at that call site. + // BufferSubDataResident the application's staging store, sized by the caller + // A defaulted argument was rejected: a default would let a site that has the number forget + // to pass it and still compile, and the failure would be a silently short upload. + + // ---- screen ------------------------------------------------------------------------ + inline Bool MGPipeRouteResourceCreate(const MGPResourceDesc& desc) { + MGPReplySlot reply = MGPipeMintReplySlot(); + MGP_ResourceCreate(&desc, &reply); + return MGPipeTakeReplyBool(reply, "resource_create"); + } + inline Bool MGPipeRouteResourceRespecify(const MGPResourceDesc& desc, const void* initialBytes, + const MGPRespecifiedLevel* level = nullptr) { + return gMGPipeRouteEscapes.ResourceRespecify(&desc, initialBytes, level); + } + inline void MGPipeRouteResourceDestroy(const MGPHandleOnly& handle) { + MGP_ResourceDestroy(&handle); + } + inline void* MGPipeRouteMapPersistent(const MGPHandleOnly& handle, Uint64 size, + const void* seedBytes) { + return gMGPipeRouteEscapes.MapPersistent(&handle, size, seedBytes); + } + inline void MGPipeRouteUnmapPersistent(const MGPHandleOnly& handle) { + MGP_UnmapPersistent(&handle); + } + + // ---- resources --------------------------------------------------------------------- + inline Bool MGPipeRouteResourceSubData(const MGPSubData& record, const void* bytes, + Uint64 byteCount, + const MGPSubRegion* regions = nullptr) { + MGPReplySlot reply = MGPipeMintReplySlot(); + MGP_ResourceSubData(&record, bytes, byteCount, regions, record.RegionCount, &reply); + return MGPipeTakeReplyBool(reply, "resource_subdata"); + } + inline void MGPipeRouteBufferSubDataResident(const MGPSubData& record, const void* bytes, + Uint64 byteCount) { + MGP_BufferSubDataResident(&record, bytes, byteCount); + } + inline void MGPipeRouteResourceFlushRange(const MGPFlushRange& record, const void* bytes) { + gMGPipeRouteEscapes.ResourceFlushRange(&record, bytes); + } + inline void MGPipeRouteResourceReadback(const MGPReadback& record) { + MGPReplySlot reply = MGPipeMintReplySlot(); + MGP_ResourceReadback(&record, &reply); + // The answer is COMPLETION, not a value: the bytes come back through SEG_EVENT's + // OnBufferWriteback (table 1 row 22). It is still TAKEN, because an untaken mailbox + // entry is what the next row's ReplyOverrun Fatal is looking for. + (void)MGPipeTakeReplyBool(reply, "resource_readback"); + } + + // ---- render state ------------------------------------------------------------------ + inline void MGPipeRouteCreateRenderState(const MGPRenderStateDesc& desc, + const void* chunkBytes) { + MGP_CreateRenderState(&desc, chunkBytes, desc.Blob.Size); + } + inline void MGPipeRouteBindRenderState(const MGPBindRenderState& bind) { + MGP_BindRenderState(&bind); + } + inline void MGPipeRouteDeleteRenderState(const MGPHandleOnly& handle) { + MGP_DeleteRenderState(&handle); + } + inline void MGPipeRouteSetDynamicState(const MGPDynamicState& dyn, const void* chunkBytes) { + MGP_SetDynamicState(&dyn, chunkBytes, dyn.Blob.Size); + } + inline void MGPipeRouteSetPixelPackState(const MGPPixelPackState& pack) { + MGP_SetPixelPackState(&pack); + } + inline void MGPipeRouteSetPatchState(const MGPPatchState& patch) { MGP_SetPatchState(&patch); } + inline void MGPipeRouteSetVertexAttribDefaults(const MGPVertexAttribDefaults& hdr, + const MGPAttribValue* tail) { + MGP_SetVertexAttribDefaults(&hdr, tail, hdr.Count); + } + // The block IS the blob (table 1 row 6) and its size is a header constant both sides + // read, so no call site has to know it. + inline void MGPipeRouteSetResidualValueState(const ResidualValueBlock& block) { + MGPResidualValueState record{}; + record.Version = 0; + MGP_SetResidualValueState(&record, &block, sizeof(ResidualValueBlock)); + } + + // ---- vertex input ------------------------------------------------------------------ + inline void MGPipeRouteCreateVertexElements(const MGPVertexElements& desc, + const void* blobBytes) { + MGP_CreateVertexElements(&desc, blobBytes, desc.Blob.Size); + } + inline void MGPipeRouteBindVertexElements(const MGPHandleOnly& handle) { + MGP_BindVertexElements(&handle); + } + inline void MGPipeRouteDeleteVertexElements(const MGPHandleOnly& handle) { + MGP_DeleteVertexElements(&handle); + } + inline void MGPipeRouteSetVertexBuffers(const MGPVertexBuffers& hdr, + const MGPVertexBuffer* tail) { + MGP_SetVertexBuffers(&hdr, tail, hdr.Count); + } + inline void MGPipeRouteSetIndexBuffer(const MGPIndexBuffer& record) { + MGP_SetIndexBuffer(&record); + } + + // ---- framebuffer, samplers, images ------------------------------------------------- + inline void MGPipeRouteSetFramebufferState(const MGPFramebufferState& state) { + MGP_SetFramebufferState(&state); + } + // The parameters row declares Size 0 today and table 1 row 17 puts it under rule A, so + // the length is stated here once, from the type the applier stores by value. + inline void MGPipeRouteCreateSamplerState(const MGPSamplerDesc& desc, + const SamplerParameters* parameters) { + MGP_CreateSamplerState(&desc, parameters, sizeof(SamplerParameters)); + } + inline void MGPipeRouteDeleteSamplerState(const MGPHandleOnly& handle) { + MGP_DeleteSamplerState(&handle); + } + inline void MGPipeRouteCreateSamplerView(const MGPSamplerView& view) { + MGP_CreateSamplerView(&view); + } + inline void MGPipeRouteDeleteSamplerView(const MGPHandleOnly& handle) { + MGP_DeleteSamplerView(&handle); + } + inline Bool MGPipeRouteSetTextureParams(const MGPTextureParams& params) { + MGPReplySlot reply = MGPipeMintReplySlot(); + MGP_SetTextureParams(¶ms, &reply); + return MGPipeTakeReplyBool(reply, "set_texture_params"); + } + inline void MGPipeRouteSetSamplerViews(const MGPSamplerViews& hdr, const MGPBoundView* tail) { + MGP_SetSamplerViews(&hdr, tail, hdr.Count); + } + inline void MGPipeRouteBindSamplerStates(const MGPSamplerStates& hdr, const MGPipeHandle* tail) { + MGP_BindSamplerStates(&hdr, tail, hdr.Count); + } + inline void MGPipeRouteSetShaderImages(const MGPShaderImages& hdr, const MGPImageView* tail) { + MGP_SetShaderImages(&hdr, tail, hdr.Count); + } + + // ---- programs ---------------------------------------------------------------------- + inline void MGPipeRouteCreateShaderState(const MGPProgramDesc& desc, + const MG_State::GLState::LinkArtifacts* link, + const MG_State::GLState::SpirvArtifacts* spirv) { + gMGPipeRouteEscapes.CreateShaderState(&desc, link, spirv); + } + inline void MGPipeRouteBindShaderState(const MGPHandleOnly& handle) { + MGP_BindShaderState(&handle); + } + inline void MGPipeRouteDeleteShaderState(const MGPHandleOnly& handle) { + MGP_DeleteShaderState(&handle); + } + inline void MGPipeRouteSetDrawProgram(const MGPHandleOnly& handle) { + MGP_SetDrawProgram(&handle); + } + inline void MGPipeRouteSetDispatchProgram(const MGPHandleOnly& handle) { + MGP_SetDispatchProgram(&handle); + } + inline void MGPipeRouteSetGlobalConstants(const MGPGlobalConstants& record, const void* bytes, + Uint64 byteCount) { + MGP_SetGlobalConstants(&record, bytes, byteCount); + } + +} // namespace MobileGL::MG_Pipe + +#endif // MOBILEGL_PIPE_PUSH diff --git a/MobileGL/MG_Pipe/generated/PipeTables.inc b/MobileGL/MG_Pipe/generated/PipeTables.inc index a5d4f966..f62d915a 100644 --- a/MobileGL/MG_Pipe/generated/PipeTables.inc +++ b/MobileGL/MG_Pipe/generated/PipeTables.inc @@ -15,7 +15,7 @@ // share group: 11 calls. A null entry means the backend does not implement this // call and the frontend keeps its own path (plan B section 4.1). struct MGPipeScreen { - void (*GetCaps)(const MGPCaps* payload, MGPReplySlot* reply); + void (*GetCaps)(const MGPCaps* payload, const void* blobBytes, Uint64 blobByteCount, MGPReplySlot* reply); void (*ResourceCreate)(const MGPResourceDesc* payload, MGPReplySlot* reply); void (*ResourceRespecify)(const MGPResourceDesc* payload, MGPReplySlot* reply); void (*ResourceDestroy)(const MGPHandleOnly* payload); @@ -37,20 +37,20 @@ struct MGPipeContext { void (*QueryAvailable)(const MGPHandleOnly* payload, MGPReplySlot* reply); void (*QueryResult)(const MGPQueryResultRequest* payload, MGPReplySlot* reply); void (*QueryDestroy)(const MGPHandleOnly* payload); - void (*CreateRenderState)(const MGPRenderStateDesc* payload); + void (*CreateRenderState)(const MGPRenderStateDesc* payload, const void* blobBytes, Uint64 blobByteCount); void (*BindRenderState)(const MGPBindRenderState* payload); void (*DeleteRenderState)(const MGPHandleOnly* payload); - void (*CreateVertexElements)(const MGPVertexElements* payload); + void (*CreateVertexElements)(const MGPVertexElements* payload, const void* blobBytes, Uint64 blobByteCount); void (*BindVertexElements)(const MGPHandleOnly* payload); void (*DeleteVertexElements)(const MGPHandleOnly* payload); - void (*CreateSamplerState)(const MGPSamplerDesc* payload); + void (*CreateSamplerState)(const MGPSamplerDesc* payload, const void* blobBytes, Uint64 blobByteCount); void (*DeleteSamplerState)(const MGPHandleOnly* payload); void (*CreateSamplerView)(const MGPSamplerView* payload); void (*DeleteSamplerView)(const MGPHandleOnly* payload); - void (*CreateShaderState)(const MGPProgramDesc* payload); + void (*CreateShaderState)(const MGPProgramDesc* payload, const void* blobBytes, Uint64 blobByteCount); void (*BindShaderState)(const MGPHandleOnly* payload); void (*DeleteShaderState)(const MGPHandleOnly* payload); - void (*SetDynamicState)(const MGPDynamicState* payload); + void (*SetDynamicState)(const MGPDynamicState* payload, const void* blobBytes, Uint64 blobByteCount); void (*SetFramebufferState)(const MGPFramebufferState* payload); void (*SetVertexBuffers)(const MGPVertexBuffers* payload, const void* varTail, Uint32 varTailCount); void (*SetIndexBuffer)(const MGPIndexBuffer* payload); @@ -60,16 +60,16 @@ struct MGPipeContext { void (*SetShaderImages)(const MGPShaderImages* payload, const void* varTail, Uint32 varTailCount); void (*SetShaderBuffers)(const MGPShaderBuffers* payload, const void* varTail, Uint32 varTailCount); void (*SetStreamOutputTargets)(const MGPStreamOutputTargets* payload, const void* varTail, Uint32 varTailCount); - void (*SetGlobalConstants)(const MGPGlobalConstants* payload); + void (*SetGlobalConstants)(const MGPGlobalConstants* payload, const void* blobBytes, Uint64 blobByteCount); void (*SetVertexAttribDefaults)(const MGPVertexAttribDefaults* payload, const void* varTail, Uint32 varTailCount); void (*SetPixelPackState)(const MGPPixelPackState* payload); void (*SetPatchState)(const MGPPatchState* payload); void (*SetDrawProgram)(const MGPHandleOnly* payload); void (*SetDispatchProgram)(const MGPHandleOnly* payload); - void (*SetResidualValueState)(const MGPResidualValueState* payload); + void (*SetResidualValueState)(const MGPResidualValueState* payload, const void* blobBytes, Uint64 blobByteCount); void (*SetTextureParams)(const MGPTextureParams* payload, MGPReplySlot* reply); - void (*ResourceSubData)(const MGPSubData* payload, const void* varTail, Uint32 varTailCount, MGPReplySlot* reply); - void (*BufferSubDataResident)(const MGPSubData* payload); + void (*ResourceSubData)(const MGPSubData* payload, const void* blobBytes, Uint64 blobByteCount, const void* varTail, Uint32 varTailCount, MGPReplySlot* reply); + void (*BufferSubDataResident)(const MGPSubData* payload, const void* blobBytes, Uint64 blobByteCount); void (*ResourceSubDataComplete)(const MGPSubDataComplete* payload); void (*ResourceFlushRange)(const MGPFlushRange* payload); void (*ResourceReadback)(const MGPReadback* payload, MGPReplySlot* reply); diff --git a/MobileGL/MG_Pipe/generated/PipeThunks.inc b/MobileGL/MG_Pipe/generated/PipeThunks.inc index 09638e04..585e2469 100644 --- a/MobileGL/MG_Pipe/generated/PipeThunks.inc +++ b/MobileGL/MG_Pipe/generated/PipeThunks.inc @@ -17,8 +17,8 @@ // unimplemented (null) entry is the caller's business to check, exactly as it is // with the table this replaces. -inline void MGP_GetCaps(const MGPCaps* payload, MGPReplySlot* reply) { - gMGPipeScreen.GetCaps(payload, reply); +inline void MGP_GetCaps(const MGPCaps* payload, const void* blobBytes, Uint64 blobByteCount, MGPReplySlot* reply) { + gMGPipeScreen.GetCaps(payload, blobBytes, blobByteCount, reply); } inline void MGP_ResourceCreate(const MGPResourceDesc* payload, MGPReplySlot* reply) { @@ -81,8 +81,8 @@ inline void MGP_QueryDestroy(const MGPHandleOnly* payload) { gMGPipeContext.QueryDestroy(payload); } -inline void MGP_CreateRenderState(const MGPRenderStateDesc* payload) { - gMGPipeContext.CreateRenderState(payload); +inline void MGP_CreateRenderState(const MGPRenderStateDesc* payload, const void* blobBytes, Uint64 blobByteCount) { + gMGPipeContext.CreateRenderState(payload, blobBytes, blobByteCount); } inline void MGP_BindRenderState(const MGPBindRenderState* payload) { @@ -93,8 +93,8 @@ inline void MGP_DeleteRenderState(const MGPHandleOnly* payload) { gMGPipeContext.DeleteRenderState(payload); } -inline void MGP_CreateVertexElements(const MGPVertexElements* payload) { - gMGPipeContext.CreateVertexElements(payload); +inline void MGP_CreateVertexElements(const MGPVertexElements* payload, const void* blobBytes, Uint64 blobByteCount) { + gMGPipeContext.CreateVertexElements(payload, blobBytes, blobByteCount); } inline void MGP_BindVertexElements(const MGPHandleOnly* payload) { @@ -105,8 +105,8 @@ inline void MGP_DeleteVertexElements(const MGPHandleOnly* payload) { gMGPipeContext.DeleteVertexElements(payload); } -inline void MGP_CreateSamplerState(const MGPSamplerDesc* payload) { - gMGPipeContext.CreateSamplerState(payload); +inline void MGP_CreateSamplerState(const MGPSamplerDesc* payload, const void* blobBytes, Uint64 blobByteCount) { + gMGPipeContext.CreateSamplerState(payload, blobBytes, blobByteCount); } inline void MGP_DeleteSamplerState(const MGPHandleOnly* payload) { @@ -121,8 +121,8 @@ inline void MGP_DeleteSamplerView(const MGPHandleOnly* payload) { gMGPipeContext.DeleteSamplerView(payload); } -inline void MGP_CreateShaderState(const MGPProgramDesc* payload) { - gMGPipeContext.CreateShaderState(payload); +inline void MGP_CreateShaderState(const MGPProgramDesc* payload, const void* blobBytes, Uint64 blobByteCount) { + gMGPipeContext.CreateShaderState(payload, blobBytes, blobByteCount); } inline void MGP_BindShaderState(const MGPHandleOnly* payload) { @@ -133,8 +133,8 @@ inline void MGP_DeleteShaderState(const MGPHandleOnly* payload) { gMGPipeContext.DeleteShaderState(payload); } -inline void MGP_SetDynamicState(const MGPDynamicState* payload) { - gMGPipeContext.SetDynamicState(payload); +inline void MGP_SetDynamicState(const MGPDynamicState* payload, const void* blobBytes, Uint64 blobByteCount) { + gMGPipeContext.SetDynamicState(payload, blobBytes, blobByteCount); } inline void MGP_SetFramebufferState(const MGPFramebufferState* payload) { @@ -173,8 +173,8 @@ inline void MGP_SetStreamOutputTargets(const MGPStreamOutputTargets* payload, co gMGPipeContext.SetStreamOutputTargets(payload, varTail, varTailCount); } -inline void MGP_SetGlobalConstants(const MGPGlobalConstants* payload) { - gMGPipeContext.SetGlobalConstants(payload); +inline void MGP_SetGlobalConstants(const MGPGlobalConstants* payload, const void* blobBytes, Uint64 blobByteCount) { + gMGPipeContext.SetGlobalConstants(payload, blobBytes, blobByteCount); } inline void MGP_SetVertexAttribDefaults(const MGPVertexAttribDefaults* payload, const void* varTail, Uint32 varTailCount) { @@ -197,20 +197,20 @@ inline void MGP_SetDispatchProgram(const MGPHandleOnly* payload) { gMGPipeContext.SetDispatchProgram(payload); } -inline void MGP_SetResidualValueState(const MGPResidualValueState* payload) { - gMGPipeContext.SetResidualValueState(payload); +inline void MGP_SetResidualValueState(const MGPResidualValueState* payload, const void* blobBytes, Uint64 blobByteCount) { + gMGPipeContext.SetResidualValueState(payload, blobBytes, blobByteCount); } inline void MGP_SetTextureParams(const MGPTextureParams* payload, MGPReplySlot* reply) { gMGPipeContext.SetTextureParams(payload, reply); } -inline void MGP_ResourceSubData(const MGPSubData* payload, const void* varTail, Uint32 varTailCount, MGPReplySlot* reply) { - gMGPipeContext.ResourceSubData(payload, varTail, varTailCount, reply); +inline void MGP_ResourceSubData(const MGPSubData* payload, const void* blobBytes, Uint64 blobByteCount, const void* varTail, Uint32 varTailCount, MGPReplySlot* reply) { + gMGPipeContext.ResourceSubData(payload, blobBytes, blobByteCount, varTail, varTailCount, reply); } -inline void MGP_BufferSubDataResident(const MGPSubData* payload) { - gMGPipeContext.BufferSubDataResident(payload); +inline void MGP_BufferSubDataResident(const MGPSubData* payload, const void* blobBytes, Uint64 blobByteCount) { + gMGPipeContext.BufferSubDataResident(payload, blobBytes, blobByteCount); } inline void MGP_ResourceSubDataComplete(const MGPSubDataComplete* payload) { diff --git a/MobileGL/MG_Remote/Client/BackendObject_Remote.cpp b/MobileGL/MG_Remote/Client/BackendObject_Remote.cpp new file mode 100644 index 00000000..44d09bb7 --- /dev/null +++ b/MobileGL/MG_Remote/Client/BackendObject_Remote.cpp @@ -0,0 +1,282 @@ +// MobileGL - MobileGL/MG_Remote/Client/BackendObject_Remote.cpp +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +// P5 package c1. See BackendObject_Remote.h for the three traps and why each is paid here. + +#include "BackendObject_Remote.h" + +#include "CapsMirror.h" +#include "ClientSession.h" +#include "EmitTables.h" + +#include "../Server/ServerLoop.h" + +#include + +// Declared rather than included: MG_Backend/BackendObjects.h drags in both concrete backend +// objects, and this translation unit must not depend on either - the client role links the same +// library but never constructs one. +namespace MobileGL::MG_Backend { + extern UniquePtr& pActiveBackendObject; +} + +namespace MobileGL::MG_Remote::Client { + + namespace { + + // ---- the EGL seam ------------------------------------------------------------------ + // + // THE NINE EGL VIRTUALS CALL v1's TWELVE FORWARDERS AND NOTHING ELSE. c1 round 1 built + // its own trampolines over ServerLoop::RunOnApplyThread and ServerLoop::Backend(), + // which ran the right driver call on the right thread and was still wrong, because + // three of the twelve do MORE than forward: + // + // ServerMakeEGLCurrent re-publishes the caps snapshot after the server's own + // InitCapabilities has run (R-12 arm (a)) + // ServerInitCapabilities the same, on the explicit path + // ServerSetWindowHandle hands the surface to the server's backend + // + // Calling `Backend()->MakeEGLCurrent(...)` skips the republish, so the client's mirror + // keeps the snapshot Accept() sent BEFORE any context existed - every limit, every + // advertised extension and the compile-env fingerprint read off an empty backend, with + // InitCapabilities below happily reporting success because a snapshot did arrive once. + // That is the failure this seam exists to make impossible: there is no second route to + // the server's EGL, so there is no route that can skip what the forwarder does. + // + // The forwarders block on mgl-srv-apply themselves and run INLINE when the caller is + // already on that thread, so this file no longer needs RunOnApplyThread, a per-call + // args struct, or a null-backend check of its own - ServerBackendOrNull() inside each + // forwarder is the one place that answers "the bring-up did not complete", and it + // answers `false` rather than crashing or succeeding locally. + + // CapsMirror's adoption hook. A free function because the hook is a raw function + // pointer (ID-8: this can fire on a path that must not allocate), and it reaches the + // live object through pActiveBackendObject rather than through a second global. + void OnCapsAdopted() { + auto* self = dynamic_cast(MG_Backend::pActiveBackendObject.get()); + if (self != nullptr) self->RefreshFormatCapabilities(); + } + + } // namespace + + BackendObject_Remote::BackendObject_Remote() { + // Installed in the constructor rather than at the first snapshot, because the first + // snapshot has usually already arrived by then: ClientSession::Start pumps the control + // plane during the handshake, and MG_Backend::Init() constructs this object after it. + // RefreshFormatCapabilities below picks up that already-adopted generation. + SetCapsAdoptedHook(&OnCapsAdopted); + RefreshFormatCapabilities(); + } + + BackendObject_Remote::~BackendObject_Remote() { + // The hook holds a raw function pointer, not a pointer to this - but the function it + // names reaches pActiveBackendObject, which is being destroyed right now. Uninstall. + SetCapsAdoptedHook(nullptr); + } + + void BackendObject_Remote::RefreshFormatCapabilities() { + CapsMirror& mirror = CapsMirrorInstance(); + if (!mirror.Valid() || mirror.Generation() == m_formatsGeneration) return; + // TRAP 2. GetFormatCapabilities() is non-virtual and hands back this member, so the + // only way a remote object can answer it is to fill it. + MutableFormatCapabilities() = mirror.Formats(); + m_formatsGeneration = mirror.Generation(); + MGLOG_I("MG_Remote client: format capabilities filled from caps mirror generation %llu", + static_cast(m_formatsGeneration)); + } + + // ---- the eight pure virtuals ---------------------------------------------------------- + + void BackendObject_Remote::Initialize() { + // NOT FORWARDED. The server's own BackendObject_DirectGLES is created and initialised + // by v1's ServerLoop, on the apply thread, before this object exists; forwarding here + // would be a second Initialize() on an already-initialised backend. What this call + // does is drain whatever the handshake left and take the caps that came with it. + if (ClientSession* session = ClientSession::Active()) { + session->PumpControlPlane(); + } + RefreshFormatCapabilities(); + } + + Bool BackendObject_Remote::InitCapabilities() { + // Reached from the base class's MakeEGLCurrent, lazily, once per surface lifetime + // (BackendObject.cpp:341-347). By this point MakeEGLCurrent below has already run the + // SERVER's MakeEGLCurrent on the apply thread, whose own base class ran the server + // backend's InitCapabilities and whose ServerSession re-published the snapshot - so + // the client's job here is to pick that snapshot up. R-12: re-arrival IS the + // invalidation, and this is the second place it is drained (the other is Present). + ClientSession* session = ClientSession::Active(); + if (session == nullptr) { + MGLOG_E("MG_Remote client: InitCapabilities with no session"); + return false; + } + // ASK THE SERVER RATHER THAN ASSUMING ServerMakeEGLCurrent HAS ALREADY ASKED IT. The + // comment above says "by this point MakeEGLCurrent has already run the SERVER's + // MakeEGLCurrent", and that is true on the make-current path - but the base class + // reaches InitCapabilities lazily, once per SURFACE lifetime, and a surface can be + // replaced without a new make-current. ServerInitCapabilities re-publishes the + // snapshot the same way, so asking twice costs one snapshot and never asking costs + // every limit in the mirror. It is idempotent on the server's side. + if (!Server::ServerInitCapabilities()) { + MGLOG_E("MG_Remote client: the server's InitCapabilities failed, so there is no " + "snapshot to adopt and going current would read default limits"); + return false; + } + session->PumpControlPlane(); + RefreshFormatCapabilities(); + // A PLACEHOLDER MIRROR IS A FAILURE HERE, unlike at startup. LogBackendInfo reading a + // placeholder costs one wrong log line; a context going current on one costs every + // limit, every advertised extension and the compile-env fingerprint. + if (!CapsMirrorInstance().Valid()) { + MGLOG_E("MG_Remote client: InitCapabilities found no CapsSnapshot - the server has " + "not published one. Going current on a placeholder caps mirror would put " + "default limits into every glGetIntegerv answer and into the compile env"); + return false; + } + return true; + } + + Bool BackendObject_Remote::InitWindowSurface() { + // The real surface work happened on the apply thread inside the server backend's own + // ActivateEGLSurface; this is the client's half of the base state machine and has + // nothing of its own to do. + return true; + } + + Bool BackendObject_Remote::InitPbufferSurface(EGLint, EGLint) { return true; } + + const RendererInfo& BackendObject_Remote::GetRendererInfo() const { + // TRAP 1: a reference, so the storage is the mirror's and not a temporary's. + return CapsMirrorInstance().Renderer(); + } + + String BackendObject_Remote::GetBackendAPIVersionString() const { + return CapsMirrorInstance().ApiVersion(); + } + + const MG_Backend::GlobalBackendFunctionsTable& BackendObject_Remote::GetBackendFunctions() const { + return RemoteEmitTable(); + } + + const MG_Backend::DynamicBackendParameters& BackendObject_Remote::GetDynamicParameters() const { + return CapsMirrorInstance().Dynamic(); + } + + BackendType BackendObject_Remote::GetBackendType() const { + // TRAP 3: the SERVER's backend, never a new enumerator. + return CapsMirrorInstance().Backend(); + } + + // ---- the nine EGL lifecycle virtuals --------------------------------------------------- + // + // FORWARD FIRST, THEN RUN THE BASE. The server has to own the context before the client's + // base class latches "the surface is initialised" and calls InitCapabilities, because + // InitCapabilities' answer comes from a snapshot the server can only publish once its own + // InitCapabilities has run. + + Bool BackendObject_Remote::InitializeEGLDisplay(EGLDisplay dpy, EGLint* major, EGLint* minor) { + if (!Server::ServerInitializeEGLDisplay(dpy, major, minor)) return false; + return MG_Backend::BackendObject::InitializeEGLDisplay(dpy, major, minor); + } + + Bool BackendObject_Remote::CreateEGLWindowSurface(EGLSurface surface, + const MG_Backend::WindowHandle& handle) { + // The handle first: the server's backend has to know which window it is about to make + // a surface for, and ServerSetWindowHandle is the only way to tell it. + Server::ServerSetWindowHandle(handle); + if (!Server::ServerCreateEGLWindowSurface(surface, handle)) return false; + return MG_Backend::BackendObject::CreateEGLWindowSurface(surface, handle); + } + + Bool BackendObject_Remote::ResizeEGLWindowSurface(EGLSurface surface, Uint32 width, Uint32 height) { + if (!Server::ServerResizeEGLWindowSurface(surface, width, height)) return false; + return MG_Backend::BackendObject::ResizeEGLWindowSurface(surface, width, height); + } + + Bool BackendObject_Remote::CreateEGLPbufferSurface(EGLSurface surface, EGLint width, EGLint height) { + if (!Server::ServerCreateEGLPbufferSurface(surface, width, height)) return false; + return MG_Backend::BackendObject::CreateEGLPbufferSurface(surface, width, height); + } + + Bool BackendObject_Remote::MakeEGLCurrent(EGLDisplay dpy, EGLSurface draw, EGLSurface read, + EGLContext ctx) { + // FORWARD FIRST, THEN RUN THE BASE. The server has to own the context before the base + // class latches "the surface is initialised" and calls InitCapabilities, because + // InitCapabilities' answer comes from a snapshot the server can only publish once its + // own InitCapabilities has run - and ServerMakeEGLCurrent is what publishes it. + if (!Server::ServerMakeEGLCurrent(dpy, draw, read, ctx)) return false; + if (!MG_Backend::BackendObject::MakeEGLCurrent(dpy, draw, read, ctx)) return false; + + // R-12 ARM (a) ON EVERY SUCCESSFUL MAKE-CURRENT (codex 12). ServerMakeEGLCurrent above + // republishes the caps snapshot on every call (ServerLoop.cpp:613-628), but the base + // class only runs InitCapabilities - the one place that pumps and refreshes - on the + // FIRST make-current per surface (BackendObject.cpp:341-347). A repeated make-current + // onto an already-initialised surface therefore left the client mirror one generation + // behind while unpumped snapshots accumulated, so a cap getter or a shader compile before + // the next Present read the prior mirror. Adopting here closes that: "a second snapshot + // arrival IS the invalidation" (R-12) now holds AT the make-current that caused it. It is + // idempotent - on the first make-current InitCapabilities already pumped, so this adopts + // 0 - and a release-current (draw/ctx cleared) publishes nothing and is skipped. + if (draw != EGL_NO_SURFACE && ctx != EGL_NO_CONTEXT) { + if (ClientSession* session = ClientSession::Active()) { + session->PumpControlPlane(); + RefreshFormatCapabilities(); + } + } + return true; + } + + Bool BackendObject_Remote::SwapEGLBuffers(EGLDisplay dpy, EGLSurface draw) { + // NOT FORWARDED, and this is the one that must not be. The base implementation's last + // act is GetBackendFunctions().Present() (BackendObject.cpp:396) - which is this + // client's class-B Present EMITTER, the only route by which Present is reached at all + // (it has zero MG_Impl call sites). Calling Server::ServerSwapEGLBuffers would present + // on the server directly and put no record on the wire, which is the shape every gate + // in this phase exists to catch. The forwarder exists for a spawned P6 client whose + // Present record cannot carry the swap; in P5 it has no caller and that is deliberate. + return MG_Backend::BackendObject::SwapEGLBuffers(dpy, draw); + } + + void BackendObject_Remote::SetEGLSwapInterval(Int interval) { + // OVERRIDDEN BECAUSE THE BASE WOULD FATAL. BackendObject.cpp:402 null-checks + // GetBackendFunctions().SetSwapInterval and calls it when non-null - one of the 41 + // null checks R-4 turns into "always supported" - and SetSwapInterval is class C, so + // the base implementation would abort on every eglSwapInterval. The answer is the + // caps-mirror-read rule's general shape: the question "can the presentation path take + // an interval" belongs to the server, so it is asked of the server. + Server::ServerSetEGLSwapInterval(interval); + } + + void BackendObject_Remote::ReleaseEGLSurface(EGLSurface surface) { + Server::ServerReleaseEGLSurface(surface); + MG_Backend::BackendObject::ReleaseEGLSurface(surface); + } + + void BackendObject_Remote::ReleaseEGLResources() { + // BLOCKING BY CONTRACT (ServerLoop.h's header note): MobileGL::Destroy() + // (MobileGL/Init.cpp:68) walks on the moment this returns, and the server still holds + // the context until the apply thread has run it. + Server::ServerReleaseEGLResources(); + MG_Backend::BackendObject::ReleaseEGLResources(); + } + + // NO strong CreateRemoteBackendObject() lives here, and the reason is a link fact, not an + // oversight. v1's Init.cpp calls MG_Remote::Client::CreateRemoteBackendObject() and ships a + // __attribute__((weak)) placeholder for it beside ServerLoop that aborts by name; its comment + // expects "c1's strong definition [to] displace it at link time". A strong definition here + // does NOT: libMobileGL is linked from a static archive, ServerLoop.o (weak) is already in + // the link and satisfies Init's reference, and nothing else references this TU's + // CreateRemoteBackendObject - so BackendObject_Remote.o is never pulled to override it, and + // the weak's abort fires (measured: readelf shows one local symbol, the log shows + // Fatal{UnimplementedRemoteBackendObject}). The integrator's Init.cpp hunk works because it + // constructs BackendObject_Remote DIRECTLY (MakeUnique), which both + // references this object - forcing its TU into the link - and bypasses the weak symbol. So + // the merge-time construction stays v1's Init.cpp edit (or a --whole-archive / forced + // reference the integrator adds); see c1-v3.md. + +} // namespace MobileGL::MG_Remote::Client diff --git a/MobileGL/MG_Remote/Client/BackendObject_Remote.h b/MobileGL/MG_Remote/Client/BackendObject_Remote.h new file mode 100644 index 00000000..e88c3f89 --- /dev/null +++ b/MobileGL/MG_Remote/Client/BackendObject_Remote.h @@ -0,0 +1,96 @@ +// MobileGL - MobileGL/MG_Remote/Client/BackendObject_Remote.h +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +// THE CLIENT ROLE'S BackendObject. Owner: package c1. +// +// MG_Backend::Init() installs one of these into pActiveBackendObject when the transport +// resolved (the hook is v1's; the object is c1's, table 3). It is NOT a backend: it owns no +// context, no driver and no GL state. It is the frontend's single answer to four questions - +// "what can the device do", "what backend is it", "what table do I call", and the nine EGL +// lifecycle calls - and each of the four is answered somewhere that is not here. +// +// THE THREE TRAPS THE SCOUT FOUND, AND WHERE EACH IS PAID: +// +// 1. GetRendererInfo() RETURNS A REFERENCE (BackendObject.h:590), so the object cannot +// synthesise one per call. CapsMirror owns the storage; this class forwards. And +// LogBackendInfo() reads it at MG_Backend/Init.cpp:21, DURING MG_Backend::Init(), before +// any surface exists - so the mirror answers a placeholder and P5 accepts one imprecise +// startup log line. MG_Backend::Init() is NOT restructured (scout-caps-reply §1.2 (a)). +// +// 2. GetFormatCapabilities() IS NOT VIRTUAL (BackendObject.h:594). It returns the base class's +// own m_formatCapabilities member, so there is no accessor to override: the cache has to be +// PUSHED into that member, and the only moment this object can know to is when a snapshot +// lands. CapsMirror's adoption hook is that moment. +// +// 3. GetBackendType() MUST ANSWER THE SERVER'S BACKEND. There is no "Remote" enumerator and +// there must not be one: GL_Framebuffer.cpp:47, GL_Texture.cpp:6536 and CompileEnv.cpp:122 +// switch on this value, and a value they do not know takes a WRONG ARM rather than failing. +// +// THE EGL VIRTUALS ARE BOTH FORWARDED AND KEPT. The base class runs a real state machine - +// surface registration, per-thread current-context bookkeeping, the lazy InitCapabilities latch, +// and SwapEGLBuffers' route into GetBackendFunctions().Present() - and the client needs all of +// it, because Present is a class-B emitter reached through exactly that route (the verb census's +// trap 3: Present has zero MG_Impl call sites). So each override does BOTH: it runs the real +// EGL work on the apply thread, through v1's ServerLoop::RunOnApplyThread, and then lets the +// base class keep the client-side books. +// +// SetEGLSwapInterval IS THE ONE THAT MUST NOT REACH THE TABLE. The base implementation +// null-checks GetBackendFunctions().SetSwapInterval (BackendObject.cpp:402) - one of the 41 +// null checks R-4 turns into "always supported" - and SetSwapInterval is class C, so the base +// implementation would Fatal on every eglSwapInterval. It is overridden to forward instead, +// which is the caps-mirror-read rule's shape for a slot whose answer is "ask the server". + +#pragma once +#include + +#include + +namespace MobileGL::MG_Remote::Client { + + class BackendObject_Remote final : public MG_Backend::BackendObject { + public: + BackendObject_Remote(); + ~BackendObject_Remote() override; + + // ---- the eight pure virtuals ------------------------------------------------------ + void Initialize() override; + Bool InitCapabilities() override; + Bool InitWindowSurface() override; + const RendererInfo& GetRendererInfo() const override; + String GetBackendAPIVersionString() const override; + const MG_Backend::GlobalBackendFunctionsTable& GetBackendFunctions() const override; + const MG_Backend::DynamicBackendParameters& GetDynamicParameters() const override; + BackendType GetBackendType() const override; + + // ---- the nine EGL lifecycle virtuals --------------------------------------------- + Bool InitializeEGLDisplay(EGLDisplay dpy, EGLint* major, EGLint* minor) override; + Bool CreateEGLWindowSurface(EGLSurface surface, const MG_Backend::WindowHandle& handle) override; + Bool ResizeEGLWindowSurface(EGLSurface surface, Uint32 width, Uint32 height) override; + Bool CreateEGLPbufferSurface(EGLSurface surface, EGLint width, EGLint height) override; + Bool MakeEGLCurrent(EGLDisplay dpy, EGLSurface draw, EGLSurface read, EGLContext ctx) override; + Bool SwapEGLBuffers(EGLDisplay dpy, EGLSurface draw) override; + void SetEGLSwapInterval(Int interval) override; + void ReleaseEGLSurface(EGLSurface surface) override; + void ReleaseEGLResources() override; + + // Copies the caps mirror's FormatCapabilityCache into the base class's + // m_formatCapabilities. Public because CapsMirror's adoption hook is a free function + // and this is what it calls; it is the whole of trap 2's answer. + void RefreshFormatCapabilities(); + + protected: + Bool InitPbufferSurface(EGLint width, EGLint height) override; + + private: + // The generation of the snapshot m_formatCapabilities was filled from. Exposed only + // through the log line on a refresh: a cache that silently stopped tracking the mirror + // is exactly the shape trap 2 exists to prevent. + Uint64 m_formatsGeneration = 0; + }; + +} // namespace MobileGL::MG_Remote::Client diff --git a/MobileGL/MG_Remote/Client/CapsMirror.cpp b/MobileGL/MG_Remote/Client/CapsMirror.cpp index 136ce840..a5c5ecad 100644 --- a/MobileGL/MG_Remote/Client/CapsMirror.cpp +++ b/MobileGL/MG_Remote/Client/CapsMirror.cpp @@ -6,49 +6,161 @@ // SPDX-License-Identifier: LGPL-3.0-only // End of Source File Header -// P5 c0 stubs for package c1. Every body is MGLOG_F + std::abort and never a silent no-op: a -// caps accessor that answers a default is how a split lane runs on the wrong device's limits. +// P5 package c1. +// +// EVERY ACCESSOR ANSWERS BEFORE THE FIRST SNAPSHOT, AND THAT IS THE RULING, NOT A WEAKENING. +// c0's stubs aborted on all but Valid()/Generation() so that nothing could answer from a zeroed +// mirror by accident. But scout-caps-reply §1.2's option (a) - the one the brief adopts - says +// LogBackendInfo() reads GetRendererInfo() at MG_Backend/Init.cpp:21, during MG_Backend::Init(), +// BEFORE any surface exists, and that P5 accepts one imprecise startup log line rather than +// restructure MG_Backend::Init(). An abort there is not a stricter mirror; it is a process that +// cannot start. So the mirror answers a placeholder and SAYS SO, once per accessor: a wrong +// number that announced itself is auditable, and the announcement is what a case asserts on. +// +// THE PLACEHOLDER IS A REAL OBJECT, NOT A TEMPORARY. GetRendererInfo() and +// GetDynamicParameters() return const references, so the storage has to outlive every caller; +// the members below are it, default-constructed, and Adopt() replaces them in place. #include "CapsMirror.h" -#include +#include "../CapsCodec.h" -#include +#include +#include namespace MobileGL::MG_Remote::Client { -#define MGP5_C0_STUB(what) \ - do { \ - MGLOG_F("MGPipe: Fatal{UnimplementedCapsMirror, \"%s\"} - P5 package c1 has not landed " \ - "this yet; c0 shipped the signature only", \ - what); \ - std::abort(); \ - } while (0) + namespace { + CapsAdoptedHook g_capsAdoptedHook = nullptr; - void CapsMirror::Adopt(const MG_Pipe::MGPCaps&, const MG_Backend::FormatCapabilityCache&, - const RendererInfo&, const String&, BackendType) { - MGP5_C0_STUB("CapsMirror::Adopt"); + // R-8's negative control, counted at the funnel. Not atomics: every reader of this + // mirror is the GL thread, by the same argument that makes one gPipeInputs legal. + Uint64 g_consumerRefusals = 0; + Uint64 g_lastRefusedSubsystem = 0; + Uint64 g_refusedSubsystemsLogged = 0; + + // One line per accessor, once, and only while the mirror is a placeholder. MGLOG_W_ONCE + // keys on the call site, so each accessor gets its own line and a hot getter cannot + // flood the log. + void WarnPlaceholder(const char* what, Uint64 generation) { + if (generation != 0) return; + MGLOG_W("MG_Remote client: CapsMirror::%s read before the first CapsSnapshot - " + "answering a PLACEHOLDER. Expected exactly once at startup, from " + "LogBackendInfo (MG_Backend/Init.cpp:21); anything later means a caps read " + "beat the handshake", + what); + } + } // namespace + + void CapsMirror::Adopt(const MG_Pipe::MGPCaps& caps, const MG_Backend::FormatCapabilityCache& formats, + const RendererInfo& renderer, const String& apiVersion, + BackendType backend) { + m_caps = caps; + // The two blobrefs inside MGPCaps name SEG_STAGE runs that belong to the SERVER's + // encoder and are meaningless on this side; the decoded objects beside them are the + // answer. Clearing them is not tidiness - a later reader that resolved one would read + // whatever the stage allocator has since put there. + m_caps.FormatCapabilities = MG_Pipe::MGPBlobRef{}; + m_caps.RendererInfo = MG_Pipe::MGPBlobRef{}; + m_formats = formats; + m_renderer = renderer; + m_apiVersion = apiVersion; + m_backend = backend; + ++m_generation; + + // R-12: RE-ARRIVAL IS THE INVALIDATION, and this is the one place that acts on it. + // GLContext::GetCompileEnv() memoises on the raw pActiveBackendObject pointer + // (Core.cpp:34); under split that pointer is the single long-lived BackendObject_Remote + // and NEVER changes, so without this line the compile env, its preprocess memos and the + // advertised-extension list stay stale for ever after a server-side InitCapabilities + // re-run. DirectVulkan already spells the monolith half of this exactly this way + // (BackendObject_DirectVulkan.cpp:390). + if (m_generation > 1 && MG_State::pGLContext != nullptr) { + MG_State::pGLContext->InvalidateCompileEnv(); + } + MGLOG_I("MG_Remote client: CapsMirror generation %llu adopted (backend=%d, callMask=0x%llx)", + static_cast(m_generation), static_cast(m_backend), + static_cast(m_caps.CallMask)); + + // LAST, and after the generation has moved: the hook reads this mirror. + if (g_capsAdoptedHook != nullptr) g_capsAdoptedHook(); } - // Not stubs: the two the placeholder contract above promises are readable before the first - // snapshot. Everything else aborts, so nothing can accidentally answer from a zeroed mirror. + void SetCapsAdoptedHook(CapsAdoptedHook hook) { g_capsAdoptedHook = hook; } + Bool CapsMirror::Valid() const { return m_generation != 0; } Uint64 CapsMirror::Generation() const { return m_generation; } - const RendererInfo& CapsMirror::Renderer() const { MGP5_C0_STUB("CapsMirror::Renderer"); } + const RendererInfo& CapsMirror::Renderer() const { + WarnPlaceholder("Renderer", m_generation); + return m_renderer; + } + const MG_Backend::DynamicBackendParameters& CapsMirror::Dynamic() const { - MGP5_C0_STUB("CapsMirror::Dynamic"); + WarnPlaceholder("Dynamic", m_generation); + return m_caps.Dynamic; } + const MG_Backend::FormatCapabilityCache& CapsMirror::Formats() const { - MGP5_C0_STUB("CapsMirror::Formats"); + WarnPlaceholder("Formats", m_generation); + return m_formats; } - const String& CapsMirror::ApiVersion() const { MGP5_C0_STUB("CapsMirror::ApiVersion"); } - BackendType CapsMirror::Backend() const { MGP5_C0_STUB("CapsMirror::Backend"); } - Uint64 CapsMirror::CallMask() const { MGP5_C0_STUB("CapsMirror::CallMask"); } - Bool CapsMirror::HasCap(MG_Pipe::MGPCapBit) const { MGP5_C0_STUB("CapsMirror::HasCap"); } - Bool CapsMirror::ServerConsumes(Uint64) const { MGP5_C0_STUB("CapsMirror::ServerConsumes"); } + + const String& CapsMirror::ApiVersion() const { + WarnPlaceholder("ApiVersion", m_generation); + return m_apiVersion; + } + + BackendType CapsMirror::Backend() const { + WarnPlaceholder("Backend", m_generation); + return m_backend; + } + + Uint64 CapsMirror::CallMask() const { return m_caps.CallMask; } + + Bool CapsMirror::HasCap(MG_Pipe::MGPCapBit bit) const { + return (m_caps.CallMask & static_cast(bit)) != 0; + } + + Bool CapsMirror::ServerConsumes(Uint64 subsystemBit) const { + // THE ONLY LEGAL CLIENT-SIDE SPELLING (R-8). Never MGPipeGetResourceOps(): that is the + // SERVER's registration, a process-wide global, right by accident under inproc and null + // under spawn - and a null read there silently stops five record families while the + // client goes on clearing its dirty flags, which is ID-39's 66 lost uploads with a wire + // in between. Folded through CapsCodec.h's helper rather than shifted here, because two + // spellings of one bit layout is how the two sides come to disagree about it. + // + // A PLACEHOLDER MIRROR CONSUMES NOTHING, and that is the safe direction: with no + // snapshot the mask is 0, every family answers "no consumer", the client emits nothing + // and the LEGACY PULL PATH runs untouched. The unsafe direction - emitting to a server + // that has no consumer - is the one that loses uploads. + const Bool consumes = MGCapsServerConsumes(m_caps.CallMask, subsystemBit); + if (!consumes) { + ++g_consumerRefusals; + g_lastRefusedSubsystem = subsystemBit; + if ((g_refusedSubsystemsLogged & subsystemBit) == 0) { + g_refusedSubsystemsLogged |= subsystemBit; + MGLOG_W("MG_Remote client: the server does not consume MGPipe subsystem 0x%llx - " + "this family emits NOTHING and the legacy pull path runs for it " + "(R-8). callMask=0x%llx, caps generation %llu", + static_cast(subsystemBit), + static_cast(m_caps.CallMask), + static_cast(m_generation)); + } + } + return consumes; + } + + Uint64 ConsumerRefusals() { return g_consumerRefusals; } + Uint64 LastRefusedSubsystem() { return g_lastRefusedSubsystem; } + void ResetConsumerRefusalsForTest() { + g_consumerRefusals = 0; + g_lastRefusedSubsystem = 0; + g_refusedSubsystemsLogged = 0; + } + Bool CapsMirror::PrefersCpuXfbPrimitiveAccounting() const { - MGP5_C0_STUB("CapsMirror::PrefersCpuXfbPrimitiveAccounting"); + return HasCap(MG_Pipe::kCapCpuXfbPrimitiveAccounting); } CapsMirror& CapsMirrorInstance() { @@ -58,6 +170,4 @@ namespace MobileGL::MG_Remote::Client { return instance; } -#undef MGP5_C0_STUB - } // namespace MobileGL::MG_Remote::Client diff --git a/MobileGL/MG_Remote/Client/CapsMirror.h b/MobileGL/MG_Remote/Client/CapsMirror.h index 600bdc1c..f961cabe 100644 --- a/MobileGL/MG_Remote/Client/CapsMirror.h +++ b/MobileGL/MG_Remote/Client/CapsMirror.h @@ -97,4 +97,28 @@ namespace MobileGL::MG_Remote::Client { // reach pipe or backend state from an exit handler. CapsMirror& CapsMirrorInstance(); + // ---- c1's addition to c0's signature block ----------------------------------------- + // + // WHY A HOOK AND NOT A READ. BackendObject::GetFormatCapabilities() is NON-VIRTUAL + // (BackendObject.h:594) and returns the base class's own m_formatCapabilities member, so a + // remote backend object cannot answer it lazily from the mirror - it has to PUSH the cache + // into that member, and the only moment it can know to is when a snapshot lands. A raw + // function pointer rather than std::function, for ID-8's reason: this can fire on paths + // that must not allocate. One hook, installed by BackendObject_Remote's constructor. + using CapsAdoptedHook = void (*)(); + void SetCapsAdoptedHook(CapsAdoptedHook hook); + + // R-8's NEGATIVE CONTROL NEEDS TO SEE THE WITHHOLDING, NOT INFER IT FROM AN ABSENCE. + // "the client emits nothing for a family the server does not consume" is, on its own, + // indistinguishable from "the client emits nothing because nothing called it" - and the + // second is how a gate goes green for the wrong reason. So the one funnel that answers the + // question counts its own refusals and names the family, once per family, in the log. + // + // Counted inside ServerConsumes(), which is R-8's only legal spelling, so a refusal that + // happened cannot fail to be counted and a count that moved cannot have come from anywhere + // else. + Uint64 ConsumerRefusals(); + Uint64 LastRefusedSubsystem(); + void ResetConsumerRefusalsForTest(); + } // namespace MobileGL::MG_Remote::Client diff --git a/MobileGL/MG_Remote/Client/ClientSession.cpp b/MobileGL/MG_Remote/Client/ClientSession.cpp index 70b4e89d..24b918aa 100644 --- a/MobileGL/MG_Remote/Client/ClientSession.cpp +++ b/MobileGL/MG_Remote/Client/ClientSession.cpp @@ -16,11 +16,15 @@ #include "../Server/ServerLoop.h" #include "../Server/ServerSession.h" #include "../Transport/InProcessTransport.h" +#include "WireTables.h" #include +#include #include +#include #include +#include #include #include @@ -101,6 +105,29 @@ namespace MobileGL::MG_Remote::Client { #endif } + // ---- c1: the barrier's two flags ------------------------------------------------ + // + // thread_local for the client's own "am I waiting", a shared atomic for "is the apply + // thread inside the applier" - see ClientSession::InBarrierWait's note. + thread_local Bool g_inBarrierWait = false; + std::atomic g_applyThreadInsideApplier{false}; + + struct BarrierWaitScope { + BarrierWaitScope() { g_inBarrierWait = true; } + ~BarrierWaitScope() { g_inBarrierWait = false; } + BarrierWaitScope(const BarrierWaitScope&) = delete; + BarrierWaitScope& operator=(const BarrierWaitScope&) = delete; + }; + + // BOUNDED, AND THE BOUND IS GENEROUS RATHER THAN TIGHT. The barrier is a correctness + // device, not a watchdog: a slow readback on a software rasterizer is a legitimate + // second-scale wait, while a lost record never completes at all. 30 s separates the two + // without turning a loaded CI machine into a red lane, and the Fatal names the seq. + constexpr Uint32 kBarrierTimeoutMs = 30000; + // How many queued control frames one pump will drain. A backlog deeper than this is a + // finding, not a steady state. + constexpr Uint32 kMaxControlFramesPerPump = 16; + const char* TransportModeName(MG_Config::TransportMode mode) { switch (mode) { case MG_Config::TransportMode::Monolith: return "monolith"; @@ -112,6 +139,170 @@ namespace MobileGL::MG_Remote::Client { return "?"; } + // ---- c1: the reverse channel's reading end --------------------------------------- + // + // R-12's three: OnBufferWriteback (#3), OnGpuWritten (#2), OnSurfaceChanged (#7). + // Drained BY THE GL THREAD BETWEEN VERBS, which under the barrier means immediately + // after appliedSeq reaches this record - the one moment at which the apply thread is + // known not to be inside the applier. + // + // THE BYTES LIVE IN THE RING ITSELF, so Drained() is called only after every payload + // pointer popped here has been consumed: retiring earlier is R-11's violation one level + // down (EventRing.h:168-171 says so in as many words). + Uint32 DrainEventRing(Transport::EventRingConsumer& events) { + if (!events.Valid()) return 0; + Uint32 delivered = 0; + Transport::RingRecordView view{}; + Bool corrupt = false; + while (events.Pop(view, &corrupt)) { + if (corrupt) { + MGLOG_F("MGPipe: Fatal{ProtocolCorruption, \"event-ring\"} - the reverse " + "channel's record stream is corrupt"); + std::abort(); + } + switch (view.kind) { + case Transport::kEventBufferWriteback: { + if (view.payloadSize < sizeof(Transport::EventBufferWritebackHead)) break; + const auto* head = + static_cast(view.payload); + const void* bytes = static_cast(view.payload) + sizeof(*head); + if (view.payloadSize - sizeof(*head) < head->Size) { + MGLOG_F("MGPipe: Fatal{ProtocolCorruption, \"buffer-writeback\"} - the " + "head declares %llu inline bytes and the record carries %llu", + static_cast(head->Size), + static_cast(view.payloadSize - sizeof(*head))); + std::abort(); + } + if (MG_Pipe::gMGPipeCallbacks.OnBufferWriteback != nullptr) { + // The blobref names SEG_EVENT and the IN-SEGMENT offset of those inline + // bytes - never a host address (R-2's rule B), which is the whole reason + // EventRingConsumer exposes OffsetInSegment at all. + MG_Pipe::MGPBlobRef blob{}; + blob.Seg = Wire::kSegEvent; + blob.Offset = events.OffsetInSegment(bytes); + blob.Size = head->Size; + MG_Pipe::gMGPipeCallbacks.OnBufferWriteback( + MG_Pipe::MGPipeHandle{head->Resource.Slot, head->Resource.Gen}, + head->Offset, blob); + ++delivered; + } + break; + } + case Transport::kEventGpuWritten: { + if (view.payloadSize < sizeof(Transport::EventGpuWrittenHead)) break; + const auto* head = + static_cast(view.payload); + const Uint64 tail = view.payloadSize - sizeof(*head); + if (tail / sizeof(Transport::EventRange) < head->RangeCount) { + MGLOG_F("MGPipe: Fatal{ProtocolCorruption, \"gpu-written\"} - RangeCount " + "%u does not fit the record's %llu tail bytes", + static_cast(head->RangeCount), + static_cast(tail)); + std::abort(); + } + if (MG_Pipe::gMGPipeCallbacks.OnGpuWritten != nullptr) { + // EventRange and MGPRange are the same two Uint64s (EventRing.h:61-66 + // asserts it), so the tail is handed over as-is rather than copied into + // a second array a later reader could get out of step with. + const auto* ranges = reinterpret_cast( + static_cast(view.payload) + sizeof(*head)); + MG_Pipe::gMGPipeCallbacks.OnGpuWritten( + MG_Pipe::MGPipeHandle{head->Resource.Slot, head->Resource.Gen}, + static_cast(head->RangeCount), ranges); + ++delivered; + } + break; + } + case Transport::kEventSurfaceChanged: { + if (view.payloadSize < sizeof(Transport::EventSurfaceChangedHead)) break; + if (MG_Pipe::gMGPipeCallbacks.OnSurfaceChanged != nullptr) { + const auto* head = + static_cast(view.payload); + MG_Pipe::MGPSurfaceInfo info{}; + info.Width = head->Width; + info.Height = head->Height; + info.InternalFormat = head->InternalFormat; + info.Samples = head->Samples; + info.Layers = head->Layers; + info.IsDefault = head->IsDefault; + MG_Pipe::gMGPipeCallbacks.OnSurfaceChanged(&info); + ++delivered; + } + break; + } + default: + MGLOG_W("MG_Remote client: reverse-channel record kind %u is not consumed in " + "P5 (R-12 takes three and a half of the ten callbacks)", + static_cast(view.kind)); + break; + } + } + // AND ONLY NOW. Every payload pointer above has been consumed. + events.Drained(); + return delivered; + } + + // ---- c1: the CapsSnapshot -> CapsMirror adoption, in ONE place ------------------- + // + // Every field of the snapshot has exactly one reader, and a field that fails to decode + // is a REFUSAL rather than a partial adopt: CompileEnv.cpp:123 copies the whole + // DynamicBackendParameters struct into the compile env, so a mirror that adopted three + // of four members would put the fourth's default into a shader fingerprint. + Bool AdoptCapsSnapshot(const ::MobileGL::Wire::CapsSnapshot* snapshot) { + if (snapshot == nullptr) return false; + + MG_Pipe::MGPCaps caps{}; + const auto* dynamicBytes = snapshot->dynamicParameters(); + if (dynamicBytes == nullptr || dynamicBytes->size() != sizeof(caps.Dynamic)) { + // The Hello/Welcome fingerprint already asserted both peers agree on + // sizeof(DynamicBackendParameters), so a disagreement HERE is a corrupt frame + // rather than an ABI skew - which is why it is a refusal and not FatalAbiMismatch. + MGLOG_E("MG_Remote client: CapsSnapshot carries %llu dynamic bytes, this build's " + "struct is %llu - the snapshot is refused whole", + static_cast(dynamicBytes == nullptr ? 0 + : dynamicBytes->size()), + static_cast(sizeof(caps.Dynamic))); + return false; + } + std::memcpy(&caps.Dynamic, dynamicBytes->data(), sizeof(caps.Dynamic)); + caps.CallMask = snapshot->callMask(); + + RendererInfo renderer{}; + const auto* rendererBytes = snapshot->rendererInfo(); + if (rendererBytes == nullptr || + !DecodeRendererInfo(rendererBytes->data(), rendererBytes->size(), renderer)) { + MGLOG_E("MG_Remote client: CapsSnapshot's rendererInfo blob did not decode"); + return false; + } + + MG_Backend::FormatCapabilityCache formats{}; + const auto* formatBytes = snapshot->formatCaps(); + if (formatBytes == nullptr || + !DecodeFormatCapabilities(formatBytes->data(), formatBytes->size(), formats)) { + MGLOG_E("MG_Remote client: CapsSnapshot's formatCaps blob did not decode"); + return false; + } + + const String apiVersion = + snapshot->apiVersion() == nullptr ? String{} : String{snapshot->apiVersion()->c_str()}; + + // THE SERVER'S BACKEND TYPE, NEVER A NEW "Remote" ENUMERATOR and never guessed from + // the renderer string: GL_Framebuffer.cpp:47, GL_Texture.cpp:6536 and + // CompileEnv.cpp:122 SWITCH on it, and a value they do not know takes a wrong arm + // rather than failing. + const Uint32 rawBackend = snapshot->backendType(); + if (rawBackend >= static_cast(BackendType::BackendTypeCount)) { + MGLOG_E("MG_Remote client: CapsSnapshot names backend type %u, which this build " + "has no enumerator for - the snapshot is refused rather than folded onto " + "Unknown, which three frontend switches would silently mis-branch on", + static_cast(rawBackend)); + return false; + } + CapsMirrorInstance().Adopt(caps, formats, renderer, apiVersion, + static_cast(rawBackend)); + return true; + } + } // namespace // Null, not a Fatal: MG_Backend::Init() asks whether a session exists before it decides to @@ -327,23 +518,26 @@ namespace MobileGL::MG_Remote::Client { m_encoder = Wire::PipeWireEncoder(control, &m_cmd, nullptr, &m_segments); // ---- 7. the first CapsSnapshot, if the server had a backend to publish one from. - if (m_transport->PeekFrameSize() != 0) { - std::vector frame; - if (ReceiveEnvelope(*m_transport, frame, 0) == MOBILEGL_OK) { - const ::MobileGL::Wire::CtrlEnvelope* envelope = ParseEnvelope(frame); - if (envelope != nullptr && - envelope->msg_type() == ::MobileGL::Wire::CtrlMsg::CapsSnapshot) { - // The mirror's Adopt and the two blob DECODERS are c1's and w1's. s1 stops - // at "the snapshot arrived and is verifiable": adopting it here would put - // the caps mirror's invalidation rule (R-12: a second arrival IS the - // invalidation) in two places. - MGLOG_I("MG_Remote client: first CapsSnapshot received (%llu bytes); adopting " - "it is package c1's CapsMirror::Adopt over package w1's decoders", - static_cast(frame.size())); - } - } + // ONE DRAIN, ONE ADOPTER (c1): PumpControlPlane below is the only thing in the client + // that turns a CapsSnapshot into a CapsMirror generation, so R-12's "a second arrival + // IS the invalidation" lives in exactly one place. s1's step here used to stop at + // "the snapshot arrived and is verifiable"; it now goes all the way, through the same + // function every later arrival goes through. + if (PumpControlPlane() == 0) { + MGLOG_W("MG_Remote client: the handshake carried no CapsSnapshot - the caps mirror is " + "a PLACEHOLDER until one arrives. Every caps read until then answers a " + "default and says so"); } + // BOTH, AND m_started FIRST. `Active()` is what the integration lane's skip reads and + // `m_started` is what `EmitAndWait` reads, and c1's round-1 rewrite of this function + // set only the second of the two - so a fully-handshaken session with an apply thread + // running and a caps mirror adopted took Fatal{NoClientSession, "Clear"} on its very + // first verb, which is the most confusing possible spelling of "the session is up". + // The order matters for the same reason it does at the other end: `Active()` hands a + // caller a session it may immediately emit on, so the flag that permits emitting has + // to be true before the pointer that grants access to it is published. `Stop()` takes + // them down in the mirror order (m_started = false, then g_active = nullptr). m_started = true; g_active = this; LogMemory("handshake"); @@ -357,10 +551,43 @@ namespace MobileGL::MG_Remote::Client { Stop(); return running; } + + // ---- 9. AND ONLY NOW THE THIRTY-SEVEN WIRE EMITTERS (R-17). This is the line that + // arms `integration-split`: the 21 `DirectGLES.Split.*` entries skip on + // `ClientSession::Active() == nullptr`, and every one of the four arming facts is true + // at exactly this point and at no earlier one. + // + // IT IS LAST, AND EACH OF THE FOUR REASONS IS A DIFFERENT FAILURE: + // - after Hello/Welcome (step 2-4), or an emitter would publish into a ring the peer + // has not mapped; + // - after the first CapsSnapshot (step 7), because R-8's liveness gates read the caps + // mirror and a PLACEHOLDER mirror consumes nothing - a record emitted before it + // would go to a server this client has not been told consumes that family; + // - after ServerLoop::Start (step 8), because EmitAndWait BLOCKS on appliedSeq and + // with no apply thread nothing advances it: the first resource_create would spend + // 30 seconds in the barrier and then Fatal{BarrierTimeout}; + // - on THIS thread, the one that called MG_Backend::Init(), because it is the GL + // thread and table 3 makes gPipeInputs its to touch while the barrier holds. + // The publication is safe without a fence because the apply thread never reads these + // tables - the server decodes straight into MGPipeApply* - and this thread wrote them + // before it can reach any GL entry point. + InstallClientWireTables(); return MOBILEGL_OK; } void ClientSession::Stop() { + // FIRST, BEFORE ANYTHING ELSE GOES AWAY (R-17 / codex 4). Every later step here frees + // something an emitter dereferences - the rings, the segments, the transports - so a + // routed GL-thread call that arrives during teardown must not run the applier on the + // caller and must not reach a half-freed ring. Round 2 reinstalled the monolith adapters + // HERE, which is running the applier on the caller - the forbidden path table 3 draws. + // Uninstall now RAISES A FLAG and leaves the wire rows in place; the next routed call + // aborts by name (Fatal{ClientTablesUninstalled}) inside RequireSession before it touches + // anything. The monolith adapters go back only at the END of teardown + // (ReinstallMonolithAfterTeardown), for the at-exit ~BufferObject deletes that reach a + // process with no session at all - and by then the rings are gone, so the applier a + // monolith adapter runs is a defined no-op rather than a use-after-free. + UninstallClientWireTables(); if (!m_started) { // Start's own failure paths land here with a half-built session. FIVE of them are // reached AFTER ServerSession::Accept has already returned OK, so tearing down @@ -377,6 +604,9 @@ namespace MobileGL::MG_Remote::Client { m_clientTransport.reset(); m_serverTransport.reset(); m_transport = nullptr; + // The rings are gone; put the monolith adapters back for a process that will make no + // more routed calls except, possibly, at-exit deletes (codex 4). + ReinstallMonolithAfterTeardown(); return; } @@ -433,6 +663,12 @@ namespace MobileGL::MG_Remote::Client { if (g_active == this) { g_active = nullptr; } + // AND ONLY NOW the monolith adapters go back (codex 4): every ring an emitter would have + // used is freed above, so from here a routed call - an at-exit ~BufferObject delete - runs + // the applier exactly as it does under monolith, which is the correct answer for a + // process that no longer has a session. During the whole span above, the raised flag made + // any routed call abort by name instead. + ReinstallMonolithAfterTeardown(); } Wire::PipeWireEncoder& ClientSession::Encoder() { return m_encoder; } @@ -444,18 +680,187 @@ namespace MobileGL::MG_Remote::Client { // cost zero extra round trips - and the client may not re-derive any of those four answers // locally. s1 supplies the four primitives it composes from: Encoder(), Producer(), // WaitForApplied() and ReadReply(). - Uint64 ClientSession::EmitAndWait(MG_Pipe::MGPWireOp, const void*, Uint64, const void*, Uint64, - void*, Uint64, Int32*) { - MGP5_C0_STUB("ClientSession::EmitAndWait"); + // + // THE ORDER IS ENCODE -> PUBLISH+NOTIFY -> WAIT -> READ REPLY, and it is not negotiable. + // Splitting the wait from the read is how a package ends up answering an acceptance + // question locally, which is the c0f/c0g defect P4a paid two contract corrections for; and + // "always accept" is ID-39's 66 lost DirectVulkan uploads with a wire in between. + Uint64 ClientSession::EmitAndWait(MG_Pipe::MGPWireOp op, const void* payload, Uint64 payloadBytes, + const void* varTail, Uint64 varTailBytes, void* replyOut, + Uint64 replyBytes, Int32* statusOut, Uint64* replySizeOut) { + if (statusOut != nullptr) *statusOut = Wire::ReplySink::kStatusError; + if (replySizeOut != nullptr) *replySizeOut = 0; + if (!m_started) { + MGLOG_F("MGPipe: Fatal{NoClientSession, \"%s\"} - EmitAndWait on a session that has " + "not started. There is no fall-through: a record that could not be emitted " + "is a verb that did not happen", + Wire::WireOpName(op)); + std::abort(); + } + + // R-1's INVARIANT, AS A RUNTIME CHECK RATHER THAN A SENTENCE. While the barrier holds, + // at most one of {GL thread, apply thread} is runnable - and that is the whole reason a + // single process-wide gPipeInputs is legal (table 3). The client is about to publish a + // record whose fields the applier will read out of gPipeInputs, so the apply thread + // being inside the applier right now means the invariant has already been broken and + // the next record would be read against a half-written residual fill. + if (ApplyThreadIsInsideApplier()) { + MGLOG_F("MGPipe: Fatal{BarrierViolation, \"%s\"} - the apply thread is inside the " + "applier while the GL thread is emitting. R-1 makes at most one of them " + "runnable, which is what keeps one process-wide gPipeInputs legal", + Wire::WireOpName(op)); + std::abort(); + } + + const Uint64 seq = + m_encoder.EncodeRecord(op, payload, payloadBytes, varTail, varTailBytes); + if (seq == Wire::kInvalidSeq) { + // The ring refused it. NOT a silent drop and not a retry loop: R-10 says P5 does no + // chunking and must prove it needs none, so a refusal is the proof failing. + MGLOG_F("MGPipe: Fatal{RingOverrun, \"%s\"} - the command ring refused a %llu-byte " + "record. P5 does not chunk (R-10); this is the proof obligation failing, not " + "a back-pressure case", + Wire::WireOpName(op), + static_cast(payloadBytes + varTailBytes)); + std::abort(); + } + + // Publish the head, record submittedSeq, THEN ring - in that order, which is + // SessionProducer's one job and RingTest.cpp:446's pin. Notify-then-publish loses the + // wakeup. + m_producer.PublishAndNotify(seq); + + // Does this row own a reply slot? kMGPipeCallFlags IS THE SINGLE SOURCE OF TRUTH + // (R-16 / ID-31) - fourteen rows now, because the four Bool acceptance entry points + // gained the flag. Asking the catalogue rather than the caller is what stops a caller + // that forgot to pass a buffer from silently turning an answer into a guess. + const Bool ownsReplySlot = + (MG_Pipe::MGPipeCallFlagsFor(op) & static_cast(MG_Pipe::kReplySlot)) != 0; + + if (!m_barrierArmed && !ownsReplySlot) { + // R-1's NEGATIVE CONTROL ARM, and the only thing it turns off is the barrier. A + // reply-slot row still waits: the answer is not derivable here and R-5 forbids + // inventing one, so MOBILEGL_IPC_VERB_BARRIER=0 makes the queue free-running, not + // the client clairvoyant. + return seq; + } + + const BarrierWaitScope waiting; + const Transport::SessionWait wait = m_producer.WaitForApplied(seq, kBarrierTimeoutMs); + if (wait == Transport::SessionWait::ShutDown) { + // The doorbell died: the server went away. The only thing that returns from a + // kWaitForever park, and therefore the only way a client blocked in the barrier + // survives a server that is gone. It is teardown, not a server fault - so the answer + // handed back is DECLINED, not the ERROR the status was pre-set to (M4): a + // reply-owning row that saw ERROR here would abort Fatal{ReplyError} on a shutting- + // down session, which is what the "teardown legitimately reaches here" comment + // promised would NOT happen. DECLINED is honest - "the verb did not happen" - and the + // acceptance rows already treat it as `false` / nullptr without aborting. + if (statusOut != nullptr) *statusOut = Wire::ReplySink::kStatusDeclined; + MGLOG_E("MG_Remote client: the barrier for %s (seq %llu) woke on a dead doorbell; the " + "server is gone and this verb did not happen (reported as DECLINED, not ERROR)", + Wire::WireOpName(op), static_cast(seq)); + return seq; + } + if (wait != Transport::SessionWait::Reached) { + MGLOG_F("MGPipe: Fatal{BarrierTimeout, \"%s\"} - appliedSeq did not reach %llu within " + "%u ms. A bounded wait is deliberate: a wedged CI job and a lost record look " + "identical from outside, and only one of them is a bug worth finding", + Wire::WireOpName(op), static_cast(seq), kBarrierTimeoutMs); + std::abort(); + } + + // THE REVERSE CHANNEL IS DRAINED HERE, and here is the only place it can be: under the + // barrier this is the one instant at which the apply thread is known not to be inside + // the applier, and MGPipeClientOnGpuWritten / OnBufferWriteback write frontend objects. + // It is what gives AwaitBufferWriteback something to have waited FOR: b1's third state + // clears when the writeback lands, and the writeback lands on this ring. + DrainEventRing(m_events); + + if (!ownsReplySlot) return seq; + + // THE SAME WAIT, NOT A SECOND ONE. appliedSeq >= seq already means the server wrote + // this record's answer, because it writes the slot before it advances the watermark. + Uint64 replySize = 0; + Int32 status = Wire::ReplySink::kStatusError; + if (!ReadReply(seq, replyOut, replyBytes, &status, &replySize)) { + MGLOG_F("MGPipe: Fatal{ReplyMissing, \"%s\"} - seq %llu carries kReplySlot and the " + "server applied it, but its slot does not stamp that seq. The stamp is what " + "makes a wrong-slot read detectable rather than plausible (R-3)", + Wire::WireOpName(op), static_cast(seq)); + std::abort(); + } + if (statusOut != nullptr) *statusOut = status; + if (replySizeOut != nullptr) *replySizeOut = replySize; + if (status == Wire::ReplySink::kStatusError) { + MGLOG_E("MG_Remote client: %s (seq %llu) answered ERROR", Wire::WireOpName(op), + static_cast(seq)); + } + // DECLINED is NOT an error and is deliberately not logged as one: it is how + // MapPersistent says nullptr (R-6) and how the four Bool acceptance rows say false + // (R-5). A client that treated it as a failure would re-create ID-39 from the other + // side. + if (replyOut != nullptr && replySize > replyBytes) { + MGLOG_F("MGPipe: Fatal{ReplyTooLarge, \"%s\"} - the answer is %llu bytes and the " + "caller offered %llu. P5 does not chunk a reply", + Wire::WireOpName(op), static_cast(replySize), + static_cast(replyBytes)); + std::abort(); + } + return seq; } Bool ClientSession::BarrierArmed() const { return m_barrierArmed; } - // False, not a Fatal, for both: these are the R-1 mutual-exclusion assertion's two probes, - // and an assertion helper that aborts when asked is worse than useless. Package c1 gives - // them real answers when it lands the barrier. - Bool ClientSession::InBarrierWait() { return false; } - Bool ClientSession::ApplyThreadIsInsideApplier() { return false; } + // R-1's mutual-exclusion invariant, as two probes that answer honestly. + // + // THE CLIENT'S FLAG IS THREAD-LOCAL AND THE SERVER'S IS NOT, and the asymmetry is the + // point: "am I inside a barrier wait" is a question about the calling thread, while "is the + // apply thread inside the applier" is a question the GL thread asks about a DIFFERENT + // thread - so the second has to be a shared atomic and the first must not be, or a second + // GL thread would see the first one's wait as its own. + Bool ClientSession::InBarrierWait() { return g_inBarrierWait; } + Bool ClientSession::ApplyThreadIsInsideApplier() { + return g_applyThreadInsideApplier.load(std::memory_order_acquire); + } + void ClientSession::NoteApplyThreadEnteredApplier() { + g_applyThreadInsideApplier.store(true, std::memory_order_release); + } + void ClientSession::NoteApplyThreadLeftApplier() { + g_applyThreadInsideApplier.store(false, std::memory_order_release); + } + + Uint32 ClientSession::PumpControlPlane() { + // NOT gated on m_started. The first snapshot arrives DURING Start(), before this session + // is started or active - and s1's half-built teardown path depends on m_started staying + // false until step 8 has succeeded, so the flag cannot be moved earlier to suit this. + if (m_transport == nullptr) return 0; + Uint32 adopted = 0; + // Bounded rather than `while (true)`: a server that queued frames faster than this + // drains them would otherwise hold the GL thread here for ever, and a frame backlog + // deeper than this is a finding rather than a steady state. + for (Uint32 guard = 0; guard < kMaxControlFramesPerPump; ++guard) { + if (m_transport->PeekFrameSize() == 0) break; + std::vector frame; + if (ReceiveEnvelope(*m_transport, frame, 0) != MOBILEGL_OK) break; + const ::MobileGL::Wire::CtrlEnvelope* envelope = ParseEnvelope(frame); + if (envelope == nullptr) { + MGLOG_E("MG_Remote client: an unverifiable control frame (%llu bytes) was dropped", + static_cast(frame.size())); + continue; + } + if (envelope->msg_type() != ::MobileGL::Wire::CtrlMsg::CapsSnapshot) { + // SurfaceOp / SurfaceReply / ResyncRequest / AuxRequest / LogLine are P6's and + // P7's. Named rather than ignored, so a phase that starts sending one does not + // discover this loop swallowing it. + MGLOG_W("MG_Remote client: control message %d is not consumed in P5", + static_cast(envelope->msg_type())); + continue; + } + if (AdoptCapsSnapshot(envelope->msg_as_CapsSnapshot())) ++adopted; + } + return adopted; + } Transport::SessionProducer& ClientSession::Producer() { return m_producer; } diff --git a/MobileGL/MG_Remote/Client/ClientSession.h b/MobileGL/MG_Remote/Client/ClientSession.h index d5ac27ba..2f971f9c 100644 --- a/MobileGL/MG_Remote/Client/ClientSession.h +++ b/MobileGL/MG_Remote/Client/ClientSession.h @@ -96,11 +96,17 @@ namespace MobileGL::MG_Remote::Client { // name where a kReplySlot answer lands; pass {nullptr, 0} for a call that has none. // Returns the record's seq, which is also its reply-slot id. // + // `replySizeOut` (optional) receives the answer's OWN byte count as the server stamped + // it - which is not always `replyBytes`: a short OK reply stamps fewer, and a DECLINE or + // ERROR stamps 0. ReadPixels is the one caller that must know, because scattering a + // reply that arrived short would spray stale bytes as pixels (M2 / codex 11); it reads + // this and refuses `replySize != DstSize` by name rather than trust the copy. + // // Waiting is spin(MOBILEGL_IPC_SPIN_US) then park, through Doorbell::Wait, with // producerParked set before blocking - the shape Doorbell.h:121 already implements. Uint64 EmitAndWait(MG_Pipe::MGPWireOp op, const void* payload, Uint64 payloadBytes, const void* varTail, Uint64 varTailBytes, void* replyOut, - Uint64 replyBytes, Int32* statusOut); + Uint64 replyBytes, Int32* statusOut, Uint64* replySizeOut = nullptr); // MOBILEGL_IPC_VERB_BARRIER. False is the R-1 negative control and is EXPECTED to be // red; it must be run once and the way it goes red recorded. @@ -113,6 +119,36 @@ namespace MobileGL::MG_Remote::Client { static Bool InBarrierWait(); static Bool ApplyThreadIsInsideApplier(); + // ---- c1's additions --------------------------------------------------------------- + + // The apply thread's half of R-1's invariant. v1's apply loop brackets its + // DecodeAndApply with these; the client checks the flag before it publishes, so + // "at most one of {GL thread, apply thread} is runnable" is a runtime assertion rather + // than a sentence in a brief. A raw pair rather than an RAII type in this header + // because the server side owns its own scoping and must not have to include a client + // header to get it - ScopedApplierEntry below is the convenience, not the contract. + static void NoteApplyThreadEnteredApplier(); + static void NoteApplyThreadLeftApplier(); + struct ScopedApplierEntry { + ScopedApplierEntry() { NoteApplyThreadEnteredApplier(); } + ~ScopedApplierEntry() { NoteApplyThreadLeftApplier(); } + ScopedApplierEntry(const ScopedApplierEntry&) = delete; + ScopedApplierEntry& operator=(const ScopedApplierEntry&) = delete; + }; + + // R-12's INVALIDATION EDGE. Drains whatever the server has queued on the control plane + // and adopts every CapsSnapshot in it - and a SECOND snapshot IS the invalidation, + // which is why there is no Invalidate(). Non-blocking: it peeks and returns. + // + // Called at the handshake, from BackendObject_Remote's Initialize/InitCapabilities, and + // once per Present. Present is the boundary every one of P5's three targets crosses, + // and a caps re-run can only follow a surface event, so once a frame is both sufficient + // and the cheapest place that is. + // + // Returns how many snapshots it adopted, so a case can assert the edge fired rather + // than assert that a number downstream of it happened to change. + Uint32 PumpControlPlane(); + // ---- s1's additions: the four primitives c1's EmitAndWait composes --------------- // // s1 owns construction and lifetime; c1 owns the barrier POLICY. So the plumbing is diff --git a/MobileGL/MG_Remote/Client/EmitTables.cpp b/MobileGL/MG_Remote/Client/EmitTables.cpp index 3b8cdb03..39fba630 100644 --- a/MobileGL/MG_Remote/Client/EmitTables.cpp +++ b/MobileGL/MG_Remote/Client/EmitTables.cpp @@ -6,13 +6,37 @@ // SPDX-License-Identifier: LGPL-3.0-only // End of Source File Header -// P5 c0 stubs for package c1. +// P5 package c1: the 71-slot emit table. +// +// THE PARTITION IS CONTRACT-P5.md §7's AND IS NOT RE-DERIVED HERE (R-15, ID-12): +// class A 2 slots answered locally from the caps mirror, never emitted, never Fatal +// class B 5 slots emitted +// class C 64 slots Fatal{UnmigratedVerb, ""} +// The three counts are static_asserted to sum to kRemoteEmitSlotCount below, so a slot that +// changes class without changing the arithmetic is a build break rather than a behaviour +// change nobody reviewed. +// +// THE PRE-VERB HOOKS RUN BEFORE THE RECORD, NEVER AFTER (b1, ID-18). PushPersistentMapsBeforeVerb +// publishes the bytes an application wrote through a coherent map with no API call at all, and +// MarkGpuWritesForDraw builds the conservative GPU-write set the client now owns. Both describe +// the work the record is ABOUT TO START, so a hook deferred past its own record is the C-1 +// regression re-committed at the transport layer. #include "EmitTables.h" +#include "ClientSession.h" +#include "GpuWritePending.h" +#include "PersistentMapTracker.h" + +#include #include +#include + +#include +#include #include +#include namespace MobileGL::MG_Remote::Client { @@ -36,12 +60,647 @@ namespace MobileGL::MG_Remote::Client { std::abort(); } + namespace { + + Bool g_dropClearEmission = false; + Uint64 g_droppedClearEmissions = 0; + + // E2's control has to be armable from OUTSIDE the process that runs the replay, because + // the statement it makes is about a trace lane and not about a unit case: "drop one + // Clear emission and OpenRA's SSIM falls below 0.99". A recompile would make the control + // arm against source text, which is ID-22(a)'s defect. + // + // READ WITH getenv RATHER THAN THROUGH MG_Config, DELIBERATELY AND TEMPORARILY. Config.h + // is c0's and a new MOBILEGL_IPC_* knob goes through the integrator; this is a + // NEGATIVE-CONTROL switch no operator may ever set, and it announces itself at warning + // level every time it arms so it cannot be on by accident. Flagged for adoption into + // IpcTable if the integrator wants it there. + Bool ReadDropClearFromEnvironment() { + const char* value = std::getenv("MOBILEGL_IPC_E2_DROP_CLEAR"); + const Bool armed = value != nullptr && value[0] == '1' && value[1] == '\0'; + if (armed) { + MGLOG_W("MG_Remote client: MOBILEGL_IPC_E2_DROP_CLEAR=1 - E2's NEGATIVE CONTROL is " + "armed and every glClear will be DROPPED on the wire. This arm is expected " + "to fail its SSIM threshold; a lane that stays green with it set is not " + "going through the wire at all"); + } + return armed; + } + + // ID-49's two halves, in one place so the emitter and its control read the same + // arithmetic. + // + // GL 4.6 8.4.4, pack side: the destination row stride is ROW_LENGTH (or the width) + // pixels rounded UP to PACK_ALIGNMENT, the first written byte is offset by SKIP_ROWS + // whole strides plus SKIP_PIXELS pixels, and only `width * bytesPerPixel` bytes of each + // stride are written - the gaps belong to the application and are never touched. That + // last clause is what the control checks with a sentinel. + Bool ReadbackPackStateIsTight(GLsizei width, Uint64 bytesPerPixel, + const PixelStoreParameters& pack) { + // SKIP_IMAGES (and IMAGE_HEIGHT) are NOT consulted: glReadPixels is a 2-D read and GL + // ignores the image-level pack parameters for it, exactly as the monolith conversion + // path does at DirectGLES.cpp:10905 (honorPackImageParams=false). A non-zero + // SkipImages therefore does not make the layout non-tight (codex 6). + if (pack.SkipRows != 0 || pack.SkipPixels != 0) return false; + if (pack.RowLength != 0 && pack.RowLength != width) return false; + const Uint64 alignment = pack.Alignment > 0 ? static_cast(pack.Alignment) : 1ull; + const Uint64 rowBytes = static_cast(width) * bytesPerPixel; + return (rowBytes % alignment) == 0; + } + + // ---- the session, demanded rather than assumed -------------------------------- + // + // Every class-B slot needs one. A null session here is NOT the monolith answer - the + // monolith answer is that this table was never installed at all, because + // MG_Backend::Init() only reaches BackendObject_Remote when the transport resolved. So + // a null one is a Fatal by name and not a fall-through to the driver: a pass-through + // slot is the "split lane ran monolith and went green" shape that every gate in this + // phase exists to prevent (R-4). + ClientSession& RequireSession(const char* slot) { + ClientSession* session = ClientSession::Active(); + if (session == nullptr) { + MGLOG_F("MGPipe: Fatal{NoClientSession, \"%s\"} - the remote emit table is " + "installed but no ClientSession is active. A slot may not fall through " + "to a driver this role does not have", + slot); + std::abort(); + } + return *session; + } + + // The two hooks b1 wrote and deliberately left with no caller, because the call site is + // this file's. ORDER: the push first (it produces resource_subdata records that must + // precede the verb on SEG_CMD), then the mark walk, then the verb record. + void BeforeDrawVerb() { + PushPersistentMapsBeforeVerb(); + MarkGpuWritesForDraw(); + } + + // A verb that reads buffers but starts no shader: clear, blit, readback, present. The + // push still has to run - a coherent map is read by the GPU on any of them - but there + // is no shader that could write one, so no mark walk. + void BeforeReadOnlyVerb() { PushPersistentMapsBeforeVerb(); } + + // ============================================================================= + // CLASS B - the five slots the verb census measured (CONTRACT-P5.md §7) + // ============================================================================= + + void EmitClear(GLbitfield mask) { + ClientSession& session = RequireSession("Clear"); + BeforeReadOnlyVerb(); + + if (g_dropClearEmission) { + // E2's negative control. Everything above still ran, so the only difference + // between this arm and the live one is the record - which is exactly the + // statement "the picture comes from the wire" that E2 exists to prove. + ++g_droppedClearEmissions; + return; + } + + MG_Pipe::MGPClear record{}; + // The DRAW framebuffer is whatever the server's own SyncRenderState resolves from + // gPipeInputs, which the client's MGP_FILL(Clear) at GL_Drawing.cpp:534 has just + // written and the verb barrier keeps still (R-1). Naming a handle here would be a + // SECOND statement of the binding, and the second one is the one that goes stale. + record.Fbo = MG_Pipe::kMGPipeNullHandle; + record.Kind = kRemoteClearWhole; + record.DrawBufferIndex = -1; + record.BufferMask = static_cast(mask); + record.ValueClass = 0; + session.EmitAndWait(MG_Pipe::MGPWireOp::Clear, &record, sizeof(record), nullptr, 0, + nullptr, 0, nullptr); + } + + void EmitDrawArrays(GLenum mode, GLint first, GLsizei count) { + ClientSession& session = RequireSession("DrawArrays"); + BeforeDrawVerb(); + + MG_Pipe::MGPDrawInfo info{}; + info.Mode = static_cast(mode); + info.IndexSize = 0; // arrays + info.Flags = 0; // NO kDrawHasUserIndices: the reduced path draws from a VBO + info.InstanceCount = 1; + info.StartInstance = 0; + info.RestartIndex = 0; + info.DrawIdOffset = 0; + info.IndexResource = MG_Pipe::kMGPipeNullHandle; + info.MinIndex = ~0u; // "unknown", MGPipeTypes.h:1330 + info.MaxIndex = ~0u; + info.XfbCpuCapturedVertices = 0; + info.NumDraws = 1; + + const MG_Pipe::MGPDrawRange range{static_cast(first), static_cast(count), 0}; + // One tail of exactly NumDraws entries. w1's encoder recomputes that from the + // payload and Fatals on a disagreement, on THIS side - so a NumDraws that drifted + // from the tail is a producer-side abort rather than a corrupt stream a peer has to + // diagnose. + session.EmitAndWait(MG_Pipe::MGPWireOp::DrawVbo, &info, sizeof(info), &range, + sizeof(range), nullptr, 0, nullptr); + } + + void EmitBlitFramebuffer(GLint srcX0, GLint srcY0, GLint srcX1, GLint srcY1, GLint dstX0, + GLint dstY0, GLint dstX1, GLint dstY1, GLbitfield mask, + GLenum filter) { + ClientSession& session = RequireSession("BlitFramebuffer"); + BeforeReadOnlyVerb(); + + MG_Pipe::MGPBlit record{}; + // Same reasoning as Clear's Fbo: the read and draw bindings are gPipeInputs', set + // by MGP_FILL(BlitFramebuffer) at GL_Framebuffer.cpp:660 and held still by the + // barrier. glBlitNamedFramebuffer, which DOES name two framebuffers, is class C. + record.ReadFbo = MG_Pipe::kMGPipeNullHandle; + record.DrawFbo = MG_Pipe::kMGPipeNullHandle; + record.SrcX0 = srcX0; + record.SrcY0 = srcY0; + record.SrcX1 = srcX1; + record.SrcY1 = srcY1; + record.DstX0 = dstX0; + record.DstY0 = dstY0; + record.DstX1 = dstX1; + record.DstY1 = dstY1; + record.Mask = static_cast(mask); + record.Filter = static_cast(filter); + session.EmitAndWait(MG_Pipe::MGPWireOp::Blit, &record, sizeof(record), nullptr, 0, + nullptr, 0, nullptr); + } + + // How many bytes glReadPixels will pack for this rectangle, from the PACK half of the + // pixel-store state. + // + // ID-49: THE PACK STATE NEVER CROSSES FOR A READ, AND DstSize IS THE TIGHT EXTENT. + // The first version of this computed GL 4.6 8.4.4's PACKED size - row length, skips, + // alignment - and handed it over as DstSize. v1's server allocates exactly DstSize and + // the real backend honours the live pack state, so a 4x3 RGBA8 read with + // PACK_ROW_LENGTH=8, SKIP_ROWS=1, SKIP_PIXELS=2 allocated 80 bytes and the driver wrote + // to byte 120. That is the shape of the joint inproc lane's two + // DepthReadbackHonoursThePackPixelStoreParameters SEGFAULTs, on both backends. + // + // So the wire carries a RECTANGLE and not a layout: the server reads with NEUTRAL pack + // state into a tight w*h*bytesPerPixel run that IS the reply payload, and the CLIENT - + // which is the side that holds the application's pack state, and the only side that + // can - scatters those rows into the application's pointer. "OnReadPixels writes + // exactly DstSize bytes" still holds; DstSize is now a number both sides derive from + // the same three values instead of one side deriving it from state the other cannot + // see. + // + // IT IS FORMAT-AGNOSTIC ON PURPOSE. The depth and depth-stencil reads the 21 split + // entries touch take the same rule with no special case, because the rule is about the + // LAYOUT and not about the component: bytesPerPixel is whatever the format sizes to. + Uint64 ReadbackBytesPerPixel(GLenum format, GLenum type) { + const TextureInputFormat inputFormat = + MG_Util::ConvertGLEnumToTextureInputFormat(format); + const TexturePixelDataType dataType = MG_Util::ConvertGLEnumToTexturePixelDataType(type); + const SizeT bytesPerPixel = MG_Util::GetInputBytesPerPixel(inputFormat, dataType); + if (bytesPerPixel == 0) { + // NOT a guess and not a zero-length reply. A format this build cannot size is a + // readback whose answer would be silently truncated, which is the one failure a + // picture comparison cannot see. + MGLOG_F("MGPipe: Fatal{UnsizedReadback, \"read_pixels\"} format=0x%04x type=0x%04x " + "- the client must declare MGPReadbackInfo::DstSize and cannot size this " + "pair; P5's reduced path reads RGBA/UNSIGNED_BYTE", + static_cast(format), static_cast(type)); + std::abort(); + } + return static_cast(bytesPerPixel); + } + + Uint64 TightReadbackBytes(GLsizei width, GLsizei height, GLenum format, GLenum type) { + if (width <= 0 || height <= 0) return 0; + return static_cast(width) * static_cast(height) * + ReadbackBytesPerPixel(format, type); + } + + // M2 / codex 11: the reply the server posted is COMPLETE and OK. A short OK reply, and a + // DECLINED or ERROR reply with a zero payload, both leave the destination full of stale + // bytes; scattering or returning it is the silently truncated picture ID-47's own comment + // says an SSIM comparison cannot see, arriving through the status field rather than + // through truncation. `expected` is the record's own DstSize (CONTRACT-P5 row 23: the + // reply's exact extent). The predicate is at namespace scope (below) and exposed for the + // control, so R-16's "drive the production predicate" holds rather than a second copy of + // the rule in the test. + + // The same predicate at the call site, with the named Fatal each failure mode owns. + // status > expected cannot reach here: EmitAndWait already aborts Fatal{ReplyTooLarge} on + // an oversize reply, so the only failures left are a wrong status or a SHORT one. + void RequireReadbackReplyComplete(Int32 status, Uint64 replySize, Uint64 expected) { + if (status == Wire::ReplySink::kStatusError) { + MGLOG_F("MGPipe: Fatal{ReplyError, \"ReadPixels\"} - the readback answered ERROR; " + "the destination is left untouched rather than filled with stale bytes"); + std::abort(); + } + if (status == Wire::ReplySink::kStatusDeclined) { + MGLOG_F("MGPipe: Fatal{ReadbackDeclined, \"ReadPixels\"} - the server has no " + "GL.ReadPixels and DECLINED; a decline is a real answer for an acceptance " + "row (R-5) but a blocking readback has no pixels to return, so it is a " + "Fatal here rather than a buffer of stale bytes"); + std::abort(); + } + if (replySize != expected) { + MGLOG_F("MGPipe: Fatal{ReadbackReplyShort, \"ReadPixels %llu < %llu\"} - the OK " + "reply carried fewer bytes than the read's own DstSize (CONTRACT-P5 row " + "23's exact extent); the missing rows would otherwise be scattered as " + "whatever the destination held", + static_cast(replySize), + static_cast(expected)); + std::abort(); + } + } + + void EmitReadPixels(GLint x, GLint y, GLsizei width, GLsizei height, GLenum format, + GLenum type, void* pixels) { + ClientSession& session = RequireSession("ReadPixels"); + + // ID-57 / M8: A PACK-PBO DESTINATION IS REFUSED BY NAME, BEFORE ANY EMISSION AND + // BEFORE `pixels` IS TOUCHED. With GL_PIXEL_PACK_BUFFER bound, the frontend permits + // `pixels` to be a byte OFFSET into that buffer, not an address (GL_Framebuffer.cpp: + // 3055 aligns it to the type size, one byte for UNSIGNED_BYTE) - and this emitter has + // no PBO branch: it sets DstOffset = 0, hands the offset to EmitAndWait as a host + // buffer, and the reply is memcpy'd to CPU address . Under monolith the + // backend maps the PBO and writes the reply into the buffer (unchanged). The real + // split form - the server writes the reply into the buffer resource and the client + // marks it GPU-written (b1's MarkReadPixelsPackBuffer becoming the producer contract + // §3 names) - is a P6 ROADMAP item. In P5 it is class C's shape (R-4), refused here. + if (MG_State::pGLContext != nullptr && + MG_State::pGLContext->GetBufferBindingSlot(::MobileGL::BufferTarget::PixelPack) + .GetBoundObject()) { + UnmigratedVerbFatal("ReadPixels+PACK_BUFFER"); + } + + BeforeReadOnlyVerb(); + + // THE PBO HALF IS b1's DESIGN AND b1 ALREADY WIRED ITS MARK, at + // GL_Framebuffer.cpp:3109 - immediately after this table call returns, inside + // ReadPixels_Backend itself. So this emitter deliberately does NOT call + // MarkReadPixelsPackBuffer(): a second call there would be the "wire it twice" + // shape, and the per-row counter b1's unit cases assert on would then count one + // read as two. (In P5 the refusal above means no PBO read reaches here at all; the + // note stays for the P6 form.) + if (width <= 0 || height <= 0) return; + const Uint64 bytesPerPixel = ReadbackBytesPerPixel(format, type); + // ONE tight-size function for production AND the control (M3 / codex 10a). The first + // cut computed this inline here while the test drove TightReadbackByteCount, so a + // `+16` on the production line stayed green - the test observed a different number. + // Now the number the server allocates and the number the test asserts come from the + // same body. + const Uint64 tight = TightReadbackBytes(width, height, format, type); + + MG_Pipe::MGPReadbackInfo info{}; + info.Res = MG_Pipe::kMGPipeNullHandle; // "the bound read surface answers" + info.Box = MG_Pipe::MGPBox{x, y, 0, static_cast(width), + static_cast(height), 1}; + info.Format = static_cast(format); + info.Type = static_cast(type); + info.Target = 0; + info.Level = 0; + info.DstOffset = 0; + info.DstSize = tight; + + // CHECKED BEFORE THE EMISSION, not after the answer (ID-47), and through S1's + // HELPER rather than a copy of it here. A reply bigger than a slot is Fatal on the + // SERVER too, and s1 keeps that as the last line of defence - but it fires on the + // apply thread with the record already on the wire, where all the client sees is a + // hang. This one names the read, at the call site that knows what the read was. + // + // THE NUMBER IS ID-49's TIGHT EXTENT and not the packed one: the pack state never + // crosses, so the answer that has to fit a slot is w*h*bytesPerPixel. The cap is + // the pool's own, read live, so s1's growth of SEG_REPLY to 16 MiB / eight 2 MiB + // slots needed no edit in this file - only the merge. + session.RequireReadPixelsReplyFits(static_cast(width), + static_cast(height), + static_cast(format), + static_cast(type), tight); + + PixelStoreParameters pack{}; + if (MG_State::pGLContext != nullptr) { + pack = MG_State::pGLContext->GetPixelStoreParameters(/*isUnpack=*/false); + } + + Int32 status = 0; + Uint64 replySize = 0; + if (ReadbackPackStateIsTight(width, bytesPerPixel, pack)) { + // THE COMMON CASE, AND IT KEEPS THE ZERO-COPY. A neutral pack state means the + // destination layout IS the tight layout, so the reply lands straight in the + // application's buffer and there is no bounce at all. It is a fast path for the + // SAME bytes, not a second rule: ScatterTightReadback below is a memcpy of the + // whole run in exactly this case, and the control drives that function. + session.EmitAndWait(MG_Pipe::MGPWireOp::ReadPixels, &info, sizeof(info), nullptr, 0, + pixels, tight, &status, &replySize); + // M2 / codex 11: an OK reply that arrived short, or a DECLINE/ERROR, must not be + // handed back as pixels. The Fatal aborts before the application reads the buffer, + // so the bytes EmitAndWait already copied into `pixels` are never observed. + RequireReadbackReplyComplete(status, replySize, tight); + return; + } + + // The bounce is the price of the application having asked for a layout. It is the + // tight size and never more, and it is freed before this returns - R-11's rule one + // level out: nothing here outlives the call. + Vector bounce(static_cast(tight)); + session.EmitAndWait(MG_Pipe::MGPWireOp::ReadPixels, &info, sizeof(info), nullptr, 0, + bounce.data(), tight, &status, &replySize); + // BEFORE THE SCATTER, so a short or non-OK reply never reaches the application's + // pointer at all (the bounce is the only thing that held the partial bytes). + RequireReadbackReplyComplete(status, replySize, tight); + ScatterTightReadbackIntoPackState(bounce.data(), pixels, width, height, bytesPerPixel, + pack); + } + + void EmitPresent() { + ClientSession& session = RequireSession("Present"); + BeforeReadOnlyVerb(); + + MG_Pipe::MGPPresent record{}; + // FrameSerial 0 = "the server stamps its own". P5 has no client-side present credit + // (MOBILEGL_IPC_PRESENT_CREDIT is P6's), so a client-minted serial would be a second + // id space with no consumer. + record.FrameSerial = 0; + session.EmitAndWait(MG_Pipe::MGPWireOp::Present, &record, sizeof(record), nullptr, 0, + nullptr, 0, nullptr); + + // R-12's invalidation edge, drained at the one boundary every target crosses. A + // second CapsSnapshot IS the invalidation; nothing else on the client can see that + // the server re-ran InitCapabilities, because GLContext::GetCompileEnv()'s memo is + // keyed on pActiveBackendObject.get() (Core.cpp:34) and that pointer never changes + // under split. + session.PumpControlPlane(); + } + + // ============================================================================= + // CLASS A - answered locally from the caps mirror (R-15). NO RECORD, EVER. + // ============================================================================= + + void AnswerGetIntegeri_v(GLenum target, GLuint index, GLint* data) { + if (data == nullptr) return; + const MG_Backend::DynamicBackendParameters& dynamic = CapsMirrorInstance().Dynamic(); + // The ONLY two indexed pnames the device owns; every other indexed pname names + // frontend state and is answered in GL_Getter::GetIntegeri_v before any table is + // consulted (BackendObject.h:196-205). The existing gate is + // AdvertisedLimitsScenario.ComputeWorkGroupLimitsAreTheCapsBlocksAnswer, which pins + // that this answer and the caps copy are ONE number. + switch (target) { + case GL_MAX_COMPUTE_WORK_GROUP_COUNT: + if (index < 3) *data = static_cast(dynamic.MaxComputeWorkGroupCount[index]); + return; + case GL_MAX_COMPUTE_WORK_GROUP_SIZE: + if (index < 3) *data = static_cast(dynamic.MaxComputeWorkGroupSize[index]); + return; + default: + // Not a Fatal: the slot's own contract is "whatever pname the frontend has no + // case for at all", and the monolith backends answer such a pname by leaving + // the driver's own default in place. Answering a wrong number would be worse + // than answering none. + MGLOG_W_ONCE("MG_Remote client: GetIntegeri_v(0x%04x, %u) is not one of the two " + "device-owned indexed pnames and has no caps-mirror answer", + static_cast(target), static_cast(index)); + return; + } + } + + Bool AnswerIsTimerQuerySupported() { + // A capability predicate, not a call. Today a null slot means COUNTER_BITS == 0 + // (GL_Query.cpp:792) - which is precisely the null check R-4 forbids, so it moves + // here, to the bit the server published. + return CapsMirrorInstance().HasCap(MG_Pipe::kCapTimerQuery); + } + + // ============================================================================= + // CLASS C - Fatal{UnmigratedVerb}. 64 slots: 63 in GLFunctionsTable + SetSwapInterval. + // ============================================================================= + // + // The list is an X-macro so the DEFINITION and the ASSIGNMENT cannot drift apart, and + // so the count is arithmetic rather than a comment. Two of them carry a pre-verb hook + // before the Fatal - see the note on DispatchCompute. + +#define MGR_UNMIGRATED_GL_SLOTS(X) \ + X(DrawElements, void, (GLenum, GLsizei, GLenum, const void*)) \ + X(DrawElementsBaseVertex, void, (GLenum, GLsizei, GLenum, const void*, GLint)) \ + X(MultiDrawArrays, void, (GLenum, const GLint*, const GLsizei*, GLsizei)) \ + X(MultiDrawElements, void, (GLenum, const GLsizei*, GLenum, const GLvoid* const*, GLsizei)) \ + X(MultiDrawElementsBaseVertex, void, \ + (GLenum, const GLsizei*, GLenum, const GLvoid* const*, GLsizei, const GLint*)) \ + X(MultiDrawElementsIndirect, void, (GLenum, GLenum, const void*, GLsizei, GLsizei)) \ + X(MultiDrawArraysIndirect, void, (GLenum, const void*, GLsizei, GLsizei)) \ + X(MultiDrawElementsIndirectCount, void, (GLenum, GLenum, const void*, GLintptr, GLsizei, GLsizei)) \ + X(MultiDrawArraysIndirectCount, void, (GLenum, const void*, GLintptr, GLsizei, GLsizei)) \ + X(DrawRangeElementsBaseVertex, void, \ + (GLenum, GLuint, GLuint, GLsizei, GLenum, const void*, GLint)) \ + X(DrawRangeElements, void, (GLenum, GLuint, GLuint, GLsizei, GLenum, const void*)) \ + X(DrawElementsInstancedBaseVertexBaseInstance, void, \ + (GLenum, GLsizei, GLenum, const void*, GLsizei, GLint, GLuint)) \ + X(DrawElementsInstancedBaseVertex, void, (GLenum, GLsizei, GLenum, const void*, GLsizei, GLint)) \ + X(DrawElementsInstancedBaseInstance, void, \ + (GLenum, GLsizei, GLenum, const void*, GLsizei, GLuint)) \ + X(DrawElementsInstanced, void, (GLenum, GLsizei, GLenum, const void*, GLsizei)) \ + X(DrawArraysInstancedBaseInstance, void, (GLenum, GLint, GLsizei, GLsizei, GLuint)) \ + X(DrawArraysInstanced, void, (GLenum, GLint, GLsizei, GLsizei)) \ + X(DrawElementsIndirect, void, (GLenum, GLenum, const void*)) \ + X(DrawArraysIndirect, void, (GLenum, const void*)) \ + X(ClearBufferfi, void, (GLenum, GLint, GLfloat, GLint)) \ + X(ClearBufferfv, void, (GLenum, GLint, const GLfloat*)) \ + X(ClearBufferuiv, void, (GLenum, GLint, const GLuint*)) \ + X(ClearBufferiv, void, (GLenum, GLint, const GLint*)) \ + X(ClearNamedFramebufferfv, void, \ + (const SharedPtr&, GLenum, GLint, const GLfloat*)) \ + X(ClearNamedFramebufferfi, void, \ + (const SharedPtr&, GLenum, GLint, GLfloat, GLint)) \ + X(ClearNamedFramebufferiv, void, \ + (const SharedPtr&, GLenum, GLint, const GLint*)) \ + X(ClearNamedFramebufferuiv, void, \ + (const SharedPtr&, GLenum, GLint, const GLuint*)) \ + X(BlitNamedFramebuffer, void, \ + (const SharedPtr&, \ + const SharedPtr&, GLint, GLint, GLint, GLint, GLint, \ + GLint, GLint, GLint, GLbitfield, GLenum)) \ + X(CopyTexImage2D, void, (GLenum, GLint, GLenum, GLint, GLint, GLsizei, GLsizei, GLint)) \ + X(CopyTexSubImage2D, void, (GLenum, GLint, GLint, GLint, GLint, GLint, GLsizei, GLsizei)) \ + X(CopyImageSubData, void, \ + (const MG_Backend::CopyImageEndpoint&, GLenum, GLint, GLint, GLint, GLint, \ + const MG_Backend::CopyImageEndpoint&, GLenum, GLint, GLint, GLint, GLint, GLsizei, GLsizei, \ + GLsizei)) \ + X(GenerateMipmap, void, (GLenum)) \ + X(GetTexImage, void, (GLenum, GLint, GLenum, GLenum, GLvoid*)) \ + X(GetTextureImage, void, \ + (const SharedPtr&, TextureUploadTarget, GLint, GLenum, \ + GLenum, GLsizei, GLvoid*)) \ + X(MemoryBarrier, void, (GLbitfield)) \ + X(MemoryBarrierByRegion, void, (GLbitfield)) \ + X(BindImageTexture, void, (GLuint, GLuint, GLint, GLboolean, GLint, GLenum, GLenum)) \ + X(ShaderStorageBlockBinding, void, (GLuint, const GLchar*, GLuint)) \ + X(WaitSync, void, (MG_Backend::BackendSyncHandle, GLbitfield, GLuint64)) \ + X(DeleteSync, void, (MG_Backend::BackendSyncHandle)) \ + X(EndTimeElapsedQuery, void, (MG_Backend::BackendQueryHandle)) \ + X(DeleteBackendQuery, void, (MG_Backend::BackendQueryHandle)) \ + X(EndOcclusionQuery, void, (MG_Backend::BackendQueryHandle)) \ + X(EndXfbPrimitivesQuery, void, (MG_Backend::BackendQueryHandle)) \ + X(PatchParameteri, void, (GLenum, GLint)) \ + X(BeginTransformFeedback, void, (GLenum)) \ + X(EndTransformFeedback, void, ()) \ + X(PauseTransformFeedback, void, ()) \ + X(ResumeTransformFeedback, void, ()) \ + X(BindTransformFeedback, void, (GLuint)) \ + X(DeleteTransformFeedback, void, (GLuint)) + + // The non-void ones, kept apart only because the macro body differs: a [[noreturn]] + // call is a complete body for a void slot and for a value-returning one alike, but a + // compiler that does not see UnmigratedVerbFatal's attribute through the macro would + // warn on the second. It does see it; they are split for readability. +#define MGR_UNMIGRATED_GL_VALUE_SLOTS(X) \ + X(FenceSync, MG_Backend::BackendSyncHandle, ()) \ + X(ClientWaitSync, GLenum, (MG_Backend::BackendSyncHandle, GLbitfield, GLuint64)) \ + X(GetSyncStatus, Bool, (MG_Backend::BackendSyncHandle)) \ + X(BeginTimeElapsedQuery, MG_Backend::BackendQueryHandle, ()) \ + X(QueryCounterTimestamp, MG_Backend::BackendQueryHandle, ()) \ + X(IsQueryResultAvailable, Bool, (MG_Backend::BackendQueryHandle)) \ + X(GetQueryResult64, Bool, (MG_Backend::BackendQueryHandle, Bool, Uint64*)) \ + X(BeginOcclusionQuery, MG_Backend::BackendQueryHandle, ()) \ + X(BeginXfbPrimitivesQuery, MG_Backend::BackendQueryHandle, (Bool)) \ + X(GetGpuTimestampNs, Int64, ()) + +#define MGR_DEFINE_UNMIGRATED(Name, Ret, Sig) \ + Ret Name##_Unmigrated Sig { UnmigratedVerbFatal(#Name); } + + MGR_UNMIGRATED_GL_SLOTS(MGR_DEFINE_UNMIGRATED) + MGR_UNMIGRATED_GL_VALUE_SLOTS(MGR_DEFINE_UNMIGRATED) +#undef MGR_DEFINE_UNMIGRATED + + // THE TWO COMPUTE SLOTS CARRY b1's DISPATCH HOOK BEFORE THE FATAL, and this is stated + // rather than hidden. MarkGpuWritesForDispatch() belongs immediately before the + // dispatch record, and the dispatch record is class C in P5 - so the call site is here, + // in the right place, and is UNREACHABLE-IN-EFFECT: the abort follows it. There is no + // gate on it and this file says so; the phase that moves DispatchCompute into class B + // replaces the Fatal and inherits a call site that is already correct rather than + // discovering that the mark walk was never wired. + void DispatchCompute_Unmigrated(GLuint, GLuint, GLuint) { + PushPersistentMapsBeforeVerb(); + MarkGpuWritesForDispatch(); + UnmigratedVerbFatal("DispatchCompute"); + } + void DispatchComputeIndirect_Unmigrated(GLintptr) { + PushPersistentMapsBeforeVerb(); + MarkGpuWritesForDispatch(); + UnmigratedVerbFatal("DispatchComputeIndirect"); + } + + void SetSwapInterval_Unmigrated(Int) { UnmigratedVerbFatal("SetSwapInterval"); } + + // The counts, as arithmetic. MGR_COUNT_ONE expands to `+ 1` per row. +#define MGR_COUNT_ONE(Name, Ret, Sig) +1 + constexpr Uint32 kUnmigratedListedSlots = + 0 MGR_UNMIGRATED_GL_SLOTS(MGR_COUNT_ONE) MGR_UNMIGRATED_GL_VALUE_SLOTS(MGR_COUNT_ONE); +#undef MGR_COUNT_ONE + // + DispatchCompute, DispatchComputeIndirect, SetSwapInterval, written out by hand + // because they carry a body the macro cannot. + constexpr Uint32 kUnmigratedSlots = kUnmigratedListedSlots + 3; + constexpr Uint32 kLocallyAnsweredSlots = 2; // GetIntegeri_v, IsTimerQuerySupported + constexpr Uint32 kEmittedSlots = 5; // Clear, DrawArrays, ReadPixels, Blit, Present + + static_assert(kUnmigratedSlots == 64, "CONTRACT-P5.md §7 class C is 64 slots"); + static_assert(kLocallyAnsweredSlots + kEmittedSlots + kUnmigratedSlots == kRemoteEmitSlotCount, + "the three classes no longer partition the 71 slots"); + + MG_Backend::GlobalBackendFunctionsTable BuildRemoteEmitTable() { + g_dropClearEmission = ReadDropClearFromEnvironment(); + MG_Backend::GlobalBackendFunctionsTable table{}; + + // ---- class C first, so that a slot forgotten below stays Fatal rather than null. + // Order matters for exactly this reason: if class B's assignment were first, a + // typo in class C would leave a NULL slot, and a null slot is 91 potential null + // calls with no diagnostic. This way the worst a mistake can do is name a verb + // that was supposed to be emitted, loudly. +#define MGR_ASSIGN_UNMIGRATED(Name, Ret, Sig) table.GL.Name = &Name##_Unmigrated; + MGR_UNMIGRATED_GL_SLOTS(MGR_ASSIGN_UNMIGRATED) + MGR_UNMIGRATED_GL_VALUE_SLOTS(MGR_ASSIGN_UNMIGRATED) +#undef MGR_ASSIGN_UNMIGRATED + table.GL.DispatchCompute = &DispatchCompute_Unmigrated; + table.GL.DispatchComputeIndirect = &DispatchComputeIndirect_Unmigrated; + table.SetSwapInterval = &SetSwapInterval_Unmigrated; + + // ---- class A + table.GL.GetIntegeri_v = &AnswerGetIntegeri_v; + table.GL.IsTimerQuerySupported = &AnswerIsTimerQuerySupported; + // NOT A SLOT and not a verb: a Bool member of the table, whose one non-test client + // reader is GL_Query.cpp:221. It does NOT ride inside MGPCaps::Dynamic - it is a + // member of GLFunctionsTable, which is exactly the thing a split client never + // receives - so it is answered from kCapCpuXfbPrimitiveAccounting. + table.GL.PrefersCpuXfbPrimitiveAccounting = + CapsMirrorInstance().PrefersCpuXfbPrimitiveAccounting(); + + // ---- class B + table.GL.Clear = &EmitClear; + table.GL.DrawArrays = &EmitDrawArrays; + table.GL.ReadPixels = &EmitReadPixels; + table.GL.BlitFramebuffer = &EmitBlitFramebuffer; + table.Present = &EmitPresent; + + return table; + } + + } // namespace + const MG_Backend::GlobalBackendFunctionsTable& RemoteEmitTable() { - MGLOG_F("MGPipe: Fatal{UnimplementedEmitTable, \"RemoteEmitTable\"} - P5 package c1 has " - "not landed this yet; c0 shipped the signature only"); - std::abort(); + // Leaked at exit like every other MG_Remote singleton (ID-8): MG_Backend::Init() + // copies it into gBackendFunctionsTable and MobileGL::Destroy() clears that copy from + // an exit handler, by which point a static destructor here would already have run. + static const MG_Backend::GlobalBackendFunctionsTable& table = + *new MG_Backend::GlobalBackendFunctionsTable{BuildRemoteEmitTable()}; + return table; } - Uint32 ImplementedVerbCount() { return 0; } + Uint32 ImplementedVerbCount() { return kEmittedSlots; } + Uint32 LocallyAnsweredSlotCount() { return kLocallyAnsweredSlots; } + Uint32 UnmigratedSlotCount() { return kUnmigratedSlots; } + + void SetDropClearEmissionForNegativeControl(Bool drop) { g_dropClearEmission = drop; } + Uint64 DroppedClearEmissions() { return g_droppedClearEmissions; } + + Bool ReadbackPackStateIsTightForTest(GLsizei width, Uint64 bytesPerPixel, + const PixelStoreParameters& pack) { + return ReadbackPackStateIsTight(width, bytesPerPixel, pack); + } + + Uint64 TightReadbackByteCount(GLsizei width, GLsizei height, GLenum format, GLenum type) { + return TightReadbackBytes(width, height, format, type); + } + + // M2 / codex 11's predicate at namespace scope: the one RequireReadbackReplyComplete decides + // on and the one the control drives. 0=OK / 1=DECLINED / 2=ERROR. + Bool ReadbackReplyIsComplete(Int32 status, Uint64 replySize, Uint64 expected) { + return status == Wire::ReplySink::kStatusOk && replySize == expected; + } + + // ID-49's scatter. Exported for the same reason as the refusal above: the control drives + // THIS, which is what the emitter calls, rather than a second copy of 8.4.4's arithmetic. + void ScatterTightReadbackIntoPackState(const void* tight, void* destination, GLsizei width, + GLsizei height, Uint64 bytesPerPixel, + const PixelStoreParameters& pack) { + if (tight == nullptr || destination == nullptr || width <= 0 || height <= 0) return; + const Uint64 rowPixels = + pack.RowLength > 0 ? static_cast(pack.RowLength) : static_cast(width); + const Uint64 alignment = pack.Alignment > 0 ? static_cast(pack.Alignment) : 1ull; + const Uint64 strideBytes = + ((rowPixels * bytesPerPixel + alignment - 1) / alignment) * alignment; + // m6: the client re-derives GL 4.6 8.4.4's pack layout, so it honours GL_PACK_ROW_LENGTH + // VERBATIM - including the ill-formed 0 < ROW_LENGTH < width, where the row stride is + // narrower than a written row and consecutive rows overlap. GL leaves that case to the + // implementation; the client reproduces exactly what the monolith backend's own scatter + // would do with the same state rather than clamping, so the two arms stay byte-identical. + // The fast path (ReadbackPackStateIsTight) already rejects any ROW_LENGTH != width, so + // this only runs on the scatter path the application asked for. + const Uint64 writtenPerRow = static_cast(width) * bytesPerPixel; + // SKIP_IMAGES and IMAGE_HEIGHT ARE IGNORED (codex 6). glReadPixels is a 2-D read; GL + // does not apply the image-level pack parameters to it, and the monolith conversion path + // says so explicitly with honorPackImageParams=false (DirectGLES.cpp:10905). Applying + // SKIP_IMAGES here shifted a read with SKIP_IMAGES=1 by a whole image and overran an + // application buffer sized for exactly `height` rows. Only SKIP_ROWS and SKIP_PIXELS - + // the 2-D skips - offset the first written byte. + auto* out = static_cast(destination) + + static_cast(pack.SkipRows) * strideBytes + + static_cast(pack.SkipPixels) * bytesPerPixel; + const auto* in = static_cast(tight); + for (Uint64 row = 0; row < static_cast(height); ++row) { + std::memcpy(out + row * strideBytes, in + row * writtenPerRow, + static_cast(writtenPerRow)); + } + } } // namespace MobileGL::MG_Remote::Client diff --git a/MobileGL/MG_Remote/Client/EmitTables.h b/MobileGL/MG_Remote/Client/EmitTables.h index 791297f3..cb791e46 100644 --- a/MobileGL/MG_Remote/Client/EmitTables.h +++ b/MobileGL/MG_Remote/Client/EmitTables.h @@ -47,19 +47,37 @@ // disappears. A null check on a slot may not survive into the client: it becomes a caps-mirror // read, which is what ARCHITECTURE.md:114 means by "CallMask replaces 'is this table slot null'". // -// NOTE the asymmetry this table does not resolve: the resource, CSO, framebuffer, texture, -// sampler and program families do NOT come through here. They are emitted from -// MG_Impl/Pipe/* by direct MGPipeApply* calls (37 entry points, 41 call sites), and under -// split each of those becomes an encode. This table covers only the verbs - the draws, -// clears, blits, readbacks, queries, fences and present. +// NOTE the asymmetry this table does not resolve, AND WHERE IT IS RESOLVED (R-17): the +// resource, CSO, framebuffer, texture, sampler and program families do NOT come through here. +// They were emitted from MG_Impl/Pipe/* by 40 direct calls to the 37 MGPipeApply* entry +// points; those call sites now go through the two generated tables +// (MG_Pipe/PipeRoute.h -> MG_Remote/Client/WireTables.cpp). This table covers only the verbs - +// the draws, clears, blits, readbacks, queries, fences and present. #pragma once #include #include +#include namespace MobileGL::MG_Remote::Client { + // MGPClear::Kind. MGPipeTypes.h:1273 states the list as a COMMENT - "Whole | Color | Depth + // | Stencil | DepthStencil" - and mints no enumerator, because until P5 the record had no + // producer. These are the values, in that comment's own order, and they are here rather + // than in MGPipeTypes.h because that file is c0's and this phase produces exactly ONE of + // them: glClear is the only entry point that reaches the Clear slot (the four + // glClearBuffer* and the four glClearNamedFramebuffer* are class C). v1's + // WireVerbSink::OnClear must therefore Fatal on anything but Whole rather than guess, and + // the phase that migrates the other eight moves these into the contract. + enum MGRemoteClearKind : Uint32 { + kRemoteClearWhole = 0, + kRemoteClearColor = 1, + kRemoteClearDepth = 2, + kRemoteClearStencil = 3, + kRemoteClearDepthStencil = 4, + }; + // The table MG_Backend::Init() installs into gBackendFunctionsTable for the remote role. // A reference to a never-destroyed block, like every other MG_Remote singleton (ID-8). const MG_Backend::GlobalBackendFunctionsTable& RemoteEmitTable(); @@ -77,4 +95,63 @@ namespace MobileGL::MG_Remote::Client { // a slot added to GLFunctionsTable without a decision here is a build break. inline constexpr Uint32 kRemoteEmitSlotCount = 71; + // The other two thirds of the census, so a case can assert the WHOLE partition rather than + // only the half that emits. CONTRACT-P5.md §7's three classes are 2 + 5 + 64, and + // EmitTables.cpp static_asserts that they sum to kRemoteEmitSlotCount: a slot that quietly + // changes class shows up as a build break in the sum, not as a silent behaviour change. + Uint32 LocallyAnsweredSlotCount(); // class A - answered from the caps mirror, R-15 + Uint32 UnmigratedSlotCount(); // class C - Fatal{UnmigratedVerb} + + // THE E2 NEGATIVE CONTROL (t1's debt against c1, BRIEF §7). When set, the Clear emitter + // SKIPS its record - it still runs the pre-verb hooks and still returns - so a replay that + // is really going through the wire loses one clear per frame and its SSIM falls below the + // 0.99 threshold, while a replay that fell through to the driver is unaffected. It is a + // function rather than a knob in Config.h for two reasons: the control has to be settable + // from a test process that has already started, and a knob would be a + // MOBILEGL_IPC_-shaped name for something no operator may ever set. + // + // Emissions actually skipped, so the control can assert that it DID something rather than + // that a picture changed - a control that silently never fired is the third shape of R-16's + // "a gate that cannot go red for its own reason". + void SetDropClearEmissionForNegativeControl(Bool drop); + Uint64 DroppedClearEmissions(); + + // ID-47. The CLIENT refuses a readback whose answer would not fit a reply slot, BEFORE it + // emits the record, and names the read. THE REFUSAL ITSELF IS s1's - + // ClientSession::RequireReadPixelsReplyFits, forwarding to + // ReplySlotPool::RequireReadPixelsFits - and this package does not own a second copy of the + // message: two spellings of one refusal is how the two sides come to disagree about which + // reads are legal. What c1 owns is the CALL SITE and the number it passes, which is ID-49's + // tight extent; the control below is over that, and s1-v3.md §1's cases are over the + // helper. + + // ID-49. `MGPReadbackInfo::DstSize` is the TIGHT w*h*bytesPerPixel extent - the reply + // payload - and nothing about the application's pack state crosses the wire. The server + // reads with a NEUTRAL pack state into that run; this is the number both sides derive. + Uint64 TightReadbackByteCount(GLsizei width, GLsizei height, GLenum format, GLenum type); + + // ID-49. Scatters the tight rows into the application's pointer per the application's own + // pack state (ROW_LENGTH, SKIP_*, ALIGNMENT), which only the client holds. Exposed for the + // same reason as the refusal above: the control drives the function the emitter calls + // rather than a second copy of GL 4.6 8.4.4's arithmetic. THE GAPS ARE NEVER WRITTEN - + // they belong to the application - and that is what the control checks with a sentinel. + void ScatterTightReadbackIntoPackState(const void* tight, void* destination, GLsizei width, + GLsizei height, Uint64 bytesPerPixel, + const PixelStoreParameters& pack); + + // ID-49. True when the destination layout IS the tight layout, which is the only condition + // under which EmitReadPixels may read the reply straight into the application pointer and + // skip the bounce. Exported because the FAST PATH and the SCATTER have to agree, and the + // only honest way to state that is to drive both and compare - a case that tested either + // alone would pass a predicate that said yes to a layout the scatter would have rearranged. + Bool ReadbackPackStateIsTightForTest(GLsizei width, Uint64 bytesPerPixel, + const PixelStoreParameters& pack); + + // M2 / codex 11. True exactly when the readback reply is OK and carries the read's own exact + // extent (CONTRACT-P5 row 23). EmitReadPixels calls this and Fatals by name when it is false + // - a short OK reply, or a DECLINE/ERROR with a zero payload, is refused rather than scattered + // as pixels. Exposed so the control drives the production predicate (R-16), not a copy: pass + // 0=OK / 1=DECLINED / 2=ERROR as `status`. + Bool ReadbackReplyIsComplete(Int32 status, Uint64 replySize, Uint64 expected); + } // namespace MobileGL::MG_Remote::Client diff --git a/MobileGL/MG_Remote/Client/SlotCaps.h b/MobileGL/MG_Remote/Client/SlotCaps.h new file mode 100644 index 00000000..63bcf4b0 --- /dev/null +++ b/MobileGL/MG_Remote/Client/SlotCaps.h @@ -0,0 +1,135 @@ +// MobileGL - MobileGL/MG_Remote/Client/SlotCaps.h +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +// THE 41 NULL CHECKS. Owner: package c1 (CONTRACT-P5.md §7, ID-14). +// +// FORTY-ONE OF THE SIXTY-NINE GLFunctionsTable SLOTS ARE NULL-CHECKED AT THEIR CALL SITE, AND +// SEVERAL OF THOSE CHECKS ARE CAPABILITY PROBES RATHER THAN SAFETY CHECKS. R-4 forbids a null +// slot in the client's emit table - so in that table every one of those probes would answer +// "supported", and whatever fallback sits behind it would silently disappear. That is not a +// theoretical risk: it is how a split lane produces a plausible picture for the wrong reason. +// ARCHITECTURE.md:114 already said what replaces the probe - "CallMask replaces 'is this table +// slot null' as the implicit capability probe" - and this header is the concrete list. +// +// TWO SPELLINGS, AND WHICH ONE A SITE TAKES IS DECIDED BY THE SLOT'S CLASS, NOT BY TASTE: +// +// MGL_BACKEND_SLOT_CAP(Slot, CapBit) +// The capability has a published bit. Under split the answer is the SERVER's bit; +// under monolith it is exactly today's null check, character for character. +// The three cases CONTRACT-P5.md §7 names by hand are all of this shape: +// GL_Query.cpp:481 / :558 / :785 BeginOcclusionQuery -> kCapOcclusionQuery +// GL_Query.cpp:534 BeginXfbPrimitivesQuery -> kCapXfbPrimitivesQuery +// PipeFill.cpp SubDataResident -> kCapResidentSubData +// (the third is not a GLFunctionsTable slot but an op-table one, so it reads the bit +// directly in PipeFill.cpp rather than through this header). +// +// MGL_BACKEND_SLOT_LOCAL(Slot) +// The capability has NO published bit and the slot is CONTRACT-P5.md §7 class C - +// Fatal{UnmigratedVerb} in the client's table. Under split the honest answer is +// "absent", which is precisely what the monolith nullptr meant, so every fallback the +// site already has survives instead of being replaced by an abort. Under monolith it is +// again today's null check. +// +// WHY "ABSENT" AND NOT "LET IT FATAL". A Fatal is loud, and for the 28 UNGUARDED slots it is +// strictly better than today (a null there is already an immediate crash with no diagnostic). +// But a guarded site is guarded because the frontend has a real answer for the absent case - +// always-signaled syncs, a CPU readback, CPU primitive accounting - and turning that answer +// into an abort is a behaviour change nobody asked for, in the direction that stops a lane +// dead. The class-C slot IS absent from this client; saying so is the accurate answer, not a +// weakening of R-4. +// +// THE TEST THAT DECIDES WHETHER A GUARDED SITE IS CONVERTED AT ALL, and it is the half of the +// walk the contract leaves to whoever does it: A PROBE MOVES ONLY WHERE "ABSENT" IS A CORRECT +// AND SUFFICIENT ANSWER. Where the fallback behind the probe produces a RIGHT result, "absent" +// is the accurate description of a client that does not have the slot, and converting keeps +// the lane running. Where the fallback produces a SILENTLY WRONG result, converting would +// manufacture exactly the defect R-4 exists to prevent, and the probe is left alone so the +// class-C slot Fatals by name instead. +// +// Converted, because the fallback is right: +// GL_Sync.cpp:59 FenceSync -> always-signaled syncs, which the table's own +// header documents as the fallback and which GL +// permits; every other sync site is already +// guarded on syncObject->backendHandle, so this +// one gate carries the whole family. +// GL_Texture.cpp:6537 GetTextureImage -> the frontend's own CPU readback, which is exact +// GL_Texture.cpp:6799 GetTexImage -> the same +// GL_Getter.cpp x2 GetGpuTimestampNs -> 0, which BackendObject.h:192 already names as +// the unsupported answer +// GL_Query.cpp the four query probes -> CPU primitive accounting / target rejection / +// COUNTER_BITS 0, all of them spec answers +// +// NOT converted, deliberately, because "absent" would be wrong rather than quiet: +// GL_Drawing.cpp:1274/:1371/:1420/:1435/:1641/:1673 the transform-feedback span family. The +// frontend reads a null EndTransformFeedback as "this backend does NOT own the capture, +// so reorder the captured records for it" (FixupGsStripCaptureOrder, :1290). Under split +// the server's backend DOES own it, so answering "absent" would reorder a capture that +// was already in GL order - a silently corrupt buffer. Transform feedback is off P5's +// reduced path (BRIEF §4's exclusion list) and its slots are class C, so the first XFB +// call aborts by name, which is the outcome R-4 asks for. +// GL_Drawing.cpp:844 PatchParameteri. "Absent" means the patch size is never set and every +// tessellation draw silently uses the previous one. Class C; it aborts by name. +// +// WHAT THIS HEADER DELIBERATELY DOES NOT DO. It does not touch the 28 unguarded slots: those +// have no probe to convert, and calling one reaches Fatal{UnmigratedVerb, ""} by name, +// which is R-4's intent. And it does not invent a cap bit - a new MGPCapBit is an +// MGPipeTypes.h edit and that file is c0's, so a family that needs one goes through the +// integrator (the XFB span family is the first that will). +// +// G1: in a build without MOBILEGL_BUILD_DISAGGREGATED both macros expand to the null check the +// site already had, so the pull build's code generation is unchanged. + +#pragma once +#include + +#include + +#if MOBILEGL_BUILD_DISAGGREGATED +#include +#include + +#define MGL_BACKEND_SLOT_CAP(Slot, CapBit) \ + (::MobileGL::MG_Config::Transport != ::MobileGL::MG_Config::TransportMode::Monolith \ + ? ::MobileGL::MG_Remote::Client::CapsMirrorInstance().HasCap(CapBit) \ + : ::MobileGL::MG_Backend::gBackendFunctionsTable.GL.Slot != nullptr) + +#define MGL_BACKEND_SLOT_LOCAL(Slot) \ + (::MobileGL::MG_Config::Transport == ::MobileGL::MG_Config::TransportMode::Monolith && \ + ::MobileGL::MG_Backend::gBackendFunctionsTable.GL.Slot != nullptr) + +// The POINTER-valued forms, for the several sites shaped `if (const auto f = TABLE.GL.Slot)`. +// They exist for G1 and for nothing else: a site rewritten from that shape into +// `if (MGL_BACKEND_SLOT_CAP(...)) { const auto f = TABLE.GL.Slot; ... }` is semantically the +// same and generated DIFFERENT CODE - the first measurement of this change moved +// GL_Getter.cpp's GetInteger64v by -150 bytes and GetIntegerv by +2, which is two "resized" +// symbols and a red G1. In a pull build these two expand to the slot expression ITSELF, so the +// init-statement survives verbatim and the pull build's code generation cannot move. +#define MGL_BACKEND_SLOT_PTR_CAP(Slot, CapBit) \ + (::MobileGL::MG_Config::Transport != ::MobileGL::MG_Config::TransportMode::Monolith \ + ? (::MobileGL::MG_Remote::Client::CapsMirrorInstance().HasCap(CapBit) \ + ? ::MobileGL::MG_Backend::gBackendFunctionsTable.GL.Slot \ + : nullptr) \ + : ::MobileGL::MG_Backend::gBackendFunctionsTable.GL.Slot) + +#define MGL_BACKEND_SLOT_PTR_LOCAL(Slot) \ + (::MobileGL::MG_Config::Transport != ::MobileGL::MG_Config::TransportMode::Monolith \ + ? nullptr \ + : ::MobileGL::MG_Backend::gBackendFunctionsTable.GL.Slot) + +#else + +#define MGL_BACKEND_SLOT_CAP(Slot, CapBit) \ + (::MobileGL::MG_Backend::gBackendFunctionsTable.GL.Slot != nullptr) +#define MGL_BACKEND_SLOT_LOCAL(Slot) \ + (::MobileGL::MG_Backend::gBackendFunctionsTable.GL.Slot != nullptr) +#define MGL_BACKEND_SLOT_PTR_CAP(Slot, CapBit) \ + (::MobileGL::MG_Backend::gBackendFunctionsTable.GL.Slot) +#define MGL_BACKEND_SLOT_PTR_LOCAL(Slot) \ + (::MobileGL::MG_Backend::gBackendFunctionsTable.GL.Slot) + +#endif diff --git a/MobileGL/MG_Remote/Client/WireTables.cpp b/MobileGL/MG_Remote/Client/WireTables.cpp new file mode 100644 index 00000000..256e2f67 --- /dev/null +++ b/MobileGL/MG_Remote/Client/WireTables.cpp @@ -0,0 +1,596 @@ +// MobileGL - MobileGL/MG_Remote/Client/WireTables.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 + +// The ENCODE TWIN of gMGPipeWireRecordApply: thirty-seven emitters that turn a table call into +// a wire record. Owner: package c1 (P5 ruling R-17). See WireTables.h for the install order +// and MG_Pipe/PipeRoute.h for what R-17 actually cost. +// +// EVERY EMITTER IS THE SAME FOUR STEPS, and the macros below exist so that a reader can check +// thirty-seven rows against PipeTables.inc in one pass instead of reading thirty-seven bodies: +// +// 1. require a session - a slot that fell through to a driver this role does not have is +// the failure R-4 exists to prevent, and there is no fall-through here either; +// 2. stage the blob, if the row has one, and name the SEG_STAGE run in the payload's own +// MGPBlobRef - which is why the payload is COPIED: the table hands it over const, and +// the blobref is the one field the client must write after the caller is done with it; +// 3. EmitAndWait, which is the barrier's wait and the reply's wait at once (R-3/R-5); +// 4. post the answer, for the rows that have one, into MG_Pipe's reply mailbox. +// +// WHAT IS DELIBERATELY NOT HERE. b1's `PushPersistentMapsBeforeVerb` / `MarkGpuWritesFor*` are +// NOT called from these thirty-seven. They are pre-VERB hooks and these are not verbs: they +// are the resource, CSO and state records that a verb is later drawn against. The five class-B +// verbs in EmitTables.cpp call them, once each, immediately before their record, which is the +// ordering b1's B-1 fix depends on. Calling them here as well would push a persistent map +// before every `set_dynamic_state` - hundreds of times a frame, and each one a real record. + +#include "WireTables.h" + +#if MOBILEGL_BUILD_DISAGGREGATED + +#include "ClientSession.h" + +#include "../Server/ServerLoop.h" + +#include +#include +#include +#include + +#include +#include +#include + +namespace MobileGL::MG_Remote::Client { + + using MG_Pipe::MGPWireOp; + + // TABLE 3's ROLE SPLIT, AS A RUNTIME CHECK. gMGPipeScreen / gMGPipeContext are PROCESS + // globals and under `inproc` the server role is a thread in this same process, so the + // apply thread running the server's own backend - the EGL bring-up, InitCapabilities, + // the applier - reaches these very emitters. A record published there would be waited + // for by the thread that is supposed to apply it: `Fatal{BarrierTimeout, + // "ResourceRespecify"}` from `mgl-srv-apply`, thirty seconds into bring-up, which is + // exactly how this was found. + // + // THE ANSWER IS NOT "SUPPRESS THE RECORD" - it is "run the server's own code", because + // on that thread this process IS the server and the applier is one call away. It is + // the same thing PipeWireCodec does on the decode side, where every arm calls + // MGPipeApply* directly and never goes through a table. + // + // Under `spawn` (P6) the predicate is constantly false in the client process and + // constantly true in the server's, so this costs one atomic load and changes nothing. + // + // NOT in the anonymous namespace, because M5 needs it from MG_Impl/Pipe/PipeFill.cpp too + // (its split-only respecify/flush branches must not run on the apply thread). Declared in + // WireTables.h. + Bool RunsAsTheServerRole() { return Server::ServerLoop::OnApplyThread(); } + + namespace { + + Uint64 g_emitted = 0; + Uint64 g_declined = 0; + + // TEARDOWN REFUSAL (codex 4). ClientSession::Stop marks the routed tables uninstalled + // BEFORE it frees the rings, the segments and the transport - so the window between then + // and the moment the monolith adapters go back is one in which a routed GL-thread call + // must not run the applier on the caller (the forbidden path table 3 draws) and must not + // reach a half-freed ring. Round 2 reinstalled the monolith adapters at the top of Stop, + // which is exactly running the applier on the caller; a routed mutation after + // UninstallClientWireTables() moved no wire ordinal and never refused (the cross-family + // verifier reproduced it). So Uninstall now RAISES THIS FLAG and leaves the wire rows in + // place; RequireSession below reads it and refuses by name before it touches anything. + // Atomic because the apply thread's own role check races a GL-thread teardown. + std::atomic g_clientTablesUninstalled{false}; + + ClientSession& RequireSession(const char* row) { + // BEFORE the session lookup and before any ring access. A routed call that arrives + // once Stop has begun tearing the session down is refused by name rather than run + // on the caller's thread - the applier is server-exclusive (table 3), and a wire + // emit into a ring being freed is a use-after-free. The monolith adapters are put + // back only as the LAST step of teardown, for the at-exit ~BufferObject deletes that + // legitimately reach a process with no session (see ReinstallMonolithAfterTeardown). + if (g_clientTablesUninstalled.load(std::memory_order_acquire)) { + MGLOG_F("MGPipe: Fatal{ClientTablesUninstalled, \"%s\"} - a routed call reached the " + "client wire tables while ClientSession::Stop was tearing the session " + "down. R-4/R-17: the applier is server-exclusive and the rings this emit " + "would use are being freed, so the call is refused by name rather than run " + "on the caller", + row); + std::abort(); + } + ClientSession* session = ClientSession::Active(); + if (session == nullptr) { + MGLOG_F("MGPipe: Fatal{NoClientSession, \"%s\"} - the client wire tables are " + "installed but no ClientSession is active. A row may not fall through to " + "a driver this role does not have", + row); + std::abort(); + } + return *session; + } + + // Stages a mandatory blob. `StageBytes` Fatals on a zero size by design (R-2.2: "the + // record declared no blob" and "the record declared an empty blob" must not be spelled + // the same way on a wire), so a row whose decoder calls RequireDeclaredBlob or + // ResolveOrFatal is checked HERE, on the producing side, where the row has a name. + MG_Pipe::MGPBlobRef StageRequired(ClientSession& session, const char* row, + const void* bytes, Uint64 count) { + if (bytes == nullptr || count == 0) { + MGLOG_F("MGPipe: Fatal{BlobMissing, \"%s\"} - the row's decoder requires a " + "declared blob and the call site handed over %llu bytes at %p. Under " + "monolith the companion pointer carries them; under split they have to " + "be staged, and there is nothing to stage", + row, static_cast(count), bytes); + std::abort(); + } + return session.Encoder().StageBytes(bytes, count); + } + + // Stages an OPTIONAL blob: the two sub-data rows, whose decoders resolve only when the + // record's own size field says there are bytes. All three fields zero is the wire's + // "no blob declared", and CheckBlobIsHonest refuses any other spelling of it. + MG_Pipe::MGPBlobRef StageOptional(ClientSession& session, const void* bytes, Uint64 count) { + if (bytes == nullptr || count == 0) return MG_Pipe::MGPBlobRef{}; + return session.Encoder().StageBytes(bytes, count); + } + + // --------------------------------------------------------------------------------- + // The three regular shapes + // --------------------------------------------------------------------------------- + +#define MGP_WIRE_PLAIN(Name, Payload, Table) \ + void Wire_##Name(const MG_Pipe::Payload* payload) { \ + if (RunsAsTheServerRole()) { MG_Pipe::MGPipeMonolith##Table().Name(payload); return; } \ + ClientSession& session = RequireSession(#Name); \ + session.EmitAndWait(MGPWireOp::Name, payload, sizeof(*payload), nullptr, 0, nullptr, 0, \ + nullptr); \ + ++g_emitted; \ + } + +#define MGP_WIRE_BLOB(Name, Payload, BlobMember) \ + void Wire_##Name(const MG_Pipe::Payload* payload, const void* blobBytes, \ + Uint64 blobByteCount) { \ + if (RunsAsTheServerRole()) { \ + MG_Pipe::MGPipeMonolithContext().Name(payload, blobBytes, blobByteCount); \ + return; \ + } \ + ClientSession& session = RequireSession(#Name); \ + MG_Pipe::Payload record = *payload; \ + record.BlobMember = StageRequired(session, #Name, blobBytes, blobByteCount); \ + session.EmitAndWait(MGPWireOp::Name, &record, sizeof(record), nullptr, 0, nullptr, 0, \ + nullptr); \ + ++g_emitted; \ + } + +#define MGP_WIRE_TAIL(Name, Payload, TailType) \ + void Wire_##Name(const MG_Pipe::Payload* payload, const void* varTail, Uint32 varTailCount) { \ + if (RunsAsTheServerRole()) { \ + MG_Pipe::MGPipeMonolithContext().Name(payload, varTail, varTailCount); \ + return; \ + } \ + ClientSession& session = RequireSession(#Name); \ + session.EmitAndWait(MGPWireOp::Name, payload, sizeof(*payload), varTail, \ + static_cast(varTailCount) * sizeof(MG_Pipe::TailType), \ + nullptr, 0, nullptr); \ + ++g_emitted; \ + } + + // -- screen ------------------------------------------------------------------- + MGP_WIRE_PLAIN(ResourceDestroy, MGPHandleOnly, Screen) + MGP_WIRE_PLAIN(UnmapPersistent, MGPHandleOnly, Screen) + + // -- context, plain ----------------------------------------------------------- + MGP_WIRE_PLAIN(BindRenderState, MGPBindRenderState, Context) + MGP_WIRE_PLAIN(DeleteRenderState, MGPHandleOnly, Context) + MGP_WIRE_PLAIN(BindVertexElements, MGPHandleOnly, Context) + MGP_WIRE_PLAIN(DeleteVertexElements, MGPHandleOnly, Context) + MGP_WIRE_PLAIN(DeleteSamplerState, MGPHandleOnly, Context) + MGP_WIRE_PLAIN(CreateSamplerView, MGPSamplerView, Context) + MGP_WIRE_PLAIN(DeleteSamplerView, MGPHandleOnly, Context) + MGP_WIRE_PLAIN(BindShaderState, MGPHandleOnly, Context) + MGP_WIRE_PLAIN(DeleteShaderState, MGPHandleOnly, Context) + MGP_WIRE_PLAIN(SetDrawProgram, MGPHandleOnly, Context) + MGP_WIRE_PLAIN(SetDispatchProgram, MGPHandleOnly, Context) + MGP_WIRE_PLAIN(SetFramebufferState, MGPFramebufferState, Context) + MGP_WIRE_PLAIN(SetIndexBuffer, MGPIndexBuffer, Context) + MGP_WIRE_PLAIN(SetPixelPackState, MGPPixelPackState, Context) + MGP_WIRE_PLAIN(SetPatchState, MGPPatchState, Context) + + // -- context, mandatory blob -------------------------------------------------- + MGP_WIRE_BLOB(CreateRenderState, MGPRenderStateDesc, Blob) + MGP_WIRE_BLOB(CreateVertexElements, MGPVertexElements, Blob) + MGP_WIRE_BLOB(CreateSamplerState, MGPSamplerDesc, Parameters) + MGP_WIRE_BLOB(SetGlobalConstants, MGPGlobalConstants, Blob) + + // -- context, variable tail --------------------------------------------------- + MGP_WIRE_TAIL(SetVertexBuffers, MGPVertexBuffers, MGPVertexBuffer) + MGP_WIRE_TAIL(SetSamplerViews, MGPSamplerViews, MGPBoundView) + MGP_WIRE_TAIL(BindSamplerStates, MGPSamplerStates, MGPipeHandle) + MGP_WIRE_TAIL(SetShaderImages, MGPShaderImages, MGPImageView) + MGP_WIRE_TAIL(SetVertexAttribDefaults, MGPVertexAttribDefaults, MGPAttribValue) + +#undef MGP_WIRE_PLAIN +#undef MGP_WIRE_BLOB +#undef MGP_WIRE_TAIL + + // -- the rows that fit none of the three shapes -------------------------------- + + // set_dynamic_state. AN OPTIONAL BLOB, NOT A MANDATORY ONE (round-2 regression, the + // census's Fatal{BlobMissing, "SetDynamicState"}). EmitRenderState (PipeFill.cpp:2405- + // 2421) sends a set_dynamic_state whenever the RenderState VERSION moved, and when the + // chunk-level suppressor finds NO dynamic chunk changed it sends the 32-byte header with + // ChunkMask == 0 and blobByteCount == 0 - a version-only update, which the comment there + // calls out as deliberate. The generic MGP_WIRE_BLOB wrapper stages through StageRequired, + // which Fatals on a zero count, so a legal header-only update aborted the process with a + // blob-shape complaint that had nothing to do with the actual state of the pipe. The + // DECODER already handles it: PipeWireCodec.cpp:1571-1587 takes the `ChunkMask == 0` arm + // (CheckBlobIsHonest, no blob resolved) and applies the header alone. So the emitter is + // the only side that was wrong; it stages OPTIONALLY, exactly like the two sub-data rows. + // A non-empty mask still stages (blobByteCount > 0), so the reduced-path scenarios, whose + // first draw is freshly primed with every chunk, are byte-for-byte unchanged. + void Wire_SetDynamicState(const MG_Pipe::MGPDynamicState* payload, const void* blobBytes, + Uint64 blobByteCount) { + if (RunsAsTheServerRole()) { + MG_Pipe::MGPipeMonolithContext().SetDynamicState(payload, blobBytes, blobByteCount); + return; + } + ClientSession& session = RequireSession("SetDynamicState"); + MG_Pipe::MGPDynamicState record = *payload; + record.Blob = StageOptional(session, blobBytes, blobByteCount); + session.EmitAndWait(MGPWireOp::SetDynamicState, &record, sizeof(record), nullptr, 0, + nullptr, 0, nullptr); + ++g_emitted; + } + + // set_residual_value_state. CONTRACT-P5 table 1 row 6: the applier takes a frontend + // `ResidualValueBlock&` and `MGPResidualValueState` is never instantiated on the live + // path, so the encoder invents BOTH the record fill and the blob fill. The block IS + // the blob, whole - the decoder requires exactly sizeof(ResidualValueBlock) and says + // why ("a size that only ever ratchets down makes a short read silently lose + // CapabilityBits"), so the two sides state the same number from the same header. + void Wire_SetResidualValueState(const MG_Pipe::MGPResidualValueState* payload, + const void* blobBytes, Uint64 blobByteCount) { + if (RunsAsTheServerRole()) { + MG_Pipe::MGPipeMonolithContext().SetResidualValueState(payload, blobBytes, + blobByteCount); + return; + } + ClientSession& session = RequireSession("SetResidualValueState"); + MG_Pipe::MGPResidualValueState record = *payload; + record.Blob = StageRequired(session, "SetResidualValueState", blobBytes, blobByteCount); + session.EmitAndWait(MGPWireOp::SetResidualValueState, &record, sizeof(record), nullptr, + 0, nullptr, 0, nullptr); + ++g_emitted; + } + + // resource_readback. kReplySlot, and the answer is COMPLETION only: the bytes travel + // server -> client in SEG_EVENT through OnBufferWriteback, because the destination is + // the client's shadow and its size is the resource's, not a slot's (CONTRACT-P5 table 1 + // row 22). So the reply buffer is deliberately {nullptr, 0} and the wait is what makes + // the writeback already drained by the time this returns. + void Wire_ResourceReadback(const MG_Pipe::MGPReadback* payload, MG_Pipe::MGPReplySlot* reply) { + if (RunsAsTheServerRole()) { MG_Pipe::MGPipeMonolithContext().ResourceReadback(payload, reply); return; } + ClientSession& session = RequireSession("ResourceReadback"); + Int32 status = 0; + const Uint64 seq = session.EmitAndWait(MGPWireOp::ResourceReadback, payload, + sizeof(*payload), nullptr, 0, nullptr, 0, + &status); + reply->Id = seq; + MG_Pipe::MGPipePostReply(*reply, status, 0); + ++g_emitted; + } + + // ---- the three acceptance rows that fit a generated signature ---------------- + // + // THE ANSWER IS THE SERVER'S AND NOTHING ELSE. `EmitAndWait` returns the record's seq + // and fills `status` from the reply slot the server stamped; DECLINED is `false` and OK + // is `true`, and neither is derived from anything this side knows. R-5 exists because + // "always accept" is ID-39's 66 lost DirectVulkan uploads and "accept if we emitted" is + // the same bug wearing a counter. + + void Wire_ResourceCreate(const MG_Pipe::MGPResourceDesc* payload, MG_Pipe::MGPReplySlot* reply) { + if (RunsAsTheServerRole()) { MG_Pipe::MGPipeMonolithScreen().ResourceCreate(payload, reply); return; } + ClientSession& session = RequireSession("ResourceCreate"); + Int32 status = 0; + const Uint64 seq = session.EmitAndWait(MGPWireOp::ResourceCreate, payload, + sizeof(*payload), nullptr, 0, nullptr, 0, + &status); + reply->Id = seq; + MG_Pipe::MGPipePostReply(*reply, status, status == 0 ? 1u : 0u); + ++g_emitted; + if (status == 1) ++g_declined; + } + + void Wire_SetTextureParams(const MG_Pipe::MGPTextureParams* payload, MG_Pipe::MGPReplySlot* reply) { + if (RunsAsTheServerRole()) { MG_Pipe::MGPipeMonolithContext().SetTextureParams(payload, reply); return; } + ClientSession& session = RequireSession("SetTextureParams"); + Int32 status = 0; + const Uint64 seq = session.EmitAndWait(MGPWireOp::SetTextureParams, payload, + sizeof(*payload), nullptr, 0, nullptr, 0, + &status); + reply->Id = seq; + MG_Pipe::MGPipePostReply(*reply, status, status == 0 ? 1u : 0u); + ++g_emitted; + if (status == 1) ++g_declined; + } + + void Wire_ResourceSubData(const MG_Pipe::MGPSubData* payload, const void* blobBytes, + Uint64 blobByteCount, const void* varTail, Uint32 varTailCount, + MG_Pipe::MGPReplySlot* reply) { + if (RunsAsTheServerRole()) { + MG_Pipe::MGPipeMonolithContext().ResourceSubData(payload, blobBytes, blobByteCount, + varTail, varTailCount, reply); + return; + } + ClientSession& session = RequireSession("ResourceSubData"); + MG_Pipe::MGPSubData record = *payload; + record.Blob = StageOptional(session, blobBytes, blobByteCount); + Int32 status = 0; + const Uint64 seq = session.EmitAndWait( + MGPWireOp::ResourceSubData, &record, sizeof(record), varTail, + static_cast(varTailCount) * sizeof(MG_Pipe::MGPSubRegion), nullptr, 0, + &status); + reply->Id = seq; + MG_Pipe::MGPipePostReply(*reply, status, status == 0 ? 1u : 0u); + ++g_emitted; + if (status == 1) ++g_declined; + } + + void Wire_BufferSubDataResident(const MG_Pipe::MGPSubData* payload, const void* blobBytes, + Uint64 blobByteCount) { + if (RunsAsTheServerRole()) { + MG_Pipe::MGPipeMonolithContext().BufferSubDataResident(payload, blobBytes, + blobByteCount); + return; + } + ClientSession& session = RequireSession("BufferSubDataResident"); + MG_Pipe::MGPSubData record = *payload; + record.Blob = StageOptional(session, blobBytes, blobByteCount); + session.EmitAndWait(MGPWireOp::BufferSubDataResident, &record, sizeof(record), nullptr, + 0, nullptr, 0, nullptr); + ++g_emitted; + } + + // ---- the four escapes -------------------------------------------------------- + + // resource_respecify. R-13.3: `initialBytes` is ALWAYS nullptr under split and the + // initial content arrives as resource_subdata records immediately after this one. The + // caller's bytes are therefore not dropped - they are re-expressed - and PipeFill.cpp's + // emitter is where that happens, because the chunking walk that has to size them + // (MGPipeForEachSubDataRecordRange) lives there. What this emitter owes is the REFUSAL: + // a non-null pointer arriving here means the call site was not converted, and silently + // ignoring it would lose exactly the bytes R-13.3 promised would follow. + Bool Wire_Escape_ResourceRespecify(const MG_Pipe::MGPResourceDesc* desc, + const void* initialBytes, + const MG_Pipe::MGPRespecifiedLevel* level) { + if (RunsAsTheServerRole()) { + return MG_Pipe::MGPipeMonolithEscapes().ResourceRespecify(desc, initialBytes, level); + } + ClientSession& session = RequireSession("ResourceRespecify"); + if (initialBytes != nullptr) { + MGLOG_F("MGPipe: Fatal{UncarriedInitialBytes, \"resource_respecify\"} - a call " + "site handed initial content to a split respecify. R-13.3 rules that " + "initialBytes never crosses and that the content follows as " + "resource_subdata; a caller that still passes it has bytes nothing will " + "carry"); + std::abort(); + } + // The scope rides in the descriptor's own pads (CONTRACT-P5 table 1 row 19b, LANDED) + // and is written only through MGPipeSetRespecifiedLevel - three fields are one + // value, and an open-coded writer that forgets the presence byte says "level 0 of + // upload target 0" where it meant "the whole resource". + MG_Pipe::MGPResourceDesc record = *desc; + if (level != nullptr) { + MG_Pipe::MGPipeSetRespecifiedLevel(record, level->UploadTarget, level->Level); + } else { + MG_Pipe::MGPipeClearRespecifiedLevel(record); + } + Int32 status = 0; + const Uint64 seq = + session.EmitAndWait(MGPWireOp::ResourceRespecify, &record, sizeof(record), nullptr, + 0, nullptr, 0, &status); + (void)seq; + ++g_emitted; + // ERROR IS NOT A DECLINE (M4 / codex 5 / R-5). status is 0 OK / 1 DECLINED / 2 ERROR; + // an escape may not fold 2 into `false`, which is what a bare `return status == 0` + // did - a transport fault then read as "the server said no". Every reply-owning row + // - the generated acceptance rows through MGPipeTakeReplyBool, and now these two + // escapes - answers ERROR with the same named Fatal. + if (status == 2) { + MGLOG_F("MGPipe: Fatal{ReplyError, \"resource_respecify\"} - the row answered " + "ERROR, which is not an acceptance answer; folding it into accepted or " + "refused would make a transport fault look like a resource decision"); + std::abort(); + } + if (status == 1) ++g_declined; + return status == 0; + } + + // resource_flush_range. R-13.2: it carries NO bytes under split - it is a + // {range, AccessFlags} control record and the bytes of exactly that range arrive ahead + // of it as resource_subdata. Same refusal as above, for the same reason: a blobref here + // would be "a second, forgeable way to say the same thing". + void Wire_Escape_ResourceFlushRange(const MG_Pipe::MGPFlushRange* record, + const void* bytes) { + if (RunsAsTheServerRole()) { + MG_Pipe::MGPipeMonolithEscapes().ResourceFlushRange(record, bytes); + return; + } + ClientSession& session = RequireSession("ResourceFlushRange"); + (void)bytes; // ruled uncarried; the emitter in PipeFill.cpp sends the range first + session.EmitAndWait(MGPWireOp::ResourceFlushRange, record, sizeof(*record), nullptr, 0, + nullptr, 0, nullptr); + ++g_emitted; + } + + // map_persistent. R-6/R-2.4: the split answer is a CONSTANT DECLINE, and it still costs + // a record, because the server has to know the client asked - the applier's + // MapPersistentRoundtrips counter is defined as "one per storage definition in both + // modes" and a client that answered locally would zero it. `size` and `seedBytes` have + // no carrier (MGPHandleOnly is {Handle, Kind}) and need none: nothing is minted. + void* Wire_Escape_MapPersistent(const MG_Pipe::MGPHandleOnly* handle, Uint64 size, + const void* seedBytes) { + if (RunsAsTheServerRole()) { + return MG_Pipe::MGPipeMonolithEscapes().MapPersistent(handle, size, seedBytes); + } + ClientSession& session = RequireSession("MapPersistent"); + (void)size; + (void)seedBytes; + Int32 status = 0; + session.EmitAndWait(MGPWireOp::MapPersistent, handle, sizeof(*handle), nullptr, 0, + nullptr, 0, &status); + ++g_emitted; + // ERROR IS NOT A DECLINE (M4 / codex 5 / R-5), the same rule the respecify escape and + // the generated acceptance rows obey: status 2 is a transport fault, and returning + // nullptr for it would make it indistinguishable from R-6's legitimate decline. + if (status == 2) { + MGLOG_F("MGPipe: Fatal{ReplyError, \"map_persistent\"} - the row answered ERROR, " + "which is not an acceptance answer; a transport fault is not a resource " + "decision and may not be folded into the decline R-6 predicts"); + std::abort(); + } + if (status == 1) ++g_declined; + // NOT "always nullptr": the answer is READ. R-6 says the server declines, and the + // day it stops declining this returns what it actually said rather than what the + // ruling predicted. + if (status == 0) { + MGLOG_F("MGPipe: Fatal{UnexpectedMapAccept, \"map_persistent\"} - the server " + "accepted a persistent map under split. R-6 makes the split answer a " + "constant decline because there is no way to hand a host pointer across " + "a process boundary in P5; a pointer arriving here is one this client " + "cannot dereference"); + std::abort(); + } + return nullptr; + } + + // create_shader_state. SEVEN blobrefs and TWO typed frontend pointers; one blobBytes + // pair cannot express seven runs. The serializer already exists and no package may + // write a second one (CONTRACT-P5 table 1 row 3): EncodeProgramArtifacts produces one + // archive, the decoder's DecodeProgramArtifacts consumes it, and the six per-stage runs + // stay UNDECLARED because the modules already travel inside the archive - a declared + // Spirv[i] is Fatal on the far side rather than ignored. + void Wire_Escape_CreateShaderState(const MG_Pipe::MGPProgramDesc* desc, + const MG_State::GLState::LinkArtifacts* link, + const MG_State::GLState::SpirvArtifacts* spirv) { + if (RunsAsTheServerRole()) { + MG_Pipe::MGPipeMonolithEscapes().CreateShaderState(desc, link, spirv); + return; + } + ClientSession& session = RequireSession("CreateShaderState"); + if (link == nullptr || spirv == nullptr) { + MGLOG_F("MGPipe: Fatal{ArtefactsMissing, \"create_shader_state\"} - the record's " + "two typed companions are null. Under monolith the applier reads the " + "modules out of spirv->generatedSpirv; under split there is nothing to " + "serialise, and emitting the record anyway would create a CSO with no " + "code"); + std::abort(); + } + // EncodeProgramArtifacts APPENDS and never fails - everything it walks is owned + // plain data - so an empty archive means the two structs themselves were empty, + // which is a linked program with no artefacts and is not a codec question. + Vector archive; + MG_State::GLState::EncodeProgramArtifacts(*link, *spirv, archive); + if (archive.empty()) { + MGLOG_F("MGPipe: Fatal{ArchiveEmpty, \"create_shader_state\"} - the program's " + "artefacts serialised to nothing"); + std::abort(); + } + MG_Pipe::MGPProgramDesc record = *desc; + for (Uint32 i = 0; i < 6; ++i) record.Spirv[i] = MG_Pipe::MGPBlobRef{}; + record.Reflection = session.Encoder().StageBytes(archive.data(), archive.size()); + session.EmitAndWait(MGPWireOp::CreateShaderState, &record, sizeof(record), nullptr, 0, + nullptr, 0, nullptr); + ++g_emitted; + } + + } // namespace + + void InstallClientWireTables() { + using namespace MG_Pipe; + + // A fresh install means the routed tables are live again: a Start after a previous + // session's Stop clears the teardown-refusal flag so its own routed calls are not + // refused. (Stop already puts the monolith adapters back and clears the flag; this is + // the belt to that braces.) + g_clientTablesUninstalled.store(false, std::memory_order_release); + + gMGPipeScreen.ResourceCreate = &Wire_ResourceCreate; + gMGPipeScreen.ResourceDestroy = &Wire_ResourceDestroy; + gMGPipeScreen.UnmapPersistent = &Wire_UnmapPersistent; + + gMGPipeContext.CreateRenderState = &Wire_CreateRenderState; + gMGPipeContext.BindRenderState = &Wire_BindRenderState; + gMGPipeContext.DeleteRenderState = &Wire_DeleteRenderState; + gMGPipeContext.CreateVertexElements = &Wire_CreateVertexElements; + gMGPipeContext.BindVertexElements = &Wire_BindVertexElements; + gMGPipeContext.DeleteVertexElements = &Wire_DeleteVertexElements; + gMGPipeContext.CreateSamplerState = &Wire_CreateSamplerState; + gMGPipeContext.DeleteSamplerState = &Wire_DeleteSamplerState; + gMGPipeContext.CreateSamplerView = &Wire_CreateSamplerView; + gMGPipeContext.DeleteSamplerView = &Wire_DeleteSamplerView; + gMGPipeContext.BindShaderState = &Wire_BindShaderState; + gMGPipeContext.DeleteShaderState = &Wire_DeleteShaderState; + gMGPipeContext.SetDrawProgram = &Wire_SetDrawProgram; + gMGPipeContext.SetDispatchProgram = &Wire_SetDispatchProgram; + gMGPipeContext.SetDynamicState = &Wire_SetDynamicState; + gMGPipeContext.SetFramebufferState = &Wire_SetFramebufferState; + gMGPipeContext.SetVertexBuffers = &Wire_SetVertexBuffers; + gMGPipeContext.SetIndexBuffer = &Wire_SetIndexBuffer; + gMGPipeContext.SetSamplerViews = &Wire_SetSamplerViews; + gMGPipeContext.BindSamplerStates = &Wire_BindSamplerStates; + gMGPipeContext.SetShaderImages = &Wire_SetShaderImages; + gMGPipeContext.SetGlobalConstants = &Wire_SetGlobalConstants; + gMGPipeContext.SetVertexAttribDefaults = &Wire_SetVertexAttribDefaults; + gMGPipeContext.SetPixelPackState = &Wire_SetPixelPackState; + gMGPipeContext.SetPatchState = &Wire_SetPatchState; + gMGPipeContext.SetResidualValueState = &Wire_SetResidualValueState; + gMGPipeContext.SetTextureParams = &Wire_SetTextureParams; + gMGPipeContext.ResourceSubData = &Wire_ResourceSubData; + gMGPipeContext.BufferSubDataResident = &Wire_BufferSubDataResident; + gMGPipeContext.ResourceReadback = &Wire_ResourceReadback; + + gMGPipeRouteEscapes.ResourceRespecify = &Wire_Escape_ResourceRespecify; + gMGPipeRouteEscapes.ResourceFlushRange = &Wire_Escape_ResourceFlushRange; + gMGPipeRouteEscapes.MapPersistent = &Wire_Escape_MapPersistent; + gMGPipeRouteEscapes.CreateShaderState = &Wire_Escape_CreateShaderState; + + MGPipeNoteInstalledArm(MGPipeRouteArm::kClientWire); + } + + void UninstallClientWireTables() { + // MARK THE ROUTED TABLES UNINSTALLED (codex 4). It does NOT reinstall the monolith + // adapters, and that is the whole fix: round 2 reinstalled them here, at the TOP of + // Stop, so a routed GL-thread call arriving during teardown ran the applier on the + // caller - the forbidden path - and moved no wire ordinal, with no refusal. Raising the + // flag leaves the Wire_* rows in place; the next routed call reaches RequireSession, + // reads the flag and aborts by name (Fatal{ClientTablesUninstalled}) before it touches a + // ring that Stop is about to free. The monolith adapters are put back only once teardown + // is complete, by ReinstallMonolithAfterTeardown, for the at-exit deletes that reach a + // process with no session at all. Idempotent: safe to call when nothing was installed. + g_clientTablesUninstalled.store(true, std::memory_order_release); + } + + void ReinstallMonolithAfterTeardown() { + // THE LAST STEP OF ClientSession::Stop, after the rings, segments and transport are gone + // and the apply thread has joined. Now a routed call can only be a process that no + // longer has a session - the canonical case is ~BufferObject running from an exit + // handler (ID-8) - and the monolith adapter, which runs the applier synchronously, is + // the correct answer for it, exactly as it is in a pure-monolith build. Clearing the + // flag re-enables the (now monolith) rows. A null row here would be an undiagnosed crash + // at whatever GL call an exit handler makes; the applier is a defined no-op-or-apply. + MG_Pipe::MGPipeInstallMonolithTables(); + g_clientTablesUninstalled.store(false, std::memory_order_release); + } + + Uint64 ClientWireRecordsEmitted() { return g_emitted; } + Uint64 ClientWireRecordsDeclined() { return g_declined; } + +} // namespace MobileGL::MG_Remote::Client + +#endif // MOBILEGL_BUILD_DISAGGREGATED diff --git a/MobileGL/MG_Remote/Client/WireTables.h b/MobileGL/MG_Remote/Client/WireTables.h new file mode 100644 index 00000000..90345dbe --- /dev/null +++ b/MobileGL/MG_Remote/Client/WireTables.h @@ -0,0 +1,88 @@ +// MobileGL - MobileGL/MG_Remote/Client/WireTables.h +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +// THE CLIENT ARM OF R-17's ROUTING: the encode twin of `gMGPipeWireRecordApply`. Owner: c1. +// +// Thirty-seven thin emitters over `ClientSession::EmitAndWait`, installed over the two +// generated tables and the escape table that `MG_Pipe/PipeRoute.h` declares, so that under +// split every resource, CSO, texture and program record leaves the GL thread as a WIRE RECORD +// instead of executing synchronously against a context the apply thread now owns. +// +// WHAT ARMS `integration-split`. The 21 `DirectGLES.Split.*` entries skip on +// `ClientSession::Active() == nullptr`. `Install()` below is called from the END of a +// successful `ClientSession::Start()`, on the GL thread (the thread that called +// `MG_Backend::Init()`), AFTER the Hello/Welcome handshake, after the first CapsSnapshot has +// been adopted, and after the in-process server role's apply thread has been started. That +// order is not a preference: +// - after the handshake, because an emitter that published before Welcome would be writing +// into a ring the peer has not mapped; +// - after the first snapshot, because R-8's liveness gates read the caps mirror and a +// placeholder mirror consumes nothing, so a record emitted before it would be emitted to a +// server this client has not yet been told consumes that family; +// - after the apply thread exists, because `EmitAndWait` BLOCKS on `appliedSeq` and nothing +// would advance it - a barrier wait with no applier is the 30-second Fatal, not a hang; +// - on the GL thread, because that is the only thread that may touch `gPipeInputs` while the +// barrier holds (table 3), and installing from the apply thread would publish the table to +// the GL thread with no synchronisation at all. +// `Uninstall()` runs at the TOP of `Stop()`, before the rings go away, so the last thing any +// straggling GL-thread call reaches is the monolith arm rather than a dangling session. + +#pragma once +#include + +#if MOBILEGL_BUILD_DISAGGREGATED + +namespace MobileGL::MG_Remote::Client { + + // Installs the thirty-seven wire emitters over gMGPipeScreen / gMGPipeContext / + // gMGPipeRouteEscapes and records the arm. Idempotent. + void InstallClientWireTables(); + + // Marks the routed tables uninstalled so a routed call refuses by name + // (Fatal{ClientTablesUninstalled, ""}) rather than running the applier on the caller + // (codex 4). It does NOT restore the monolith adapters - that is ReinstallMonolithAfterTeardown + // below, run only once the session's rings are freed. Idempotent. + void UninstallClientWireTables(); + + // The LAST step of ClientSession::Stop: after the rings, segments and transport are gone, + // puts the monolith adapters back and clears the refusal flag, so the at-exit ~BufferObject + // deletes that reach a process with no session run the applier as they do under monolith. + void ReinstallMonolithAfterTeardown(); + + // How many records the thirty-seven emitters have published. It counts the ROUTED rows + // only - a resource_create, a set_vertex_buffers, a create_shader_state - and never the + // five class-B verbs, so it is the one number that says "the resource/CSO/state path really + // ran" as opposed to "a Clear crossed". + // + // WHAT IT IS NOT (M7, corrected). This is NOT what arms the split lane. WireTables.h round 2 + // claimed it was "t1's FOURTH arming fact", and that was wrong: the harness reads the + // encoder's EmitSeq (Harness/SplitRuntimePeek.cpp:50, Harness/ScenarioFixture.h:85), which + // moves for the class-B verbs too. So an armed Clear-only lane is green on EmitSeq while this + // counter stays 0. Making the harness read THIS instead is t1's file (SplitRuntimePeek), so + // the honest statement is the one here: c1 counts the routed ordinal at the only place that + // can, PipeFill's InitialBytesNotCarried self-check reads it (PipeFill.cpp:755), and the + // lane's own arming remains EmitSeq until t1 re-points it. c1-v3.md M7 has the full note. + Uint64 ClientWireRecordsEmitted(); + + // How many of those were refused by the server, by acceptance row. Counted rather than + // inferred, R-8's rule one level out. + Uint64 ClientWireRecordsDeclined(); + + // TRUE ON THE SERVER ROLE's OWN THREAD (the apply thread), false everywhere else. It is + // v1's ServerLoop::OnApplyThread(), exposed here because it is table 3's role split made + // into one predicate and TWO packages read it: the wire emitters below (a routed call that + // finds itself on the apply thread runs the monolith adapter, because on that thread this + // process IS the server), and MG_Impl/Pipe/PipeFill.cpp's split-only respecify/flush + // branches (M5: those branches emit CLIENT wire records and must not run on the server's + // apply thread, which produces none - the InitialBytesNotCarried self-check would abort the + // server otherwise). Declared here so PipeFill does not have to include a server header. + Bool RunsAsTheServerRole(); + +} // namespace MobileGL::MG_Remote::Client + +#endif // MOBILEGL_BUILD_DISAGGREGATED diff --git a/MobileGL/MG_Remote/Server/PipeApplier.cpp b/MobileGL/MG_Remote/Server/PipeApplier.cpp index 2961477e..2afe9416 100644 --- a/MobileGL/MG_Remote/Server/PipeApplier.cpp +++ b/MobileGL/MG_Remote/Server/PipeApplier.cpp @@ -6,31 +6,26 @@ // SPDX-License-Identifier: LGPL-3.0-only // End of Source File Header -// P5 c0 stubs for package v1 (with p1 for the stamp rule). +// P5 package v1: the applier bridge, and the consumer for contract 7's five class-B verbs. #include "PipeApplier.h" #include "../Transport/ReplySlot.h" +#include +#include #include #include +#include namespace MobileGL::MG_Remote::Server { -#define MGP5_C0_STUB(what) \ - do { \ - MGLOG_F("MGPipe: Fatal{UnimplementedPipeApplier, \"%s\"} - P5 package v1 has not landed " \ - "this yet; c0 shipped the signature only", \ - what); \ - std::abort(); \ - } while (0) - ReplyPool::ReplyPool(void* base, Uint64 sizeBytes, Uint32 slotCount, Uint32 slotBytes) : m_base(static_cast(base)), m_size(sizeBytes), m_slots(slotCount), m_slotBytes(slotBytes) {} // PACKAGE s1's, not v1's, even though the class is declared in v1's header: the SEG_REPLY - // slot pool is s1's deliverable (BRIEF §5) and its addressing lives in one place, + // slot pool is s1's deliverable (BRIEF 5) and its addressing lives in one place, // Transport/ReplySlot.h, which the CLIENT reads the same slots back through. Duplicating // `seq % slots` on this side is how the two halves come to disagree about which slot an // answer is in - and because seq IS the reply-slot id (R-3), a disagreement reads another @@ -47,21 +42,324 @@ namespace MobileGL::MG_Remote::Server { Uint32 ReplyPool::SlotBytes() const { return m_slotBytes; } + // ----------------------------------------------------------------------------------- + // ServerVerbSink - the five class-B verbs + // ----------------------------------------------------------------------------------- + + void ServerVerbSink::SetBackend(MG_Backend::BackendObject* backend) { m_backend = backend; } + + const MG_Backend::GlobalBackendFunctionsTable* ServerVerbSink::Table(const char* verb) const { + if (m_backend == nullptr) { + // DECLINE BY NAME, DO NOT DEREFERENCE. A verb that arrives before + // ServerLoop::CreateBackend has run means the hook order changed under us, and the + // honest answer is "this build did not apply it" - which DecodeAndApply reports as + // false and the lane sees as a record that did not render, rather than as a crash + // with no line saying which verb was first. + MGLOG_E_ONCE("MG_Remote server: %s arrived with no backend object; the verb is " + "DECLINED. ServerLoop::CreateBackend runs from MG_Backend::Init()'s " + "hook, before ClientSession::Start", + verb); + return nullptr; + } + return &m_backend->GetBackendFunctions(); + } + + Bool ServerVerbSink::OnClear(const MG_Pipe::MGPClear& clear) { + const MG_Backend::GlobalBackendFunctionsTable* table = Table("clear"); + if (table == nullptr) return false; + const MG_Backend::GLFunctionsTable& gl = table->GL; + + // THE FBO HANDLE IS NOT RESOLVED HERE, AND THAT IS THE RULING RATHER THAN AN OMISSION. + // MGPClear::Fbo names the framebuffer the clear belongs to, but the BINDING is already + // server state: set_framebuffer_state (op 33) arrives ahead of the clear and the + // applier has bound it. Re-resolving the handle to a frontend FramebufferObject here + // would need the SharedPtr the four ClearNamedFramebuffer* entries take - a frontend + // heap reference that table 2 lists as one of the six fields with no wire carrier. So + // P5 clears THE BOUND FRAMEBUFFER, which for the reduced path (default FBO) is exactly + // right, and the named form is P7's along with the handle it needs. + switch (clear.Kind) { + case kMGPClearKindWhole: + if (gl.Clear == nullptr) return false; + gl.Clear(static_cast(clear.BufferMask)); + break; + case kMGPClearKindColor: + switch (clear.ValueClass) { + case kMGPClearValueClassFloat: + if (gl.ClearBufferfv == nullptr) return false; + gl.ClearBufferfv(GL_COLOR, clear.DrawBufferIndex, + reinterpret_cast(clear.ColorValue)); + break; + case kMGPClearValueClassInt: + if (gl.ClearBufferiv == nullptr) return false; + gl.ClearBufferiv(GL_COLOR, clear.DrawBufferIndex, + reinterpret_cast(clear.ColorValue)); + break; + case kMGPClearValueClassUint: + if (gl.ClearBufferuiv == nullptr) return false; + gl.ClearBufferuiv(GL_COLOR, clear.DrawBufferIndex, + reinterpret_cast(clear.ColorValue)); + break; + default: + // A value class outside the three is a wire fault, not a fallback: all three + // representations of a clear colour are numerically populated by the frontend + // and only this field says which one the backend must use, so guessing renders + // a plausible wrong colour. + Wire::WireProtocolFatalAt("MGPClear::ValueClass", clear.ValueClass, 3); + } + break; + case kMGPClearKindDepth: + if (gl.ClearBufferfv == nullptr) return false; + gl.ClearBufferfv(GL_DEPTH, 0, &clear.DepthValue); + break; + case kMGPClearKindStencil: + if (gl.ClearBufferiv == nullptr) return false; + gl.ClearBufferiv(GL_STENCIL, 0, &clear.StencilValue); + break; + case kMGPClearKindDepthStencil: + if (gl.ClearBufferfi == nullptr) return false; + gl.ClearBufferfi(GL_DEPTH_STENCIL, 0, clear.DepthValue, clear.StencilValue); + break; + default: + Wire::WireProtocolFatalAt("MGPClear::Kind", clear.Kind, kMGPClearKindDepthStencil + 1); + } + ++m_clears; + return true; + } + + Bool ServerVerbSink::OnBlit(const MG_Pipe::MGPBlit& blit) { + const MG_Backend::GlobalBackendFunctionsTable* table = Table("blit"); + if (table == nullptr) return false; + if (table->GL.BlitFramebuffer == nullptr) return false; + // Same ruling as OnClear's: the read and draw framebuffers are already bound by the + // set_framebuffer_state records that preceded this one, so the unnamed entry point is + // the one that matches what the server's state actually is. BlitNamedFramebuffer needs + // two frontend SharedPtrs, which table 2 lists as uncarried. + table->GL.BlitFramebuffer(blit.SrcX0, blit.SrcY0, blit.SrcX1, blit.SrcY1, blit.DstX0, + blit.DstY0, blit.DstX1, blit.DstY1, + static_cast(blit.Mask), + static_cast(blit.Filter)); + ++m_blits; + return true; + } + + Bool ServerVerbSink::OnPresent(const MG_Pipe::MGPPresent& present) { + const MG_Backend::GlobalBackendFunctionsTable* table = Table("present"); + if (table == nullptr) return false; + if (table->Present == nullptr) return false; + // Present is the ONLY frame-boundary drain the backend has (DirectGLES.cpp:12424-12470: + // the fence poll, the four ring OnPresent hooks, TrimBufferPool, PipeStats::OnPresent), + // which is why ARCHITECTURE.md:531 insists present <-> eglSwapBuffers stays strictly + // 1:1. It is NOT eglSwapBuffers itself: the swap is the client's EGL call and crosses + // as the SwapEGLBuffers control request, which runs Present on this thread through this + // same table. Both paths therefore end here and the 1:1 is structural. + table->Present(); + ++m_presents; + // FrameSerial 0 means "the server stamps its own" (c1-v1 8.3): P5 has no client-side + // present credit, so the client sends 0 and the frame count on this side IS the serial. + m_lastPresentSerial = present.FrameSerial != 0 ? present.FrameSerial : m_presents; + return true; + } + + Bool ServerVerbSink::OnReadPixels(const MG_Pipe::MGPReadbackInfo& info, Uint64 seq, + Wire::ReplySink* replies) { + if (replies == nullptr) { + // The decoder always passes its ReplySink; a null one means the applier was built + // without a reply pool, and answering nothing would leave the client's barrier + // waiting for a slot that never gets stamped - a hang, not a wrong picture. + MGLOG_F("MGPipe: Fatal{ProtocolCorruption, \"read_pixels without a reply sink\"} - " + "the pixels' only destination in P5 is SEG_REPLY (contract table 1 row 23) " + "and a client blocked on seq %llu would never be answered", + static_cast(seq)); + std::abort(); + } + const MG_Backend::GlobalBackendFunctionsTable* table = Table("read_pixels"); + if (table == nullptr || table->GL.ReadPixels == nullptr) { + // DECLINED IS A REAL ANSWER (table 0's slot-header row) and it is the RIGHT one + // here: the client is parked on this seq inside the verb barrier, so returning + // false without posting would convert "not implemented" into "never returns". + replies->PostReply(seq, Wire::ReplySink::kStatusDeclined, nullptr, 0); + return false; + } + if (info.DstSize == 0) { + replies->PostReply(seq, Wire::ReplySink::kStatusError, nullptr, 0); + return false; + } + // THE CLIENT DECLARES THE BYTE COUNT AND THE SERVER DOES NOT RECOMPUTE IT. DstSize is + // sized on the client from the same GL_PACK_* state the frontend owns, and it is what + // the client will read back out of the slot; a server that recomputed from Box x + // Format x Type would be a SECOND opinion about a pack alignment the client half owns, + // and the two disagreeing is a short read with plausible pixels in it. + if (info.DstSize > m_readbackScratch.size()) { + m_readbackScratch.resize(static_cast(info.DstSize)); + } + table->GL.ReadPixels(info.Box.X, info.Box.Y, static_cast(info.Box.W), + static_cast(info.Box.H), static_cast(info.Format), + static_cast(info.Type), m_readbackScratch.data()); + replies->PostReply(seq, Wire::ReplySink::kStatusOk, m_readbackScratch.data(), info.DstSize); + m_readbackBytes += info.DstSize; + ++m_readbacks; + return true; + } + + Bool ServerVerbSink::OnDrawVbo(const MG_Pipe::MGPDrawInfo& info, + const MG_Pipe::MGPDrawRange* ranges, + const MG_Pipe::MGHostSpan* userIndices) { + const MG_Backend::GlobalBackendFunctionsTable* table = Table("draw_vbo"); + if (table == nullptr) return false; + if (userIndices != nullptr) { + // kCapNeedsHostIndexBytes is 0 for the whole of P5 by ruling (table 0's cap-bit + // row) precisely so this tail never appears; a span that arrived anyway means the + // client's cap gate did not hold, and filling one is P8's. + MGLOG_E_ONCE("MG_Remote server: draw_vbo carries an MGHostSpan of user indices. P5 " + "rules kCapNeedsHostIndexBytes and kCapNeedsHostUboBytes to 0 so that " + "no host span reaches the first IPC frame (contract table 0); filling " + "one under split is P8's. The draw is DECLINED rather than drawn from " + "a pointer that does not belong to this process"); + return false; + } + if (ranges == nullptr || info.NumDraws == 0) return false; + + // P5 IMPLEMENTS THE TWO SHAPES ITS REDUCED PATH USES AND DECLINES THE REST BY NAME. + // draw_vbo collapses all twenty draw entry points, and picking the right one needs the + // instancing / base-vertex / base-instance / multi-draw cross product. TriangleScenario + // is a single non-instanced array draw and OpenRA's are single indexed draws from a + // bound element buffer; the rest are P8's, together with the MGPDrawIndirect record + // that has no producer yet. + const MG_Backend::GLFunctionsTable& gl = table->GL; + const Bool instanced = info.InstanceCount > 1 || info.StartInstance != 0; + if (info.NumDraws != 1 || instanced) { + MGLOG_E_ONCE("MG_Remote server: draw_vbo with NumDraws=%u InstanceCount=%u " + "StartInstance=%u is DECLINED - P5's reduced path is the single " + "non-instanced draw (BRIEF 4); the multi-draw and instanced arms are " + "P8's", + info.NumDraws, info.InstanceCount, info.StartInstance); + return false; + } + const MG_Pipe::MGPDrawRange& range = ranges[0]; + if (info.IndexSize == 0) { + if (gl.DrawArrays == nullptr) return false; + gl.DrawArrays(static_cast(info.Mode), static_cast(range.Start), + static_cast(range.Count)); + } else { + if (gl.DrawElementsBaseVertex == nullptr) return false; + GLenum indexType = GL_UNSIGNED_INT; + switch (info.IndexSize) { + case 1: indexType = GL_UNSIGNED_BYTE; break; + case 2: indexType = GL_UNSIGNED_SHORT; break; + case 4: indexType = GL_UNSIGNED_INT; break; + default: + // IndexSize is "0 = arrays, else 1 / 2 / 4" (MGPipeTypes.h:1323) and nothing + // else is a legal width; defaulting to 4 would read past the element buffer. + Wire::WireProtocolFatalAt("MGPDrawInfo::IndexSize", info.IndexSize, 4); + } + // Start is the FIRST INDEX, so the byte offset into the bound element buffer is + // Start * IndexSize - the same arithmetic PipeFill's emitter inverted. + const auto offset = static_cast(range.Start) * info.IndexSize; + gl.DrawElementsBaseVertex(static_cast(info.Mode), + static_cast(range.Count), indexType, + reinterpret_cast(offset), range.IndexBias); + } + ++m_draws; + return true; + } + + // ----------------------------------------------------------------------------------- + // PipeApplier + // ----------------------------------------------------------------------------------- + PipeApplier::PipeApplier(Wire::SegmentTable* segments, ReplyPool* replies) : m_segments(segments), m_replies(replies) {} - Bool PipeApplier::ApplyOne(const Transport::RingRecordView&) { MGP5_C0_STUB("PipeApplier::ApplyOne"); } - - void PipeApplier::StampVerbBoundary(MG_Pipe::MGPWireOp) { - MGP5_C0_STUB("PipeApplier::StampVerbBoundary"); + void PipeApplier::Attach(Transport::RingControl* control, MG_Backend::BackendObject* backend) { + if (control == nullptr || m_segments == nullptr) { + MGLOG_F("MGPipe: Fatal{ProtocolCorruption, \"PipeApplier::Attach\"} - no control " + "page or no segment table; ServerSession::Accept builds both before the " + "apply thread starts"); + std::abort(); + } + m_verbs.SetBackend(backend); + m_decoder = Wire::PipeWireDecoder(control, m_segments, m_replies); + m_decoder.SetVerbSink(&m_verbs); + m_attached = true; } - Uint64 PipeApplier::ResidualPullCount() const { return m_residualPulls; } - - void PipeApplier::PoisonRetiredStageBytes(Uint64, Uint64) { - MGP5_C0_STUB("PipeApplier::PoisonRetiredStageBytes"); + void PipeApplier::Detach() { + m_decoder = Wire::PipeWireDecoder(); + m_verbs.SetBackend(nullptr); + m_attached = false; } -#undef MGP5_C0_STUB + Bool PipeApplier::Attached() const { return m_attached; } + + Bool PipeApplier::ApplyOne(const Transport::RingRecordView& record) { + if (!m_attached) { + MGLOG_F("MGPipe: Fatal{ProtocolCorruption, \"PipeApplier::ApplyOne before Attach\"} " + "- a record reached the applier with no decoder; the apply thread calls " + "Attach once before its first pop"); + std::abort(); + } + // ORDER IS THE CONTRACT'S: stamp, then apply. The stamp is what makes any server-side + // read of gPipeInputs legal at all (PipeApplier.h's block 1), so a record applied + // before it aborts on the FIRST field inside SyncRenderState. + StampVerbBoundary(static_cast(record.kind)); + const Bool applied = m_decoder.DecodeAndApply(record); + // AND THE CLEAR IS INSIDE ApplyOne, NOT AFTER THE DRAIN BATCH. That is not tidiness, + // it is the barrier invariant. s1's SessionConsumer::ApplyOne publishes appliedSeq the + // instant this returns, and publishing appliedSeq is what makes the CLIENT runnable + // again (R-1: the barrier waits on exactly that watermark). A clear that ran after the + // batch would therefore be a second writer of gPipeInputs while the client is already + // touching it - the one thing table 3 says may not be introduced before the barrier + // retires - and the first version of this file had it there. It was caught by + // AClearRecordCrossesAndIsStampedAsAVerbBoundary failing INTERMITTENTLY, which is what + // a race looks like from the outside. + // + // THE COST, STATED: a record that is NOT a verb boundary now applies with the flag + // disarmed, so a sticky forward pulled from inside such a record's applier is not + // counted in `rsp`. Closing that needs an "enter the applier" entry point beside + // MGPipeServerStampVerbBoundary that arms the flag WITHOUT re-stamping - re-stamping on + // a non-verb op is what p1 forbids outright - and PipeInputs.cpp is p1's file. Left for + // the integrator to sequence; it makes `rsp` larger, never smaller, so the number this + // phase reports is a floor. + LeaveApplier(); + return applied; + } + + // p1's rule verbatim (p1-v1 2). MGPipeVerbForWireOp is generated from MGP_VERB_OP_LIST in + // FieldOwnership.def and answers kVerbCount for every op that is NOT a verb boundary, so + // calling it unconditionally on every record is both correct and cheap. Four ops stamp: + // Clear -> Clear, DrawVbo -> DrawArrays, ReadPixels -> ReadPixels, Blit -> BlitFramebuffer. + // + // PRESENT IS DELIBERATELY NOT ONE, although contract 7 puts it in class B: FillPoints.def:21 + // says Present and SetSwapInterval "go through BackendObject virtuals and read no frontend + // state, so they are not verbs here". There is no MGPipeVerb::Present, and stamping there + // would retire the previous verb's answers with nothing to put in their place. + void PipeApplier::StampVerbBoundary(MG_Pipe::MGPWireOp op) { + const MG_Pipe::MGPipeVerb verb = MG_Pipe::MGPipeVerbForWireOp(op); + if (verb == MG_Pipe::MGPipeVerb::kVerbCount) return; // not a verb boundary: stamp nothing + MG_Pipe::MGPipeServerStampVerbBoundary(verb); + } + + void PipeApplier::LeaveApplier() { MG_Pipe::MGPipeServerClearVerbBoundary(); } + + Uint64 PipeApplier::ResidualPullCount() const { return MG_Pipe::MGPipeResidualPullCount(); } + + // The decoder poisons EXACTLY the runs it resolved, from inside DecodeAndApply, once the + // applier has returned - so this entry point is the manual one, for a caller that knows a + // range is dead and is not the decoder. It is kept because c0's signature block declares + // it and because the R-11 copy in Managers.cpp is verified by poisoning a range by hand in + // a unit case; nothing on the live path calls it. + void PipeApplier::PoisonRetiredStageBytes(Uint64 offset, Uint64 size) { + if (size == 0 || m_segments == nullptr) return; +#if MOBILEGL_BUILD_DISAGGREGATED + if (!MG_Config::Ipc.Audit) return; +#endif + const void* run = m_segments->Resolve(Wire::kSegStage, offset, size); + if (run == nullptr) return; + std::memset(const_cast(run), 0xDD, static_cast(size)); + } + + Uint64 PipeApplier::PoisonedStageBytes() const { return m_decoder.PoisonedStageBytes(); } + + Uint64 PipeApplier::DecoderAppliedSeq() const { return m_decoder.AppliedSeq(); } } // namespace MobileGL::MG_Remote::Server diff --git a/MobileGL/MG_Remote/Server/PipeApplier.h b/MobileGL/MG_Remote/Server/PipeApplier.h index 48401b3c..c11ca7e5 100644 --- a/MobileGL/MG_Remote/Server/PipeApplier.h +++ b/MobileGL/MG_Remote/Server/PipeApplier.h @@ -40,6 +40,7 @@ #pragma once #include +#include #include #include "../Transport/Ring.h" @@ -69,34 +70,147 @@ namespace MobileGL::MG_Remote::Server { Uint32 m_slotBytes = 0; }; + // ---- MGPClear's two discriminants --------------------------------------------------- + // + // MGPClear (MGPipeTypes.h:1271) names `Kind` "Whole | Color | Depth | Stencil | + // DepthStencil" and `ValueClass` "Float | Int | Uint" IN A COMMENT AND NOWHERE ELSE: the + // catalogue ships no enum for either, and the record has no producer or consumer in the + // tree, so P5 writes both halves and the two halves have to agree on a number. Declaring + // them here rather than open-coding 0..4 on each side is table 0's own rule for exactly + // this shape ("a decoder that open-codes it is the class-1 defect"), applied to a field + // table 0 did not reach. + // + // THE ORDER IS THE COMMENT'S, LEFT TO RIGHT, and ValueClass reuses the numbering + // MG_State/GLState/Core.h:39-41 already gives the identical three-way split on + // MGPAttribValue::ValueClass. c1 encodes against these constants; a disagreement is a + // clear of the wrong attachment with the wrong value type, which renders plausibly. + // FLAGGED FOR THE INTEGRATOR: this belongs in MGPipeTypes.h, which is c0's file. + inline constexpr Uint32 kMGPClearKindWhole = 0; // glClear(mask) + inline constexpr Uint32 kMGPClearKindColor = 1; // glClearBuffer{f,i,ui}v(GL_COLOR, i, v) + inline constexpr Uint32 kMGPClearKindDepth = 2; // glClearBufferfv(GL_DEPTH, 0, &d) + inline constexpr Uint32 kMGPClearKindStencil = 3; // glClearBufferiv(GL_STENCIL, 0, &s) + inline constexpr Uint32 kMGPClearKindDepthStencil = 4; // glClearBufferfi(GL_DEPTH_STENCIL,...) + inline constexpr Uint32 kMGPClearValueClassFloat = 0; + inline constexpr Uint32 kMGPClearValueClassInt = 1; + inline constexpr Uint32 kMGPClearValueClassUint = 2; + + // ---- the five class-B verbs' consumer ------------------------------------------------ + // + // Contract §7 class B is Clear (57), Blit (56), ReadPixels (58), DrawVbo (59) and Present + // (67), and NONE of them has an MGPipeApply* entry point - the 37 that exist are the object + // and state families. So w1's decoder validates and hands over a checked argument list and + // stops, and this is the other half: the SERVER'S OWN BACKEND CALL, through the private + // GlobalBackendFunctionsTable ServerLoop holds. It is not gBackendFunctionsTable, which in + // a split process is the client's emit table (table 3) - calling THAT here would re-emit + // the record the server is in the middle of applying, which is an infinite loop that + // renders nothing and looks like a hang. + class ServerVerbSink final : public Wire::WireVerbSink { + public: + // The server's private backend. Null until ServerLoop::CreateBackend has run, and a + // verb that arrives before then declines by name rather than dereferencing. + void SetBackend(MG_Backend::BackendObject* backend); + + Bool OnClear(const MG_Pipe::MGPClear& clear) override; + Bool OnBlit(const MG_Pipe::MGPBlit& blit) override; + Bool OnPresent(const MG_Pipe::MGPPresent& present) override; + Bool OnReadPixels(const MG_Pipe::MGPReadbackInfo& info, Uint64 seq, + Wire::ReplySink* replies) override; + Bool OnDrawVbo(const MG_Pipe::MGPDrawInfo& info, const MG_Pipe::MGPDrawRange* ranges, + const MG_Pipe::MGHostSpan* userIndices) override; + + // Per-verb tallies. The lane asserts these moved, because "the scenario passed" on a + // split build is also what a scenario that ran entirely on the monolith path looks + // like (R-16: a probe may not arm against a stub). + Uint64 Clears() const { return m_clears; } + Uint64 Draws() const { return m_draws; } + Uint64 Readbacks() const { return m_readbacks; } + Uint64 Blits() const { return m_blits; } + Uint64 Presents() const { return m_presents; } + Uint64 LastPresentSerial() const { return m_lastPresentSerial; } + Uint64 ReadbackBytes() const { return m_readbackBytes; } + + private: + const MG_Backend::GlobalBackendFunctionsTable* Table(const char* verb) const; + + MG_Backend::BackendObject* m_backend = nullptr; + Uint64 m_clears = 0; + Uint64 m_draws = 0; + Uint64 m_readbacks = 0; + Uint64 m_blits = 0; + Uint64 m_presents = 0; + Uint64 m_lastPresentSerial = 0; + Uint64 m_readbackBytes = 0; + // ReadPixels' destination. The pixels go into the reply slot, but GLFunctionsTable:: + // ReadPixels writes into a caller buffer, so one staging vector per session sits + // between them. Grown, never shrunk, and never handed out past the call. + Vector m_readbackScratch; + }; + class PipeApplier { public: PipeApplier() = default; PipeApplier(Wire::SegmentTable* segments, ReplyPool* replies); - // Decode one record, stamp the verb, apply, post the reply if the call has one, then - // advance appliedSeq by exactly one. P5 FORBIDS BATCHING appliedSeq (R-9): the barrier's - // waiter reads it, and a batched watermark promises work that has not run. + // Builds the decoder over the session's control page and points it at this applier's + // verb sink. Separate from the constructor because ServerSession::Accept constructs the + // applier before it has decided anything about the apply thread, and the decoder needs + // the RingControl the constructor was never given. + // + // CALLED ON THE APPLY THREAD, ONCE, BEFORE THE FIRST RECORD. PipeWireDecoder is "not + // thread safe: one decoder on the apply thread, by construction", and its constructor + // installs the process-wide apply hook. + void Attach(Transport::RingControl* control, MG_Backend::BackendObject* backend); + void Detach(); + Bool Attached() const; + + // Decode one record, stamp the verb, apply, post the reply if the call has one. THE + // CALLER advances appliedSeq by exactly one, through s1's SessionConsumer::ApplyOne, + // which is that watermark's single writer; P5 FORBIDS BATCHING it (R-9), because the + // barrier's waiter reads it and a batched watermark promises work that has not run. Bool ApplyOne(const Transport::RingRecordView& record); // p1's rule, v1's call site. Called at the verb boundary, before the record's applier // runs, with the verb the record belongs to. void StampVerbBoundary(MG_Pipe::MGPWireOp op); + // MANDATORY on leaving the applier (p1's M-5, PipeInputs.h's ServerStampedVerb block). + // The client's MGPipeValidateForVerb / MGPipeLeaveVerb also clear it, which is enough + // for inproc and NOT enough for a spawned server, where MG_Impl is not in the process: + // there the flag would latch TRUE for the server's life, every later read anywhere + // would be judged against the last verb's mask, and the sticky forwards would start + // aborting under strict on exactly the case their exemption exists for. + void LeaveApplier(); + // R-7.2's counter, read by the gate. A BARRIER-PULLED field read on the server side // increments PipeStats::CallClass::ResidualPulls (short name `rsp`); its value at the // end of P5 IS the size of the P6/P7/P8 debt and goes into MEASUREMENTS. + // + // IT FORWARDS TO MGPipeResidualPullCount() AND KEEPS NO MEMBER OF ITS OWN. The member + // c0's signature block declared is deleted rather than wired: the counter is + // process-wide in PipeInputs.cpp because the reads that increment it happen inside the + // BACKEND, arbitrarily deep under an MGPipeApply* call, with no PipeApplier in scope. + // A second copy here could only ever be a number that disagreed with the one the exit + // gate reads (p1-v1 5). Uint64 ResidualPullCount() const; // R-11's audit: after a record retires, fill the SEG_STAGE bytes it referenced with // 0xDD. Only under MOBILEGL_IPC_AUDIT=1, because it costs a write of every staged byte. void PoisonRetiredStageBytes(Uint64 offset, Uint64 size); + // How many staged bytes the decoder has poisoned, and how many records it has applied - + // the two numbers the audit lane asserts are non-zero, since an instrumentation that + // cannot be observed to have run is decoration. + Uint64 PoisonedStageBytes() const; + Uint64 DecoderAppliedSeq() const; + ServerVerbSink& Verbs() { return m_verbs; } + const ServerVerbSink& Verbs() const { return m_verbs; } + private: Wire::SegmentTable* m_segments = nullptr; ReplyPool* m_replies = nullptr; Wire::PipeWireDecoder m_decoder; - Uint64 m_residualPulls = 0; + ServerVerbSink m_verbs; + Bool m_attached = false; }; } // namespace MobileGL::MG_Remote::Server diff --git a/MobileGL/MG_Remote/Server/ServerLoop.cpp b/MobileGL/MG_Remote/Server/ServerLoop.cpp index 29ed1af8..ed1d488d 100644 --- a/MobileGL/MG_Remote/Server/ServerLoop.cpp +++ b/MobileGL/MG_Remote/Server/ServerLoop.cpp @@ -6,36 +6,500 @@ // SPDX-License-Identifier: LGPL-3.0-only // End of Source File Header -// P5 c0 stubs for package v1 - the phase's highest-risk package. +// P5 package v1: the apply thread, its affinity, its parking, and the EGL ownership move. #include "ServerLoop.h" +#include +#include #include +#include #include +#include + +#if defined(__linux__) || defined(__ANDROID__) +#include +#include +#endif namespace MobileGL::MG_Remote::Server { -#define MGP5_C0_STUB(what) \ - do { \ - MGLOG_F("MGPipe: Fatal{UnimplementedServerLoop, \"%s\"} - P5 package v1 has not landed " \ - "this yet; c0 shipped the signature only", \ - what); \ - std::abort(); \ - } while (0) + namespace { - MobileGLResult ServerLoop::Start(ServerSession&) { MGP5_C0_STUB("ServerLoop::Start"); } + // The bounded join. InProcessTransportTest.cpp:344 uses five seconds for the same + // reason: a lost wakeup must be a RED TEST and not a hung CI job. + constexpr Uint32 kJoinTimeoutMs = 5000; - void ServerLoop::Stop() { MGP5_C0_STUB("ServerLoop::Stop"); } + // A core counts as "big" if its cpufreq ceiling is within 15% of the fastest core's - + // the identical rule and the identical constant as ShaderCompilePool.cpp:28 and :73-95. + // The probe is re-derived here rather than exported from there because the two want + // DIFFERENT ANSWERS from the same data: the pool wants a COUNT (how many workers), and + // an affinity wants a MASK (which cpus). DetectBigCoreCount() cannot answer the second, + // so exporting it would have meant either a second function in another package's file + // or a mask reconstructed from a count, which is wrong on any asymmetric topology whose + // big cores are not cpu0..cpuN-1. + constexpr Uint64 kBigCoreFrequencyPercent = 85; - // Not a stub: teardown asks this to decide whether to Kill and join at all, and a teardown - // helper that aborts when the thread was never started is a hang in the shutdown path. - Bool ServerLoop::Running() const { return m_running; } + Uint64 ReadCpuMaxFrequencyKHz(const Uint cpu) { + char path[128]; + std::snprintf(path, sizeof(path), + "/sys/devices/system/cpu/cpu%u/cpufreq/cpuinfo_max_freq", cpu); + std::FILE* file = std::fopen(path, "r"); + if (file == nullptr) return 0; + unsigned long long value = 0; + const int scanned = std::fscanf(file, "%llu", &value); + std::fclose(file); + return scanned == 1 ? static_cast(value) : 0; + } - MG_Backend::BackendObject* ServerLoop::Backend() { MGP5_C0_STUB("ServerLoop::Backend"); } + // The set of cpus whose ceiling is within 15% of the peak, as a bit per cpu. Zero when + // the topology cannot be read - Windows, macOS, a container that hides the cpufreq + // tree, or a partially readable one - because with no asymmetry information the honest + // answer is "do not pin", not "pin to a guess". + Uint64 DetectBigCoreMask() { + const Uint cpuCount = std::min(64u, std::max(1u, std::thread::hardware_concurrency())); + Uint64 frequencies[64] = {}; + for (Uint cpu = 0; cpu < cpuCount; ++cpu) { + frequencies[cpu] = ReadCpuMaxFrequencyKHz(cpu); + if (frequencies[cpu] == 0) return 0; + } + Uint64 peak = 0; + for (Uint cpu = 0; cpu < cpuCount; ++cpu) peak = std::max(peak, frequencies[cpu]); + if (peak == 0) return 0; + const Uint64 threshold = peak * kBigCoreFrequencyPercent / 100; + Uint64 mask = 0; + for (Uint cpu = 0; cpu < cpuCount; ++cpu) { + if (frequencies[cpu] >= threshold) mask |= (1ull << cpu); + } + return mask; + } - MobileGLResult ServerLoop::RunOnApplyThread(ControlWork, void*) { - MGP5_C0_STUB("ServerLoop::RunOnApplyThread"); + // MOBILEGL_IPC_SERVER_AFFINITY = `auto` | `off` | an explicit mask (0x... or decimal). + // The RAW STRING is what Config keeps, because the resolved mask is what gets logged - + // "an affinity that silently did nothing looks exactly like one that worked" + // (CONTRACT-P5 5). + Uint64 RequestedAffinityMask(const char* raw, Bool* outRecognised) { + *outRecognised = true; + if (raw == nullptr || raw[0] == '\0') return DetectBigCoreMask(); + if (std::strcmp(raw, "auto") == 0) return DetectBigCoreMask(); + if (std::strcmp(raw, "off") == 0) return 0; + char* end = nullptr; + const unsigned long long parsed = std::strtoull(raw, &end, 0); + if (end == raw || (end != nullptr && *end != '\0')) { + *outRecognised = false; + return 0; + } + return static_cast(parsed); + } + + void NameThisThread(const char* name) { +#if defined(__linux__) || defined(__ANDROID__) + pthread_setname_np(pthread_self(), name); +#else + (void)name; +#endif + } + + // Returns the mask that was actually APPLIED, which is 0 when nothing was. + Uint64 ApplyAffinity(Uint64 requested) { + if (requested == 0) return 0; +#if defined(__linux__) || defined(__ANDROID__) + cpu_set_t set; + CPU_ZERO(&set); + Uint applied = 0; + for (Uint cpu = 0; cpu < 64; ++cpu) { + if ((requested & (1ull << cpu)) != 0) { + CPU_SET(cpu, &set); + ++applied; + } + } + if (applied == 0) return 0; + if (sched_setaffinity(0, sizeof(set), &set) != 0) return 0; + return requested; +#else + return 0; +#endif + } + + Uint32 SpinUsFromConfig() { +#if MOBILEGL_BUILD_DISAGGREGATED + return MG_Config::Ipc.SpinUs; +#else + return Transport::kDefaultSpinUs; +#endif + } + + const char* AffinityStringFromConfig() { +#if MOBILEGL_BUILD_DISAGGREGATED + return MG_Config::Ipc.ServerAffinity.c_str(); +#else + return "off"; +#endif + } + + } // namespace + + // --------------------------------------------------------------------------------- + // The private backend object + // --------------------------------------------------------------------------------- + + MobileGLResult ServerLoop::CreateBackend(BackendType type) { + if (m_backend != nullptr) { + return MOBILEGL_ERR_INVALID_ARGUMENT; + } + switch (type) { + case BackendType::DirectGLES: + m_backend = MakeUnique(); + break; + case BackendType::DirectVulkan: + m_backend = MakeUnique(); + break; + default: + // NOT a fallback to DirectGLES. An unknown backend under split has to be refused by + // name: the alternative is a lane that says "split, DirectVulkan" and renders with + // the other backend. + MGLOG_E("MG_Remote server: MG_Config::ActiveBackendType is Unknown; the server role " + "has no backend to own a context with and the session is refused"); + return MOBILEGL_ERR_UNSUPPORTED; + } + // Loads the driver entry points and registers the resource op table + // (BackendObject_DirectGLES.cpp:844-851). NO GL CALL AND NO EGL CALL HAPPENS HERE - the + // native context is created and made current later, on the apply thread, when the + // client's eglMakeCurrent crosses as a blocking control request. + m_backend->Initialize(); + return MOBILEGL_OK; + } + + MG_Backend::BackendObject* ServerLoop::Backend() { return m_backend.get(); } + + // --------------------------------------------------------------------------------- + // Start / the loop / Stop + // --------------------------------------------------------------------------------- + + MobileGLResult ServerLoop::Start(ServerSession& session) { + if (m_running.load(std::memory_order_acquire)) { + return MOBILEGL_ERR_INVALID_ARGUMENT; + } + if (!session.Accepted()) { + MGLOG_E("MG_Remote server: ServerLoop::Start before ServerSession::Accept - there " + "are no rings to park on"); + return MOBILEGL_ERR_NOT_INITIALIZED; + } + m_session = &session; + m_stopRequested.store(false, std::memory_order_release); + { + const std::lock_guard lock(m_exitMutex); + m_exited = false; + } + m_running.store(true, std::memory_order_release); + m_thread = std::thread([this] { ApplyThreadMain(); }); + return MOBILEGL_OK; + } + + Bool ServerLoop::Running() const { return m_running.load(std::memory_order_acquire); } + + Bool ServerLoop::OnApplyThread() { + ServerLoop& loop = ServerLoopInstance(); + return loop.m_running.load(std::memory_order_acquire) && + loop.m_applyThreadId.load(std::memory_order_acquire) == std::this_thread::get_id(); + } + + Uint64 ServerLoop::ResolvedAffinityMask() const { return m_affinityMask; } + Uint64 ServerLoop::DrainedRecords() const { return m_drained.load(std::memory_order_acquire); } + Uint64 ServerLoop::ParkCount() const { return m_parks.load(std::memory_order_acquire); } + + void ServerLoop::ApplyThreadMain() { + m_applyThreadId.store(std::this_thread::get_id(), std::memory_order_release); + NameThisThread("mgl-srv-apply"); + + Bool recognised = true; + const char* raw = AffinityStringFromConfig(); + const Uint64 requested = RequestedAffinityMask(raw, &recognised); + m_affinityMask = ApplyAffinity(requested); + if (!recognised) { + MGLOG_E("MG_Remote server: MOBILEGL_IPC_SERVER_AFFINITY='%s' is not `auto`, `off` or " + "a number; NO affinity was applied. This is logged rather than defaulted " + "because an affinity that silently did nothing is indistinguishable from " + "one that worked", + raw == nullptr ? "" : raw); + } + // THE RESOLVED MASK, ALWAYS, INCLUDING 0. Contract 5's whole point: the string is what + // an operator typed and the mask is what the kernel took. + MGLOG_I("MG_Remote server: mgl-srv-apply started. MOBILEGL_IPC_SERVER_AFFINITY='%s' " + "requested mask 0x%llx, RESOLVED mask 0x%llx (0 = no affinity applied), spin " + "%u us", + raw == nullptr ? "" : raw, static_cast(requested), + static_cast(m_affinityMask), SpinUsFromConfig()); + + ServerSession& session = *m_session; + // The decoder is built HERE, on this thread, because PipeWireDecoder is "not thread + // safe: one decoder on the apply thread, by construction" and its constructor installs + // the process-wide apply hook. + session.Applier().Attach(&session.Control(), m_backend.get()); + + Transport::RingControl& control = session.Control(); + Transport::Doorbell& bell = session.ConsumerDoorbell(); + Transport::RingConsumer& ring = session.CommandRing(); + const Uint32 spinUs = SpinUsFromConfig(); + + // THE PARK CONDITION IS THREE THINGS, NOT ONE, AND THAT IS THE WHOLE REASON THIS LOOP + // DOES NOT CALL SessionConsumer::WaitForWork. + // + // WaitForWork's predicate is "a record is waiting" and nothing else. Park a thread on + // kWaitForever with that predicate and neither a control request nor a Stop() can get + // it out: Doorbell::Wait consumes the Notify with one Park, re-tests a condition + // nothing published, finds the bell alive, and parks again - forever. Only + // CondVarDoorbell::Kill() breaks that, and Kill is the CLIENT's teardown call, not + // something an EGL make-current request may perform. So the predicate carries all + // three arming conditions and the doorbell wakes the thread for any of them. + const auto ready = [this, &control, &ring] { + return control.cmdHead.load(std::memory_order_acquire) != ring.LocalTail() || + m_stopRequested.load(std::memory_order_acquire) || ControlIsPending(); + }; + + for (;;) { + PumpControlRequest(); + if (m_stopRequested.load(std::memory_order_acquire)) break; + DrainRing(); + if (m_stopRequested.load(std::memory_order_acquire)) break; + if (ready()) continue; + + m_parks.fetch_add(1, std::memory_order_acq_rel); + const bool woke = bell.Wait(control.consumerParked, ready, spinUs, Transport::kWaitForever); + if (!woke) { + // Wait returns false only on a dead bell or an expired deadline, and this park + // has no deadline. A dead bell IS the shutdown signal (table 3's teardown step + // 2); treating it as anything else would spin at full clock for ever, since + // parking on a dead bell no longer blocks. + if (bell.Dead()) { + MGLOG_D("MG_Remote server: the consumer doorbell is dead; mgl-srv-apply is " + "shutting down"); + break; + } + MGLOG_E("MG_Remote server: Doorbell::Wait(kWaitForever) returned false on a live " + "bell - that is impossible by Doorbell.h:139-178 and means the bell's " + "Dead() answer moved under the waiter. Shutting the loop down rather " + "than spinning"); + break; + } + } + + // Whatever is still queued gets applied before the context goes. The client's own + // drain (ClientSession::Stop step 1) normally empties the ring first and is BOUNDED, so + // a timeout there leaves records here; applying them costs nothing and dropping them + // would leave the emitter's var-tails referenced by records nobody ever read. + PumpControlRequest(); + DrainRing(); + + // THE BACKEND IS DESTROYED ON THIS THREAD, WHILE IT IS STILL THE CONTEXT OWNER. + // ~BackendObject_DirectGLES calls DestroyEGLContext (BackendObject_DirectGLES.cpp: + // 833-835), which runs eglMakeCurrent(NO_SURFACE) / eglDestroyContext / eglTerminate. + // Those must happen on the thread eglMakeCurrent was issued from; doing it on the app + // thread - which is what pActiveBackendObject.reset() at MobileGL/Init.cpp:68 would do + // - destroys a context this thread still holds current. + session.Applier().Detach(); + if (m_backend != nullptr) { + MGLOG_I("MG_Remote server: destroying the server's BackendObject on mgl-srv-apply, " + "which is the context owner"); + m_backend.reset(); + } + + // Anything still parked in RunOnApplyThread has to be answered rather than left + // waiting: this thread is the only thing that could have run it. + { + const std::lock_guard lock(m_controlMutex); + if (m_controlPending) { + m_controlPending = false; + m_controlFinished = true; + m_controlResult = MOBILEGL_ERR_NOT_INITIALIZED; + MGLOG_E("MG_Remote server: a control request was still posted when mgl-srv-apply " + "exited; it is answered NOT_INITIALIZED rather than left blocking"); + } + } + m_controlDone.notify_all(); + + m_running.store(false, std::memory_order_release); + SignalExited(); + } + + Bool ServerLoop::ControlIsPending() const { + const std::lock_guard lock(const_cast(m_controlMutex)); + return m_controlPending; + } + + Bool ServerLoop::PumpControlRequest() { + ControlWork work = nullptr; + void* user = nullptr; + { + const std::lock_guard lock(m_controlMutex); + if (!m_controlPending) return false; + work = m_controlWork; + user = m_controlUser; + } + const MobileGLResult result = work == nullptr ? MOBILEGL_ERR_INVALID_ARGUMENT : work(user); + { + const std::lock_guard lock(m_controlMutex); + m_controlResult = result; + m_controlPending = false; + m_controlFinished = true; + } + m_controlDone.notify_all(); + return true; + } + + Uint64 ServerLoop::DrainRing() { + ServerSession& session = *m_session; + Transport::SessionConsumer& consumer = session.Consumer(); + PipeApplier& applier = session.Applier(); + Uint64 applied = 0; + for (;;) { + bool corrupt = false; + const bool popped = consumer.ApplyOne( + [this, &applier](const Transport::RingRecordView& record) { + applier.ApplyOne(record); + // THE TALLY MOVES HERE, INSIDE THE CALLBACK, AND NOT AFTER THE BATCH. s1's + // SessionConsumer::ApplyOne publishes appliedSeq the instant this callback + // returns, and publishing appliedSeq is what releases the client from the + // verb barrier (R-1). Anything this thread records about the record AFTER + // that publish is visible to the client only eventually - and a diagnostic + // that can lag the watermark it describes is the exact shape of the + // LeaveApplier race PipeApplier::ApplyOne's block describes, one level up. + // The first version of this function tallied once per batch, below, and + // AClearRecordCrossesAndIsStampedAsAVerbBoundary / + // TheSessionWatermarkAndTheDecoderTallyAgreeAfterEveryRecord read + // DrainedRecords() one behind appliedSeq on 1 run in ~40 under `-j 8`. + // Per-record, before the publish, it can never be behind. + m_drained.fetch_add(1, std::memory_order_acq_rel); + }, + &corrupt); + if (corrupt) { + // Ring.h's own rule: a header the producer could not have written is + // Fatal{ProtocolCorruption}, never a retry. Retrying re-reads the same bytes + // for ever; skipping desynchronises seq, and seq IS the reply-slot id. + MGLOG_F("MGPipe: Fatal{ProtocolCorruption, \"SEG_CMD record header\"} - the " + "consumer refused a record header at applied seq %llu", + static_cast(consumer.AppliedSeq())); + std::abort(); + } + if (!popped) break; + ++applied; + } + if (applied != 0) { + // THE TWO TALLIES MUST AGREE, AND THAT IS WHAT MAKES R-9's BATCHING BAN CHECKABLE. + // RingControl::appliedSeq has one writer (SessionConsumer::ApplyOne, +1 per record) + // and PipeWireDecoder keeps its own count; a batched publish would move one and not + // the other, which a single counter could not have told apart (w1-v1 5). + if (applier.DecoderAppliedSeq() != consumer.AppliedSeq()) { + MGLOG_F("MGPipe: Fatal{ProtocolCorruption, \"appliedSeq batched\"} - the " + "session's watermark is %llu and the decoder applied %llu records. P5 " + "forbids batching appliedSeq (R-9): the verb barrier's waiter reads it, " + "and a watermark ahead of the decoder promises work that has not run", + static_cast(consumer.AppliedSeq()), + static_cast(applier.DecoderAppliedSeq())); + std::abort(); + } + // RETIRE. MANDATORY, not optional: RingProducer::FreeBytes() reclaims against + // retiredTail only, and w1's SEG_STAGE linear allocator reclaims on retiredSeq - so + // a loop that applies and never retires ends the first MOBILEGL_IPC_STAGE_MB of + // staging in Fatal{RingOverrun, "SEG_STAGE"} (w1-v1 5). Once per drain batch, not + // once per record: retiring LATE is always legal, retiring EARLY never is. + consumer.RetireThrough(consumer.AppliedSeq()); + // LEAVING THE APPLIER (p1's M-5) IS *NOT* DONE HERE. It is done inside + // PipeApplier::ApplyOne, before s1's SessionConsumer::ApplyOne publishes appliedSeq + // - see the block there. A clear at this point races the client, which the barrier + // has already released by then. + } + return applied; + } + + MobileGLResult ServerLoop::RunOnApplyThread(ControlWork work, void* user) { + if (work == nullptr) return MOBILEGL_ERR_INVALID_ARGUMENT; + // RE-ENTRANCY IS NOT A DEADLOCK. The teardown path posts from the apply thread itself - + // ~BackendObject_DirectGLES reaches ReleaseEGLResources - and so does anything the + // applier calls that wants "run this where the context is". Running inline is the + // correct answer there and the only non-hanging one. + if (OnApplyThread()) return work(user); + // NO THREAD YET, OR ALREADY GONE: run inline on the caller. That is right for the + // window between MG_Backend::Init()'s hook and ClientSession::Start (the backend object + // exists, the thread does not, and nothing has made a context current), and for the + // window after Stop(). It is NOT a silent fallback to monolith: the thread's absence in + // those two windows is a fact about the lifecycle, not about the transport. + if (!m_running.load(std::memory_order_acquire)) return work(user); + + const std::lock_guard callerLock(m_callerMutex); + { + std::unique_lock lock(m_controlMutex); + m_controlWork = work; + m_controlUser = user; + m_controlPending = true; + m_controlFinished = false; + } + // Publish THEN ring, in that order and never the other (Doorbell.h:186-193). The bell + // is rung unconditionally rather than through NotifyIfParked because this side does not + // know whether the apply thread is parked or spinning, and CondVarDoorbell remembers a + // wakeup that arrives while nobody is waiting. + if (m_session != nullptr) { + m_session->ConsumerDoorbell().Notify(); + } + std::unique_lock lock(m_controlMutex); + m_controlDone.wait(lock, [this] { return m_controlFinished; }); + return m_controlResult; + } + + void ServerLoop::SignalExited() { + { + const std::lock_guard lock(m_exitMutex); + m_exited = true; + } + m_exitCv.notify_all(); + } + + void ServerLoop::Stop() { + if (!m_thread.joinable()) { + // Never started, or already stopped. The backend may still exist - the hook builds + // it before the thread - and it has to go somewhere, so it goes here, on whatever + // thread called Stop. No context was ever made current from another thread in that + // case, which is exactly the condition that makes this safe. + if (m_backend != nullptr) m_backend.reset(); + m_running.store(false, std::memory_order_release); + return; + } + m_stopRequested.store(true, std::memory_order_release); + if (m_session != nullptr) { + // The bell may already be dead - ClientSession::Stop calls transport->Shutdown() + // first, which is what table 3's step 2 requires - and Notify on a dead bell is + // harmless. Ringing anyway covers the paths that Stop without a Kill. + m_session->ConsumerDoorbell().Notify(); + } + + // THE JOIN IS BOUNDED. std::thread::join has no deadline, so a lost wakeup would wedge + // CI rather than fail it; the thread signals m_exited last and this waits with the same + // five seconds InProcessTransportTest.cpp:344 uses. + Bool exited = false; + { + std::unique_lock lock(m_exitMutex); + exited = m_exitCv.wait_for(lock, std::chrono::milliseconds(kJoinTimeoutMs), + [this] { return m_exited; }); + } + if (!exited) { + // ABORT, NOT DETACH. A detached apply thread still owns the EGL context and would + // run on into the client's teardown, reading rings the client is about to unmap - + // a use-after-free whose only symptom is an intermittent crash somewhere else. + // Aborting here is red, immediate, and names the cause. + MGLOG_F("MGPipe: Fatal{ApplyThreadJoinTimeout} - mgl-srv-apply did not exit within " + "%u ms of Stop(). It parks on Doorbell::Wait(kWaitForever), which only " + "CondVarDoorbell::Kill() can break (Doorbell.h:211-221, table 3 step 2), so " + "this is a lost wakeup and not a slow thread. Aborting rather than " + "detaching: a detached apply thread still owns the context and would read " + "rings the client is about to unmap", + kJoinTimeoutMs); + std::abort(); + } + m_thread.join(); + m_applyThreadId.store(std::thread::id{}, std::memory_order_release); + m_running.store(false, std::memory_order_release); + m_session = nullptr; } ServerLoop& ServerLoopInstance() { @@ -44,6 +508,257 @@ namespace MobileGL::MG_Remote::Server { return instance; } -#undef MGP5_C0_STUB + // --------------------------------------------------------------------------------- + // The twelve EGL forwarders + // --------------------------------------------------------------------------------- + + namespace { + + // One captureless trampoline for every call: ControlWork is a raw function pointer + // plus a void*, not a std::function, because these run on the TEARDOWN path and the + // teardown path may not allocate (ID-8 exists because frontend destructors reach here + // from exit handlers). + template + MobileGLResult RunOnApply(Args& args) { + return ServerLoopInstance().RunOnApplyThread( + +[](void* user) -> MobileGLResult { return static_cast(user)->Run(); }, + &args); + } + + // Null means the hook never ran or the session is already down. Every forwarder + // answers false / no-op rather than dereferencing: a client that calls eglMakeCurrent + // on a process whose split bring-up failed must see a refusal, not a crash. + MG_Backend::BackendObject* ServerBackendOrNull() { return ServerLoopInstance().Backend(); } + + } // namespace + + Bool ServerInitializeEGLDisplay(EGLDisplay dpy, EGLint* major, EGLint* minor) { + struct Args { + EGLDisplay dpy; + EGLint* major; + EGLint* minor; + Bool ok = false; + MobileGLResult Run() { + MG_Backend::BackendObject* backend = ServerBackendOrNull(); + if (backend == nullptr) return MOBILEGL_ERR_NOT_INITIALIZED; + ok = backend->InitializeEGLDisplay(dpy, major, minor); + return MOBILEGL_OK; + } + } args{dpy, major, minor}; + return RunOnApply(args) == MOBILEGL_OK && args.ok; + } + + Bool ServerCreateEGLWindowSurface(EGLSurface surface, const MG_Backend::WindowHandle& handle) { + struct Args { + EGLSurface surface; + const MG_Backend::WindowHandle* handle; + Bool ok = false; + MobileGLResult Run() { + MG_Backend::BackendObject* backend = ServerBackendOrNull(); + if (backend == nullptr) return MOBILEGL_ERR_NOT_INITIALIZED; + ok = backend->CreateEGLWindowSurface(surface, *handle); + return MOBILEGL_OK; + } + } args{surface, &handle}; + return RunOnApply(args) == MOBILEGL_OK && args.ok; + } + + Bool ServerResizeEGLWindowSurface(EGLSurface surface, Uint32 width, Uint32 height) { + struct Args { + EGLSurface surface; + Uint32 width; + Uint32 height; + Bool ok = false; + MobileGLResult Run() { + MG_Backend::BackendObject* backend = ServerBackendOrNull(); + if (backend == nullptr) return MOBILEGL_ERR_NOT_INITIALIZED; + ok = backend->ResizeEGLWindowSurface(surface, width, height); + return MOBILEGL_OK; + } + } args{surface, width, height}; + return RunOnApply(args) == MOBILEGL_OK && args.ok; + } + + Bool ServerCreateEGLPbufferSurface(EGLSurface surface, EGLint width, EGLint height) { + struct Args { + EGLSurface surface; + EGLint width; + EGLint height; + Bool ok = false; + MobileGLResult Run() { + MG_Backend::BackendObject* backend = ServerBackendOrNull(); + if (backend == nullptr) return MOBILEGL_ERR_NOT_INITIALIZED; + ok = backend->CreateEGLPbufferSurface(surface, width, height); + return MOBILEGL_OK; + } + } args{surface, width, height}; + return RunOnApply(args) == MOBILEGL_OK && args.ok; + } + + Bool ServerMakeEGLCurrent(EGLDisplay dpy, EGLSurface draw, EGLSurface read, EGLContext ctx) { + struct Args { + EGLDisplay dpy; + EGLSurface draw; + EGLSurface read; + EGLContext ctx; + Bool ok = false; + MobileGLResult Run() { + MG_Backend::BackendObject* backend = ServerBackendOrNull(); + if (backend == nullptr) return MOBILEGL_ERR_NOT_INITIALIZED; + // THE ONE eglMakeCurrent OF THE PROCESS'S LIFE, ON THIS THREAD. Every later + // client-side eglMakeCurrent onto the same surface finds the context already + // current here and costs nothing; the owner slot is written once. + ok = backend->MakeEGLCurrent(dpy, draw, read, ctx); + if (!ok) return MOBILEGL_OK; + // R-12, arm (a): the caps snapshot is REPUBLISHED because InitCapabilities has + // now run for real. DirectGLES has no OnCapsInvalidated producer at all, and + // c0's answer is that a SECOND arrival IS the invalidation - so the client's + // mirror is refreshed with no dev-shaped backend edit and with no eleventh + // MGPipeCallbacks slot (MGPipeCallbacks.h:56-58's static_assert exists to make + // that cost visible). + ServerSession* session = ServerSession::Active(); + if (session != nullptr && session->Accepted()) { + const MobileGLResult published = session->PublishCapsSnapshot(); + if (published != MOBILEGL_OK) { + MGLOG_E("MG_Remote server: the post-make-current CapsSnapshot could not " + "be published (rc=%d); the client's mirror still holds the empty " + "snapshot Accept() sent before any context existed", + static_cast(published)); + } + } + return MOBILEGL_OK; + } + } args{dpy, draw, read, ctx}; + return RunOnApply(args) == MOBILEGL_OK && args.ok; + } + + Bool ServerSwapEGLBuffers(EGLDisplay dpy, EGLSurface draw) { + struct Args { + EGLDisplay dpy; + EGLSurface draw; + Bool ok = false; + MobileGLResult Run() { + MG_Backend::BackendObject* backend = ServerBackendOrNull(); + if (backend == nullptr) return MOBILEGL_ERR_NOT_INITIALIZED; + ok = backend->SwapEGLBuffers(dpy, draw); + return MOBILEGL_OK; + } + } args{dpy, draw}; + return RunOnApply(args) == MOBILEGL_OK && args.ok; + } + + void ServerSetEGLSwapInterval(Int interval) { + struct Args { + Int interval; + MobileGLResult Run() { + MG_Backend::BackendObject* backend = ServerBackendOrNull(); + if (backend == nullptr) return MOBILEGL_ERR_NOT_INITIALIZED; + backend->SetEGLSwapInterval(interval); + return MOBILEGL_OK; + } + } args{interval}; + (void)RunOnApply(args); + } + + void ServerReleaseEGLSurface(EGLSurface surface) { + struct Args { + EGLSurface surface; + MobileGLResult Run() { + MG_Backend::BackendObject* backend = ServerBackendOrNull(); + if (backend == nullptr) return MOBILEGL_ERR_NOT_INITIALIZED; + backend->ReleaseEGLSurface(surface); + return MOBILEGL_OK; + } + } args{surface}; + (void)RunOnApply(args); + } + + void ServerReleaseEGLResources() { + // BLOCKING BY CONTRACT. EGLImpl.cpp:326 calls this on the app thread and then, if no + // display and no context are left, calls MobileGL::Destroy() at :333. For DirectGLES it + // runs DestroyEGLContext - eglMakeCurrent(NO_SURFACE), eglDestroyContext, eglTerminate - + // and those must happen on the thread that made the context current. A fire-and-forget + // here lets Destroy() walk on while the server still holds the context, which is + // scout-install 5.3's named hazard. + struct Args { + MobileGLResult Run() { + MG_Backend::BackendObject* backend = ServerBackendOrNull(); + if (backend == nullptr) return MOBILEGL_ERR_NOT_INITIALIZED; + backend->ReleaseEGLResources(); + return MOBILEGL_OK; + } + } args{}; + (void)RunOnApply(args); + } + + Bool ServerInitCapabilities() { + struct Args { + Bool ok = false; + MobileGLResult Run() { + MG_Backend::BackendObject* backend = ServerBackendOrNull(); + if (backend == nullptr) return MOBILEGL_ERR_NOT_INITIALIZED; + ok = backend->InitCapabilities(); + if (!ok) return MOBILEGL_OK; + ServerSession* session = ServerSession::Active(); + if (session != nullptr && session->Accepted()) { + (void)session->PublishCapsSnapshot(); + } + return MOBILEGL_OK; + } + } args{}; + return RunOnApply(args) == MOBILEGL_OK && args.ok; + } + + Bool ServerInitWindowSurface() { + struct Args { + Bool ok = false; + MobileGLResult Run() { + MG_Backend::BackendObject* backend = ServerBackendOrNull(); + if (backend == nullptr) return MOBILEGL_ERR_NOT_INITIALIZED; + ok = backend->InitWindowSurface(); + return MOBILEGL_OK; + } + } args{}; + return RunOnApply(args) == MOBILEGL_OK && args.ok; + } + + void ServerSetWindowHandle(const MG_Backend::WindowHandle& handle) { + struct Args { + const MG_Backend::WindowHandle* handle; + MobileGLResult Run() { + MG_Backend::BackendObject* backend = ServerBackendOrNull(); + if (backend == nullptr) return MOBILEGL_ERR_NOT_INITIALIZED; + backend->SetWindowHandle(*handle); + return MOBILEGL_OK; + } + } args{&handle}; + (void)RunOnApply(args); + } } // namespace MobileGL::MG_Remote::Server + +// --------------------------------------------------------------------------------- +// The weak placeholder for package c1's remote backend object +// --------------------------------------------------------------------------------- +// +// MG_Backend/Init.cpp's hook calls MG_Remote::Client::CreateRemoteBackendObject(), which is +// c1's BackendObject_Remote and does not exist while v1 is written. A WEAK definition here +// lets v1 compile, link and be tested today, and c1's strong definition displaces it at link +// time with no edit anywhere. +// +// IT ABORTS BY NAME AND DOES NOT RETURN A WORKING MONOLITH OBJECT. That distinction is the +// whole point: a placeholder that handed back a BackendObject_DirectGLES would give a lane +// called "split" a correct picture produced entirely by the monolith path, which is +// ARCHITECTURE.md 10.3's failure and the one this phase exists to make impossible. +#if defined(__GNUC__) || defined(__clang__) +namespace MobileGL::MG_Remote::Client { + __attribute__((weak)) UniquePtr CreateRemoteBackendObject() { + MGLOG_F("MGPipe: Fatal{UnimplementedRemoteBackendObject} - MG_Backend::Init()'s split " + "hook asked for the client's BackendObject_Remote and only v1's weak " + "placeholder is linked in. That object is package c1's (BRIEF 5); until it " + "lands there is no client role, and this build refuses to substitute the " + "monolith backend for it"); + std::abort(); + } +} // namespace MobileGL::MG_Remote::Client +#endif diff --git a/MobileGL/MG_Remote/Server/ServerLoop.h b/MobileGL/MG_Remote/Server/ServerLoop.h index 724d58ef..05a85fec 100644 --- a/MobileGL/MG_Remote/Server/ServerLoop.h +++ b/MobileGL/MG_Remote/Server/ServerLoop.h @@ -45,6 +45,11 @@ #include "ServerSession.h" +#include +#include +#include +#include + namespace MobileGL::MG_Remote::Server { class ServerLoop { @@ -81,11 +86,129 @@ namespace MobileGL::MG_Remote::Server { using ControlWork = MobileGLResult (*)(void* user); MobileGLResult RunOnApplyThread(ControlWork work, void* user); + // ---- v1's additions beyond c0's signature block --------------------------------- + + // Constructs the SERVER ROLE's private BackendObject and runs its non-GL Initialize(). + // Called from MG_Backend::Init()'s single hook, BEFORE ClientSession::Start(), because + // ServerSession::Accept() publishes the first CapsSnapshot from it and because the two + // CallMask halves - which have no default and Fatal when unset (ServerSession.h) - are + // answered from what this backend is. + // + // NO GL AND NO EGL HAPPENS HERE. BackendObject_DirectGLES::Initialize() loads the + // driver's entry points; the native context does not exist until the client's first + // eglMakeCurrent crosses as a blocking control request and runs on the apply thread. + // That split is what lets the backend object be BUILT on the app thread while its + // context is never OWNED by it. + MobileGLResult CreateBackend(BackendType type); + + // True on the apply thread itself. RunOnApplyThread uses it to run inline rather than + // deadlock when the apply thread posts to itself - which the EGL teardown path does, + // because ~BackendObject_DirectGLES runs THERE and reaches ReleaseEGLResources. + static Bool OnApplyThread(); + + // The CPU mask MOBILEGL_IPC_SERVER_AFFINITY resolved to and sched_setaffinity accepted. + // 0 means "no affinity was applied" - the honest answer for `off`, for a platform with + // no affinity call, and for a failed syscall - and is exactly why the RESOLVED mask is + // logged rather than the string an operator typed. + Uint64 ResolvedAffinityMask() const; + + // Diagnostics the tests read. DrainedRecords is how many records this thread has handed + // to the applier; ParkCount how many times it actually parked. A shutdown test that + // asserts only "Stop() returned" cannot tell a thread that parked and was woken by Kill + // from one that never parked at all - which is the R-16 shape of a check that cannot + // fail for its own reason. + Uint64 DrainedRecords() const; + Uint64 ParkCount() const; + private: + void ApplyThreadMain(); + // Part of the apply thread's park predicate: a posted control request must be able to + // un-park a thread waiting on kWaitForever, which a Notify alone cannot do. + Bool ControlIsPending() const; + // Runs a posted control request, if there is one. Returns true if it ran one. + Bool PumpControlRequest(); + // Pops and applies every record currently in the ring; returns how many it applied. + Uint64 DrainRing(); + void SignalExited(); + ServerSession* m_session = nullptr; - Bool m_running = false; + std::atomic m_running{false}; + std::atomic m_stopRequested{false}; + std::thread m_thread; + std::atomic m_applyThreadId{}; + + // The private backend object. Destroyed ON the apply thread while it still owns the + // context - see Stop(). + UniquePtr m_backend; + + // The blocking control mailbox. ONE slot, because the verb barrier already leaves one + // client thread runnable at a time; m_callerMutex serialises anything that is not. + std::mutex m_callerMutex; + std::mutex m_controlMutex; + std::condition_variable m_controlPosted; + std::condition_variable m_controlDone; + ControlWork m_controlWork = nullptr; + void* m_controlUser = nullptr; + MobileGLResult m_controlResult = MOBILEGL_OK; + Bool m_controlPending = false; + Bool m_controlFinished = false; + + // The BOUNDED join's other half. std::thread::join has no deadline, so a lost wakeup + // would wedge CI rather than fail it; the thread signals here last and Stop() waits + // with a deadline (InProcessTransportTest.cpp:344's five seconds). + std::mutex m_exitMutex; + std::condition_variable m_exitCv; + Bool m_exited = false; + + Uint64 m_affinityMask = 0; + std::atomic m_drained{0}; + std::atomic m_parks{0}; }; ServerLoop& ServerLoopInstance(); + // --------------------------------------------------------------------------------- + // THE EGL OWNERSHIP MOVE - the part that can sink the phase, expressed as twelve calls + // --------------------------------------------------------------------------------- + // + // Under split the app thread must never reach the driver's eglMakeCurrent. Today it does: + // EGLImpl.cpp:284 -> BackendObject_DirectGLES.cpp:963 -> DirectGLES.cpp:11925, and the + // owner slot g_backendContextOwnerThread (DirectGLES.cpp:11865) is stamped with whatever + // thread got there. So BackendObject_Remote's nine EGL virtuals - package c1's - call these + // twelve, each of which is a BLOCKING control request that runs the SERVER's backend object + // on mgl-srv-apply. eglMakeCurrent then runs ONCE, on that thread, and is never released: + // g_backendContextOwnerThread is written once, DirectGLES.cpp:11933-11953's six cache + // invalidations become a one-off startup cost instead of a per-migration storm, the + // per-frame EGL re-verification stamp is permanently true, and the 16 + // IsBackendContextCurrentOnThisThread() sites plus the 16 CanTouchGLNow() sites answer TRUE + // on the server instead of silently degrading. + // + // TWO OF THEM MUST BLOCK OR THE PROCESS TEARS ITS OWN CONTEXT DOWN UNDER ITSELF: + // ReleaseEGLResources (reached from EGLImpl.cpp:326, which for DirectGLES runs + // DestroyEGLContext) and ~BackendObject_DirectGLES (reached from + // pActiveBackendObject.reset() at MobileGL/Init.cpp:68). Both are blocking here - the first + // by being one of these calls, the second because ServerLoop::Stop() destroys the private + // backend ON the apply thread before that thread exits and Stop() itself waits. + // + // WHY THEY ARE FREE FUNCTIONS AND NOT MEMBERS: c1 needs exactly this surface and nothing + // else of the server, so the seam between the two packages is a list of twelve signatures + // rather than a class with a lifecycle. + Bool ServerInitializeEGLDisplay(EGLDisplay dpy, EGLint* major, EGLint* minor); + Bool ServerCreateEGLWindowSurface(EGLSurface surface, const MG_Backend::WindowHandle& handle); + Bool ServerResizeEGLWindowSurface(EGLSurface surface, Uint32 width, Uint32 height); + Bool ServerCreateEGLPbufferSurface(EGLSurface surface, EGLint width, EGLint height); + // Also RE-PUBLISHES THE CAPS SNAPSHOT on success (R-12). BackendObject::MakeEGLCurrent runs + // InitCapabilities() on the first make-current per surface (BackendObject.cpp:341-347), so + // this is the moment the server's answers stop being the empty ones Accept() published - + // and a SECOND arrival IS the invalidation signal, which is how DirectGLES, which has no + // OnCapsInvalidated producer at all, tells the client without a dev-shaped backend edit. + Bool ServerMakeEGLCurrent(EGLDisplay dpy, EGLSurface draw, EGLSurface read, EGLContext ctx); + Bool ServerSwapEGLBuffers(EGLDisplay dpy, EGLSurface draw); + void ServerSetEGLSwapInterval(Int interval); + void ServerReleaseEGLSurface(EGLSurface surface); + void ServerReleaseEGLResources(); + Bool ServerInitCapabilities(); + Bool ServerInitWindowSurface(); + void ServerSetWindowHandle(const MG_Backend::WindowHandle& handle); + } // namespace MobileGL::MG_Remote::Server diff --git a/MobileGL/MG_Remote/Server/StagedShadow.h b/MobileGL/MG_Remote/Server/StagedShadow.h new file mode 100644 index 00000000..77e63b75 --- /dev/null +++ b/MobileGL/MG_Remote/Server/StagedShadow.h @@ -0,0 +1,182 @@ +// MobileGL - MobileGL/MG_Remote/Server/StagedShadow.h +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +// R-11 - THE SERVER'S OWN COPY OF THE STAGED BYTES. Owner: package v1. +// +// GLESBufferResource::hostBytes (Managers.h:839) is the tree's ONE violation of rule C ("an +// applier entry point may not hold a pointer past its return"): Ops_H_SubData (Managers.cpp: +// 1980-1983) and Ops_H_FlushRange (:2035) record the client's shadow base and six later drains +// read it (:2000, :2062, :2080, :2111, :2741, :2843). In monolith that is correct - the bytes +// belong to a frontend object that outlives the call. Under split the pointer names SEG_STAGE, +// which is valid only until retiredSeq passes the record that named it, and w1's +// MOBILEGL_IPC_AUDIT=1 fills retired staging bytes with 0xDD precisely so an implementation +// that kept the pointer is DISTINGUISHABLE from one that copied. So this copies. +// +// THE SNAPSHOT EXTENT IS EXACTLY WHAT THE RECORD DECLARED, NEVER WIDENED (the integrator's +// ruling on b1's open M-6 half). Widening looked free once before and was not: a page-aligned +// INVALIDATE_RANGE clobbered GPU-written data - an SSBO counter beside the app's SubData - with +// stale shadow bytes (Managers.cpp:1126-1129). Tier 1's INVALIDATE_RANGE is an ASSERTION that +// the old bytes are dead, so declaring bytes covered that nothing staged is not a missing +// optimisation, it is a silent data loss. The coverage set below is therefore exact, and a +// drain that reaches outside it is Fatal rather than a re-read of whatever happens to be there. +// +// WHY IT IS A HEADER AND NOT A BLOCK INSIDE Managers.cpp. Two reasons, and the second is the +// one that matters. Managers.h is package b1's, so v1's R-11 edit may not add a member to +// GLESBufferResource and the storage has to live beside it rather than in it. And a block +// inside Managers.cpp could only ever be exercised by a test that also has a GL context, a +// resource twin and a live session - which is exactly how a rule ends up with no check that +// can fail for its own reason (R-16). Here, CopiesIntoServerStorage is a parameter rather than +// a read of MG_Config::Transport, so a unit case builds one store of each kind and asserts the +// DIFFERENCE between them. + +#pragma once +#include + +#include +#include + +#include +#include +#include +#include + +namespace MobileGL::MG_Remote::Server { + + // Keyed by the resource twin's ADDRESS, which is stable: the twins are heap-allocated and + // held by SharedPtr in the backend slot table, and every event that ends a base's life - + // an orphaning respecify, a successful map_persistent, destroy, context death - already has + // a call site in Managers.cpp to drop it from. + class StagedShadowStore { + public: + // `copies` is "this process is really split". False reproduces the monolith expression + // character for character (`raw - offset`), which is what keeps every push and verify + // lane byte-identical to what it was before R-11. + explicit StagedShadowStore(Bool copies) : m_copies(copies) {} + + Bool CopiesIntoServerStorage() const { return m_copies; } + + // Copies [offset, offset+size) of the record's staged bytes into server-owned storage + // and returns the SERVER base (offset 0 of the resource). Under monolith it returns the + // client base unchanged and allocates nothing. + const Uint8* Adopt(const void* key, SizeT width, const void* bytes, SizeT offset, SizeT size) { + const auto* raw = static_cast(bytes); + if (!m_copies) return raw - offset; + const std::lock_guard lock(m_mutex); + Shadow& shadow = m_shadows[key]; + const SizeT needed = std::max(width, offset + size); + if (shadow.Bytes.size() < needed) shadow.Bytes.resize(needed, 0); + if (size != 0 && raw != nullptr) { + std::memcpy(shadow.Bytes.data() + offset, raw, size); + CoverageAdd(shadow.Covered, offset, offset + size); + } + m_any.store(true, std::memory_order_release); + // Growing REALLOCATES, so every caller assigns the returned base to hostBytes on + // the same call. No other resource's base moves: each Shadow owns its own vector, + // and a rehash of the map MOVES that vector, which preserves its data pointer. + return shadow.Bytes.data(); + } + + void Drop(const void* key) { + if (!m_any.load(std::memory_order_acquire)) return; + const std::lock_guard lock(m_mutex); + m_shadows.erase(key); + } + + void DropAll() { + if (!m_any.load(std::memory_order_acquire)) return; + const std::lock_guard lock(m_mutex); + m_shadows.clear(); + } + + // Fatal when a drain reaches bytes no record staged. It fires ONLY for a base that is + // this key's server shadow: the legacy arm passes the frontend object's own + // MappedData(), which is valid for the whole store and is not this rule's subject. + void RequireCoverage(const void* key, const Uint8* hostBase, SizeT start, SizeT end, + const char* site) const { + if (!m_any.load(std::memory_order_acquire) || hostBase == nullptr) return; + const std::lock_guard lock(m_mutex); + const auto it = m_shadows.find(key); + if (it == m_shadows.end() || it->second.Bytes.data() != hostBase) return; + if (CoverageHas(it->second.Covered, start, end)) return; + MGLOG_F("MGPipe: Fatal{StageSnapshotTooNarrow, \"%s\"} - the server's ladder wants " + "[%zu, %zu) of a buffer whose staged coverage does not include it. Under " + "split the authoritative shadow is SERVER-OWNED (rule C) and " + "resource_subdata is the only way bytes reach it, so bytes outside a staged " + "range have never existed on this side. Re-reading them would move zeroes " + "into the store, and an INVALIDATE_RANGE over them would declare live " + "GPU-written bytes dead (Managers.cpp:1126-1129). This is a missing record, " + "not a missing widening", + site, start, end); + std::abort(); + } + + // Diagnostics the unit cases read, so that a check can assert WHAT HAPPENED rather than + // that nothing blew up. + Bool IsCovered(const void* key, SizeT start, SizeT end) const { + const std::lock_guard lock(m_mutex); + const auto it = m_shadows.find(key); + if (it == m_shadows.end()) return false; + return CoverageHas(it->second.Covered, start, end); + } + SizeT CoveredRunCount(const void* key) const { + const std::lock_guard lock(m_mutex); + const auto it = m_shadows.find(key); + return it == m_shadows.end() ? 0 : it->second.Covered.size(); + } + SizeT TrackedResources() const { + const std::lock_guard lock(m_mutex); + return m_shadows.size(); + } + + // Adjacent ranges merge - there is no gap between them, so the union really is one run. + // Ranges with a gap do NOT merge, and that is the whole mechanism: it is what makes a + // missing record detectable instead of papered over. + static void CoverageAdd(Vector& covered, SizeT start, SizeT end) { + if (start >= end) return; + Vector merged; + merged.reserve(covered.size() + 1); + SizeT s = start; + SizeT e = end; + for (const auto& range : covered) { + if (range.end < s || range.start > e) { + merged.push_back(range); + continue; + } + s = std::min(s, range.start); + e = std::max(e, range.end); + } + merged.push_back({s, e}); + std::sort(merged.begin(), merged.end(), + [](const Range1D& a, const Range1D& b) { return a.start < b.start; }); + covered = std::move(merged); + } + + static Bool CoverageHas(const Vector& covered, SizeT start, SizeT end) { + if (start >= end) return true; + for (const auto& range : covered) { + if (range.start <= start && end <= range.end) return true; + } + return false; + } + + private: + struct Shadow { + Vector Bytes; + // Sorted, disjoint, EXACT. + Vector Covered; + }; + + const Bool m_copies; + mutable std::mutex m_mutex; + ska::flat_hash_map m_shadows; + // Read on every UploadRangeFrom, so the monolith cost is one acquire load of a + // never-written flag rather than a mutex and a hash lookup. + std::atomic m_any{false}; + }; + +} // namespace MobileGL::MG_Remote::Server diff --git a/MobileGL/MG_Remote/Wire/PipeWireCodec.cpp b/MobileGL/MG_Remote/Wire/PipeWireCodec.cpp index f6c0b846..1f901c27 100644 --- a/MobileGL/MG_Remote/Wire/PipeWireCodec.cpp +++ b/MobileGL/MG_Remote/Wire/PipeWireCodec.cpp @@ -1310,11 +1310,27 @@ namespace MobileGL::MG_Remote::Wire { // may not re-derive it, because an if-constexpr discard, a stale handle and a refused // record are all invisible from the call site). // - // IT DOES NOT GO IN A REPLY SLOT. None of the four carries kReplySlot, and s1 sizes - // ReplyPool from that table - see PostReply. The answer is recorded here and read - // through LastAcceptance() / Accepted+DeclinedRecords() until the integrator rules on + // IT NOW RIDES THE REPLY SLOT, AND THAT IS THE RULING THIS COMMENT ASKED FOR. The text + // that stood here said the answer could not ride a slot "until the integrator rules on // which half of the contract moves (table 0 says these four use DECLINED; the - // catalogue gives them no slot). + // catalogue gives them no slot)". Both halves have since moved the same way: ID-31 + // gave `ResourceCreate` kReplySlot and `kMGPipeCallFlags` now carries the flag on all + // four (PipeWire.inc rows 2, 3, 47, 48), and P5 ruling R-17 states that the acceptance + // answers "come back through the reply slot inside the barrier's wait". So the + // conflict is resolved in favour of table 0, the pool reserves these seqs like any + // other, and `PostReply`'s own Fatal - which trips on a row with no kReplySlot - is + // what keeps this honest if a flag is ever taken away again. + // + // WITHOUT THIS THE CLIENT CANNOT RUN AT ALL: `ClientSession::EmitAndWait` asks the + // catalogue, not the caller, whether a row owns a slot, so all four would wait for an + // answer nobody wrote and take `Fatal{ReplyMissing}` inside the barrier. Recording the + // acceptance locally as well is kept, because `LastAcceptance()` and the two counters + // are what the monolith-side decoder cases are asserted on. + // + // OWNERSHIP: MG_Remote/Wire/* is package w1's and w1 is not in wave 2. This hunk is + // four lines inside one lambda, made under R-17 by c1 because it is the server half of + // the routing R-17 assigns, and it is called out in c1-v2.md so the integrator can + // move it if the call belongs elsewhere. const auto noteAcceptance = [&](Bool accepted) { m_lastAcceptanceKnown = true; m_lastAcceptance = accepted; @@ -1323,6 +1339,10 @@ namespace MobileGL::MG_Remote::Wire { } else { ++m_declined; } + // DECLINED is a real answer and carries no payload (ReplySlot.h): the four Bool + // rows say `false` with it, exactly as MapPersistent says nullptr with it. + PostReply(op, seq, accepted ? ReplySink::kStatusOk : ReplySink::kStatusDeclined, + nullptr, 0); }; switch (op) { diff --git a/MobileGL/MG_Test/Pipe/PipeCatalogueTest.cpp b/MobileGL/MG_Test/Pipe/PipeCatalogueTest.cpp index a3ea0bb0..b3e6c0c7 100644 --- a/MobileGL/MG_Test/Pipe/PipeCatalogueTest.cpp +++ b/MobileGL/MG_Test/Pipe/PipeCatalogueTest.cpp @@ -25,6 +25,10 @@ #if MOBILEGL_PIPE_PUSH #include #include +// P5 R-17: the routing that INSTALLS the two tables. Included here so that the installation +// case below states the partition deterministically rather than depending on whether some +// other object in this particular test binary happened to drag the installer in. +#include #endif using namespace MobileGL; @@ -101,17 +105,111 @@ TEST(PipeCatalogue, GeneratedTablesHoldTheWholeCatalogue) { EXPECT_EQ(ClassCount(), 13u); } -// An uninstalled pipe is every entry null - which is exactly what "this subsystem has not -// been migrated, keep pulling" means (plan B section 4.1). +// A row nobody has migrated is null - which is exactly what "this subsystem has not been +// migrated, keep pulling" means (plan B section 4.1). +// +// UNTIL P5 R-17 THAT WAS EVERY ROW, and this case said so. It is now EXACTLY THE 34 ROWS WITH +// NO MGPipeApply* ENTRY POINT: the other 37 have an applier, R-17 installs adapters over them, +// and a null there would no longer mean "keep pulling" - `MG_Impl/Pipe`'s call sites go through +// the thunks, so a null would mean "call through a null pointer". The number is asserted rather +// than the emptiness, because "37 installed" and "34 still null" are the two halves of a +// partition and a case that checked only one of them would pass an installer that had +// overwritten rows it does not own. +// THE NAME IS KEPT, AND SO IS THE STATEMENT IT MAKES - only the ROWS it makes it about have +// narrowed. G2/G14 compare ctest names against a pre-P5 baseline and require ZERO removed, so +// renaming a case is a removal even when the new name is better: it is indistinguishable, from +// the gate's side, from a case that was deleted. So this case stays, asserting the half that is +// still true, and the new half below is an ADDED name. TEST(PipeCatalogue, UninstalledTablesAreAllNull) { const void* const* screen = reinterpret_cast(&gMGPipeScreen); - for (SizeT i = 0; i < kMGPipeScreenCallCount; ++i) { - EXPECT_EQ(screen[i], nullptr) << "screen entry " << i; - } const void* const* context = reinterpret_cast(&gMGPipeContext); - for (SizeT i = 0; i < kMGPipeContextCallCount; ++i) { - EXPECT_EQ(context[i], nullptr) << "context entry " << i; +#if MOBILEGL_PIPE_PUSH + MGPipeInstallMonolithTables(); + // The 34 rows with no MGPipeApply* entry point are still null, and null still means "this + // subsystem has not been migrated, keep pulling". Named rather than counted, because the + // count is the other case's job and two cases asserting the same number would both go red + // for one change. + EXPECT_EQ(gMGPipeContext.SetShaderBuffers, nullptr); + EXPECT_EQ(gMGPipeContext.SetStreamOutputTargets, nullptr); + EXPECT_EQ(gMGPipeContext.DrawVbo, nullptr); + EXPECT_EQ(gMGPipeContext.Present, nullptr); + EXPECT_EQ(gMGPipeContext.SetSwapInterval, nullptr); + EXPECT_EQ(gMGPipeScreen.GetCaps, nullptr); + EXPECT_EQ(gMGPipeContext.QueryCreate, nullptr); + EXPECT_EQ(gMGPipeScreen.FenceCreate, nullptr); +#else + // A pull build compiles no applier and no routing, so the pre-migration statement is the + // whole truth there and this case is the one that says so. + for (SizeT i = 0; i < kMGPipeScreenCallCount; ++i) EXPECT_EQ(screen[i], nullptr) << i; + for (SizeT i = 0; i < kMGPipeContextCallCount; ++i) EXPECT_EQ(context[i], nullptr) << i; +#endif + (void)screen; + (void)context; +} + +TEST(PipeCatalogue, ExactlyTheRoutedRowsAreInstalledAndTheRestAreStillNull) { + const void* const* screen = reinterpret_cast(&gMGPipeScreen); + const void* const* context = reinterpret_cast(&gMGPipeContext); + SizeT installed = 0; + SizeT nulls = 0; + +#if MOBILEGL_PIPE_PUSH + // IDEMPOTENT, and called here on purpose: what this case observes is WHICH rows the + // installer fills, not whether an installer ran somewhere in this binary. Leaving that to + // ambient linkage is what made the same assertion pass in one build directory and fail in + // another - the object file carrying a static initialiser was dropped by the linker in the + // binaries that did not name a symbol in it. + MGPipeInstallMonolithTables(); +#endif + + for (SizeT i = 0; i < kMGPipeScreenCallCount; ++i) { + if (screen[i] != nullptr) ++installed; else ++nulls; } + for (SizeT i = 0; i < kMGPipeContextCallCount; ++i) { + if (context[i] != nullptr) ++installed; else ++nulls; + } + EXPECT_EQ(installed + nulls, static_cast(kMGPipeCallCount)); + +#if MOBILEGL_PIPE_PUSH + // 33 + 4 = 37, and the split is the honest shape of R-17 rather than an implementation + // detail: 37 is the number of MGPipeApply* entry points PipeApply.h declares, 33 of them + // fit a GENERATED row and go in the two tables, and FOUR cannot be expressed by any + // generated signature and go in the hand-written escape table beside them + // (ResourceRespecify's uncarried initialBytes, ResourceFlushRange's likewise, + // MapPersistent's size + seedBytes + void* return, CreateShaderState's seven blobrefs and + // two typed pointers - each one a CONTRACT-P5 ruling, see MG_Pipe/PipeRoute.h). + // + // BOTH NUMBERS ARE ASSERTED. If the escape table were left out of this case, moving a row + // out of the generated tables and forgetting to install its escape would read as a smaller + // "installed" count and nothing else - and the call site would take a null. + EXPECT_EQ(installed, 33u) << "the routed rows and the applier's entry points disagree"; + EXPECT_EQ(nulls, static_cast(kMGPipeCallCount) - 33u); + const void* const* escapes = reinterpret_cast(&gMGPipeRouteEscapes); + SizeT escapesInstalled = 0; + for (SizeT i = 0; i < sizeof(MGPipeRouteEscapes) / sizeof(void*); ++i) { + if (escapes[i] != nullptr) ++escapesInstalled; + } + EXPECT_EQ(escapesInstalled, 4u) << "an escape row is null; its call site would take a null " + "pointer rather than fall back to anything"; + EXPECT_EQ(installed + escapesInstalled, 37u) + << "the two tables plus the escapes must be exactly PipeApply.h's entry points"; + + // And the rows that MUST still be null, named rather than counted: these are calls with no + // applier at all (CONTRACT-P5 table 1 rows 13, 14: "no applier entry point exists"), plus + // the two verbs the census measured as having zero MG_Impl call sites. An installer that + // filled one of these would be claiming an implementation that does not exist. + EXPECT_EQ(gMGPipeContext.SetShaderBuffers, nullptr); + EXPECT_EQ(gMGPipeContext.SetStreamOutputTargets, nullptr); + EXPECT_EQ(gMGPipeContext.DrawVbo, nullptr); + EXPECT_EQ(gMGPipeContext.Present, nullptr); + EXPECT_EQ(gMGPipeContext.SetSwapInterval, nullptr); + EXPECT_EQ(gMGPipeScreen.GetCaps, nullptr); +#else + // A pull build compiles no applier and no routing, so the pre-migration statement is still + // the whole truth there. + EXPECT_EQ(installed, 0u); + EXPECT_EQ(nulls, static_cast(kMGPipeCallCount)); +#endif } // The retirement ratchet of the migration carrier (section 6.3): the constant and the diff --git a/MobileGL/MG_Test/SanityTest.cpp b/MobileGL/MG_Test/SanityTest.cpp index 45393eff..98da37b5 100644 --- a/MobileGL/MG_Test/SanityTest.cpp +++ b/MobileGL/MG_Test/SanityTest.cpp @@ -41,6 +41,13 @@ #include #include #include + +#if MOBILEGL_BUILD_DISAGGREGATED +// P5 c1 / R-8: the client's liveness gates read the caps mirror's consumer mask under split, so +// a split-armed case has to arm that half too - registering an op table is the SERVER's arming. +#include +#include +#endif #include #include #include @@ -4695,6 +4702,21 @@ TEST(DirectGLESBufferDrawProbe, UnderSplitTheRecordAloneAnswersTheLiveHostMapQue MG_Pipe::MGPipeSetResourceOps(&ops); MG_Config::Transport = MG_Config::TransportMode::InProcess; MG_Config::Ipc.PersistentBlockKb = 64; + // P5 c1 / R-8: UNDER SPLIT THE OP TABLE IS NO LONGER THE ARMING CONDITION, and this case is + // the first place that shows. `MGPipeSetResourceOps(&ops)` is the SERVER's registration; the + // client's liveness gate now reads the caps mirror's consumer mask instead, because under a + // spawn the client process has no op table at all and reading one would silently stop five + // record families. So the probe has to arm BOTH halves - and the fact that it did not is the + // defect R-8 exists to catch, reproduced here by a change rather than argued about. + const Uint64 previousCapsGeneration = MG_Remote::Client::CapsMirrorInstance().Generation(); + { + MG_Pipe::MGPCaps caps{}; + caps.CallMask = MG_Remote::MGCapsConsumerBits(MG_Pipe::kMGPipeSubsystemResources); + MG_Remote::Client::CapsMirrorInstance().Adopt(caps, MG_Backend::FormatCapabilityCache{}, + RendererInfo{}, String{}, + BackendType::DirectGLES); + } + (void)previousCapsGeneration; { // The constructor mints the handle and emits resource_create; Respecify emits the @@ -4780,6 +4802,93 @@ TEST(DirectGLESBufferDrawProbe, UnderSplitTheRecordAloneAnswersTheLiveHostMapQue #endif } +// R-8's NEGATIVE CONTROL, at the level of the probe above rather than at the level of the +// accessor. `CapsMirrorTest.AMaskWithoutAFamilyRefusesItAndNamesIt` already pins that +// `ServerConsumes` counts and names a refusal; what it cannot pin is that the LIVENESS GATE +// the probe above depends on actually asks it. This case is the pair: the op table is +// registered - so the pre-R-8 read (`MGPipeGetResourceOps() != nullptr`) would answer +// "enabled" - and the caps mask is EMPTY, so the only honest answer is "no consumer". +// +// AND THE REFUSAL IS COUNTED, NOT INFERRED. "No record appeared" is satisfied by a client that +// never ran at all: a typo in the fixture, a subsystem bit left clear, a BufferObject that +// threw. Asking `ConsumerRefusals()` for a DELTA and `LastRefusedSubsystem()` for the family's +// own bit is a statement that the gate was reached, asked the mirror, and was told no - which +// is the fact R-8 exists to establish. Put `MGPipeGetResourceOps() != nullptr` back into +// MGPipeResourceSubsystemEnabled() and this goes red on the record, not on the counter. +TEST(DirectGLESBufferDrawProbe, ACapsMaskWithoutTheResourceFamilyEmitsNothingAndCountsTheRefusal) { + using namespace MobileGL; + using namespace MobileGL::MG_Backend::DirectGLES; + using namespace MobileGL::MG_State::GLState; + + if (!EsprytSlotTablesEnabled()) { + GTEST_SKIP() << "the handle-keyed resource table only exists on the {slot, gen} arm"; + } +#if !MOBILEGL_BUILD_DISAGGREGATED + GTEST_SKIP() << "MG_Config::Transport is a constexpr Monolith without the transport built in, " + "so the split arm of this probe cannot be entered"; +#else + const Uint64 previousPush = MG_Config::Features.PipePush; + const auto previousTransport = MG_Config::Transport; + MG_Pipe::MGPipeResourceOps ops{}; + MG_Config::Features.PipePush |= MG_Pipe::kMGPipeSubsystemResources; + // THE SERVER's registration is present. Under the pre-R-8 gate this alone armed the client. + MG_Pipe::MGPipeSetResourceOps(&ops); + MG_Config::Transport = MG_Config::TransportMode::InProcess; + + { + // The CLIENT's answer: a snapshot that names no consumer at all. R-12 makes a second + // arrival the invalidation, so adopting is how a mask is replaced; there is no + // Invalidate() to call and inventing one would be a second spelling of the same edge. + MG_Pipe::MGPCaps caps{}; + caps.CallMask = 0; + MG_Remote::Client::CapsMirrorInstance().Adopt(caps, MG_Backend::FormatCapabilityCache{}, + RendererInfo{}, String{}, + BackendType::DirectGLES); + } + ASSERT_FALSE(MG_Remote::Client::CapsMirrorInstance().ServerConsumes( + MG_Pipe::kMGPipeSubsystemResources)) + << "the fixture's own mask consumes the family, so this case cannot observe a refusal"; + + MG_Remote::Client::ResetConsumerRefusalsForTest(); + { + auto owner = MakeShared(0u); + owner->Respecify(256, nullptr); + + // The MINT is unconditional in a push build (set_vertex_buffers names a buffer by + // handle whether or not the resource family is on), so a handle is the expected state + // and is NOT what this case reads. + const MG_Pipe::MGPipeHandle res = MG_Pipe::MGPipeResourceTrackerInstance().Find(*owner); + ASSERT_FALSE(MG_Pipe::MGPipeHandleIsNull(res)) + << "the mint is unconditional; without a handle this case is observing the wrong " + "absence"; + + auto& applier = MG_Pipe::MGPipeApplier(); + if (applier.Resources.size() > static_cast(res.Slot)) { + EXPECT_FALSE(applier.Resources[res.Slot].Live) + << "resource_create reached the applier for a family the server told this client " + "it does not consume - which is ID-39's 66 lost uploads in the other " + "direction: records emitted to a consumer that is not there"; + } + } + + // THE COUNTED HALF. Both statements, because either alone is satisfiable by an accident: + // a non-zero count alone could come from any family, and the family id alone could be left + // over from an earlier case. + EXPECT_GT(MG_Remote::Client::ConsumerRefusals(), 0u) + << "the liveness gate never asked the caps mirror. It is still reading " + "MGPipeGetResourceOps(), which is the SERVER's registration and is null under a " + "spawn - R-8's whole defect"; + EXPECT_EQ(MG_Remote::Client::LastRefusedSubsystem(), MG_Pipe::kMGPipeSubsystemResources) + << "a refusal was counted for some other family, so this case is not observing the " + "resource gate it names"; + + MG_Config::Transport = previousTransport; + MG_Pipe::MGPipeSetResourceOps(nullptr); + MG_Config::Features.PipePush = previousPush; + MG_Remote::Client::ResetConsumerRefusalsForTest(); +#endif +} + // P3a REWORK M-1's gate (contract-review M2). The minting overload's symmetric `!=` is safe // because its handle comes out of the allocator and can never be behind the entry; the HANDLE // overload's input ARRIVES in a payload, so a generation BEHIND the live entry's is reachable - @@ -4914,4 +5023,8 @@ TEST(DirectGLESBufferDrawProbe, ALiveHostMapKeepsTheHandleArmProbeDirtyBetweenTw TEST(DirectGLESBufferDrawProbe, UnderSplitTheRecordAloneAnswersTheLiveHostMapQuestion) { GTEST_SKIP() << "the handle-keyed resource table is compiled only under MOBILEGL_PIPE_PUSH"; } + +TEST(DirectGLESBufferDrawProbe, ACapsMaskWithoutTheResourceFamilyEmitsNothingAndCountsTheRefusal) { + GTEST_SKIP() << "the handle-keyed resource table is compiled only under MOBILEGL_PIPE_PUSH"; +} #endif // MOBILEGL_PIPE_PUSH diff --git a/MobileGL/MG_Test/Wire/CMakeLists.txt b/MobileGL/MG_Test/Wire/CMakeLists.txt index cdc2fd9c..240e228a 100644 --- a/MobileGL/MG_Test/Wire/CMakeLists.txt +++ b/MobileGL/MG_Test/Wire/CMakeLists.txt @@ -66,32 +66,45 @@ endif () gtest_discover_tests(PipeWireCodecTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit) -# P5 s1's handshake suite (wave 1.5, ID-46 findings 6 and 7): the two null-union guards driven -# THROUGH ServerSession::Accept and ClientSession::StartOverTransportPair, and the ABI -# fingerprint's sensitivity case driven from CapsAbiFingerprint(), the production entry point. -# Separate from SessionTest for two reasons: it needs CapsCodec.h and the sessions, i.e. the GL -# frontend's umbrella header that the ring-owning suite deliberately keeps out; and each guard's -# refusal is asserted BY MESSAGE, which with the console sink compiled out (Defines.h) means a -# log file this process names before anything logs - PipeWireCodecTest's own-main() shape. -add_executable(SessionHandshakeTest SessionHandshakeTest.cpp) +# P5 c1's, v1's and s1's suites. All three are registered on their own, and all three for +# PipeWireCodecTest's reason: a Fatal arm reports through MGLOG_F, which writes to stdout and to +# a named file and NEVER to stderr (and with the console sink compiled out, Defines.h, only to +# the file), so asserting one means naming a log file before anything logs - which needs a +# main() of its own. All three also reach MG_Pipe, MG_Backend and MG_State, so all three carry +# those include paths. +# +# RemoteClientTest the 71-slot emit table, the caps mirror, R-8's liveness gates, R-17's +# routing and ID-47/ID-49's readback rules (c1) +# ServerLoopTest the apply thread, the blocking control mailbox, the verb stamp and +# R-11's server-owned staging copy (v1) +# SessionHandshakeTest the two null-union guards driven THROUGH ServerSession::Accept and +# ClientSession::StartOverTransportPair, and the ABI fingerprint's +# sensitivity case driven from CapsAbiFingerprint() (s1, ID-46 6 and 7) +# +# THIS FILE IS THE PHASE'S ONE RECURRING MERGE CONFLICT, and it is the same-point-append shape +# BRIEF §5's ownership table exists to prevent: three packages, three targets, one end-of-file. +# The loop is what stops there being a fourth: a package adding a suite adds a NAME. +foreach (wiretest IN ITEMS RemoteClientTest ServerLoopTest SessionHandshakeTest) + add_executable(${wiretest} ${wiretest}.cpp) -target_include_directories(SessionHandshakeTest 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(SessionHandshakeTest PRIVATE - GTest::gtest - ${LINK_LIBRARIES} -) + target_link_libraries(${wiretest} PRIVATE + GTest::gtest + ${LINK_LIBRARIES} + ) -if (MSVC) - target_compile_options(SessionHandshakeTest PRIVATE /Zc:preprocessor) -endif () + if (MSVC) + target_compile_options(${wiretest} PRIVATE /Zc:preprocessor) + endif () -gtest_discover_tests(SessionHandshakeTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit) + gtest_discover_tests(${wiretest} DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit) +endforeach () diff --git a/MobileGL/MG_Test/Wire/PipeWireCodecTest.cpp b/MobileGL/MG_Test/Wire/PipeWireCodecTest.cpp index e61acbdb..4e1b8508 100644 --- a/MobileGL/MG_Test/Wire/PipeWireCodecTest.cpp +++ b/MobileGL/MG_Test/Wire/PipeWireCodecTest.cpp @@ -233,6 +233,30 @@ namespace { std::uint64_t m_sessionApplied = 0; }; + // ID-31 + R-17's cross-check, written once because four cases need it. + // + // The four Bool acceptance rows now answer into SEG_REPLY, and "an answer was written" is + // a weak statement on its own - a PostReply that stamped a constant OK would satisfy it. + // So the STATUSES are compared against the decoder's own accepted/declined counters, which + // are produced by a different line of code from a different value. A single-sided + // assertion here is exactly the shape R-16 was written after. + void ExpectRepliesAgreeWithTheAcceptanceTally(Wire2& wire) { + std::uint64_t ok = 0; + std::uint64_t declined = 0; + for (const auto& reply : wire.Answers().All) { + EXPECT_NE(reply.Status, ReplySink::kStatusError) + << "seq " << reply.Seq << ": ERROR is not an acceptance answer"; + if (reply.Status == ReplySink::kStatusOk) ++ok; + if (reply.Status == ReplySink::kStatusDeclined) ++declined; + } + EXPECT_EQ(ok, wire.Decoder().AcceptedRecords()) + << "the slots say " << ok << " accepted, the decoder's tally says " + << wire.Decoder().AcceptedRecords(); + EXPECT_EQ(declined, wire.Decoder().DeclinedRecords()) + << "the slots say " << declined << " declined, the decoder's tally says " + << wire.Decoder().DeclinedRecords(); + } + MGPipeHandle MakeHandle(Uint32 slot, Uint32 gen = 1) { MGPipeHandle handle{}; handle.Slot = slot; @@ -576,12 +600,22 @@ TEST_F(PipeWireCodecTest, KNeedsAckRespecifyCarriesItsRedefinitionScope) { kInvalidSeq); ASSERT_TRUE(wire.PumpOne(&applied)); EXPECT_TRUE(applied); - // Two records, two ACCEPTANCE answers - and NOT in a reply slot. Neither ResourceCreate - // nor ResourceRespecify carries kReplySlot, and s1 sizes ReplyPool from that table, so a - // reply written here would overwrite some waiter's slot. - EXPECT_TRUE(wire.Answers().All.empty()); + // Two records, two ACCEPTANCE answers, AND THEY NOW RIDE THE REPLY SLOT (ID-31 + R-17). + // Both rows carry kReplySlot in kMGPipeCallFlags, ReplyPool is sized from that same table, + // and CONTRACT-P5 table 0 always said DECLINED "is how the four Bool acceptance entry + // points say false". The two halves agreed the moment ID-31 landed the flag. + ASSERT_EQ(wire.Answers().All.size(), 2u); + EXPECT_EQ(wire.Answers().All[0].Seq, 1u); + EXPECT_EQ(wire.Answers().All[1].Seq, 2u); + EXPECT_TRUE(wire.Answers().All[0].Bytes.empty()); + EXPECT_TRUE(wire.Answers().All[1].Bytes.empty()); EXPECT_EQ(wire.Decoder().AcceptedRecords() + wire.Decoder().DeclinedRecords(), 2u); EXPECT_TRUE(wire.Decoder().LastAcceptanceKnown()); + // THE CROSS-CHECK, and it is the point of asserting the status at all: the slot's status + // and the decoder's own tally are two independent statements of the same fact, so a + // PostReply that stamped a constant would disagree with the counters even though every + // other assertion above still passed. + ExpectRepliesAgreeWithTheAcceptanceTally(wire); } TEST_F(PipeWireCodecTest, KOptionalUnmapPersistentRoundTrips) { @@ -774,7 +808,12 @@ TEST_F(PipeWireCodecTest, ResourceSubDataCarriesABlobAndARegionTailTogether) { kInvalidSeq); ASSERT_TRUE(wire.PumpOne(&applied)); EXPECT_TRUE(applied); - EXPECT_TRUE(wire.Answers().All.empty()); + // ID-31 + R-17: ResourceCreate and ResourceSubData both carry kReplySlot now, so both + // answer into a slot rather than only into the decoder's tally. + ASSERT_EQ(wire.Answers().All.size(), 2u); + EXPECT_EQ(wire.Answers().All[0].Seq, 1u); + EXPECT_EQ(wire.Answers().All[1].Seq, 2u); + ExpectRepliesAgreeWithTheAcceptanceTally(wire); // ACCEPTANCE IS NOT "APPLIED", and this case is where the difference shows. The record // crossed and reached MGPipeApplyResourceSubData, which is the codec's whole job; the // applier then DECLINED it, because no backend registered a P4a texture consumer in this @@ -1268,11 +1307,24 @@ TEST_F(PipeWireCodecTest, NoReplyIsWrittenForARowTheCatalogueGivesNoReplySlot) { // s1 sizes ReplyPool from MGPipeCallFlagsFor, so a reply written for a record the pool // reserved no slot for overwrites a waiter's answer - and because the slot header stamps // the WRITER's seq for self-check, the waiter's check then fails for ever and the barrier - // HANGS rather than returning something wrong. + // HANGS rather than returning something wrong. That statement is unchanged and is what + // this case still asserts. + // + // WHAT CHANGED IS THE ROW IT ASSERTS IT ABOUT (ID-31 fallout, closed by c1 round 2). It + // used to name ResourceCreate and SetTextureParams as rows "the catalogue gives no reply + // slot", which ID-31 made false - it gave ResourceCreate the flag, and all four Bool + // acceptance rows carry it now. A case that names a kReplySlot row while asserting a row + // has no slot is not testing the rule, it is testing a stale catalogue, so the row moved + // to one that genuinely carries kNone and the rule stayed exactly where it was. Wire2 wire; - ASSERT_EQ(MGPipeCallFlagsFor(MGPWireOp::ResourceCreate) & static_cast(kReplySlot), 0u); - ASSERT_EQ(MGPipeCallFlagsFor(MGPWireOp::SetTextureParams) & static_cast(kReplySlot), 0u); + ASSERT_EQ(MGPipeCallFlagsFor(MGPWireOp::ResourceDestroy) & static_cast(kReplySlot), 0u) + << "ResourceDestroy gained a reply slot; this case needs a kNone row to mean anything"; + ASSERT_EQ(MGPipeCallFlagsFor(MGPWireOp::BindRenderState) & static_cast(kReplySlot), 0u); + // A create first, so the destroy has a live record to reach - and its OWN answer is the + // control on the control: if PostReply were firing indiscriminately there would be two + // answers here, not one, and the case would fail on the count rather than pass because + // nothing was ever written. MGPResourceDesc create{}; create.Resource = MakeHandle(131); create.Target = static_cast(MGPipeResourceTarget::Buffer); @@ -1286,23 +1338,63 @@ TEST_F(PipeWireCodecTest, NoReplyIsWrittenForARowTheCatalogueGivesNoReplySlot) { kInvalidSeq); bool applied = false; ASSERT_TRUE(wire.PumpOne(&applied)); + ASSERT_EQ(wire.Answers().All.size(), 1u) << "the kReplySlot row did not answer"; + + const MGPHandleOnly destroy = HandleOnly(131, MGPipeKind::Buffer); + ASSERT_NE(wire.Encoder().EncodeRecord(MGPWireOp::ResourceDestroy, &destroy, sizeof(destroy)), + kInvalidSeq); + ASSERT_TRUE(wire.PumpOne(&applied)); EXPECT_TRUE(applied); - EXPECT_TRUE(wire.Answers().All.empty()) << "a reply slot was written for a kNone row"; - // The acceptance answer R-5 requires is still produced - it just does not ride SEG_REPLY. - EXPECT_TRUE(wire.Decoder().LastAcceptanceKnown()); - EXPECT_EQ(wire.Decoder().AcceptedRecords() + wire.Decoder().DeclinedRecords(), 1u); + EXPECT_EQ(wire.Answers().All.size(), 1u) << "a reply slot was written for a kNone row"; + EXPECT_EQ(wire.Answers().All[0].Seq, 1u) << "the answer that exists is the create's"; } TEST_F(PipeWireCodecTest, EveryRowThatDoesWriteAReplyCarriesKReplySlot) { - // The other half: the three rows in P5 that answer into a slot all carry the flag, so - // PostReply's gate cannot be firing on any of them. - for (const MGPWireOp op : - {MGPWireOp::MapPersistent, MGPWireOp::ResourceReadback, MGPWireOp::ReadPixels}) { + // The other half: every row in P5 that answers into a slot carries the flag, so + // PostReply's gate cannot be firing on any of them. The four Bool acceptance rows joined + // this list at ID-31 and R-17 - and they are listed BY NAME rather than derived from the + // flags table, because deriving the expectation from the same table the assertion reads + // would make this case true by construction. + for (const MGPWireOp op : {MGPWireOp::MapPersistent, MGPWireOp::ResourceReadback, + MGPWireOp::ReadPixels, MGPWireOp::ResourceCreate, + MGPWireOp::ResourceRespecify, MGPWireOp::ResourceSubData, + MGPWireOp::SetTextureParams}) { EXPECT_NE(MGPipeCallFlagsFor(op) & static_cast(kReplySlot), 0u) << WireOpName(op); } } +TEST_F(PipeWireCodecTest, TheFourAcceptanceRowsAnswerThroughTheSlotAndNotOnlyThroughTheTally) { + // R-17's server half, and the reason c1 could not simply write thirty-seven emitters: the + // client asks the CATALOGUE whether a row owns a slot (ClientSession::EmitAndWait), so the + // moment ID-31 gave these rows kReplySlot, a decoder that answered only into + // LastAcceptance() left the client parked in the barrier until Fatal{ReplyMissing}. + // + // THE ASSERTION IS THAT THE ANSWER MATCHES THE APPLIER's, not that one exists. Under a + // unit process no backend registers a P4a consumer, so set_texture_params is DECLINED - + // and that is the useful direction: a PostReply hard-coded to OK would pass a case that + // only counted answers. + Wire2 wire; + MGPTextureParams params{}; + params.Res = MakeHandle(77); + params.BuiltinSampler = MakeHandle(78); + ASSERT_NE(wire.Encoder().EncodeRecord(MGPWireOp::SetTextureParams, ¶ms, sizeof(params)), + kInvalidSeq); + bool applied = false; + ASSERT_TRUE(wire.PumpOne(&applied)); + EXPECT_TRUE(applied) << "the record must still have CROSSED; acceptance is not 'applied'"; + + ASSERT_EQ(wire.Answers().All.size(), 1u); + EXPECT_EQ(wire.Answers().All[0].Seq, 1u) << "the id is the record's own ordinal (R-3)"; + EXPECT_TRUE(wire.Answers().All[0].Bytes.empty()) + << "an acceptance answer is a STATUS; DECLINED carries no payload"; + ASSERT_TRUE(wire.Decoder().LastAcceptanceKnown()); + EXPECT_EQ(wire.Answers().All[0].Status, wire.Decoder().LastAcceptance() + ? ReplySink::kStatusOk + : ReplySink::kStatusDeclined); + ExpectRepliesAgreeWithTheAcceptanceTally(wire); +} + TEST_F(PipeWireCodecTest, TheAuditFillOverwritesExactlyTheRunsTheRecordResolved) { // R-2.5, the only mechanical control on rule C ("no applier entry point retains a pointer // past its return"). An instrumentation that cannot be observed to have run is decoration, diff --git a/MobileGL/MG_Test/Wire/RemoteClientTest.cpp b/MobileGL/MG_Test/Wire/RemoteClientTest.cpp new file mode 100644 index 00000000..fd2c7b33 --- /dev/null +++ b/MobileGL/MG_Test/Wire/RemoteClientTest.cpp @@ -0,0 +1,811 @@ +// MobileGL - MobileGL/MG_Test/Wire/RemoteClientTest.cpp +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +// P5 package c1's suite: the 71-slot emit table, the caps mirror and R-8's liveness gates. No +// session, no transport and no thread - s1's SessionTest owns those and w1's PipeWireCodecTest +// owns the bytes. +// +// IT LINKS gtest RATHER THAN gtest_main AND CARRIES ITS OWN main(), for PipeWireCodecTest's and +// PipeInputsTest's reason: the Fatal arms report through MGLOG_F + std::abort, and MGLOG_F +// writes to STDOUT and to a named file, NEVER to stderr - so EXPECT_DEATH's stderr regex could +// only ever match the empty string. A case that drives one FORKS and reads the Fatal line back +// out of a log file this process names before anything logs. That is what makes each control +// assert ITS OWN failure string (R-16) instead of asserting that something, somewhere, died. + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "Includes.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#if !defined(_WIN32) +#include +#include +#include +#define MGTEST_HAVE_FORK 1 +#else +#define MGTEST_HAVE_FORK 0 +#endif + +using namespace MobileGL; +using namespace MobileGL::MG_Pipe; +using namespace MobileGL::MG_Remote; +using namespace MobileGL::MG_Remote::Client; + +namespace { + + std::string g_logPath; + + std::string ReadLog() { + std::ifstream in(g_logPath, std::ios::binary); + if (!in) return {}; + std::ostringstream out; + out << in.rdbuf(); + return out.str(); + } + + int ProcessId() { +#if defined(_WIN32) + return static_cast(::_getpid()); +#else + return static_cast(::getpid()); +#endif + } + + // A caps snapshot the client could plausibly have received, with every field distinct from + // its default so a mirror that answered from a zeroed struct cannot look like one that + // adopted. THIS IS NOT THE STATE UNDER TEST - it is the INPUT to Adopt(), which is the + // producer; the assertions below read what the mirror hands the frontend's own accessors + // back, never what this function wrote (R-16). + struct Snapshot { + MGPCaps Caps{}; + MG_Backend::FormatCapabilityCache Formats{}; + RendererInfo Renderer{}; + String ApiVersion; + BackendType Backend = BackendType::DirectGLES; + }; + + Snapshot MakeSnapshot(Uint64 consumedSubsystems, Uint64 capBits) { + Snapshot s; + s.Caps.CallMask = capBits | MGCapsConsumerBits(consumedSubsystems); + s.Caps.Dynamic.MaxComputeWorkGroupCount[0] = 65531; + s.Caps.Dynamic.MaxComputeWorkGroupCount[1] = 65532; + s.Caps.Dynamic.MaxComputeWorkGroupCount[2] = 65533; + s.Caps.Dynamic.MaxComputeWorkGroupSize[0] = 1021; + s.Caps.Dynamic.MaxComputeWorkGroupSize[1] = 1022; + s.Caps.Dynamic.MaxComputeWorkGroupSize[2] = 1023; + s.Caps.Dynamic.UniformBufferOffsetAlignment = 64; + s.Renderer.RendererName = "MobileGL Remote Test Renderer"; + s.Renderer.BackendName = "Espryt"; + s.Renderer.ExtraVendor = String{"c1"}; + s.Renderer.RendererGLInfo.TargetGLVersion = Version{4, 6, 0, {}, {}}; + s.Renderer.RendererGLInfo.TargetGLSLVersion = Version{4, 6, 0, {}, {}}; + s.ApiVersion = "4.6"; + return s; + } + + void AdoptSnapshot(const Snapshot& s) { + CapsMirrorInstance().Adopt(s.Caps, s.Formats, s.Renderer, s.ApiVersion, s.Backend); + } + +#if MGTEST_HAVE_FORK + struct ChildResult { + int Status = -1; + std::string Log; + }; + + template + ChildResult RunInChild(Body body) { + ChildResult result; + // TRUNCATE, RATHER THAN REMEMBER AN OFFSET, and the difference is not cosmetic: this + // parent never logs, so MG_Util::Debug's FILE* is opened FOR THE FIRST TIME by each + // child - with "w", which truncates. An offset taken before the fork therefore points + // past the end of the child's own log, and `substr` hands back the tail of a DIFFERENT + // child's output. That is how the second death case in this file came to see the first + // one's slot name and assert on it: a control reading another control's message, which + // is one of the three shapes R-16 was written after. + { std::ofstream truncate(g_logPath, std::ios::trunc | std::ios::binary); } + std::fflush(nullptr); + const pid_t pid = ::fork(); + if (pid < 0) return result; + if (pid == 0) { + body(); + ::_exit(0); + } + int status = 0; + if (::waitpid(pid, &status, 0) != pid) return result; + result.Status = status; + result.Log = ReadLog(); + return result; + } + + bool DiedOfAbort(const ChildResult& r) { + return WIFSIGNALED(r.Status) && WTERMSIG(r.Status) == SIGABRT; + } + std::string DescribeStatus(const ChildResult& r) { + if (r.Status < 0) return "fork/waitpid failed"; + if (WIFEXITED(r.Status)) return "exited " + std::to_string(WEXITSTATUS(r.Status)); + if (WIFSIGNALED(r.Status)) return "signal " + std::to_string(WTERMSIG(r.Status)); + return "status " + std::to_string(r.Status); + } +#endif + +} // namespace + +// ===================================================================================== +// The emit table: the partition, and that no slot is null +// ===================================================================================== + +TEST(RemoteEmitTable, TheThreeClassesPartitionAllSeventyOneSlots) { + // CONTRACT-P5.md §7: 2 answered locally + 5 emitted + 64 Fatal. Read from the functions the + // table itself reports with - which is also what t1's arming condition reads - rather than + // recomputed here, so a table that lost an emitter cannot look like one that never had it. + EXPECT_EQ(LocallyAnsweredSlotCount(), 2u); + EXPECT_EQ(ImplementedVerbCount(), 5u); + EXPECT_EQ(UnmigratedSlotCount(), 64u); + EXPECT_EQ(LocallyAnsweredSlotCount() + ImplementedVerbCount() + UnmigratedSlotCount(), + kRemoteEmitSlotCount); +} + +TEST(RemoteEmitTable, NoSlotIsNull) { + // R-4's whole rule, asserted over the STRUCT rather than over the list that built it. 91 + // MG_Impl sites call through this table directly; a null slot is 91 potential null calls, + // and the one thing a list-driven check could not catch is a slot the list forgot to name. + // + // Walked as a block of function pointers because that is exactly what the struct is - the + // static_asserts in EmitTables.cpp pin that shape - so a slot ADDED to GLFunctionsTable is + // covered here on the day it appears, without this file being edited. + const MG_Backend::GlobalBackendFunctionsTable& table = RemoteEmitTable(); + const void* const* cells = reinterpret_cast(&table); + const SizeT cellCount = sizeof(table) / sizeof(void*); + // The struct is 71 function pointers plus ONE cell holding the packed Bool + // PrefersCpuXfbPrimitiveAccounting and its padding (EmitTables.cpp static_asserts exactly + // that shape). That Bool is legitimately zero when the server did not publish + // kCapCpuXfbPrimitiveAccounting, so at most one cell may read null - and this is stated as + // a bound rather than an index, because an index would drift the day a slot is inserted. + SizeT nullCells = 0; + for (SizeT i = 0; i < cellCount; ++i) { + if (cells[i] == nullptr) ++nullCells; + } + EXPECT_LE(nullCells, 1u) + << "a slot in the remote emit table is null (" << nullCells << " null cells out of " + << cellCount + << "). R-4 forbids it: 91 MG_Impl sites call through this table with no null check at all"; +} + +TEST(RemoteEmitTable, TheFiveEmittersAreTheOnesTheCensusMeasured) { + const MG_Backend::GlobalBackendFunctionsTable& table = RemoteEmitTable(); + // Named, so that a table which emitted a DIFFERENT five would be red rather than merely + // counted. The census's answer is Clear, DrawArrays, ReadPixels, BlitFramebuffer, Present. + EXPECT_NE(table.GL.Clear, nullptr); + EXPECT_NE(table.GL.DrawArrays, nullptr); + EXPECT_NE(table.GL.ReadPixels, nullptr); + EXPECT_NE(table.GL.BlitFramebuffer, nullptr); + EXPECT_NE(table.Present, nullptr); + // And the two R-15 answers them locally, so they are not the same pointer as any Fatal one. + EXPECT_NE(table.GL.GetIntegeri_v, nullptr); + EXPECT_NE(table.GL.IsTimerQuerySupported, nullptr); + EXPECT_NE(reinterpret_cast(table.GL.DrawArrays), + reinterpret_cast(table.GL.DrawElements)) + << "DrawArrays is class B and DrawElements is class C; they cannot share a thunk"; +} + +#if MGTEST_HAVE_FORK +TEST(RemoteEmitTable, AnUnmigratedSlotAbortsAndNamesItself) { + // THE DEATH TEST ON THE UnmigratedVerbFatal ARM. It asserts the exact wording, not merely + // that the child died: a control that trips on any abort is satisfied by the wrong abort, + // which is one of the three shapes R-16 was written after. + const ChildResult r = RunInChild([] { + RemoteEmitTable().GL.DrawElements(0x0004 /*GL_TRIANGLES*/, 3, 0x1405 /*GL_UNSIGNED_INT*/, + nullptr); + }); + ASSERT_TRUE(DiedOfAbort(r)) << DescribeStatus(r) << "\n" << r.Log; + EXPECT_NE(r.Log.find("Fatal{UnmigratedVerb, \"DrawElements\"}"), std::string::npos) << r.Log; +} + +TEST(RemoteEmitTable, EachUnmigratedSlotNamesItsOwnSlot) { + // The half the case above cannot state on its own: that the name in the message is the + // slot's and not a constant. Two different slots, two different names. + const ChildResult r = RunInChild([] { RemoteEmitTable().GL.GenerateMipmap(0x0DE1); }); + ASSERT_TRUE(DiedOfAbort(r)) << DescribeStatus(r) << "\n" << r.Log; + EXPECT_NE(r.Log.find("Fatal{UnmigratedVerb, \"GenerateMipmap\"}"), std::string::npos) << r.Log; + EXPECT_EQ(r.Log.find("DrawElements"), std::string::npos) + << "the Fatal message names a slot other than the one that was called:\n" + << r.Log; +} + +TEST(RemoteEmitTable, SetSwapIntervalIsClassCAndSaysSo) { + // The slot the verb census found by NOT mirroring GLImpl: SetSwapInterval has zero MG_Impl + // call sites and is reached only through the EGL path, so a table built from the 89 GLImpl + // sites would have left it null. + const ChildResult r = RunInChild([] { RemoteEmitTable().SetSwapInterval(1); }); + ASSERT_TRUE(DiedOfAbort(r)) << DescribeStatus(r) << "\n" << r.Log; + EXPECT_NE(r.Log.find("Fatal{UnmigratedVerb, \"SetSwapInterval\"}"), std::string::npos) << r.Log; +} + +TEST(RemoteEmitTable, AClassBSlotWithNoSessionAbortsRatherThanFallingThrough) { + // The other half of "no slot may fall through to the driver". With no ClientSession the + // emitter has nowhere to put the record, and the one thing it may not do is return quietly: + // that is the split lane running monolith and going green. + const ChildResult r = RunInChild([] { RemoteEmitTable().GL.Clear(0x4000 /*COLOR_BUFFER_BIT*/); }); + ASSERT_TRUE(DiedOfAbort(r)) << DescribeStatus(r) << "\n" << r.Log; + EXPECT_NE(r.Log.find("Fatal{NoClientSession, \"Clear\"}"), std::string::npos) << r.Log; +} +#endif // MGTEST_HAVE_FORK + +// ===================================================================================== +// The caps mirror: the three read paths the acceptance names +// ===================================================================================== + +TEST(CapsMirrorTest, GlGetStringReadsTheRendererStringsBackOutOfTheMirror) { + // glGetString's path is GL_Getter.cpp:596 -> pActiveBackendObject->GetRendererInfo(), which + // BackendObject_Remote answers from this mirror BY REFERENCE - so the test reads the + // reference, holds it across a second adoption, and requires it to follow. A mirror that + // handed back a temporary would pass an equality check and dangle here. + const Snapshot first = MakeSnapshot(kMGPipeSubsystemResources, 0); + AdoptSnapshot(first); + const RendererInfo& bound = CapsMirrorInstance().Renderer(); + EXPECT_EQ(bound.RendererName, "MobileGL Remote Test Renderer"); + EXPECT_EQ(bound.BackendName, "Espryt"); + ASSERT_TRUE(bound.ExtraVendor.has_value()); + EXPECT_EQ(*bound.ExtraVendor, "c1"); + + Snapshot second = MakeSnapshot(kMGPipeSubsystemResources, 0); + second.Renderer.RendererName = "A Different Device"; + AdoptSnapshot(second); + EXPECT_EQ(bound.RendererName, "A Different Device") + << "GetRendererInfo() returns a reference, so a re-arrival must be visible through a " + "reference a caller already holds"; +} + +TEST(CapsMirrorTest, GlGetIntegervReadsTheDynamicParametersBackOutOfTheMirror) { + // glGetIntegerv's limit family is GL_Getter.cpp:2400 -> GetDynamicParameters(), which binds + // a reference and then reads many members - which is why a partial snapshot is not an + // option and why the whole struct crosses. + AdoptSnapshot(MakeSnapshot(kMGPipeSubsystemResources, 0)); + const MG_Backend::DynamicBackendParameters& dynamic = CapsMirrorInstance().Dynamic(); + EXPECT_EQ(dynamic.MaxComputeWorkGroupCount[0], 65531); + EXPECT_EQ(dynamic.MaxComputeWorkGroupCount[2], 65533); + EXPECT_EQ(dynamic.MaxComputeWorkGroupSize[1], 1022); + EXPECT_EQ(dynamic.UniformBufferOffsetAlignment, 64u); +} + +TEST(CapsMirrorTest, GlGetStringiReadsTheAdvertisedExtensionListBackOutOfTheMirror) { + // glGetStringi(GL_EXTENSIONS) is GL_Getter.cpp:654 -> GetRendererInfo().RendererGLInfo, the + // same list CompileEnv.cpp:124 copies into the compile env. + Snapshot s = MakeSnapshot(kMGPipeSubsystemResources, 0); + s.Renderer.RendererGLInfo.Extensions.push_back(E_GL_ARB_timer_query); + AdoptSnapshot(s); + const auto& extensions = CapsMirrorInstance().Renderer().RendererGLInfo.Extensions; + ASSERT_EQ(extensions.size(), 1u); + EXPECT_EQ(extensions[0], E_GL_ARB_timer_query); + EXPECT_EQ(CapsMirrorInstance().Renderer().RendererGLInfo.TargetGLVersion.Major, 4); +} + +TEST(CapsMirrorTest, ReArrivalIsTheInvalidationAndMovesTheGeneration) { + // R-12 has no Invalidate(), so Generation() is the ONLY thing on the client that can see a + // server context death - and a client memo has to key on it. + const Uint64 before = CapsMirrorInstance().Generation(); + AdoptSnapshot(MakeSnapshot(kMGPipeSubsystemResources, 0)); + const Uint64 after = CapsMirrorInstance().Generation(); + EXPECT_EQ(after, before + 1); + AdoptSnapshot(MakeSnapshot(kMGPipeSubsystemResources, 0)); + EXPECT_EQ(CapsMirrorInstance().Generation(), after + 1); + EXPECT_TRUE(CapsMirrorInstance().Valid()); +} + +TEST(CapsMirrorTest, TheBackendTypeIsTheServersAndNeverANewEnumerator) { + Snapshot s = MakeSnapshot(kMGPipeSubsystemResources, 0); + s.Backend = BackendType::DirectVulkan; + AdoptSnapshot(s); + EXPECT_EQ(CapsMirrorInstance().Backend(), BackendType::DirectVulkan); + s.Backend = BackendType::DirectGLES; + AdoptSnapshot(s); + EXPECT_EQ(CapsMirrorInstance().Backend(), BackendType::DirectGLES); +} + +TEST(CapsMirrorTest, ThePrefersCpuXfbAnswerComesFromTheCapBitAndNotFromTheTable) { + // GLFunctionsTable::PrefersCpuXfbPrimitiveAccounting is a member of the FUNCTION TABLE, + // which is exactly the thing a split client never receives - so it cannot ride in + // MGPCaps::Dynamic and must come from kCapCpuXfbPrimitiveAccounting. + AdoptSnapshot(MakeSnapshot(kMGPipeSubsystemResources, 0)); + EXPECT_FALSE(CapsMirrorInstance().PrefersCpuXfbPrimitiveAccounting()); + AdoptSnapshot(MakeSnapshot(kMGPipeSubsystemResources, kCapCpuXfbPrimitiveAccounting)); + EXPECT_TRUE(CapsMirrorInstance().PrefersCpuXfbPrimitiveAccounting()); +} + +// ===================================================================================== +// R-8: the liveness gates read the caps mirror, and a family with no consumer says so +// ===================================================================================== + +TEST(CapsMirrorTest, AMaskWithoutAFamilyRefusesItAndNamesIt) { + // R-8's NEGATIVE CONTROL. "The client emits nothing for a family the server does not + // consume" is, on its own, indistinguishable from "nothing called it" - so the refusal is + // COUNTED at the one funnel that answers the question, and the count is what this asserts. + AdoptSnapshot(MakeSnapshot(kMGPipeSubsystemPrograms, 0)); + ResetConsumerRefusalsForTest(); + + EXPECT_TRUE(CapsMirrorInstance().ServerConsumes(kMGPipeSubsystemPrograms)); + EXPECT_EQ(ConsumerRefusals(), 0u) << "a family the server DOES consume must not be counted " + "as refused"; + + EXPECT_FALSE(CapsMirrorInstance().ServerConsumes(kMGPipeSubsystemResources)); + EXPECT_EQ(ConsumerRefusals(), 1u); + EXPECT_EQ(LastRefusedSubsystem(), kMGPipeSubsystemResources) + << "the refusal must name the family; a counter that only says 'something was withheld' " + "cannot tell five silent families apart"; + + EXPECT_FALSE(CapsMirrorInstance().ServerConsumes(kMGPipeSubsystemTextureResources)); + EXPECT_EQ(ConsumerRefusals(), 2u); + EXPECT_EQ(LastRefusedSubsystem(), kMGPipeSubsystemTextureResources); +} + +TEST(CapsMirrorTest, APlaceholderMirrorConsumesNothing) { + // The safe direction, stated as a case. With no snapshot the mask is zero, every family + // answers "no consumer", the client emits nothing and the legacy pull path runs. The unsafe + // direction - emitting to a server that has no consumer - is ID-39's 66 lost uploads. + MGPCaps empty{}; + CapsMirror mirror; + EXPECT_FALSE(mirror.Valid()); + EXPECT_FALSE(mirror.ServerConsumes(kMGPipeSubsystemResources)); + EXPECT_FALSE(mirror.ServerConsumes(kMGPipeSubsystemPrograms)); + EXPECT_FALSE(mirror.HasCap(kCapResidentSubData)); + (void)empty; +} + +TEST(CapsMirrorTest, TheConsumerBlockDoesNotCollideWithTheFeatureBits) { + // The two halves of CallMask, asserted against each other rather than against a constant: + // bits 0..8 are MGPCapBit and bits 32..47 are the consumer mask, and the whole reason R-8 + // became implementable is that they do not overlap. + AdoptSnapshot(MakeSnapshot(kMGPipeSubsystemResources | kMGPipeSubsystemPrograms, + kCapTimerQuery | kCapOcclusionQuery)); + EXPECT_TRUE(CapsMirrorInstance().HasCap(kCapTimerQuery)); + EXPECT_TRUE(CapsMirrorInstance().HasCap(kCapOcclusionQuery)); + EXPECT_FALSE(CapsMirrorInstance().HasCap(kCapXfbPrimitivesQuery)); + EXPECT_TRUE(CapsMirrorInstance().ServerConsumes(kMGPipeSubsystemResources)); + EXPECT_TRUE(CapsMirrorInstance().ServerConsumes(kMGPipeSubsystemPrograms)); + EXPECT_FALSE(CapsMirrorInstance().ServerConsumes(kMGPipeSubsystemSamplers)); +} + +// ===================================================================================== +// E2's emitter-drop control: the switch itself, driven through the real emitter's own counter +// ===================================================================================== + +TEST(RemoteEmitTable, TheE2DropSwitchStartsDisarmed) { + // The half a unit case can state. E2's statement - "drop one Clear emission and OpenRA's + // SSIM falls below 0.99" - is a TRACE LANE's, because the picture is the thing it is about; + // what belongs here is that the control is off unless someone armed it, so a lane that + // forgot to disarm cannot look like a lane that was never armed. + EXPECT_EQ(DroppedClearEmissions(), 0u); + SetDropClearEmissionForNegativeControl(true); + SetDropClearEmissionForNegativeControl(false); + EXPECT_EQ(DroppedClearEmissions(), 0u) + << "arming and disarming the control must not, by itself, drop anything"; +} + +// ===================================================================================== +// ID-47: a readback larger than a reply slot is refused at the CLIENT, by name +// ===================================================================================== + +TEST(RemoteReadback, ExactlyTheCapacityPassesAndOneByteMoreIsRefusedByName) { + // ID-47's boundary pair, and it is THE CALL SITE'S half. The refusal itself is s1's + // (ReplySlotPool::RequireReadPixelsFits, its own cases in s1-v3.md §1); what c1 owns is + // that the number handed to it is the one the emitter computes - ID-49's TIGHT extent - + // and that exactly the cap is legal while one byte more is not. So this drives the real + // pool with the real helper, over the real arithmetic, and never restates the message. + // + // A real pool over a real mapping, because CanHold answers false for a null base and a + // control built on a default-constructed pool would "refuse" everything for that reason. + constexpr std::uint32_t kSlots = 8; + constexpr std::uint64_t kSlotBytes = 2u * 1024u * 1024u; + std::vector backing(static_cast(kSlots) * kSlotBytes); + Transport::ReplySlotPool pool(backing.data(), backing.size(), kSlots); + const std::uint64_t cap = pool.MaxReplyBytes(); + ASSERT_GT(cap, 0u) << "the fixture's pool is not configured, so every answer would be refused"; + + // ID-47's own number: the E2 retrace snapshot reads 640x480 RGBA8 and it must now FIT. + EXPECT_TRUE(pool.CanHold(TightReadbackByteCount(640, 480, 0x1908, 0x1401))) + << "the read ID-47 grew SEG_REPLY for still does not fit"; + + pool.RequireReadPixelsFits(724, 724, 0x1908, 0x1401, cap); + SUCCEED() << "exactly the capacity is not an overflow"; + +#if MGTEST_HAVE_FORK + const ChildResult child = RunInChild([&] { + Transport::ReplySlotPool inner(backing.data(), backing.size(), kSlots); + inner.RequireReadPixelsFits(640, 480, 0x1908, 0x1401, inner.MaxReplyBytes() + 1); + }); + EXPECT_TRUE(DiedOfAbort(child)) << "one byte over the slot did not abort: " << DescribeStatus(child); + // ITS OWN FAILURE STRING, AND THE READ'S OWN NUMBERS. A control that only asserted + // "something died" would pass on Fatal{NoClientSession}, Fatal{UnmigratedVerb} or a + // segfault, and this file has four other cases that abort for those reasons. + EXPECT_NE(child.Log.find("Fatal{ReplyTooLarge"), std::string::npos) << child.Log; + EXPECT_NE(child.Log.find("ReadPixels 640x480"), std::string::npos) + << "the message does not name the read, so an operator cannot tell which one: " << child.Log; + EXPECT_NE(child.Log.find(std::to_string(cap + 1)), std::string::npos) + << "the message does not carry the byte count"; +#else + GTEST_SKIP() << "the refusal reports through a Fatal + abort and needs fork() to read back"; +#endif +} + +// ===================================================================================== +// ID-49: the pack state never crosses; the client scatters the tight rows +// ===================================================================================== + +TEST(RemoteReadback, DstSizeIsTheTightExtentAndNeverThePackedOne) { + // The number v1 allocates on the server. It must not move with the pack state, because the + // server reads with a NEUTRAL one and cannot see the client's: the first version of this + // emitter declared GL 8.4.4's PACKED size, v1 allocated exactly that, and the driver then + // wrote past it - two SEGFAULTs on the joint inproc lane, both backends. + constexpr GLenum kRgba = 0x1908; + constexpr GLenum kUByte = 0x1401; + EXPECT_EQ(TightReadbackByteCount(4, 3, kRgba, kUByte), 4u * 3u * 4u); + EXPECT_EQ(TightReadbackByteCount(640, 480, kRgba, kUByte), 640u * 480u * 4u); + // ID-47's own arithmetic depends on this: 640x480 RGBA8 is what the E2 retrace snapshot + // reads, and 1,228,800 is the number that forced SEG_REPLY to grow. + EXPECT_EQ(TightReadbackByteCount(640, 480, kRgba, kUByte), 1228800u); +} + +TEST(RemoteReadback, TheTightRowsAreScatteredWhereThePackStateSaysAndTheGapsAreLeftAlone) { + // ID-49's control, and it is the exact case the cross-family review found: 4x3 RGBA8 with + // PACK_ROW_LENGTH=8, SKIP_ROWS=1, SKIP_PIXELS=2. Stride = 8*4 = 32; the first written byte + // is 1*32 + 2*4 = 40; each row writes 4*4 = 16 bytes and the remaining 16 of its stride + // belong to the application. + constexpr Uint64 kBpp = 4; + constexpr GLsizei kW = 4; + constexpr GLsizei kH = 3; + constexpr Uint8 kSentinel = 0xCD; + + PixelStoreParameters pack{}; + pack.RowLength = 8; + pack.SkipRows = 1; + pack.SkipPixels = 2; + pack.Alignment = 4; + + // THE INPUT, not the state under test: every source byte is distinct, so a scatter that + // wrote the right COUNT of bytes from the wrong offset cannot look correct. + std::vector tight(static_cast(kW) * kH * kBpp); + for (size_t i = 0; i < tight.size(); ++i) tight[i] = static_cast(i); + + std::vector destination(512, kSentinel); + ScatterTightReadbackIntoPackState(tight.data(), destination.data(), kW, kH, kBpp, pack); + + const size_t stride = 8 * 4; + const size_t first = 1 * stride + 2 * 4; + for (size_t row = 0; row < static_cast(kH); ++row) { + for (size_t byte = 0; byte < static_cast(kW) * kBpp; ++byte) { + EXPECT_EQ(destination[first + row * stride + byte], + tight[row * static_cast(kW) * kBpp + byte]) + << "row " << row << " byte " << byte << " landed somewhere else"; + } + } + + // THE GAPS, which is the half that makes this a control rather than a copy of the loop + // above: the bytes the pack state does not name belong to the application and must still + // hold their sentinel. Removing the skips from the scatter passes the loop above and fails + // here; widening the per-row copy to the stride passes both loops above and fails here. + size_t touched = 0; + for (size_t i = 0; i < destination.size(); ++i) { + const bool inWrittenRow = + i >= first && ((i - first) % stride) < static_cast(kW) * kBpp && + ((i - first) / stride) < static_cast(kH); + if (!inWrittenRow) { + EXPECT_EQ(destination[i], kSentinel) + << "byte " << i << " is outside the rectangle GL names and was overwritten"; + } else { + ++touched; + } + } + EXPECT_EQ(touched, tight.size()) << "the scatter wrote a different number of bytes than the " + "reply carried"; +} + +TEST(RemoteReadback, PackSkipImagesIsIgnoredForATwoDimensionalRead) { + // codex 6: SKIP_IMAGES (and IMAGE_HEIGHT) are image-level pack parameters and GL ignores + // them for glReadPixels, a 2-D read - the monolith conversion path says so with + // honorPackImageParams=false (DirectGLES.cpp:10905). The first cut applied SKIP_IMAGES as a + // whole-image offset: a 4x3 RGBA8 read with SKIP_IMAGES=1 wrote bytes 48..95 of a 96-byte + // destination sized for one image, overrunning it. With the fix the reply lands at bytes + // 0..47 and the second image's worth of bytes keeps its sentinel. + constexpr Uint64 kBpp = 4; + constexpr GLsizei kW = 4; + constexpr GLsizei kH = 3; + constexpr Uint8 kSentinel = 0xEE; + + PixelStoreParameters pack{}; + pack.SkipImages = 1; // the parameter under test + pack.ImageHeight = kH; // and its companion; both must be ignored + + std::vector tight(static_cast(kW) * kH * kBpp); + for (size_t i = 0; i < tight.size(); ++i) tight[i] = static_cast(i + 1); + + std::vector destination(96, kSentinel); + ScatterTightReadbackIntoPackState(tight.data(), destination.data(), kW, kH, kBpp, pack); + + for (size_t i = 0; i < tight.size(); ++i) + EXPECT_EQ(destination[i], tight[i]) << "byte " << i << " should hold the reply at offset 0"; + for (size_t i = tight.size(); i < destination.size(); ++i) + EXPECT_EQ(destination[i], kSentinel) + << "byte " << i << " is past the read's own extent and SKIP_IMAGES must not have moved " + "the write there"; + // And the fast path takes it: an otherwise-neutral read with only SKIP_IMAGES set is tight. + EXPECT_TRUE(ReadbackPackStateIsTightForTest(kW, kBpp, pack)) + << "SKIP_IMAGES alone must not force the bounce path for a 2-D read"; +} + +TEST(RemoteReadback, TheFastPathIsTakenExactlyWhenTheScatterWouldChangeNothing) { + // EmitReadPixels reads the reply STRAIGHT into the application's pointer when + // ReadbackPackStateIsTight says so, and pays for a bounce buffer otherwise. That is only + // legal if the predicate and the scatter AGREE - so this case drives BOTH and compares + // them, rather than testing either alone. A predicate that said "tight" for a layout the + // scatter would have rearranged is a silently wrong picture with no bounce to blame. + constexpr Uint64 kBpp = 4; + constexpr GLsizei kW = 5; + constexpr GLsizei kH = 3; + std::vector tight(static_cast(kW) * kH * kBpp); + for (size_t i = 0; i < tight.size(); ++i) tight[i] = static_cast(i * 7 + 1); + + // Six layouts, chosen so both answers appear: a bare default, an explicit equal row + // length, an alignment the row already satisfies, an alignment it does not, a skip, and a + // wider row. If every case agreed on "tight" the comparison below would be vacuous, so the + // count of each answer is asserted too. + std::vector layouts(6); + layouts[1].RowLength = kW; + layouts[2].Alignment = 4; // 5*4 = 20, already a multiple of 4 + layouts[3].Alignment = 8; // 20 is not a multiple of 8 - the rows gain padding + layouts[4].SkipPixels = 1; + layouts[5].RowLength = 8; + + int tightCount = 0; + for (size_t i = 0; i < layouts.size(); ++i) { + const PixelStoreParameters& pack = layouts[i]; + std::vector destination(4096, 0); + ScatterTightReadbackIntoPackState(tight.data(), destination.data(), kW, kH, kBpp, pack); + destination.resize(tight.size()); + const bool scatterChangedNothing = (destination == tight); + const bool predicateSaysTight = + ReadbackPackStateIsTightForTest(kW, kBpp, pack) != 0; + EXPECT_EQ(predicateSaysTight, scatterChangedNothing) + << "layout " << i << ": the fast-path predicate and the scatter disagree"; + if (predicateSaysTight) ++tightCount; + } + EXPECT_GT(tightCount, 0) << "no layout took the fast path, so the equality above is vacuous"; + EXPECT_LT(tightCount, static_cast(layouts.size())) + << "every layout took the fast path, so the equality above is vacuous"; +} + +// ===================================================================================== +// R-17: the routing, its reply mailbox, and the arm that is actually installed +// ===================================================================================== + +TEST(PipeRouting, TheTablesAreInstalledWithoutAnybodyHavingRememberedTo) { + // The gate on the install MECHANISM rather than on the table contents (PipeCatalogueTest + // owns the partition). Nothing in this file calls an installer; the tables are installed + // because MG_Pipe/PipeRoute.h's inline variable is in this binary, which is the property + // that a static initialiser inside PipeRoute.cpp did NOT have - the linker dropped that + // object from every test binary that named no symbol in it, and five CsoCacheTest cases + // took a null function pointer. + EXPECT_TRUE(MGPipeTablesAreInstalled()); + EXPECT_EQ(static_cast(MGPipeInstalledArm()), static_cast(MGPipeRouteArm::kMonolith)) + << "this process has no ClientSession, so the monolith adapters must be the arm"; +} + +TEST(PipeRouting, AnAnswerIsWhatTheRowSaidAndDeclinedIsFalseRatherThanAFailure) { + const Uint64 takenBefore = MGPipeRepliesTaken(); + const Uint64 declinedBefore = MGPipeRepliesDeclined(); + + const MGPReplySlot ok = MGPipeMintReplySlot(); + MGPipePostReply(ok, 0 /*OK*/, 1); + EXPECT_TRUE(MGPipeTakeReplyBool(ok, "unit")); + + const MGPReplySlot declined = MGPipeMintReplySlot(); + MGPipePostReply(declined, 1 /*DECLINED*/, 0); + EXPECT_FALSE(MGPipeTakeReplyBool(declined, "unit")) + << "DECLINED is how the four Bool acceptance rows say false (R-5), not how they fail"; + + EXPECT_EQ(MGPipeRepliesTaken(), takenBefore + 2u); + EXPECT_EQ(MGPipeRepliesDeclined(), declinedBefore + 1u) + << "the refusal was not COUNTED, so 'the client accepted everything' and 'the client " + "never asked' are still the same observation from outside"; + + // The two slots are different ids, which is what makes the mismatch Fatal below meaningful. + EXPECT_NE(ok.Id, declined.Id); + EXPECT_NE(ok.Id, 0u) << "slot 0 is ReplySlot.h's 'no record' and must stay unmintable"; +} + +#if MGTEST_HAVE_FORK +TEST(PipeRouting, AnUnansweredRowIsFatalRatherThanAcceptedOrRefused) { + // R-5's whole point, made structural. There is no default: "always accept" is ID-39's 66 + // lost DirectVulkan uploads with a wire in between, and "always refuse" is an emitter that + // re-sends for ever. A row that forgets to answer has to be impossible to READ. + const ChildResult child = RunInChild([] { + const MGPReplySlot slot = MGPipeMintReplySlot(); + (void)MGPipeTakeReplyBool(slot, "resource_create"); + }); + EXPECT_TRUE(DiedOfAbort(child)) << DescribeStatus(child); + EXPECT_NE(child.Log.find("Fatal{ReplyMissing"), std::string::npos) << child.Log; + EXPECT_NE(child.Log.find("resource_create"), std::string::npos) + << "the Fatal does not name the row, so it cannot say WHICH answer went missing"; +} + +TEST(PipeRouting, TwoOutstandingAnswersAreFatalBecauseTheBarrierIsOneDeep) { + // The mailbox is one entry deep because R-1's verb barrier makes the in-flight depth one + // (ReplySlot.h). A second posting before the first is taken is not a capacity problem, it + // is a barrier that has stopped holding - so it must not be absorbed by a deeper mailbox. + const ChildResult child = RunInChild([] { + const MGPReplySlot first = MGPipeMintReplySlot(); + const MGPReplySlot second = MGPipeMintReplySlot(); + MGPipePostReply(first, 0, 1); + MGPipePostReply(second, 0, 1); + }); + EXPECT_TRUE(DiedOfAbort(child)) << DescribeStatus(child); + EXPECT_NE(child.Log.find("Fatal{ReplyOverrun"), std::string::npos) << child.Log; +} + +TEST(PipeRouting, AnErrorStatusIsNotFoldedIntoAcceptedOrRefused) { + // ERROR is a transport fault and DECLINED is a resource decision. Folding the first into + // either arm of the second makes a broken wire look like a server that said no. + const ChildResult child = RunInChild([] { + const MGPReplySlot slot = MGPipeMintReplySlot(); + MGPipePostReply(slot, 2 /*ERROR*/, 0); + (void)MGPipeTakeReplyBool(slot, "set_texture_params"); + }); + EXPECT_TRUE(DiedOfAbort(child)) << DescribeStatus(child); + EXPECT_NE(child.Log.find("Fatal{ReplyError"), std::string::npos) << child.Log; + EXPECT_NE(child.Log.find("set_texture_params"), std::string::npos) << child.Log; +} +#endif // MGTEST_HAVE_FORK + +// ===================================================================================== +// B3 / codex 9: the CLIENT arm's 37 rows are observed, not just the monolith install +// ===================================================================================== + +namespace { + // How many function-pointer cells of `a` differ from `b`, walked as a block of void* - + // the structs ARE their function pointers (PipeCatalogueTest static_asserts that shape). A + // routed row that stayed on the monolith adapter reads EQUAL and is not counted, which is + // exactly the defect this measures. + template + SizeT CountDifferingCells(const T& a, const T& b) { + const void* const* pa = reinterpret_cast(&a); + const void* const* pb = reinterpret_cast(&b); + SizeT n = 0; + for (SizeT i = 0; i < sizeof(T) / sizeof(void*); ++i) + if (pa[i] != pb[i]) ++n; + return n; + } +} // namespace + +TEST(PipeRouting, TheInstalledClientArmIsWireAndNotMonolithAndEveryRoutedRowMoved) { + // WHAT B3 SAYS IS MISSING. PipeCatalogueTest installs and COUNTS the monolith table, so a + // client row that was never overwritten stays non-null and still counts - deleting one + // `gMGPipe*.X = &Wire_X` assignment left every gate green (the cross-family verifier + // reproduced it: catalogue 32/32, all lanes green). The only thing that catches it is + // observing that the CLIENT install actually MOVED each routed row off the monolith adapter. + // + // The monolith adapters are kept beside the installed tables (MGPipeMonolith*()), so the + // client install differs from them at exactly the routed rows and nowhere else. This reads + // the pointers back rather than constructing them (R-16): a deleted assignment reads equal. + InstallClientWireTables(); + EXPECT_EQ(static_cast(MGPipeInstalledArm()), static_cast(MGPipeRouteArm::kClientWire)) + << "InstallClientWireTables did not record the client-wire arm"; + + const SizeT movedScreen = CountDifferingCells(gMGPipeScreen, MGPipeMonolithScreen()); + const SizeT movedContext = CountDifferingCells(gMGPipeContext, MGPipeMonolithContext()); + EXPECT_EQ(movedScreen + movedContext, 33u) + << "exactly the 33 generated routed rows must differ from the monolith adapters; " + << movedScreen + movedContext + << " did, so a row was left on the monolith adapter (it would run the applier on the GL " + "thread under split) or an unrouted row was overwritten"; + + const SizeT movedEscapes = CountDifferingCells(gMGPipeRouteEscapes, MGPipeMonolithEscapes()); + EXPECT_EQ(movedEscapes, 4u) + << "the four escape routes must move off the monolith escapes too"; + + // Restore the monolith arm for the sibling cases that assert it (and for a clean binary). + MGPipeInstallMonolithTables(); + EXPECT_EQ(static_cast(MGPipeInstalledArm()), static_cast(MGPipeRouteArm::kMonolith)); +} + +#if MGTEST_HAVE_FORK +TEST(PipeRouting, AClientWireRowWithNoSessionRefusesByNameRatherThanApplying) { + // THE RUNTIME HALF of B3, and it distinguishes a Wire_* row from the monolith adapter by + // BEHAVIOUR: with the client tables installed and no session, a routed call reaches + // RequireSession and aborts Fatal{NoClientSession}. The monolith adapter (the deleted- + // assignment state) would instead run MGPipeApply* and NOT abort with that string - so the + // control goes red the moment a row falls back to monolith. + const ChildResult child = RunInChild([] { + InstallClientWireTables(); + MGPHandleOnly handle{}; + handle.Handle = MGPipeHandle{1, 0}; + handle.Kind = static_cast(MGPipeKind::Renderbuffer); + gMGPipeScreen.ResourceDestroy(&handle); // Wire_ResourceDestroy, no session + }); + EXPECT_TRUE(DiedOfAbort(child)) << DescribeStatus(child) << "\n" << child.Log; + EXPECT_NE(child.Log.find("Fatal{NoClientSession, \"ResourceDestroy\"}"), std::string::npos) + << "the installed row did not refuse by name; it may be the monolith adapter (B3)\n" + << child.Log; +} + +TEST(PipeRouting, ARoutedCallDuringTeardownRefusesByNameNotRunsTheApplier) { + // codex 4: UninstallClientWireTables marks the tables uninstalled; a routed call in that + // window must abort by name rather than run the applier on the caller. Reverting Uninstall + // to reinstall the monolith adapters (round 2's behaviour) makes this call run the applier + // and NOT abort with this string - the red-once. + const ChildResult child = RunInChild([] { + InstallClientWireTables(); + UninstallClientWireTables(); + MGPHandleOnly handle{}; + handle.Handle = MGPipeHandle{1, 0}; + handle.Kind = static_cast(MGPipeKind::Renderbuffer); + gMGPipeScreen.ResourceDestroy(&handle); + }); + EXPECT_TRUE(DiedOfAbort(child)) << DescribeStatus(child) << "\n" << child.Log; + EXPECT_NE(child.Log.find("Fatal{ClientTablesUninstalled, \"ResourceDestroy\"}"), std::string::npos) + << "a routed call after UninstallClientWireTables ran the applier on the caller instead " + "of refusing by name (codex 4)\n" + << child.Log; +} +#endif // MGTEST_HAVE_FORK + +// ===================================================================================== +// M2 / codex 11: a short or non-OK reply is refused, never scattered as pixels +// ===================================================================================== + +TEST(RemoteReadback, AReplyIsScatteredOnlyWhenItIsOkAndExactlyTheReadsExtent) { + // EmitReadPixels decides on ReadbackReplyIsComplete before it scatters or returns (the Fatal + // wording each mode owns is at the call site). Driving the production predicate directly: + // only a full OK reply is complete; a short OK reply, and a DECLINE or ERROR with a zero + // payload, are not - and those are the shapes that would otherwise spray stale destination + // bytes as pixels. `tight` is the read's own DstSize (CONTRACT-P5 row 23). + constexpr Int32 kOk = 0, kDeclined = 1, kError = 2; + const Uint64 tight = TightReadbackByteCount(4, 3, 0x1908, 0x1401); // 48 + EXPECT_TRUE(ReadbackReplyIsComplete(kOk, tight, tight)) << "a full OK reply is the only one scattered"; + EXPECT_FALSE(ReadbackReplyIsComplete(kOk, tight - 16, tight)) + << "a SHORT OK reply (one row missing) must not be scattered - the missing rows would be " + "whatever the destination held"; + EXPECT_FALSE(ReadbackReplyIsComplete(kDeclined, 0, tight)) + << "a DECLINED reply carries no pixels"; + EXPECT_FALSE(ReadbackReplyIsComplete(kError, 0, tight)) << "an ERROR reply carries no pixels"; + EXPECT_FALSE(ReadbackReplyIsComplete(kOk, 0, tight)) << "an OK reply of zero bytes is not the extent"; +} + +int main(int argc, char** argv) { + namespace fs = std::filesystem; + const fs::path path = + fs::temp_directory_path() / ("mobilegl-remoteclient-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 + ::testing::InitGoogleTest(&argc, argv); + const int rc = RUN_ALL_TESTS(); + fs::remove(path, ec); + return rc; +} diff --git a/MobileGL/MG_Test/Wire/ServerLoopTest.cpp b/MobileGL/MG_Test/Wire/ServerLoopTest.cpp new file mode 100644 index 00000000..db84ea37 --- /dev/null +++ b/MobileGL/MG_Test/Wire/ServerLoopTest.cpp @@ -0,0 +1,625 @@ +// MobileGL - MobileGL/MG_Test/Wire/ServerLoopTest.cpp +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +// Package v1's suite: the apply thread, the blocking control mailbox, the verb stamp, the +// bounded shutdown, and R-11's server-owned staging copy. +// +// WHAT THIS SUITE REFUSES TO DO, and why it is written the way it is (R-16). +// +// Every case here drives the REAL path: a real ServerSession over four real ShmSegment-backed +// segments, a real apply thread parked on a real Doorbell, and records that go in through w1's +// encoder and come out through w1's decoder. None of them writes a record field by hand, none +// of them calls PipeApplier::ApplyOne directly, and none of them sets a flag the case then +// observes. That matters because the three defects the first P5 wave shipped were all of that +// shape - a persistent-map case that wrote the record field itself still passed with the +// producer deleted, and a lane probe armed against stub SOURCE TEXT went green having run +// monolith on 8 of 11 lanes. +// +// The two things this suite deliberately CANNOT reach are named rather than faked: +// * there is no GL context here, so ServerLoop::CreateBackend is not called and the five +// class-B verbs DECLINE. That is asserted as a decline, not skipped - a Clear that was +// APPLIED with no backend would mean the sink found a table it should not have. +// * R-11's end-to-end control is MOBILEGL_IPC_AUDIT=1 over the reduced path, which needs the +// client's emit table (package c1). What IS reachable here is the property that control +// exists to test: bytes that were copied survive the source being overwritten with 0xDD. + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(_WIN32) +#include +#else +#include +#endif + +using namespace MobileGL; + +// NOT `using namespace MobileGL::MG_Remote`. MobileGL::Wire (the flatbuffers control-plane +// schema) and MobileGL::MG_Remote::Wire (the G3 codec) are two different namespaces with the +// same last name, and a using-directive over the second makes every mention of `Wire` +// ambiguous - including the ones in the generated header. +namespace Transport = MobileGL::MG_Remote::Transport; +namespace Server = MobileGL::MG_Remote::Server; +namespace Codec = MobileGL::MG_Remote::Wire; +using MobileGL::MG_Remote::CapsAbiFingerprint; + +namespace { + + std::string g_logPath; + + std::string ReadLog() { + std::ifstream in(g_logPath, std::ios::binary); + if (!in) return {}; + return std::string(std::istreambuf_iterator(in), std::istreambuf_iterator()); + } + + unsigned ProcessId() { +#if defined(_WIN32) + return static_cast(_getpid()); +#else + return static_cast(::getpid()); +#endif + } + + Transport::SessionSegmentSizes TestSizes() { + Transport::SessionSegmentSizes sizes; + sizes.CmdRingBytes = 64ull * 1024; + sizes.StageBytes = 64ull * 1024; + sizes.ReplyBytes = 64ull * 1024; + sizes.EventRingBytes = 16ull * 1024; + sizes.ReplySlotCount = 8; + return sizes; + } + + // The client half of a session, wired exactly the way ClientSession::Start wires it: the + // transport pair, a real Hello, the server's Accept, the client's attach to the SAME + // mapping, and w1's encoder over the shared SEG_CMD ring. It is NOT ClientSession itself, + // because ClientSession::Start finishes by installing package c1's BackendObject_Remote, + // which does not exist yet - and a fixture that skipped the handshake to get around that + // would be testing a session this tree does not build. + struct ServerFixture { + std::unique_ptr clientTransport; + std::unique_ptr serverTransport; + Transport::SessionSegments clientSegments; + Transport::RingProducer cmd; + Transport::SessionProducer producer; + Codec::SegmentTable clientTable; + Codec::PipeWireEncoder encoder; + Server::ServerSession* session = nullptr; + + bool Handshake() { + Transport::InProcessTransport::CreatePair(clientTransport, serverTransport); + { + ::flatbuffers::FlatBufferBuilder builder(512); + auto stamp = builder.CreateString(GIT_COMMIT_HASH_SHORT); + auto hello = ::MobileGL::Wire::CreateHello( + builder, MOBILEGL_PROTOCOL_ABI_MAJOR, MOBILEGL_PROTOCOL_ABI_MINOR, stamp, + /*backendType=*/0u, /*pid=*/0u, /*configBlob=*/0, CapsAbiFingerprint()); + auto root = ::MobileGL::Wire::CreateCtrlEnvelope( + builder, ::MobileGL::Wire::CtrlMsg::Hello, hello.Union()); + ::MobileGL::Wire::FinishCtrlEnvelopeBuffer(builder, root); + if (clientTransport->SendFrame(MobileGLByteSpan{builder.GetBufferPointer(), + builder.GetSize()}) != MOBILEGL_OK) { + return false; + } + } + session = &Server::ServerSessionInstance(); + session->SetSegmentSizes(TestSizes()); + // The two halves v1 owns. Neither has a default and CallMask() Fatals on an unset + // one, which is s1's BLOCKER fix and the reason this is stated rather than derived. + session->SetCapabilityBits(0); + session->SetConsumedSubsystems(MG_Pipe::kMGPipeSubsystemsMigratedAtP4a); + if (session->Accept(*serverTransport) != MOBILEGL_OK) return false; + if (clientSegments.AttachInProcess(session->Shm(), Transport::MemoryRole::Client) != + MOBILEGL_OK) { + return false; + } + Transport::RingControl* control = clientSegments.CmdControl(); + cmd = Transport::RingProducer(control, clientSegments.CmdRingBase(), + clientSegments.CmdRingCapacity(), Transport::RingCursorSet::Cmd); + if (!cmd.Valid()) return false; + producer.Attach(control, &cmd, &clientTransport->PeerDoorbell(), + &clientTransport->SelfDoorbell(), MG_Config::Ipc.SpinUs); + clientTable.Install(Codec::kSegCmd, Codec::SegmentView{clientSegments.CmdRingBase(), + clientSegments.CmdRingCapacity()}); + clientTable.Install(Codec::kSegStage, + Codec::SegmentView{clientSegments.StageBase(), clientSegments.StageBytes()}); + clientTable.Install(Codec::kSegReply, + Codec::SegmentView{clientSegments.ReplyBase(), clientSegments.ReplyBytes()}); + encoder = Codec::PipeWireEncoder(control, &cmd, nullptr, &clientTable); + return true; + } + + bool StartLoop() { + return Server::ServerLoopInstance().Start(*session) == MOBILEGL_OK; + } + + // Emit one record and wait for the server to apply it. This is the verb barrier's own + // wait (R-3/R-5) - appliedSeq, not a sleep - so a case that goes green here has really + // seen the apply thread move the watermark. + bool EmitAndWait(MG_Pipe::MGPWireOp op, const void* payload, Uint64 payloadBytes, + Uint32 timeoutMs = 5000) { + const Uint64 seq = encoder.EncodeRecord(op, payload, payloadBytes); + if (seq == Codec::kInvalidSeq) return false; + encoder.Publish(); + producer.PublishAndNotify(seq); + return producer.WaitForApplied(seq, timeoutMs) == Transport::SessionWait::Reached; + } + + bool EmitAndWaitWithTail(MG_Pipe::MGPWireOp op, const void* payload, Uint64 payloadBytes, + const void* tail, Uint64 tailBytes, Uint32 timeoutMs = 5000) { + const Uint64 seq = encoder.EncodeRecord(op, payload, payloadBytes, tail, tailBytes); + if (seq == Codec::kInvalidSeq) return false; + encoder.Publish(); + producer.PublishAndNotify(seq); + return producer.WaitForApplied(seq, timeoutMs) == Transport::SessionWait::Reached; + } + + void Stop() { + // Table 3's order: the client publishes and lets the server drain (EmitAndWait + // already did), then Doorbell::Kill through the transport's Shutdown, THEN the + // bounded join, and only then is anything an emitter owns released. + if (clientTransport) clientTransport->Shutdown(); + Server::ServerLoopInstance().Stop(); + producer.Detach(); + encoder = Codec::PipeWireEncoder(); + cmd = Transport::RingProducer(); + clientSegments.Close(); + if (session != nullptr) session->Close(); + clientTransport.reset(); + serverTransport.reset(); + } + }; + + MG_Pipe::MGPClear WholeFramebufferClear() { + MG_Pipe::MGPClear clear{}; + clear.Kind = Server::kMGPClearKindWhole; + clear.BufferMask = 0x00004000; // GL_COLOR_BUFFER_BIT + clear.DrawBufferIndex = -1; + clear.ValueClass = Server::kMGPClearValueClassFloat; + return clear; + } + +} // namespace + +// ===================================================================================== +// The thread +// ===================================================================================== + +// A control request must be able to un-park a thread waiting on kWaitForever. A Notify alone +// cannot do that - Doorbell::Wait consumes it with one Park, re-tests a condition nothing +// published, finds the bell alive and parks again - so the park predicate has to carry the +// control flag too. The case asserts the thread REALLY PARKED first, because a loop that +// spun instead would pass this without the predicate ever mattering. +TEST(ServerLoopTest, AControlRequestRunsOnTheApplyThreadAndUnparksIt) { + ServerFixture fixture; + ASSERT_TRUE(fixture.Handshake()); + ASSERT_TRUE(fixture.StartLoop()); + + Server::ServerLoop& loop = Server::ServerLoopInstance(); + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5); + while (loop.ParkCount() == 0 && std::chrono::steady_clock::now() < deadline) { + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + ASSERT_GT(loop.ParkCount(), 0u) + << "the apply thread never parked, so this case would prove nothing about waking it"; + + struct Probe { + std::thread::id ranOn{}; + Bool onApplyThread = false; + } probe; + const MobileGLResult rc = loop.RunOnApplyThread( + +[](void* user) -> MobileGLResult { + auto* p = static_cast(user); + p->ranOn = std::this_thread::get_id(); + p->onApplyThread = Server::ServerLoop::OnApplyThread(); + return MOBILEGL_OK; + }, + &probe); + + EXPECT_EQ(rc, MOBILEGL_OK); + EXPECT_NE(probe.ranOn, std::this_thread::get_id()) + << "the control request ran on the CALLER, which means the EGL lifecycle calls would " + "reach the driver from the app thread and the context would never migrate"; + EXPECT_TRUE(probe.onApplyThread); + EXPECT_FALSE(Server::ServerLoop::OnApplyThread()); + + fixture.Stop(); +} + +// Re-entrancy is not a deadlock: ~BackendObject_DirectGLES reaches ReleaseEGLResources FROM the +// apply thread, so a post from there must run inline. +TEST(ServerLoopTest, APostFromTheApplyThreadItselfRunsInlineRatherThanDeadlocking) { + ServerFixture fixture; + ASSERT_TRUE(fixture.Handshake()); + ASSERT_TRUE(fixture.StartLoop()); + + struct Outer { + Bool innerRan = false; + MobileGLResult innerRc = MOBILEGL_ERR_INVALID_ARGUMENT; + } outer; + const MobileGLResult rc = Server::ServerLoopInstance().RunOnApplyThread( + +[](void* user) -> MobileGLResult { + auto* o = static_cast(user); + o->innerRc = Server::ServerLoopInstance().RunOnApplyThread( + +[](void* inner) -> MobileGLResult { + *static_cast(inner) = true; + return MOBILEGL_OK; + }, + &o->innerRan); + return MOBILEGL_OK; + }, + &outer); + + EXPECT_EQ(rc, MOBILEGL_OK); + EXPECT_EQ(outer.innerRc, MOBILEGL_OK); + EXPECT_TRUE(outer.innerRan); + + fixture.Stop(); +} + +// The bounded join. The thread is parked on kWaitForever when Stop() is called, so this is the +// exact lost-wakeup shape table 3 step 2 is about - and the bound is what turns a regression +// into a red test rather than a wedged CI job. +TEST(ServerLoopTest, StopKillsTheDoorbellJoinsAndTheThreadReallyExits) { + ServerFixture fixture; + ASSERT_TRUE(fixture.Handshake()); + ASSERT_TRUE(fixture.StartLoop()); + Server::ServerLoop& loop = Server::ServerLoopInstance(); + + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5); + while (loop.ParkCount() == 0 && std::chrono::steady_clock::now() < deadline) { + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + ASSERT_GT(loop.ParkCount(), 0u) << "the thread must be parked for this to be a shutdown test"; + ASSERT_TRUE(loop.Running()); + + const auto began = std::chrono::steady_clock::now(); + fixture.Stop(); + const auto took = std::chrono::steady_clock::now() - began; + + EXPECT_FALSE(loop.Running()); + EXPECT_LT(std::chrono::duration_cast(took).count(), 5000) + << "Stop() took the whole bound, which means it hit the join timeout rather than a " + "wakeup"; +} + +// MOBILEGL_IPC_SERVER_AFFINITY is kept as the raw string BECAUSE the resolved mask is what gets +// logged: an affinity that silently did nothing looks exactly like one that worked. So the +// resolved mask has to be readable, and `off` has to resolve to zero rather than to "auto". +TEST(ServerLoopTest, TheAffinityStringResolvesToAMaskAndTheMaskIsLogged) { + const String saved = MG_Config::Ipc.ServerAffinity; + MG_Config::Ipc.ServerAffinity = "off"; + + ServerFixture fixture; + ASSERT_TRUE(fixture.Handshake()); + ASSERT_TRUE(fixture.StartLoop()); + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5); + while (Server::ServerLoopInstance().ParkCount() == 0 && + std::chrono::steady_clock::now() < deadline) { + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + EXPECT_EQ(Server::ServerLoopInstance().ResolvedAffinityMask(), 0u) + << "`off` did not resolve to no-affinity"; + + const std::string log = ReadLog(); + EXPECT_NE(log.find("RESOLVED mask"), std::string::npos) + << "the apply thread did not log its resolved affinity mask; an affinity that silently " + "did nothing is indistinguishable from one that worked"; + EXPECT_NE(log.find("mgl-srv-apply started"), std::string::npos); + + fixture.Stop(); + MG_Config::Ipc.ServerAffinity = saved; +} + +// ===================================================================================== +// The record path: stamp, apply, watermark +// ===================================================================================== + +// The whole phase's prerequisite. MGPipeApplyAccess deliberately does not stamp the poison +// generations, so under split NOTHING stamps unless the applier does - every FilledGen[] stays +// 0, MGPipeInputFieldIsFresh answers false for everything, and a server-side read aborts on the +// FIRST field inside SyncRenderState. This case asserts the stamp happened by reading the verb +// the stamp SET, which the case itself never writes. +TEST(ServerLoopTest, AClearRecordCrossesAndIsStampedAsAVerbBoundary) { + ServerFixture fixture; + ASSERT_TRUE(fixture.Handshake()); + ASSERT_TRUE(fixture.StartLoop()); + + // The pre-state is the honest one: nothing has stamped yet in this process. + ASSERT_NE(MG_Pipe::gPipeInputs.CurrentVerb(), MG_Pipe::MGPipeVerb::Clear); + + const MG_Pipe::MGPClear clear = WholeFramebufferClear(); + ASSERT_TRUE(fixture.EmitAndWait(MG_Pipe::MGPWireOp::Clear, &clear, sizeof(clear))); + + Server::ServerLoop& loop = Server::ServerLoopInstance(); + EXPECT_EQ(loop.DrainedRecords(), 1u); + EXPECT_EQ(MG_Pipe::gPipeInputs.CurrentVerb(), MG_Pipe::MGPipeVerb::Clear) + << "PipeApplier::StampVerbBoundary did not run, so every server-side PipeInputs read " + "would abort on the first field inside SyncRenderState"; + + // MANDATORY on leaving the applier (p1's M-5): without it a SPAWNED server latches the flag + // for its whole life, every later read is judged against the last verb's mask, and the + // sticky forwards start aborting under strict on the very case their exemption exists for. + EXPECT_FALSE(MG_Pipe::gPipeInputs.ServerStampedVerb()) + << "MGPipeServerClearVerbBoundary was not called when the apply thread left the applier"; + + // No backend object in this process, so the five class-B verbs DECLINE. Asserted rather + // than skipped: a Clear that was APPLIED here would mean the sink found a function table + // it had no business finding. + EXPECT_EQ(fixture.session->Applier().Verbs().Clears(), 0u); + + fixture.Stop(); +} + +// R-9's batching ban, made checkable rather than merely stated. RingControl::appliedSeq has ONE +// writer (SessionConsumer::ApplyOne, +1 per record) and PipeWireDecoder keeps its own tally; a +// batched publish moves one and not the other, which a single counter could never tell apart. +TEST(ServerLoopTest, TheSessionWatermarkAndTheDecoderTallyAgreeAfterEveryRecord) { + ServerFixture fixture; + ASSERT_TRUE(fixture.Handshake()); + ASSERT_TRUE(fixture.StartLoop()); + + const MG_Pipe::MGPClear clear = WholeFramebufferClear(); + for (int i = 0; i < 8; ++i) { + ASSERT_TRUE(fixture.EmitAndWait(MG_Pipe::MGPWireOp::Clear, &clear, sizeof(clear))) << i; + EXPECT_EQ(fixture.session->Consumer().AppliedSeq(), static_cast(i + 1)); + EXPECT_EQ(fixture.session->Applier().DecoderAppliedSeq(), static_cast(i + 1)); + } + EXPECT_EQ(Server::ServerLoopInstance().DrainedRecords(), 8u); + + // retiredSeq is the watermark w1's SEG_STAGE allocator reclaims against; a loop that + // applies and never retires ends the first MOBILEGL_IPC_STAGE_MB in Fatal{RingOverrun}. + // + // IT IS POLLED, NOT READ ONCE, and that is R-9's own rule rather than a papered-over race: + // appliedSeq is the ONLY watermark P5 forbids batching, and every other one "may be + // published LATE but never EARLY". The apply thread retires after the drain batch and + // before it parks, so the barrier can release the client between the two - reading it + // instantly would be asserting a promise R-9 deliberately does not make. The BOUND is what + // keeps this a check: a loop that never retires times out here instead of passing. + const auto retireDeadline = std::chrono::steady_clock::now() + std::chrono::seconds(2); + while (fixture.clientSegments.CmdControl()->retiredSeq.load() < 8u && + std::chrono::steady_clock::now() < retireDeadline) { + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + EXPECT_GE(fixture.clientSegments.CmdControl()->retiredSeq.load(), 8u) + << "the apply loop advanced appliedSeq but never retired within 2 s, so SEG_STAGE would " + "never be reclaimed and the first MOBILEGL_IPC_STAGE_MB would end in " + "Fatal{RingOverrun}"; + + fixture.Stop(); +} + +// ===================================================================================== +// R-2.5 / rule C's mechanical control, running for real +// ===================================================================================== + +// MOBILEGL_IPC_AUDIT=1 fills a retired SEG_STAGE run with 0xDD once the applier has RETURNED, +// so an applier that kept the pointer reads the poison on the next frame instead of bytes that +// happen to still be there. Without this, an inproc implementation that kept a pointer is +// INDISTINGUISHABLE from one that copied - which is R-2's entire argument. +// +// The case asserts three separate things, because each one alone can be true for the wrong +// reason: that the record's bytes really crossed (memcmp before the poison), that the poison +// really ran (PoisonedStageBytes moved), and that it landed on the run the record named (the +// bytes read 0xDD afterwards, through the CLIENT's mapping of the same segment). +TEST(ServerLoopTest, TheAuditPoisonFillsExactlyTheStagedRunAfterTheApplierReturns) { + const Bool savedAudit = MG_Config::Ipc.Audit; + // Set BEFORE the loop starts: PipeWireDecoder reads it in its constructor, and its + // constructor runs on the apply thread inside ServerLoop::Start. + MG_Config::Ipc.Audit = true; + + ServerFixture fixture; + ASSERT_TRUE(fixture.Handshake()); + ASSERT_TRUE(fixture.StartLoop()); + + MG_Pipe::MGPResourceDesc create{}; + create.Resource.Slot = 61; + create.Resource.Gen = 1; + create.Target = static_cast(MG_Pipe::MGPipeResourceTarget::Tex2D); + create.InternalFormat = 1; + create.Width = 4; + create.Height = 4; + create.Depth = 1; + create.ArrayLayers = 1; + create.Levels = 1; + create.Samples = 1; + ASSERT_TRUE(fixture.EmitAndWait(MG_Pipe::MGPWireOp::ResourceCreate, &create, sizeof(create))); + + Vector texels(4 * 4 * 4, 0x5A); + MG_Pipe::MGPSubData upload{}; + upload.Res = create.Resource; + upload.Target = MG_Pipe::MGPipePackSubDataTarget( + static_cast(MG_Pipe::MGPipeResourceTarget::Tex2D), 0u); + upload.Level = 0; + upload.UnionBox = MG_Pipe::MGPBox{0, 0, 0, 4, 4, 1}; + upload.RegionCount = 1; + upload.Blob = fixture.encoder.StageBytes(texels.data(), texels.size()); + ASSERT_NE(upload.Blob.Size, 0u) << "rule A: a content record must declare non-zero bytes"; + + // The bytes are in SEG_STAGE and readable through the CLIENT's own mapping right now - the + // record has not been published yet, so nothing has retired it. + const void* stagedBefore = + fixture.clientTable.Resolve(upload.Blob.Seg, upload.Blob.Offset, upload.Blob.Size); + ASSERT_NE(stagedBefore, nullptr); + ASSERT_EQ(std::memcmp(stagedBefore, texels.data(), texels.size()), 0); + + MG_Pipe::MGPSubRegion region{}; + region.W = 4; + region.H = 4; + region.D = 1; + ASSERT_TRUE(fixture.EmitAndWaitWithTail(MG_Pipe::MGPWireOp::ResourceSubData, &upload, + sizeof(upload), ®ion, sizeof(region))); + + EXPECT_GT(fixture.session->Applier().PoisonedStageBytes(), 0u) + << "MOBILEGL_IPC_AUDIT=1 poisoned nothing, so R-2.5's only mechanical control against a " + "retained SEG_STAGE pointer never ran"; + + const auto* poisoned = static_cast( + fixture.clientTable.Resolve(upload.Blob.Seg, upload.Blob.Offset, upload.Blob.Size)); + ASSERT_NE(poisoned, nullptr); + for (Uint64 i = 0; i < upload.Blob.Size; ++i) { + ASSERT_EQ(poisoned[i], 0xDD) << "staged byte " << i << " was not poisoned; an applier " + "that kept this pointer would still read real bytes and " + "the control would prove nothing"; + } + + fixture.Stop(); + MG_Config::Ipc.Audit = savedAudit; +} + +// ===================================================================================== +// R-11 - the server's own copy of the staged bytes +// ===================================================================================== + +// THIS IS THE PROPERTY MOBILEGL_IPC_AUDIT=1's 0xDD FILL EXISTS TO TEST, at unit scope: after +// the source bytes are overwritten - which is exactly what the decoder does to a retired +// SEG_STAGE run - the server's copy still reads the original. An implementation that returned +// `raw - offset` cannot pass this, and the monolith arm below is the control that says so: it +// is the SAME call with the same inputs, and it must see the 0xDD. +TEST(StagedShadowTest, TheSplitArmCopiesAndSurvivesTheSourceBeingPoisoned) { + Server::StagedShadowStore splitStore(/*copies=*/true); + Server::StagedShadowStore monolithStore(/*copies=*/false); + const int key = 0; + + Vector staged(32, 0xAB); + const Uint8* splitBase = splitStore.Adopt(&key, 64, staged.data(), 16, staged.size()); + const Uint8* monolithBase = monolithStore.Adopt(&key, 64, staged.data(), 16, staged.size()); + + ASSERT_NE(splitBase, nullptr); + EXPECT_EQ(monolithBase, staged.data() - 16) + << "the monolith arm must be the ORIGINAL expression, character for character, or every " + "push and verify lane is running new code it was never measured against"; + EXPECT_NE(splitBase, monolithBase); + + // w1's retired-stage poison, by hand and at the right moment: the record has retired, so + // the staging run is dead. + std::fill(staged.begin(), staged.end(), Uint8{0xDD}); + + for (SizeT i = 0; i < 32; ++i) { + EXPECT_EQ(splitBase[16 + i], 0xAB) << "byte " << i << " of the server's copy is the " + "poison, so the copy never happened"; + } + // And the control: the monolith arm reads the poison, which is what makes the assertion + // above a statement about copying rather than about the test's own buffer. + EXPECT_EQ(monolithBase[16], 0xDD); +} + +// The extent is EXACTLY what the record declared. Adjacent runs merge because there is no gap +// between them; runs with a gap do not, and that is the whole mechanism - it is what makes a +// missing record detectable instead of papered over by a widened INVALIDATE_RANGE. +TEST(StagedShadowTest, CoverageIsExactAndAGapIsNotCovered) { + Server::StagedShadowStore store(/*copies=*/true); + const int key = 0; + Vector bytes(16, 0x11); + + store.Adopt(&key, 64, bytes.data(), 0, 16); + store.Adopt(&key, 64, bytes.data(), 32, 16); + EXPECT_EQ(store.CoveredRunCount(&key), 2u) << "two runs with a 16-byte gap merged into one"; + EXPECT_TRUE(store.IsCovered(&key, 0, 16)); + EXPECT_TRUE(store.IsCovered(&key, 32, 48)); + EXPECT_FALSE(store.IsCovered(&key, 16, 32)) << "the gap reads as covered"; + EXPECT_FALSE(store.IsCovered(&key, 0, 48)) << "a span across the gap reads as covered"; + EXPECT_FALSE(store.IsCovered(&key, 8, 40)); + + // Filling the gap merges all three into one run, which is the proof that adjacency (not + // proximity) is the merge rule. + store.Adopt(&key, 64, bytes.data(), 16, 16); + EXPECT_EQ(store.CoveredRunCount(&key), 1u); + EXPECT_TRUE(store.IsCovered(&key, 0, 48)); +} + +TEST(StagedShadowTest, DropForgetsOneResourceAndDropAllForgetsEveryOne) { + Server::StagedShadowStore store(/*copies=*/true); + const int a = 0; + const int b = 0; + Vector bytes(8, 0x22); + store.Adopt(&a, 8, bytes.data(), 0, 8); + store.Adopt(&b, 8, bytes.data(), 0, 8); + ASSERT_EQ(store.TrackedResources(), 2u); + store.Drop(&a); + EXPECT_EQ(store.TrackedResources(), 1u); + EXPECT_FALSE(store.IsCovered(&a, 0, 8)); + EXPECT_TRUE(store.IsCovered(&b, 0, 8)); + store.DropAll(); + EXPECT_EQ(store.TrackedResources(), 0u); +} + +// The Fatal, and it asserts ITS OWN failure string rather than "the process died". A death +// test that only checks for a crash goes green on any other abort in the same body, which is +// exactly the shape this wave shipped three of. +#if !defined(_WIN32) +TEST(StagedShadowTest, ADrainOutsideTheStagedCoverageIsFatalByName) { + Server::StagedShadowStore store(/*copies=*/true); + const int key = 0; + Vector bytes(16, 0x33); + const Uint8* base = store.Adopt(&key, 64, bytes.data(), 0, 16); + + // In coverage: no Fatal, and this is asserted first so that the death below cannot be a + // function that aborts on everything. + store.RequireCoverage(&key, base, 0, 16, "unit"); + // A base that is not this resource's shadow is not this rule's subject - that is the + // legacy arm, whose MappedData() is valid for the whole store. + store.RequireCoverage(&key, bytes.data(), 0, 64, "unit"); + + EXPECT_DEATH(store.RequireCoverage(&key, base, 0, 64, "unit_out_of_range"), ""); + const std::string log = ReadLog(); + EXPECT_NE(log.find("Fatal{StageSnapshotTooNarrow, \"unit_out_of_range\"}"), std::string::npos) + << "the abort happened but not for this rule's reason; the log says: " << log; +} +#endif + +int main(int argc, char** argv) { + // Before anything logs: MG_Util::Debug::InitFile() reads the variable once, on the first + // write, and caches the FILE*. The name carries this process's pid, because + // gtest_discover_tests runs every case as its own process, in parallel under ctest -j. + namespace fs = std::filesystem; + const fs::path path = + fs::temp_directory_path() / ("mobilegl-serverloop-test-" + std::to_string(ProcessId()) + ".log"); + std::error_code ec; + fs::remove(path, ec); + g_logPath = path.string(); +#if defined(_WIN32) + _putenv_s("MOBILEGL_LOG_FILE_PATH", g_logPath.c_str()); +#else + setenv("MOBILEGL_LOG_FILE_PATH", g_logPath.c_str(), 1); +#endif + // THIS PROCESS IS A SPLIT ONE. Everything under test reads MG_Config::Transport - the + // staged-shadow arm, the applier's stamp path, ConfigLoader's own knobs - and a suite that + // left it at Monolith would be a server test running the monolith answers, which is the + // failure this phase is built to make impossible. + MG_Config::Transport = MG_Config::TransportMode::InProcess; + ::testing::InitGoogleTest(&argc, argv); + const int rc = RUN_ALL_TESTS(); + fs::remove(path, ec); + return rc; +} diff --git a/scripts/gen_pipe.py b/scripts/gen_pipe.py index 00f593ff..e0e4bab6 100644 --- a/scripts/gen_pipe.py +++ b/scripts/gen_pipe.py @@ -152,6 +152,19 @@ class Call(object): """(parameter declaration list, argument list) for this call.""" params = ["const %s* payload" % self.Payload] args = ["payload"] + # P5 R-17. EXACTLY the shape kVarTail already has, one flag over: a kHasBlob row's + # payload owns an MGPBlobRef, and a blobref names bytes that live somewhere the + # payload cannot reach on its own. In monolith "somewhere" is the caller's own + # memory and the companion is the raw pointer every MG_Impl call site passes today + # (CONTRACT-P5 table 1's "companion pointer today" column, nine rows); under split + # it is a SEG_STAGE run the client staged before it published. A table row that + # omitted the pair could reproduce NEITHER, which is why nine of the thirty-seven + # entry points had no routing that could be written at all. + if "kHasBlob" in self.Flags: + params.append("const void* blobBytes") + params.append("Uint64 blobByteCount") + args.append("blobBytes") + args.append("blobByteCount") if "kVarTail" in self.Flags: params.append("const void* varTail") params.append("Uint32 varTailCount")