[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
@@ -34,8 +34,10 @@
#include <MG_Util/Debug/Log.h>
#include <atomic>
#include <chrono>
#include <cstdlib>
#include <cstring>
#include <thread>
#include <utility>
#include <vector>
@@ -87,6 +89,34 @@ namespace MobileGL::MG_Remote::Client {
// deadline there turns a lost record into a hung process exit.
constexpr Uint32 kDrainTimeoutMs = 5000;
// ---- F1 (P7 wave 2): WHO IS FETCHING THE FIRST SNAPSHOT --------------------------
//
// CapsMirror::RequireFirstSnapshot needs one fact this file owns and no other does:
// is a bring-up in flight, and is it on THIS thread. The counter answers the first;
// the thread-local answers the second, and it is the one that matters, because the
// defect F1 closes had the emitter and the handshake on the same thread in sequence -
// a wait there would burn its whole budget and change nothing.
//
// Not guarded by a mutex: the counter is only ever compared against zero and the
// thread-local is private to its thread by construction.
::std::atomic<int> g_bringUpsInFlight{0};
thread_local Bool tl_bringingUpHere = false;
struct BringUpScope {
BringUpScope() {
g_bringUpsInFlight.fetch_add(1, ::std::memory_order_acq_rel);
m_outer = tl_bringingUpHere;
tl_bringingUpHere = true;
}
~BringUpScope() {
tl_bringingUpHere = m_outer;
g_bringUpsInFlight.fetch_sub(1, ::std::memory_order_acq_rel);
}
BringUpScope(const BringUpScope&) = delete;
BringUpScope& operator=(const BringUpScope&) = delete;
Bool m_outer = false;
};
MobileGLResult ReceiveEnvelope(Transport::ITransport& transport, std::vector<Uint8>& out,
Uint32 timeoutMs) {
Uint64 size = 0;
@@ -500,9 +530,44 @@ namespace MobileGL::MG_Remote::Client {
return *instance;
}
namespace {
// F1 (P7 wave 2). The wait CapsMirror::RequireFirstSnapshot calls. Its whole job is to
// distinguish "the snapshot is on its way and somebody else is fetching it" from "no
// snapshot can arrive while you block", and to answer the second immediately.
//
// IT DOES NOT PUMP. Pumping a half-built session from a thread that does not own it is
// how two readers end up splitting one control frame; the thread that IS bringing the
// session up already pumps, in FinishStartup step 7, and this one only has to see the
// generation it publishes.
Bool AwaitFirstCapsSnapshot(Uint32 timeoutMs) {
if (PublishedCapsGeneration() != 0) return true;
// The two immediate falses. Nothing is fetching, or the fetcher is the caller -
// which is the exact shape MobileGL::Initialize produced before F1 moved the
// split bring-up above MG_State::Init().
if (tl_bringingUpHere) return false;
if (g_bringUpsInFlight.load(::std::memory_order_acquire) == 0) return false;
const auto deadline =
std::chrono::steady_clock::now() + std::chrono::milliseconds(timeoutMs);
while (std::chrono::steady_clock::now() < deadline) {
if (PublishedCapsGeneration() != 0) return true;
if (g_bringUpsInFlight.load(::std::memory_order_acquire) == 0) break;
std::this_thread::sleep_for(std::chrono::milliseconds(1));
}
return PublishedCapsGeneration() != 0;
}
} // namespace
Bool BringUpInFlight() {
return g_bringUpsInFlight.load(::std::memory_order_acquire) != 0;
}
ClientSession::ClientSession() {
m_link = Transport::CreateSharedLink(Transport::TransportRoleTag::ClientProducer);
m_cmd = &m_link->CommandsOut(); m_events = &m_link->EventsIn();
// F1: installed from the constructor rather than from Start, because the question it
// answers is asked by frontend object births that may precede any Start at all - and
// the honest answer to those is the Fatal, not a silent false.
SetCapsFirstSnapshotWait(&AwaitFirstCapsSnapshot);
}
ClientSession::~ClientSession() {
@@ -514,6 +579,12 @@ namespace MobileGL::MG_Remote::Client {
Bool ClientSession::Started() const { return m_started; }
MobileGLResult ClientSession::Start(MG_Config::TransportMode mode, const String& endpoint) {
// F1: the bring-up window opens HERE and not at FinishStartup, because under inproc the
// server's first CapsSnapshot is produced inside ServerSession::Accept - step 3 below -
// and a window that started after it would not contain the one moment a reader on
// another thread has to be able to wait through. Re-entrant: StartOverTransportPair
// opens a nested scope and BringUpScope nests.
const BringUpScope bringUp;
if (m_started) {
return MOBILEGL_ERR_INVALID_ARGUMENT;
}
@@ -540,6 +611,7 @@ namespace MobileGL::MG_Remote::Client {
MobileGLResult ClientSession::StartOverTransportPair(
std::unique_ptr<Transport::InProcessTransport> clientEnd,
std::unique_ptr<Transport::InProcessTransport> serverEnd) {
const BringUpScope bringUp; // F1, and see Start() for why it opens this early.
if (m_started) {
return MOBILEGL_ERR_INVALID_ARGUMENT;
}
@@ -687,6 +759,7 @@ namespace MobileGL::MG_Remote::Client {
}
MobileGLResult ClientSession::StartSpawned() {
const BringUpScope bringUp; // F1, and see Start() for why it opens this early.
if (m_started) {
return MOBILEGL_ERR_INVALID_ARGUMENT;
}
@@ -771,6 +844,7 @@ namespace MobileGL::MG_Remote::Client {
MobileGLResult ClientSession::StartOverSocket(
std::unique_ptr<Transport::SocketTransport> transport) {
const BringUpScope bringUp; // F1, and see Start() for why it opens this early.
if (m_started) {
return MOBILEGL_ERR_INVALID_ARGUMENT;
}
@@ -1218,6 +1292,10 @@ namespace MobileGL::MG_Remote::Client {
MobileGLResult ClientSession::FinishStartup(Transport::Doorbell* peerBell,
Transport::Doorbell* selfBell,
bool startApplyThreadHere) {
// F1: every startup path - inproc, spawn, unix, tcp - reaches step 7 through here, so
// this is the one place that has to declare "a first snapshot is being fetched, on
// this thread". CapsMirror reads both halves of that (see AwaitFirstCapsSnapshot).
const BringUpScope bringUp;
m_link->InitializeEndpoints();
// NO STAGE RING. SEG_STAGE is package w1's encoder-local LINEAR ALLOCATOR:
// a staged byte run carries no RingRecordHeader, nothing consumes SEG_STAGE,