Files
MobileGL/MobileGL/MG_Remote/Client/CapsMirror.h
T
swung0x48 df17c416c0 [MobileGL, MG_Backend, MG_Remote, scripts] (P7): the split wire comes up before the first frontend object, and the caps mirror stops deciding a family's fate from a placeholder
F1. `MG_State::Init()` runs before `MG_Backend::Init()`, and the second is what
starts the session - so `GLContext`'s `TextureState` constructor materialised its
eleven default texture objects, each of them emitted `resource_create` through
`FamilyIsLive`, and `CapsMirror::ServerConsumes(0x400)` answered every one of them
from a generation-0 placeholder with `callMask=0`. All eleven records were dropped
and the whole event was one WARN line:

  MG_Remote client: the server does not consume MGPipe subsystem 0x400 - this
  family emits NOTHING and the legacy pull path runs for it (R-8). callMask=0x0,
  caps generation 0

It is NOT a race. The line reproduces byte-for-byte on lavapipe and on the Adreno
830, because the two Init calls are two statements on one thread. And "the legacy
pull path runs for it" is false on the client under split: there is no pull path
on this side of the wire, so withholding is not a fallback, it is a drop that only
a later respecify's self-healing create ever undoes.

Two of the three options the brief offered do not apply, and both facts are worth
recording. (b) "extend the P6.5 fix to inproc" is already the tree: `FinishStartup`
step 7 is the one funnel every transport reaches and it will not return without a
real snapshot. (a) "a bounded wait in Consumes()" cannot work alone and would
deadlock: at `MG_State::Init()` no handshake is in flight, and the one that will
land is started later BY THE SAME THREAD.

So the fix is the ORDER, with the rule behind it as the gate:

  * `MobileGL::Initialize` calls `MG_Backend::InitSplitRolesBeforeState()` before
    `MG_State::Init()`. `Init()`'s split arm becomes one body, `InitSplitBackend()`,
    shared with the direct callers that still reach `Init()`; the monolith arm keeps
    its place after `MG_State::Init()`, because moving it would move pull `.text`.
  * `CapsMirror::ServerConsumes()` refuses to answer from a placeholder. It waits,
    bounded, when a bring-up is genuinely in flight on ANOTHER thread, and otherwise
    dies as `Fatal{CapsBeforeFirstSnapshot, "<family>"}` through `SessionFail` -
    a new family word, one row in `FatalFamilies.def`, no new abort site.
  * Two arms are exempt and say why in place: the server arm (`MGPipeServerArm()` -
    the apply thread and the spawn server, where the client's mirror is the wrong
    object rather than a late one), and a process whose own configuration never
    asked for a split transport (`MG_Config::SplitTransportRequestedByConfig`, set
    by `MG_ConfigLoader::InitTransport`, which is what tells a real run apart from a
    fixture that assigns `MG_Config::Transport` by hand).

The read accessors keep P5's ruling untouched: `LogBackendInfo` reads
`GetRendererInfo()` during `MG_Backend::Init()` and an abort there is a process that
cannot start. Only the DECISION changed. The warning was not made quieter and the
callMask was not defaulted to all-ones.

`MOBILEGL_TEST_DELAY_FIRST_CAPS_MS` (server side, disaggregated builds, inert unless
set) holds back the FIRST CapsSnapshot. On spawn/tcp that is a genuinely late
snapshot in another process and it exercises the client's step-7 wait; on inproc it
only makes `Start` slower, which is itself the proof that the only window a client
can read a placeholder in is the one before `Start` is called.

G1: `.text` 0xa52203 and the 30570-symbol list identical to p7-before/pull-syms.txt.
Census: 79 abort sites unchanged, 44 family words (was 43). Link ratchet: 186,
unchanged.
2026-09-22 15:55:08 -04:00

186 lines
10 KiB
C++

