[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.
This commit is contained in:
2026-09-22 15:55:08 -04:00
parent 206abdfa44
commit df17c416c0
12 changed files with 448 additions and 52 deletions
+104 -4
View File
@@ -20,19 +20,35 @@
// 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.
//
// P7 F1 NARROWS "EVERY ACCESSOR" BY EXACTLY ONE. The ruling above is about the READ accessors
// and it stands for them: an imprecise startup log line is cheaper than restructuring
// MG_Backend::Init(). ServerConsumes() is not a read, it is a DECISION about whether a whole
// record family reaches the wire at all, and P5's "a placeholder consumes nothing, which is the
// safe direction because the legacy pull path runs" was simply untrue under split - the client
// has no pull path. It now refuses to answer from a placeholder; see RequireFirstSnapshot.
#include "CapsMirror.h"
#include "../CapsCodec.h"
#include "../FatalFunnel.h"
#include <Config.h> // MG_Config::SplitTransportRequestedByConfig
#include <MG_Backend/MGPipe/PipeInputs.h> // MGPipeServerArm, D1c's role predicate
#include <MG_State/GLState/Core.h>
#include <MG_Util/Debug/Log.h>
#include <atomic>
namespace MobileGL::MG_Remote::Client {
namespace {
CapsAdoptedHook g_capsAdoptedHook = nullptr;
// F1. See CapsMirror.h for the contract of each of these three.
CapsFirstSnapshotWait g_firstSnapshotWait = nullptr;
::std::atomic<Uint64> g_publishedGeneration{0};
// 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;
@@ -67,6 +83,9 @@ namespace MobileGL::MG_Remote::Client {
m_apiVersion = apiVersion;
m_backend = backend;
++m_generation;
// F1: published BEFORE the log line and before the hook, because a thread parked in
// RequireFirstSnapshot is waiting on exactly this store and has nothing else to read.
g_publishedGeneration.store(m_generation, ::std::memory_order_release);
// 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
@@ -130,10 +149,19 @@ namespace MobileGL::MG_Remote::Client {
// 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.
// A PLACEHOLDER MIRROR ANSWERS NOTHING (F1, P7 wave 2). P5 wrote the opposite here -
// "a placeholder mirror consumes nothing, and that is the safe direction" - on the
// reasoning that withholding leaves the legacy pull path running. That reasoning is
// false on the client under split: there is no legacy pull path on this side of the
// wire, so withholding is not a fallback, it is a DROP. Measured: MG_State::Init()
// built GLContext's eleven default texture objects before MG_Backend::Init() had
// started the session, all eleven resource_creates were withheld against callMask=0 at
// caps generation 0, and the whole event was one WARN line in a log nobody diffs.
//
// The decision now waits for a real snapshot, or dies naming the family that asked.
if (m_generation == 0) {
RequireFirstSnapshot(subsystemBit);
}
const Bool consumes = MGCapsServerConsumes(m_caps.CallMask, subsystemBit);
if (!consumes) {
++g_consumerRefusals;
@@ -151,6 +179,72 @@ namespace MobileGL::MG_Remote::Client {
return consumes;
}
void CapsMirror::RequireFirstSnapshot(Uint64 subsystemBit) const {
// THE SERVER ARM IS NOT A CLIENT WITH A LATE SNAPSHOT (D1c, PipeInputs.h). On the apply
// thread, and in a spawn server process, the CLIENT's caps mirror is not merely
// un-adopted - it is the wrong object to ask, and it will never be adopted, because
// there is no client half here to adopt it. That arm answers through
// RunsAsTheServerRole()'s monolith rows in WireTables.cpp and through the server
// session's own mask in PipeApply.cpp; the placeholder's "no consumer" is what keeps a
// server-role birth off a wire it has no business writing to, and it is correct there.
// Only the CLIENT may not decide a family's fate from a placeholder.
if (MG_Pipe::MGPipeServerArm()) return;
// AND NEITHER IS A PROCESS THAT NEVER ASKED FOR A TRANSPORT. MG_Config::Transport can
// be assigned by hand - unit fixtures do it to put the code under test on its split
// arm while building no client at all (ServerLoopTest's main says so in as many words)
// - and holding those to a rule about a handshake they will never perform would be
// holding them to nothing. The gate is armed by MG_ConfigLoader::Init having RESOLVED a
// split transport, which is the same decision that makes MG_Backend::Init bring a
// client half up. See Config.h.
if (!MG_Config::SplitTransportRequestedByConfig) return;
// The wait is real only when somebody else is fetching the snapshot. ClientSession's
// hook answers false when no bring-up is in flight and when the CALLING thread is the
// one performing it - both of which mean nothing can land while this call blocks.
if (g_firstSnapshotWait != nullptr && g_firstSnapshotWait(kFirstSnapshotWaitMs) &&
m_generation != 0) {
return;
}
// NOT MGLOG_W AND CARRY ON. The whole defect this closes is that carrying on was
// indistinguishable, in every downstream artefact, from a server that genuinely
// consumes nothing: same absence of records, same cleared dirty flags, same green
// lane. A death names the family, publishes a SessionFault to a peer if one is
// connected, and lands in the census like every other one.
SessionFail(MGFatalFamily::CapsBeforeFirstSnapshot,
"MG_Remote client: Fatal{CapsBeforeFirstSnapshot, \"%s\"} - the liveness gate "
"for MGPipe subsystem 0x%llx asked a caps mirror that is still a PLACEHOLDER "
"(generation 0, callMask=0). Answering it would withhold that family's whole "
"record stream on a mask the server never published, and there is no legacy "
"pull path on the client to take over. The session must be started - and its "
"first CapsSnapshot adopted - before any frontend object of this family is "
"born; MobileGL::Initialize does that before MG_State::Init()",
CapsSubsystemName(subsystemBit),
static_cast<unsigned long long>(subsystemBit));
}
void SetCapsFirstSnapshotWait(CapsFirstSnapshotWait wait) { g_firstSnapshotWait = wait; }
Uint64 PublishedCapsGeneration() {
return g_publishedGeneration.load(::std::memory_order_acquire);
}
const char* CapsSubsystemName(Uint64 subsystemBit) {
switch (subsystemBit) {
case MG_Pipe::kMGPipeSubsystemResources: return "Resources";
case MG_Pipe::kMGPipeSubsystemVertexInput: return "VertexInput";
case MG_Pipe::kMGPipeSubsystemFramebuffer: return "Framebuffer";
case MG_Pipe::kMGPipeSubsystemTextureResources: return "TextureResources";
case MG_Pipe::kMGPipeSubsystemSamplers: return "Samplers";
case MG_Pipe::kMGPipeSubsystemPrograms: return "Programs";
case MG_Pipe::kMGPipeSubsystemBufferBindings: return "BufferBindings";
default: break;
}
// A bit with no word yet, or a mask of several. Named by its value rather than by a
// guess: G14 lets the list grow, never lets a wrong word into a Fatal.
return "unnamed-subsystem";
}
Uint64 ConsumerRefusals() { return g_consumerRefusals; }
Uint64 LastRefusedSubsystem() { return g_lastRefusedSubsystem; }
void ResetConsumerRefusalsForTest() {
@@ -159,6 +253,12 @@ namespace MobileGL::MG_Remote::Client {
g_refusedSubsystemsLogged = 0;
}
void ResetCapsMirrorForTest() {
CapsMirrorInstance() = CapsMirror{};
g_publishedGeneration.store(0, ::std::memory_order_release);
ResetConsumerRefusalsForTest();
}
Bool CapsMirror::PrefersCpuXfbPrimitiveAccounting() const {
return HasCap(MG_Pipe::kCapCpuXfbPrimitiveAccounting);
}