[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
+77 -47
View File
@@ -276,55 +276,16 @@ namespace MobileGL::MG_Backend {
pActiveBackendObject = MakeUnique<MG_Remote::Client::BackendObject_Remote>();
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();
// M-6: ClientSession::Stop's !m_started arm (a Start that FAILED after
// ServerSession::Accept - a refused Accept, an invalid cmd/reply ring) tears down only
// the client half and never stops the apply thread or drops the server's private backend,
// which ServerLoop::CreateBackend already built and which holds the process-wide
// g_resourceOps. So call ServerLoop::Stop() here unconditionally. It is idempotent: on the
// started path ClientSession::Stop already joined the thread, so this hits Stop's
// !joinable arm, which resets a backend that never ran a thread and is otherwise a no-op.
// Without this an early Start failure leaves BackendObject_DirectGLES permanently alive
// and every later split bring-up in the process fails at CreateBackend's m_backend!=null
// guard.
MG_Remote::Server::ServerLoopInstance().Stop();
}
#endif
// F1 (P7 wave 2). THE WHOLE SPLIT ARM OF Init(), AS ONE BODY, so it can run from
// either of the two places that may reach it - MobileGL::Initialize's early hook,
// before MG_State::Init(), and Init() itself for a direct caller such as a unit
// fixture - without a second copy. Two copies of a bring-up order is how the two
// orders come to differ on the day one of them is wrong.
Bool g_splitBackendBroughtUp = false;
#if MOBILEGL_BUILD_DISAGGREGATED
// The spawn server's entry into the SAME bring-up the inproc server role
// runs (ServerRole.h). A thin forwarder on purpose: the body stays in the
// anonymous namespace beside InitSplitRoles so the two cannot drift.
//
// THE WHOLE FUNCTION IS INSIDE THE GUARD, not just its body. A version with
// the guard inside still DEFINES the symbol in a pull build, and G1 caught
// it: 2 symbols added, 1 removed, .text +16 bytes. The pull build's identity
// is byte-for-byte, and "it returns false there" is not the same as "it is
// not there".
Bool InitServerRoleForSpawn() { return InitServerRoleCommon(); }
#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) {
void InitSplitBackend() {
g_splitBackendBroughtUp = true;
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
@@ -373,6 +334,75 @@ namespace MobileGL::MG_Backend {
}
}
LogBackendInfo();
}
} // namespace
#endif
#if MOBILEGL_BUILD_DISAGGREGATED
// F1 (P7 wave 2). See BackendObjects.h for why this exists and MobileGL/Init.cpp for the
// defect the order closes.
Bool InitSplitRolesBeforeState() {
if (MG_Config::Transport == MG_Config::TransportMode::Monolith) return false;
InitSplitBackend();
return true;
}
#endif
#if MOBILEGL_BUILD_DISAGGREGATED
void ShutdownSplitRoles() {
g_splitBackendBroughtUp = false;
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();
// M-6: ClientSession::Stop's !m_started arm (a Start that FAILED after
// ServerSession::Accept - a refused Accept, an invalid cmd/reply ring) tears down only
// the client half and never stops the apply thread or drops the server's private backend,
// which ServerLoop::CreateBackend already built and which holds the process-wide
// g_resourceOps. So call ServerLoop::Stop() here unconditionally. It is idempotent: on the
// started path ClientSession::Stop already joined the thread, so this hits Stop's
// !joinable arm, which resets a backend that never ran a thread and is otherwise a no-op.
// Without this an early Start failure leaves BackendObject_DirectGLES permanently alive
// and every later split bring-up in the process fails at CreateBackend's m_backend!=null
// guard.
MG_Remote::Server::ServerLoopInstance().Stop();
}
#endif
#if MOBILEGL_BUILD_DISAGGREGATED
// The spawn server's entry into the SAME bring-up the inproc server role
// runs (ServerRole.h). A thin forwarder on purpose: the body stays in the
// anonymous namespace beside InitSplitRoles so the two cannot drift.
//
// THE WHOLE FUNCTION IS INSIDE THE GUARD, not just its body. A version with
// the guard inside still DEFINES the symbol in a pull build, and G1 caught
// it: 2 symbols added, 1 removed, .text +16 bytes. The pull build's identity
// is byte-for-byte, and "it returns false there" is not the same as "it is
// not there".
Bool InitServerRoleForSpawn() { return InitServerRoleCommon(); }
#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) {
// F1: MobileGL::Initialize now brings the split roles up BEFORE MG_State::Init(),
// through InitSplitRolesBeforeState() below, so by the time Init() is reached on
// that path the work is done. A DIRECT caller - a unit fixture that calls
// MG_Backend::Init() itself - still gets the whole bring-up here. The flag rather
// than `pActiveBackendObject == nullptr`, because a bring-up that FAILED leaves
// that pointer null too and retrying it would refuse at CreateBackend's
// m_backend != nullptr guard with a second, misleading line.
if (!g_splitBackendBroughtUp) InitSplitBackend();
return;
}
#endif