// MobileGL - MobileGL/MG_Remote/Client/CapsMirror.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's copy of the server's capabilities. Owner: package c1. Signatures by c0.
//
// WHY A MIRROR AND NOT A ROUND TRIP. There are 56 client-side caps read points
// (40 GetDynamicParameters + 7 GetRendererInfo + 4 GetFormatCapabilities + 3 GetBackendType +
// 2 GetBackendAPIVersionString), and several of them - GL_Getter.cpp:2400 and
// ShaderTranspiler/CompileEnv.cpp:120-124 - bind a reference and then read many members, so a
// partial snapshot is not an option. Every one of the 56 must be answerable locally, with no
// record on the wire.
//
// GetRendererInfo() RETURNS A REFERENCE (BackendObject.h:590), so the mirror must OWN a
// RendererInfo instance to hand back - including before the first snapshot arrives, because
// LogBackendInfo() reads it at MG_Backend/Init.cpp:21, during MG_Backend::Init(), long before
// any context exists. Ruling (scout-caps-reply §1.2 option (a)): the mirror answers with a
// placeholder until the first snapshot, P5 accepts one inaccurate startup log line, and
// MG_Backend::Init() is NOT restructured.
//
// GetFormatCapabilities() is NON-VIRTUAL (BackendObject.h:594), so a remote backend object
// cannot override the accessor: it must FILL BackendObject::m_formatCapabilities from this
// mirror instead.
//
// INVALIDATION IS RE-ARRIVAL (R-12). DirectGLES has no OnCapsInvalidated producer at all - it
// re-runs UpdateAdvertisedCapabilityExtensions + UpdateDynamicBackendParameters at
// BackendObject_DirectGLES.cpp:865-871 and tells the frontend nothing, which is correct in
// monolith and a silent bug under split. Rather than add a DirectGLES-side callback (a
// dev-shaped backend edit), the SERVER re-sends the whole snapshot on every InitCapabilities
// re-run and the CLIENT treats a second arrival as the invalidation. Generation() is what a
// client-side memo keys on, and it is also the re-open signal for the server-context-death
// case that MGPipeCallbacks has no eleventh slot for (MGPipeCallbacks.h:56-58).
#pragma once
#include <Includes.h>
#include <MG_Backend/BackendObject.h>
#include <MG_Pipe/MGPipe.h>
namespace MobileGL::MG_Remote::Client {
class CapsMirror {
public:
// Replaces the whole mirror and bumps Generation(). Called once per CapsSnapshot,
// including the re-sends that mean "invalidate" (R-12).
void Adopt(const MG_Pipe::MGPCaps& caps, const MG_Backend::FormatCapabilityCache& formats,
const RendererInfo& renderer, const String& apiVersion,
BackendType backend);
// False until the first snapshot. The placeholder answers below are still safe to
// read - that is the point - but a caller that can wait should.
Bool Valid() const;
// ++ on every Adopt. A client memo that survives a server context loss must key on
// this; nothing else on the client can see that the server's context died.
Uint64 Generation() const;
const RendererInfo& Renderer() const;
const MG_Backend::DynamicBackendParameters& Dynamic() const;
const MG_Backend::FormatCapabilityCache& Formats() const;
const String& ApiVersion() const;
// The SERVER's backend type, never a new "Remote" enumerator: frontend branches
// switch on this (GL_Framebuffer.cpp:47, GL_Texture.cpp:6536, CompileEnv.cpp:122) and
// a value they do not know silently takes the wrong arm.
BackendType Backend() const;
Uint64 CallMask() const;
Bool HasCap(MG_Pipe::MGPCapBit bit) const;
// R-8. `subsystemBit` is a kMGPipeSubsystem* constant. THIS IS THE ONLY LEGAL SOURCE
// of the answer on the client under split: MGPipeGetResourceOps() is the SERVER's
// registration and is null in the client process, which would silently disable the
// whole push path in the one mode that matters.
//
// F1 (P7 wave 2): IT NEVER ANSWERS FROM A PLACEHOLDER ANY MORE. See RequireFirstSnapshot.
Bool ServerConsumes(Uint64 subsystemBit) const;
// GLFunctionsTable::PrefersCpuXfbPrimitiveAccounting (BackendObject.h:274) does NOT
// ride in MGPCaps::Dynamic - it is a member of the function table, which is precisely
// the thing a split client never receives. Its only non-test client reader is
// GL_Query.cpp:221, and under split it must be answered from kCapCpuXfbPrimitiveAccounting.
Bool PrefersCpuXfbPrimitiveAccounting() const;
private:
// F1 (P7 wave 2). THE PLACEHOLDER MAY NOT ANSWER "THIS FAMILY HAS NO CONSUMER".
//
// The placeholder rule above is right for the READ accessors - one imprecise startup
// log line is cheaper than restructuring MG_Backend::Init() - and it was catastrophic
// for this one, which is not a read but a DECISION: answering it from a zeroed mask
// says "the server consumes nothing, emit nothing, run the legacy pull path", and
// under split there IS no legacy pull path on the client. Every record the caller
// would have emitted is simply lost, the emitter clears its dirty flags on the
// acceptance it never asked for, and the one R-8 warning that says so is emitted once
// per family for the life of the process.
//
// So: generation 0 is not an answer. This waits, bounded, for a handshake that is
// genuinely in flight on ANOTHER thread, and otherwise dies by name -
// Fatal{CapsBeforeFirstSnapshot, "<family>"} - naming the family that asked. It does
// NOT wait when the calling thread is the one performing the handshake: that is the
// shape MobileGL::Initialize used to produce (MG_State::Init() before
// MG_Backend::Init()), and waiting for a snapshot this thread has not yet gone to
// fetch is how a deadlock is spelled. The ORDER is the fix; this is the gate that
// makes a regression of it loud instead of silent.
void RequireFirstSnapshot(Uint64 subsystemBit) const;
MG_Pipe::MGPCaps m_caps{};
MG_Backend::FormatCapabilityCache m_formats{};
RendererInfo m_renderer{};
String m_apiVersion;
BackendType m_backend = BackendType::Unknown;
Uint64 m_generation = 0;
};
// Per client context in principle; one per process in P5, because P5 serves one context.
// Leak-at-exit like every other MG_Remote singleton (ID-8): no frontend destructor may
// 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();
// F1's cases need the ONE state a running process can never get back: a mirror that has
// adopted nothing. A fork child inherits its parent's adopted instance, so without this a
// case about the placeholder would be a case about whatever the previous case adopted.
// Test-only by name, like ResetConsumerRefusalsForTest above it; nothing in the library
// calls either.
void ResetCapsMirrorForTest();
// ---- F1 (P7 wave 2) --------------------------------------------------------------------
//
// THE BOUNDED WAIT'S ONE HONEST SHAPE. ServerConsumes cannot pump a control plane itself -
// CapsMirror deliberately knows nothing about ClientSession - so the session installs the
// wait and the mirror calls it. The contract is narrow on purpose:
//
// * it returns FALSE IMMEDIATELY when no session bring-up is in flight, or when the
// CALLING thread is the one performing it. Both mean the snapshot cannot arrive while
// this call blocks, and a wait there would burn the budget and then die anyway;
// * otherwise it waits up to `timeoutMs` for the first Adopt() and reports whether one
// landed.
//
// Installed once, by ClientSession's constructor. A raw function pointer for ID-8's
// reason, like SetCapsAdoptedHook beside it.
using CapsFirstSnapshotWait = Bool (*)(Uint32 timeoutMs);
void SetCapsFirstSnapshotWait(CapsFirstSnapshotWait wait);
// The generation, readable from a thread that does not own the mirror. m_generation is
// GL-thread state by the same argument that makes one gPipeInputs legal; this is the one
// fact a WAITING thread has to see, so it is published separately and atomically.
Uint64 PublishedCapsGeneration();
// `kMGPipeSubsystem*` -> the word the Fatal and the R-8 warning print. Names only grow
// (G14); an unknown bit prints as a hex literal rather than as a guess.
const char* CapsSubsystemName(Uint64 subsystemBit);
// How long RequireFirstSnapshot gives a handshake that IS in flight. Bounded and small:
// an in-process Adopt lands in microseconds and a socket one in single-digit milliseconds,
// so anything past this is a peer that is not coming.
inline constexpr Uint32 kFirstSnapshotWaitMs = 250;
} // namespace MobileGL::MG_Remote::Client